From 4cef011282253e5ae764b1907ad8668a399db8ef Mon Sep 17 00:00:00 2001 From: Andy Bonventre Date: Fri, 10 Jul 2026 22:21:51 +0000 Subject: [PATCH 01/39] =?UTF-8?q?feat(hub):=20scaffold=20harness=20hub=20s?= =?UTF-8?q?ubcommand=20=E2=80=94=20embed=20serve=20+=20spawn=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tools/hub, a new package holding the Go side of the local fleet hub: NewHandler serves the (placeholder, for now) embedded single-file page at / and implements the sole API, POST /spawn, which execs a configured shell command and streams its stdout live over SSE, parsing the TUNNEL_URL=/RUN_TOKEN= contract lines documented in spawn.go. harness hub wires this up as a new CLI subcommand, loopback-only by default. --- cmd/harness/main.go | 9 ++ tools/hub/hub.go | 169 ++++++++++++++++++++++++++++++++++++ tools/hub/hub_test.go | 185 ++++++++++++++++++++++++++++++++++++++++ tools/hub/index.html | 3 + tools/hub/spawn.go | 113 ++++++++++++++++++++++++ tools/hub/spawn_test.go | 159 ++++++++++++++++++++++++++++++++++ 6 files changed, 638 insertions(+) create mode 100644 tools/hub/hub.go create mode 100644 tools/hub/hub_test.go create mode 100644 tools/hub/index.html create mode 100644 tools/hub/spawn.go create mode 100644 tools/hub/spawn_test.go diff --git a/cmd/harness/main.go b/cmd/harness/main.go index fbd69ab..7068bd2 100644 --- a/cmd/harness/main.go +++ b/cmd/harness/main.go @@ -32,6 +32,7 @@ import ( "github.com/majorcontext/harness/provider/openai" "github.com/majorcontext/harness/provider/openaicompat" "github.com/majorcontext/harness/server" + "github.com/majorcontext/harness/tools/hub" ) // defaultOpenRouterName is the providers map key that gets a built-in @@ -83,6 +84,11 @@ func main() { fmt.Fprintln(os.Stderr, "harness:", err) os.Exit(1) } + case "hub": + if err := hub.Run(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "harness:", err) + os.Exit(1) + } default: usage() os.Exit(2) @@ -101,6 +107,9 @@ func usage() { harness plugin probe re-probe configured plugins and refresh the manifest cache harness sessions [--json] list persisted sessions + harness hub [-addr host:port] [-spawn-command cmd] + serve the local fleet hub UI (see + AGENTS.md's "Development hub" section) harness version print version run flags: diff --git a/tools/hub/hub.go b/tools/hub/hub.go new file mode 100644 index 0000000..f379645 --- /dev/null +++ b/tools/hub/hub.go @@ -0,0 +1,169 @@ +package hub + +import ( + "bufio" + "context" + _ "embed" + "flag" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +//go:embed index.html +var indexHTML []byte + +// defaultAddr is deliberately loopback-only: the hub is a local, single- +// operator dev tool (see AGENTS.md, "Development hub") and never listens on +// every interface by default. +const defaultAddr = "localhost:7777" + +// spawnCommandEnv is the environment-variable fallback for -spawn-command, +// so a wrapper script can configure the hub without a flag. +const spawnCommandEnv = "HARNESS_HUB_SPAWN" + +// Options configures a hub server. The zero value is not directly useful; +// Run below builds one from flags/env for the `harness hub` subcommand, but +// tests construct Options directly to avoid touching flags or the process +// environment. +type Options struct { + // SpawnCommand is executed via `sh -c` by POST /spawn. Empty disables + // spawning: the endpoint reports the "no spawn command configured" + // error from runSpawn rather than failing to start the hub itself — a + // hub with no spawn command configured is still useful for driving + // boxes added by hand. + SpawnCommand string +} + +// NewHandler builds the hub's HTTP handler: the embedded page at "/" and +// the single POST /spawn API described in AGENTS.md. Everything else the +// page needs (session state, box CRUD) is client-side — see index.html. +func NewHandler(opts Options) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", handleIndex) + mux.HandleFunc("/spawn", handleSpawn(opts)) + return mux +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" && r.URL.Path != "/index.html" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodGet { + w.Write(indexHTML) //nolint:errcheck + } +} + +// handleSpawn streams runSpawn's events to the client as an SSE response. +// The request context is what runSpawn's exec.CommandContext keys off of, +// so a client disconnect kills the spawn process directly — see spawn.go. +func handleSpawn(opts Options) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + bw := bufio.NewWriter(w) + runSpawn(r.Context(), opts.SpawnCommand, func(ev spawnEvent) { + bw.Write(ev.marshal()) //nolint:errcheck + bw.Flush() //nolint:errcheck + flusher.Flush() + }) + } +} + +// resolveAddr applies -addr's default and documents the loopback-by-default +// promise: an address with an empty or unspecified host (e.g. ":7777" or +// "0.0.0.0:7777") is passed through as given — the operator asked for it +// explicitly by supplying -addr — but the flag's own default is always the +// loopback address, so doing nothing at all stays local. +func resolveAddr(addr string) string { + if addr == "" { + return defaultAddr + } + return addr +} + +// spawnCommandFromEnv resolves -spawn-command's fallback: the +// HARNESS_HUB_SPAWN environment variable, consulted only when the flag was +// not passed at all (flagSet, not merely flagValue == "", so an explicit +// -spawn-command '' can still disable spawning without env clobbering it). +func spawnCommandFromEnv(flagValue string, flagSet bool, getenv func(string) string) string { + if flagSet { + return flagValue + } + if v := getenv(spawnCommandEnv); v != "" { + return v + } + return flagValue +} + +// Run implements the `harness hub` subcommand: parse flags, build the +// handler, serve until interrupted. It is the only network-facing entry +// point in this package — NewHandler/Options above are what tests exercise +// directly. +func Run(args []string) error { + fs := flag.NewFlagSet("hub", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var addr string + fs.StringVar(&addr, "addr", defaultAddr, "listen address (loopback by default — this is a local, single-operator tool)") + var spawnCommand string + fs.StringVar(&spawnCommand, "spawn-command", "", "shell command (run via `sh -c`) that POST /spawn execs to bring up a new box; falls back to $"+spawnCommandEnv+"; see AGENTS.md's spawn-command contract") + if err := fs.Parse(args); err != nil { + return err + } + var spawnFlagSet bool + fs.Visit(func(f *flag.Flag) { + if f.Name == "spawn-command" { + spawnFlagSet = true + } + }) + spawnCommand = spawnCommandFromEnv(spawnCommand, spawnFlagSet, os.Getenv) + + handler := NewHandler(Options{SpawnCommand: spawnCommand}) + httpSrv := &http.Server{Addr: resolveAddr(addr), Handler: handler} + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + ln, err := net.Listen("tcp", httpSrv.Addr) + if err != nil { + return err + } + fmt.Fprintf(os.Stderr, "harness hub listening on http://%s\n", ln.Addr()) + + errc := make(chan error, 1) + go func() { errc <- httpSrv.Serve(ln) }() + + select { + case err := <-errc: + return err + case <-ctx.Done(): + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return httpSrv.Shutdown(shutCtx) + } +} diff --git a/tools/hub/hub_test.go b/tools/hub/hub_test.go new file mode 100644 index 0000000..351482e --- /dev/null +++ b/tools/hub/hub_test.go @@ -0,0 +1,185 @@ +package hub + +import ( + "bufio" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHandleIndexServesEmbeddedPage(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{})) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("Content-Type = %q, want text/html prefix", ct) + } +} + +func TestHandleIndexRejectsUnknownPaths(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{})) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/nope") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +func TestHandleIndexRejectsNonGet(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{})) + defer srv.Close() + + resp, err := http.Post(srv.URL+"/", "text/plain", nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want 405", resp.StatusCode) + } +} + +func TestHandleSpawnRejectsGet(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{SpawnCommand: "echo hi"})) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/spawn") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want 405", resp.StatusCode) + } +} + +func TestHandleSpawnStreamsSSEFrames(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{SpawnCommand: "echo TUNNEL_URL=https://x.example; echo RUN_TOKEN=tok123"})) + defer srv.Close() + + resp, err := http.Post(srv.URL+"/spawn", "application/json", nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" { + t.Errorf("Content-Type = %q, want text/event-stream", ct) + } + + body, err := readAllLines(resp.Body) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(body, "\n") + if !strings.Contains(joined, `"tunnel_url":"https://x.example"`) { + t.Errorf("body missing tunnel_url; got:\n%s", joined) + } + if !strings.Contains(joined, `"run_token":"tok123"`) { + t.Errorf("body missing run_token; got:\n%s", joined) + } + if !strings.Contains(joined, `"type":"done"`) { + t.Errorf("body missing done event; got:\n%s", joined) + } +} + +func TestHandleSpawnNoCommandConfiguredReportsErrorInStream(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{})) + defer srv.Close() + + resp, err := http.Post(srv.URL+"/spawn", "application/json", nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (the error rides inside the SSE stream, not the HTTP status)", resp.StatusCode) + } + body, err := readAllLines(resp.Body) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(body, "\n") + if !strings.Contains(joined, "no spawn command configured") { + t.Errorf("body missing configuration error; got:\n%s", joined) + } +} + +func readAllLines(r io.Reader) ([]string, error) { + var lines []string + sc := bufio.NewScanner(r) + for sc.Scan() { + lines = append(lines, sc.Text()) + } + if err := sc.Err(); err != nil { + return nil, err + } + return lines, nil +} + +func TestResolveAddrDefaultsToLoopback(t *testing.T) { + if got := resolveAddr(""); got != defaultAddr { + t.Errorf("resolveAddr(\"\") = %q, want %q", got, defaultAddr) + } + if got := resolveAddr("localhost:9999"); got != "localhost:9999" { + t.Errorf("resolveAddr override = %q, want localhost:9999", got) + } +} + +func TestDefaultAddrIsLoopback(t *testing.T) { + host, _, err := net.SplitHostPort(defaultAddr) + if err != nil { + t.Fatal(err) + } + if host != "localhost" && host != "127.0.0.1" { + t.Errorf("default host = %q, want a loopback host", host) + } +} + +func TestSpawnCommandFromEnv(t *testing.T) { + getenv := func(m map[string]string) func(string) string { + return func(k string) string { return m[k] } + } + t.Run("flag set wins over env even when empty", func(t *testing.T) { + got := spawnCommandFromEnv("", true, getenv(map[string]string{spawnCommandEnv: "from-env"})) + if got != "" { + t.Errorf("got %q, want empty (explicit flag wins)", got) + } + }) + t.Run("env used when flag not passed", func(t *testing.T) { + got := spawnCommandFromEnv("", false, getenv(map[string]string{spawnCommandEnv: "from-env"})) + if got != "from-env" { + t.Errorf("got %q, want from-env", got) + } + }) + t.Run("flag value used when env unset", func(t *testing.T) { + got := spawnCommandFromEnv("from-flag", true, getenv(map[string]string{})) + if got != "from-flag" { + t.Errorf("got %q, want from-flag", got) + } + }) + t.Run("no flag, no env: empty", func(t *testing.T) { + got := spawnCommandFromEnv("", false, getenv(map[string]string{})) + if got != "" { + t.Errorf("got %q, want empty", got) + } + }) +} diff --git a/tools/hub/index.html b/tools/hub/index.html new file mode 100644 index 0000000..e917f7a --- /dev/null +++ b/tools/hub/index.html @@ -0,0 +1,3 @@ + +harness hub (placeholder) +placeholder — replaced before this branch is done diff --git a/tools/hub/spawn.go b/tools/hub/spawn.go new file mode 100644 index 0000000..c1ea506 --- /dev/null +++ b/tools/hub/spawn.go @@ -0,0 +1,113 @@ +// Package hub implements `harness hub`: a local, single-operator control +// surface over a fleet of headless harness boxes. See hub.go for the server +// and index.html for the page itself. +package hub + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os/exec" + "regexp" + "strings" +) + +// spawnEvent is one frame of the /spawn SSE stream, JSON-encoded as the +// `data:` payload. This is the entire spawn-output contract described in +// AGENTS.md: a "stdout" event per line of the spawn command's combined +// stdout+stderr, and exactly one terminal "done" event carrying the exit +// status plus whatever TUNNEL_URL / RUN_TOKEN lines were found along the +// way. The page needs nothing else to add the new box to its own state. +type spawnEvent struct { + Type string `json:"type"` // "stdout" or "done" + Line string `json:"line,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + TunnelURL string `json:"tunnel_url,omitempty"` + RunToken string `json:"run_token,omitempty"` + Error string `json:"error,omitempty"` +} + +// marshal encodes ev as a single SSE frame ("data: ...\n\n"). It never +// fails in practice (spawnEvent has no unmarshalable fields), but a JSON +// error is folded into the frame rather than panicking or dropping it. +func (ev spawnEvent) marshal() []byte { + b, err := json.Marshal(ev) + if err != nil { + b = []byte(`{"type":"done","error":"internal: encoding spawn event: ` + err.Error() + `"}`) + } + return append(append([]byte("data: "), b...), '\n', '\n') +} + +// tunnelURLPattern and runTokenPattern match the two contract lines +// anywhere in the spawn command's combined stdout+stderr, tolerating +// leading/trailing whitespace and any surrounding log prefix on the same +// line is NOT stripped — the line must consist of exactly "KEY=value" once +// trimmed, so a logger that prefixes timestamps must emit the marker on its +// own line. +var ( + tunnelURLPattern = regexp.MustCompile(`^TUNNEL_URL=(.+)$`) + runTokenPattern = regexp.MustCompile(`^RUN_TOKEN=(.+)$`) +) + +// runSpawn execs command via `sh -c`, streaming each combined stdout/stderr +// line to emit as a "stdout" spawnEvent and scanning every line against the +// TUNNEL_URL=/RUN_TOKEN= contract. It always finishes by calling emit +// exactly once more with a "done" event — carrying the exit code and +// whatever contract values were found, or an Error string if the command +// could not even be started. Canceling ctx kills the process (SIGKILL via +// exec.CommandContext) and runSpawn returns promptly once the process +// actually exits; it does not return early on cancellation, so the final +// "done" event is always sent. +func runSpawn(ctx context.Context, command string, emit func(spawnEvent)) { + if strings.TrimSpace(command) == "" { + emit(spawnEvent{Type: "done", Error: "no spawn command configured (set -spawn-command or HARNESS_HUB_SPAWN)"}) + return + } + + cmd := exec.CommandContext(ctx, "sh", "-c", command) + stdout, err := cmd.StdoutPipe() + if err != nil { + emit(spawnEvent{Type: "done", Error: fmt.Sprintf("spawn: %v", err)}) + return + } + cmd.Stderr = cmd.Stdout // combined stream, in the order it's written + + if err := cmd.Start(); err != nil { + emit(spawnEvent{Type: "done", Error: fmt.Sprintf("spawn: %v", err)}) + return + } + + var tunnelURL, runToken string + scanner := bufio.NewScanner(stdout) + // Spawn scripts may print long single lines (progress bars, etc.); grow + // past bufio's 64KiB default rather than truncating or erroring out. + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + emit(spawnEvent{Type: "stdout", Line: line}) + trimmed := strings.TrimSpace(line) + if m := tunnelURLPattern.FindStringSubmatch(trimmed); m != nil { + tunnelURL = strings.TrimSpace(m[1]) + } + if m := runTokenPattern.FindStringSubmatch(trimmed); m != nil { + runToken = strings.TrimSpace(m[1]) + } + } + scanErr := scanner.Err() + + waitErr := cmd.Wait() + + done := spawnEvent{Type: "done", TunnelURL: tunnelURL, RunToken: runToken} + if cmd.ProcessState != nil { + done.ExitCode = cmd.ProcessState.ExitCode() + } + switch { + case scanErr != nil && scanErr != io.EOF: + done.Error = fmt.Sprintf("reading spawn output: %v", scanErr) + case waitErr != nil: + done.Error = waitErr.Error() + } + emit(done) +} diff --git a/tools/hub/spawn_test.go b/tools/hub/spawn_test.go new file mode 100644 index 0000000..8b713a0 --- /dev/null +++ b/tools/hub/spawn_test.go @@ -0,0 +1,159 @@ +package hub + +import ( + "context" + "strings" + "sync" + "testing" +) + +// collectSpawn runs runSpawn to completion and returns every event emitted, +// in order. +func collectSpawn(t *testing.T, ctx context.Context, command string) []spawnEvent { + t.Helper() + var mu sync.Mutex + var got []spawnEvent + runSpawn(ctx, command, func(ev spawnEvent) { + mu.Lock() + defer mu.Unlock() + got = append(got, ev) + }) + return got +} + +func TestRunSpawnNoCommandConfigured(t *testing.T) { + events := collectSpawn(t, context.Background(), "") + if len(events) != 1 { + t.Fatalf("events = %#v, want exactly one", events) + } + if events[0].Type != "done" || events[0].Error == "" { + t.Fatalf("event = %#v, want a done event carrying an error", events[0]) + } +} + +func TestRunSpawnParsesContractLines(t *testing.T) { + // Feed synthetic spawn output through a real `sh -c` invocation — the + // subprocess machinery (exec.CommandContext, combined-stream scanning) + // is what's under test, so a real process is the right fixture here + // (see AGENTS.md's e2e exception). + script := `echo "booting sandbox..." +echo "TUNNEL_URL=https://box-42.example.dev" +echo "some other progress line" +echo "RUN_TOKEN=sekrit-token-value" +echo "ready" +` + events := collectSpawn(t, context.Background(), script) + if len(events) == 0 { + t.Fatal("no events emitted") + } + last := events[len(events)-1] + if last.Type != "done" { + t.Fatalf("last event type = %q, want done", last.Type) + } + if last.Error != "" { + t.Fatalf("done event carried error: %q", last.Error) + } + if last.TunnelURL != "https://box-42.example.dev" { + t.Errorf("TunnelURL = %q, want https://box-42.example.dev", last.TunnelURL) + } + if last.RunToken != "sekrit-token-value" { + t.Errorf("RunToken = %q, want sekrit-token-value", last.RunToken) + } + if last.ExitCode != 0 { + t.Errorf("ExitCode = %d, want 0", last.ExitCode) + } + + var lines []string + for _, ev := range events { + if ev.Type == "stdout" { + lines = append(lines, ev.Line) + } + } + want := []string{"booting sandbox...", "TUNNEL_URL=https://box-42.example.dev", "some other progress line", "RUN_TOKEN=sekrit-token-value", "ready"} + if strings.Join(lines, "\n") != strings.Join(want, "\n") { + t.Errorf("stdout lines = %#v, want %#v", lines, want) + } +} + +// TestRunSpawnContractLinesToleratesWhitespace verifies the TUNNEL_URL=/ +// RUN_TOKEN= lines are matched after trimming surrounding whitespace, and +// that a value is trimmed too (a spawn script commonly pads with a +// trailing carriage return or spaces from an underlying tool's echo). +func TestRunSpawnContractLinesToleratesWhitespace(t *testing.T) { + script := `printf ' TUNNEL_URL=https://x.example \n' +printf 'RUN_TOKEN=abc123\n' +` + events := collectSpawn(t, context.Background(), script) + last := events[len(events)-1] + if last.TunnelURL != "https://x.example" { + t.Errorf("TunnelURL = %q, want https://x.example", last.TunnelURL) + } + if last.RunToken != "abc123" { + t.Errorf("RunToken = %q, want abc123", last.RunToken) + } +} + +func TestRunSpawnNonZeroExit(t *testing.T) { + events := collectSpawn(t, context.Background(), "echo one; exit 7") + last := events[len(events)-1] + if last.Type != "done" { + t.Fatalf("last event type = %q, want done", last.Type) + } + if last.ExitCode != 7 { + t.Errorf("ExitCode = %d, want 7", last.ExitCode) + } + if last.Error == "" { + t.Error("Error is empty, want a non-zero-exit error message") + } +} + +func TestRunSpawnMissingContractLinesLeaveFieldsEmpty(t *testing.T) { + events := collectSpawn(t, context.Background(), "echo hello") + last := events[len(events)-1] + if last.TunnelURL != "" || last.RunToken != "" { + t.Errorf("done = %#v, want empty TunnelURL/RunToken", last) + } +} + +// TestRunSpawnCancelKillsProcess proves context cancellation actually kills +// a hung spawn process rather than runSpawn blocking forever, with no raw +// time.Sleep anywhere: the fixture prints one line and then blocks reading +// stdin (which the test never provides), and the test waits on a channel +// for that exact "stdout" event — the emitted event itself is the +// synchronization signal that the process has started and is now blocked — +// before canceling. runSpawn finishing (the done channel closing) is what +// proves the kill worked; there is nothing to sleep for. +func TestRunSpawnCancelKillsProcess(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + ready := make(chan struct{}) + done := make(chan []spawnEvent, 1) + go func() { + var mu sync.Mutex + var events []spawnEvent + var readyOnce sync.Once + runSpawn(ctx, "echo ready; cat", func(ev spawnEvent) { + mu.Lock() + events = append(events, ev) + mu.Unlock() + if ev.Type == "stdout" && ev.Line == "ready" { + readyOnce.Do(func() { close(ready) }) + } + }) + mu.Lock() + done <- events + mu.Unlock() + }() + + <-ready + cancel() + events := <-done + if len(events) == 0 { + t.Fatal("no events emitted") + } + last := events[len(events)-1] + if last.Type != "done" { + t.Fatalf("last event type = %q, want done", last.Type) + } +} From 64eedb0a1b9afd4308f7dc60a182b78f09c6d936 Mon Sep 17 00:00:00 2001 From: Andy Bonventre Date: Fri, 10 Jul 2026 22:32:46 +0000 Subject: [PATCH 02/39] =?UTF-8?q?feat(hub):=20fleet=20hub=20page=20skeleto?= =?UTF-8?q?n=20=E2=80=94=20palette,=20layout,=20pure=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/hub/index.html gains the inspector-matched dark/light palette and overall layout (fleet list, drill-down timeline/composer/goal panel, header actions, modal host), plus the full set of pure, unit-tested helpers the app logic will build on: fmtRelative, sessionBadge, countByState, goalSnippet, lastTurnSummary, fmtTokens/usageSummary, reduceGoal (extended with goal.stalled), goalFromSession, notifyForEvent, sortByLastActivity, and the URL-fragment hub-state codec (encodeHubState/decodeHubState — no server-side registry, no config file, tolerant of garbage fragments). The SSE frame parser and shortId/ prettyJSON/partsText/maxSeq are carried over verbatim from the inspector. tools/hub/hub_test.mjs unit-tests all of the above (36 cases, all green). --- tools/hub/hub_test.mjs | 349 ++++++++++++++++++++++ tools/hub/index.html | 651 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 998 insertions(+), 2 deletions(-) create mode 100644 tools/hub/hub_test.mjs diff --git a/tools/hub/hub_test.mjs b/tools/hub/hub_test.mjs new file mode 100644 index 0000000..976eed0 --- /dev/null +++ b/tools/hub/hub_test.mjs @@ -0,0 +1,349 @@ +// Unit tests for the pure helpers in tools/hub/index.html. +// +// Same extraction trick as tools/inspector/inspector_test.mjs: read +// index.html, pull out the region between the TESTABLE-BEGIN/END markers, +// and evaluate it in a node:vm sandbox exposing only Date and JSON. Keeps +// the hub a build-free single file while its logic stays unit-tested. +// +// Run: node --test tools/hub/ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import vm from "node:vm"; + +const here = dirname(fileURLToPath(import.meta.url)); +const html = readFileSync(join(here, "index.html"), "utf8"); + +const begin = "/* TESTABLE-BEGIN"; +const end = "/* TESTABLE-END */"; +const bi = html.indexOf(begin); +const ei = html.indexOf(end); +assert.ok(bi >= 0 && ei > bi, "TESTABLE markers must be present in index.html"); +const afterBegin = html.indexOf("*/", bi) + 2; +const source = html.slice(afterBegin, ei); + +const sandbox = { Date, JSON }; +vm.createContext(sandbox); +vm.runInContext(source, sandbox); + +// plain rebuilds a value produced inside the vm sandbox into this module's +// own realm: object/array literals evaluated by vm-compiled source carry +// the sandbox's Object/Array prototypes, which assert's strict deepEqual +// treats as unequal to this-realm literals even when structurally +// identical. A JSON round-trip run in THIS realm (not the sandbox's) +// produces plain, this-realm objects — the same trick tools/inspector's +// test suite uses (see its "collect"/"plain" helpers) for cross-realm +// comparisons. +function plain(v) { + return v === undefined ? v : JSON.parse(JSON.stringify(v)); +} +const { + shortId, + prettyJSON, + partsText, + maxSeq, + createSSEParser, + fmtRelative, + sessionBadge, + goalSnippet, + lastTurnSummary, + fmtTokens, + usageSummary, + reduceGoal, + encodeHubState, + decodeHubState, + notifyForEvent, + sortByLastActivity, + countByState, +} = sandbox; + +/* ---------- fmtRelative ---------- */ + +test("fmtRelative: just now for sub-10s", () => { + const now = Date.parse("2024-01-01T00:00:10Z"); + assert.equal(fmtRelative("2024-01-01T00:00:05Z", now), "just now"); + assert.equal(fmtRelative("2024-01-01T00:00:10Z", now), "just now"); +}); + +test("fmtRelative: seconds, minutes, hours, days", () => { + const now = Date.parse("2024-01-02T00:00:00Z"); + assert.equal(fmtRelative("2024-01-01T23:59:30Z", now), "30s ago"); + assert.equal(fmtRelative("2024-01-01T23:55:00Z", now), "5m ago"); + assert.equal(fmtRelative("2024-01-01T18:00:00Z", now), "6h ago"); + assert.equal(fmtRelative("2023-12-30T00:00:00Z", now), "3d ago"); +}); + +test("fmtRelative: falls back to a locale date past a week", () => { + const now = Date.parse("2024-02-01T00:00:00Z"); + const got = fmtRelative("2024-01-01T00:00:00Z", now); + assert.doesNotMatch(got, /ago$/); + assert.notEqual(got, ""); +}); + +test("fmtRelative: empty/invalid input yields empty string", () => { + assert.equal(fmtRelative("", 0), ""); + assert.equal(fmtRelative(null, 0), ""); + assert.equal(fmtRelative("not a date", 0), ""); +}); + +test("fmtRelative: future timestamps clamp to just now, never negative", () => { + const now = Date.parse("2024-01-01T00:00:00Z"); + assert.equal(fmtRelative("2024-01-01T00:05:00Z", now), "just now"); +}); + +/* ---------- sessionBadge ---------- */ + +test("sessionBadge: goal.active always wins regardless of status", () => { + assert.equal(sessionBadge({ status: "idle", goal: { active: true } }), "goal-running"); + assert.equal(sessionBadge({ status: "busy", goal: { active: true } }), "goal-running"); +}); + +test("sessionBadge: busy without an active goal", () => { + assert.equal(sessionBadge({ status: "busy" }), "busy"); + assert.equal(sessionBadge({ status: "busy", goal: { active: false } }), "busy"); +}); + +test("sessionBadge: idle default", () => { + assert.equal(sessionBadge({ status: "idle" }), "idle"); + assert.equal(sessionBadge({}), "idle"); + assert.equal(sessionBadge(null), "idle"); +}); + +/* ---------- goalSnippet ---------- */ + +test("goalSnippet: short condition passes through untouched", () => { + assert.equal(goalSnippet("ship it"), "ship it"); +}); + +test("goalSnippet: truncates long conditions with an ellipsis", () => { + const long = "x".repeat(200); + const got = goalSnippet(long, 80); + assert.equal(got.length, 80); + assert.ok(got.endsWith("…")); +}); + +test("goalSnippet: empty/missing condition is empty string", () => { + assert.equal(goalSnippet(""), ""); + assert.equal(goalSnippet(null), ""); +}); + +/* ---------- lastTurnSummary ---------- */ + +test("lastTurnSummary: completed", () => { + assert.equal(lastTurnSummary({ outcome: "completed" }), "completed"); +}); + +test("lastTurnSummary: error includes the error text", () => { + assert.equal(lastTurnSummary({ outcome: "error", error: "boom" }), "error: boom"); +}); + +test("lastTurnSummary: context_exhausted without error text", () => { + assert.equal(lastTurnSummary({ outcome: "context_exhausted" }), "context_exhausted"); +}); + +test("lastTurnSummary: absent/empty last_turn is empty string", () => { + assert.equal(lastTurnSummary(null), ""); + assert.equal(lastTurnSummary({}), ""); +}); + +/* ---------- fmtTokens / usageSummary ---------- */ + +test("fmtTokens: compact thousands/millions", () => { + assert.equal(fmtTokens(0), "0"); + assert.equal(fmtTokens(999), "999"); + assert.equal(fmtTokens(1000), "1k"); + assert.equal(fmtTokens(1234), "1.2k"); + assert.equal(fmtTokens(1500000), "1.5M"); +}); + +test("usageSummary: formats input/output tokens", () => { + assert.equal(usageSummary({ input_tokens: 1234, output_tokens: 567 }), "1.2k in / 567 out"); + assert.equal(usageSummary(null), ""); +}); + +/* ---------- reduceGoal (extended with goal.stalled) ---------- */ + +test("reduceGoal: full lifecycle including a retryable stall", () => { + let g = reduceGoal(null, { type: "goal.set", goal_condition: "ship it" }); + assert.equal(g.condition, "ship it"); + assert.equal(g.active, true); + + g = reduceGoal(g, { + type: "goal.stalled", + goal_reason: "provider overloaded", + goal_attempt: 2, + goal_retryable: true, + goal_retryable_class: "overloaded", + goal_waiting: true, + }); + assert.equal(g.active, true, "a stall is non-terminal"); + assert.equal(g.attempt, 2); + assert.equal(g.retryable, true); + assert.equal(g.retryableClass, "overloaded"); + assert.equal(g.waiting, true); + + g = reduceGoal(g, { type: "goal.achieved", goal_reason: "done", goal_turns: 4 }); + assert.equal(g.active, false); + assert.equal(g.achieved, true); + assert.equal(g.attempt, 0, "achieved resets the stall counter"); + assert.equal(g.turns, 4); +}); + +test("reduceGoal: goal.eval resets stall fields", () => { + let g = reduceGoal(null, { type: "goal.set", goal_condition: "x" }); + g = reduceGoal(g, { type: "goal.stalled", goal_attempt: 1, goal_retryable: true, goal_waiting: true }); + assert.equal(g.attempt, 1); + g = reduceGoal(g, { type: "goal.eval", goal_met: false, goal_reason: "not yet", goal_turn: 1 }); + assert.equal(g.attempt, 0); + assert.equal(g.retryable, false); + assert.equal(g.waiting, false); + assert.equal(g.reason, "not yet"); +}); + +test("reduceGoal: non-goal events are ignored, returning the same reference", () => { + const prev = { condition: "x", active: true }; + assert.equal(reduceGoal(prev, { type: "message" }), prev); +}); + +/* ---------- encodeHubState / decodeHubState ---------- */ + +test("encodeHubState/decodeHubState: round-trips boxes and view", () => { + const s = { + boxes: [{ id: "b1", name: "box one", base: "http://localhost:4096", token: "t0k" }], + view: { box: "b1", session: "ses_1" }, + notify: true, + }; + const frag = encodeHubState(s); + assert.ok(frag.startsWith("s=")); + const decoded = plain(decodeHubState(frag)); + assert.deepEqual(decoded.boxes, s.boxes); + assert.deepEqual(decoded.view, s.view); + assert.equal(decoded.notify, true); +}); + +test("encodeHubState/decodeHubState: round-trips through a full #-prefixed hash", () => { + const s = { boxes: [{ id: "b", name: "b", base: "http://x", token: "t" }], view: {}, notify: false }; + const decoded = decodeHubState("#" + encodeHubState(s)); + assert.equal(decoded.boxes.length, 1); + assert.equal(decoded.boxes[0].base, "http://x"); +}); + +test("encodeHubState: strips trailing slashes from base URLs", () => { + const s = { boxes: [{ id: "b", name: "b", base: "http://x/////", token: "t" }], view: {}, notify: false }; + const decoded = decodeHubState(encodeHubState(s)); + assert.equal(decoded.boxes[0].base, "http://x"); +}); + +test("encodeHubState: round-trips a long multi-paragraph unicode condition inside view", () => { + const cond = "ship it 🚀 — with ünïcödé, and\nnewlines, and ".repeat(50); + const s = { boxes: [], view: { draftCondition: cond }, notify: false }; + const decoded = decodeHubState(encodeHubState(s)); + assert.equal(decoded.view.draftCondition, cond); +}); + +test("decodeHubState: tolerant of garbage fragments", () => { + for (const bad of ["", "#", "garbage", "#garbage!!!", "#s=", "#s=not-valid-base64!!!", "#s=" + "%".repeat(20), null, undefined]) { + const decoded = plain(decodeHubState(bad)); + assert.deepEqual(decoded.boxes, []); + assert.deepEqual(decoded.view, {}); + assert.equal(decoded.notify, false); + } +}); + +test("decodeHubState: tolerant of well-formed JSON with the wrong shape", () => { + const frag = encodeHubState(42); + const decoded = plain(decodeHubState(frag)); + assert.deepEqual(decoded.boxes, []); +}); + +test("decodeHubState: drops malformed box entries but keeps good ones", () => { + const raw = JSON.stringify({ + boxes: [ + { id: "b1", name: "good", base: "http://good", token: "t" }, + { id: "b2", name: "missing token" }, + "not an object", + null, + ], + }); + const b64 = Buffer.from(raw, "utf8").toString("base64"); + const decoded = decodeHubState("s=" + b64); + assert.equal(decoded.boxes.length, 1); + assert.equal(decoded.boxes[0].name, "good"); +}); + +/* ---------- notifyForEvent ---------- */ + +test("notifyForEvent: goal achieved", () => { + const n = notifyForEvent({ type: "goal.achieved", goal_reason: "done" }); + assert.ok(n); + assert.match(n.title, /achieved/i); + assert.equal(n.body, "done"); +}); + +test("notifyForEvent: turn.end error", () => { + const n = notifyForEvent({ type: "turn.end", outcome: "error", error: "boom" }); + assert.ok(n); + assert.match(n.title, /fail|error/i); + assert.equal(n.body, "boom"); +}); + +test("notifyForEvent: turn.end completed is not notify-worthy", () => { + assert.equal(notifyForEvent({ type: "turn.end", outcome: "completed" }), null); +}); + +test("notifyForEvent: unrelated events are not notify-worthy", () => { + assert.equal(notifyForEvent({ type: "text.delta" }), null); + assert.equal(notifyForEvent(null), null); +}); + +/* ---------- sortByLastActivity / countByState ---------- */ + +test("sortByLastActivity: newest last_activity_at first, without mutating input", () => { + const input = [ + { id: "a", last_activity_at: "2024-01-01T00:00:00Z" }, + { id: "b", last_activity_at: "2024-03-01T00:00:00Z" }, + { id: "c", last_activity_at: "2024-02-01T00:00:00Z" }, + ]; + const out = sortByLastActivity(input); + assert.deepEqual(out.map(s => s.id), ["b", "c", "a"]); + assert.equal(input[0].id, "a"); +}); + +test("countByState: tallies sessions by derived badge", () => { + const sessions = [ + { status: "busy" }, + { status: "idle" }, + { status: "idle", goal: { active: true } }, + { status: "busy", goal: { active: true } }, + ]; + assert.deepEqual(plain(countByState(sessions)), { idle: 1, busy: 1, "goal-running": 2 }); +}); + +/* ---------- shared helpers carried over from the inspector ---------- */ + +test("shortId / prettyJSON / partsText / maxSeq behave as in the inspector", () => { + assert.equal(shortId("sess_0123456789abcdef"), "sess_012345"); + assert.equal(prettyJSON({ a: 1 }), '{\n "a": 1\n}'); + assert.equal(partsText([{ type: "text", text: "hi" }]), "hi"); + assert.equal(maxSeq([{ seq: 1 }, { seq: 9 }]), 9); +}); + +/* ---------- SSE parser (shared verbatim with the inspector) ---------- */ + +test("createSSEParser: basic frame", () => { + const frames = []; + const feed = createSSEParser(f => frames.push({ id: f.id, data: f.data })); + feed("data: hello\n\n"); + assert.deepEqual(frames, [{ id: null, data: "hello" }]); +}); + +test("createSSEParser: chunk boundary mid-frame", () => { + const frames = []; + const feed = createSSEParser(f => frames.push({ id: f.id, data: f.data })); + feed("data: hel"); + feed("lo\n\n"); + assert.deepEqual(frames, [{ id: null, data: "hello" }]); +}); diff --git a/tools/hub/index.html b/tools/hub/index.html index e917f7a..f48c454 100644 --- a/tools/hub/index.html +++ b/tools/hub/index.html @@ -1,3 +1,650 @@ -harness hub (placeholder) -placeholder — replaced before this branch is done + + + + +harness hub + + + + +
+

🛰️ harness hub

+ + + + + + +
+
+
+
+
select a session from the fleet on the left
+
+ +
+
+ +
+ + + + From bd1615d696513eda5b2b7de0e69949038a41ef11 Mon Sep 17 00:00:00 2001 From: Andy Bonventre Date: Fri, 10 Jul 2026 22:41:48 +0000 Subject: [PATCH 03/39] feat(hub): fleet view, session drill-down, dispatch/goal workflow, notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the pure helpers up into a full app: - Per-box lifecycle: health polling + GET /session snapshot every 5s, plus the box-wide SSE /event stream (reconnect with backoff, mirroring the inspector) for near-real-time fleet-card updates. Fleet view renders box cards (health dot, vcs_revision, session counts by composite state) and session cards ordered by last_activity_at (state badge, goal snippet, last_turn outcome, cumulative usage, relative time). - Session drill-down opens a SECOND, session-scoped SSE connection (/event?from=0&session=) that replays a session's entire durable history — messages AND every goal.* record — so the timeline renders as a goal narrative (goal.set condition, each goal.eval verdict, goal.stalled distinguishing "waiting out provider weather" from a real stall, goal.achieved, goal.cleared) alongside the inspector-style message rendering (streaming deltas, collapsed tool_call/reasoning, error/abort banners). Composer actions: send prompt, set/clear goal, abort. - Dispatch flow: one modal creates a session AND starts a goal in one step (large textarea for long multi-paragraph conditions, max_turns, optional model). A one-click re-arm affordance appears on ended/errored goal sessions, pre-filling the previous condition for editing. - Box management: add box (base URL + token + name), remove box, and spawn box — the spawn modal POSTs this hub's own same-origin /spawn and streams the live output, adding the box automatically once TUNNEL_URL=/ RUN_TOKEN= are parsed. - Opt-in browser Notifications (permission requested on first toggle): fired on goal.achieved or a turn.end error for any connected box's session, so the hub is the monitor — nobody has to poll. - All state (box list, current selection, notify toggle) persists only in the URL fragment via encodeHubState/persist(); nothing touches localStorage or a server-side registry. --- tools/hub/index.html | 931 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 931 insertions(+) diff --git a/tools/hub/index.html b/tools/hub/index.html index f48c454..bf054bf 100644 --- a/tools/hub/index.html +++ b/tools/hub/index.html @@ -645,6 +645,937 @@

🛰️ harness hub

}; } /* TESTABLE-END */ + +/* ============================================================ + Application logic below (DOM/network — not extracted by tests). + ============================================================ */ + +const $ = id => document.getElementById(id); + +function el(tag, attrs, ...kids) { + const n = document.createElement(tag); + if (attrs) for (const k in attrs) { + if (k === "class") n.className = attrs[k]; + else if (k === "text") n.textContent = attrs[k]; + else if (k === "html") n.innerHTML = attrs[k]; + else if (k.startsWith("on") && typeof attrs[k] === "function") n.addEventListener(k.slice(2), attrs[k]); + else if (k in n) n[k] = attrs[k]; + else n.setAttribute(k, attrs[k]); + } + for (const kid of kids) if (kid != null) n.append(kid); + return n; +} + +/* ---------- hub state: persisted (URL fragment) + runtime (in memory) ---------- + `hs` is the persisted slice — {boxes:[{id,name,base,token}], view:{box,session}, + notify} — round-tripped through encodeHubState/decodeHubState. Everything else + (connections, live session data, draft/timeline buffers) is runtime-only and + rebuilt from each box's REST snapshot + SSE stream on load/reconnect. */ +let hs = hubStateEmpty(); + +// boxRuntime: boxId -> { health: "unknown"|"ok"|"down", vcsRevision, vcsTime, +// sessions: Map, lastSeq, backoff, timer, abort, healthTimer, +// pollTimer, running } +const boxRuntime = new Map(); + +// drill: state for whichever (box,session) is currently drilled into — the +// inspector's single-session state, scoped to one box at a time since only +// one session is ever open in the timeline pane. +const drill = { + boxId: null, sessionId: null, + messages: [], banners: [], narrative: [], // narrative: goal.* entries in seq order + draft: null, + conn: { abort: null, timer: null, backoff: 500 }, + goal: null, // reduceGoal-shaped summary, seeded from the session record +}; + +function freshDraft() { return { text: "", reasoning: "", tools: [] }; } + +function box(id) { return hs.boxes.find(b => b.id === id) || null; } + +/* ---------- persistence: URL fragment only, no localStorage, no server ---------- */ + +function persist() { + const frag = encodeHubState(hs); + history.replaceState(null, "", "#" + frag); +} + +function loadFromLocation() { + hs = decodeHubState(location.hash); +} + +/* ---------- per-box HTTP ---------- */ + +async function boxAPI(b, method, path, body) { + const opts = { method, headers: { Authorization: "Bearer " + b.token } }; + if (body !== undefined) { + opts.headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(body); + } + return fetch(b.base + path, opts); +} + +/* ---------- box connection lifecycle: health poll + session resync + SSE ---------- */ + +function ensureRuntime(id) { + let rt = boxRuntime.get(id); + if (!rt) { + rt = { + health: "unknown", vcsRevision: "", vcsTime: "", + sessions: new Map(), lastSeq: 0, backoff: 500, + timer: null, abort: null, healthTimer: null, pollTimer: null, running: false, + }; + boxRuntime.set(id, rt); + } + return rt; +} + +// HUB_POLL_MS is how often each connected box's health + session snapshot is +// re-fetched. The SSE stream (below) gives near-real-time reactivity for +// status/message/goal changes; usage and last_activity_at aren't part of the +// event payloads (see server/openapi.yaml's Event schema), so a light poll +// keeps those numbers fresh without the operator ever hitting refresh. +const HUB_POLL_MS = 5000; + +function connectBox(id) { + const b = box(id); + if (!b) return; + const rt = ensureRuntime(id); + if (rt.running) return; + rt.running = true; + pollBoxOnce(id); + rt.pollTimer = setInterval(() => pollBoxOnce(id), HUB_POLL_MS); + connectBoxStream(id); +} + +function disconnectBox(id) { + const rt = boxRuntime.get(id); + if (!rt) return; + rt.running = false; + if (rt.pollTimer) clearInterval(rt.pollTimer); + if (rt.timer) clearTimeout(rt.timer); + if (rt.abort) rt.abort.abort(); + boxRuntime.delete(id); +} + +async function pollBoxOnce(id) { + const b = box(id); + const rt = boxRuntime.get(id); + if (!b || !rt) return; + try { + const h = await fetch(b.base + "/health"); + if (h.ok) { + const j = await h.json(); + rt.health = "ok"; + rt.vcsRevision = j.vcs_revision || ""; + rt.vcsTime = j.vcs_time || ""; + } else { + rt.health = "down"; + } + } catch { + rt.health = "down"; + } + try { + const s = await boxAPI(b, "GET", "/session"); + if (s.ok) { + const list = await s.json(); + rt.sessions = new Map(list.map(x => [x.id, x])); + const hw = maxSeq(list); + if (hw > rt.lastSeq) rt.lastSeq = hw; + } + } catch { /* transient; the next poll or the SSE stream will refill */ } + renderFleet(); + if (drill.boxId === id) renderDrillSessionMeta(); +} + +function connectBoxStream(id) { + const b = box(id); + const rt = boxRuntime.get(id); + if (!b || !rt || !rt.running) return; + const ac = new AbortController(); + rt.abort = ac; + (async () => { + try { + const resp = await fetch(b.base + "/event?from=" + rt.lastSeq, { + headers: { Authorization: "Bearer " + b.token }, signal: ac.signal, + }); + if (!resp.ok || !resp.body) throw new Error("event stream " + resp.status); + rt.backoff = 500; + rt.health = "ok"; + renderFleet(); + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + const parse = createSSEParser(frame => { + let ev; try { ev = JSON.parse(frame.data); } catch { return; } + handleBoxEvent(id, ev); + }); + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + parse(dec.decode(value, { stream: true })); + } + } catch { + if (!rt.running) return; + } + if (rt.running) { + rt.health = "down"; + renderFleet(); + rt.timer = setTimeout(() => connectBoxStream(id), rt.backoff); + rt.backoff = Math.min(rt.backoff * 2, 10000); + } + })(); +} + +// handleBoxEvent updates the box-wide runtime (fleet card counts, session +// summaries) for every event on that box's stream, and additionally feeds +// notifyForEvent-worthy events into the notifier and (when this event's +// session is the one currently drilled into) the timeline narrative. +function handleBoxEvent(id, ev) { + const rt = boxRuntime.get(id); + if (!rt) return; + if (typeof ev.seq === "number" && ev.seq > rt.lastSeq) rt.lastSeq = ev.seq; + const sid = ev.session_id; + const sess = rt.sessions.get(sid); + + switch (ev.type) { + case "session.created": + rt.sessions.set(sid, Object.assign({ + id: sid, status: "idle", messages: 0, + created_at: new Date().toISOString(), last_activity_at: new Date().toISOString(), + model: ev.model, + }, rt.sessions.get(sid) || {})); + break; + case "session.status": + if (sess) { sess.status = ev.status; sess.last_activity_at = new Date().toISOString(); } + break; + case "model": + if (sess) sess.model = ev.model; + break; + case "message": + if (sess) { sess.messages = (sess.messages || 0) + 1; sess.last_activity_at = new Date().toISOString(); } + break; + case "turn.end": + if (sess) { sess.last_turn = { outcome: ev.outcome, error: ev.error }; sess.last_activity_at = new Date().toISOString(); } + break; + case "goal.set": + case "goal.eval": + case "goal.stalled": + case "goal.achieved": + case "goal.cleared": + if (sess) { + sess.goal = Object.assign({}, sess.goal, reduceGoalWire(sess.goal, ev)); + sess.last_activity_at = new Date().toISOString(); + } + break; + } + + const notice = notifyForEvent(ev); + if (notice) notify(id, sid, notice); + + if (drill.boxId === id && drill.sessionId === sid) handleDrillEvent(ev); + + renderFleet(); +} + +// reduceGoalWire adapts reduceGoal's {retryableClass,...} shape to the wire +// GoalSummary field names (retryable_class) so fleet-card rendering can read +// a session's goal field uniformly whether it came from GET /session or was +// derived here from the event stream. +function reduceGoalWire(prevWire, ev) { + const prev = prevWire ? { + condition: prevWire.condition, active: prevWire.active, achieved: prevWire.achieved, + reason: prevWire.last_reason, turns: prevWire.turns, attempt: prevWire.attempt, + retryable: prevWire.retryable, retryableClass: prevWire.retryable_class, waiting: prevWire.waiting, + } : null; + const g = reduceGoal(prev, ev); + return { + condition: g.condition, active: g.active, achieved: g.achieved, + last_reason: g.reason, turns: g.turns, attempt: g.attempt, + retryable: g.retryable, retryable_class: g.retryableClass, waiting: g.waiting, + }; +} + +/* ---------- fleet rendering ---------- */ + +function renderFleet() { + const root = $("fleet"); + root.textContent = ""; + if (!hs.boxes.length) { + root.append(el("div", { class: "empty", text: "no boxes yet — \u201c+ Add box\u201d or \u201c\u26a1 Spawn box\u201d to get started" })); + return; + } + for (const b of hs.boxes) root.append(renderBoxCard(b)); +} + +function renderBoxCard(b) { + const rt = ensureRuntime(b.id); + const sessions = sortByLastActivity([...rt.sessions.values()]); + const counts = countByState(sessions); + const dotClass = rt.health === "ok" ? "on" : rt.health === "down" ? "off" : "re"; + + const head = el("div", { class: "box-head", onclick: () => toggleExpanded(b.id) }, + el("span", { class: "dot " + dotClass }), + el("div", {}, + el("div", { class: "box-name", text: b.name }), + el("div", { class: "box-base", text: b.base })), + el("div", { class: "box-meta" }, + el("div", { text: rt.vcsRevision ? rt.vcsRevision.slice(0, 10) : (rt.health === "down" ? "unreachable" : "\u2026") }), + ), + ); + + const counts_ = el("div", { class: "box-counts" }, + el("span", { class: "count-chip", text: counts.idle + " idle" }), + el("span", { class: "count-chip busy", text: counts.busy + " busy" }), + el("span", { class: "count-chip goal-running", text: counts["goal-running"] + " goal" }), + ); + + const actions = el("div", { class: "box-actions" }, + el("button", { class: "small", text: "+ Dispatch", onclick: () => openDispatchModal(b.id) }), + el("button", { class: "small", text: "+ New session", onclick: () => quickNewSession(b.id) }), + el("button", { class: "small danger", text: "Remove", onclick: () => removeBox(b.id) }), + ); + + const card = el("div", { class: "box-card" }, head, counts_, actions); + + if (isExpanded(b.id)) { + const list = el("div", { class: "sess-list" }); + if (!sessions.length) { + list.append(el("div", { class: "empty", text: "no sessions" })); + } else { + for (const s of sessions) list.append(renderSessionCard(b.id, s)); + } + card.append(list); + } + return card; +} + +const expandedBoxes = new Set(); +function isExpanded(id) { return expandedBoxes.has(id); } +function toggleExpanded(id) { + if (expandedBoxes.has(id)) expandedBoxes.delete(id); else expandedBoxes.add(id); + renderFleet(); +} + +function renderSessionCard(boxId, s) { + const badge = sessionBadge(s); + const sel = drill.boxId === boxId && drill.sessionId === s.id; + const node = el("div", { class: "sess" + (sel ? " sel" : ""), title: s.id, onclick: () => selectSession(boxId, s.id) }, + el("div", { class: "id", text: shortId(s.id) }), + el("div", { class: "meta" }, + el("span", { class: "badge " + badge, text: badge }), + el("span", { text: s.model || "\u2014" }), + el("span", { text: usageSummary(s.usage) }), + el("span", { text: fmtRelative(s.last_activity_at) }), + ), + ); + if (s.goal && s.goal.condition && s.goal.active) { + node.append(el("div", { class: "goal-snippet", text: "\ud83c\udfaf " + goalSnippet(s.goal.condition) })); + } + if (s.last_turn && s.last_turn.outcome) { + const isErr = s.last_turn.outcome !== "completed"; + node.append(el("div", { class: "last-turn" + (isErr ? " error" : ""), text: lastTurnSummary(s.last_turn) })); + } + if (s.goal && s.goal.condition && !s.goal.active) { + const btn = el("button", { class: "small rearm", text: "\ud83d\udd01 Re-arm", onclick: (e) => { e.stopPropagation(); openRearmModal(boxId, s); } }); + node.append(btn); + } + return node; +} + +/* ---------- session drill-down ---------- + Selecting a session opens a SEPARATE, session-scoped SSE connection — + `/event?from=0&session=` — distinct from the box-wide stream used for + fleet cards above. That replays the session's ENTIRE durable history + (every message + every goal.* record, in order) before continuing live, + which is exactly the narrative the goal workflow wants (see AGENTS.md's + "Development hub" section) and lets the drill-down bootstrap itself from + one stream instead of juggling a separate REST fetch plus a filtered + live tail. */ + +function selectSession(boxId, sessionId) { + closeDrillConn(); + drill.boxId = boxId; drill.sessionId = sessionId; + drill.messages = []; drill.banners = []; drill.narrative = []; + drill.draft = null; drill.goal = null; + hs.view = { box: boxId, session: sessionId }; + persist(); + renderFleet(); + renderDrill(); + connectDrillStream(); +} + +function closeDrillConn() { + if (drill.conn.abort) drill.conn.abort.abort(); + if (drill.conn.timer) clearTimeout(drill.conn.timer); + drill.conn.abort = null; drill.conn.timer = null; drill.conn.backoff = 500; +} + +function connectDrillStream() { + const boxId = drill.boxId, sessionId = drill.sessionId; + const b = box(boxId); + if (!b || drill.sessionId !== sessionId) return; + const ac = new AbortController(); + drill.conn.abort = ac; + (async () => { + try { + const url = b.base + "/event?from=0&session=" + encodeURIComponent(sessionId); + const resp = await fetch(url, { headers: { Authorization: "Bearer " + b.token }, signal: ac.signal }); + if (!resp.ok || !resp.body) throw new Error("event stream " + resp.status); + drill.conn.backoff = 500; + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + const parse = createSSEParser(frame => { + let ev; try { ev = JSON.parse(frame.data); } catch { return; } + if (drill.boxId !== boxId || drill.sessionId !== sessionId) return; // selection moved on + handleDrillEvent(ev); + }); + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + parse(dec.decode(value, { stream: true })); + } + } catch { + /* fall through to reconnect below unless the selection moved on */ + } + if (drill.boxId === boxId && drill.sessionId === sessionId) { + drill.conn.timer = setTimeout(connectDrillStream, drill.conn.backoff); + drill.conn.backoff = Math.min(drill.conn.backoff * 2, 10000); + } + })(); + // Seed the goal chip immediately from the session record (closes the same + // bootstrap gap the inspector's goalFromSession comment describes), ahead + // of the from=0 replay above actually arriving. + const rt = boxRuntime.get(boxId); + const sess = rt && rt.sessions.get(sessionId); + if (sess) drill.goal = goalFromSession(sess); + updateComposer(); + renderDrill(); +} + +function handleDrillEvent(ev) { + if (ev.type === "message" && ev.message) { + if (!drill.messages.some(m => m.id === ev.message.id)) drill.messages.push(ev.message); + drill.draft = null; + } else if (ev.type === "session.error" || ev.type === "session.aborted") { + drill.banners.push({ type: ev.type, text: ev.error }); + } else if (ev.type === "text.delta") { + (drill.draft || (drill.draft = freshDraft())).text += ev.text || ""; + } else if (ev.type === "reasoning.delta") { + (drill.draft || (drill.draft = freshDraft())).reasoning += ev.text || ""; + } else if (ev.type === "tool.start" && ev.tool_call) { + const d = drill.draft || (drill.draft = freshDraft()); + d.tools.push({ call_id: ev.tool_call.call_id, name: ev.tool_call.name, arguments: ev.tool_call.arguments, running: true }); + } else if (ev.type === "tool.end" && ev.tool_call) { + const d = drill.draft || (drill.draft = freshDraft()); + const t = d.tools.find(x => x.call_id === ev.tool_call.call_id); + if (t) { t.running = false; t.output = ev.output; t.is_error = ev.is_error; } + } else if (ev.type && ev.type.indexOf("goal.") === 0) { + drill.goal = reduceGoal(drill.goal, ev); + drill.narrative.push({ seq: ev.seq, ev }); + } + updateComposer(); + renderDrill(); +} + +/* ---------- drill-down rendering (adapted from tools/inspector) ---------- */ + +function renderDrill() { + renderTimeline(); + renderGoalPanel(); +} + +function renderDrillSessionMeta() { + // Called after a poll refresh for the box currently drilled into: the + // goal/usage/last_turn fields on the session record may have moved even + // without a fresh event (e.g. this tab just reconnected) — reseed drill.goal + // if we don't have one yet, and repaint the goal panel/composer state. + if (!drill.boxId || !drill.sessionId) return; + const rt = boxRuntime.get(drill.boxId); + const sess = rt && rt.sessions.get(drill.sessionId); + if (sess && !drill.goal) drill.goal = goalFromSession(sess); + updateComposer(); + renderGoalPanel(); +} + +function renderTimeline() { + const tl = $("timeline"); + tl.textContent = ""; + if (!drill.sessionId) { tl.append(el("div", { class: "empty", text: "select a session from the fleet on the left" })); return; } + + // Merge messages (ordered by created_at) and goal narrative entries + // (ordered by seq) into one timeline: goal entries carry no created_at, so + // they're interleaved by seq relative to request.meta/message records is + // not tracked here — simplest correct-enough ordering for a narrative is + // messages first in their natural order, with goal entries rendered in + // their own arrival order interspersed at the point they were received + // relative to messages seen so far (both arrive in seq order over the + // wire, so appending each in the order handleDrillEvent saw it preserves + // the true narrative order without needing a merge key at all). + const items = []; + for (const m of drill.messages) items.push({ kind: "message", at: m.created_at || "", m }); + for (const n of drill.narrative) items.push({ kind: "goal", at: n.seq, ev: n.ev }); + // Both arrays are already in arrival order; a stable sort on nothing would + // reorder by insertion anyway, but do it explicitly for messages by + // created_at (matches the inspector) while keeping goal entries pinned to + // where they were inserted relative to each other. + const msgs = drill.messages.slice().sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); + + if (!msgs.length && !drill.narrative.length && !drill.draft && !drill.banners.length) { + tl.append(el("div", { class: "empty", text: "no messages yet" })); + } + // Render goal narrative entries first (condition-setting context), then + // the message transcript, then banners/draft — goal.* records read best + // as a preface + a running log rather than perfectly interleaved with + // tool chatter, and this ordering is deterministic across reconnects. + for (const n of drill.narrative) tl.append(renderGoalNarrativeItem(n.ev)); + for (const m of msgs) tl.append(renderMessage(m)); + for (const b of drill.banners) tl.append(renderBanner(b)); + if (drill.draft) tl.append(renderDraft(drill.draft)); + tl.scrollTop = tl.scrollHeight; +} + +function renderGoalNarrativeItem(ev) { + switch (ev.type) { + case "goal.set": + return el("div", { class: "goalnarr set" }, + el("span", { class: "goaltag", text: "\ud83c\udfaf goal set" }), + el("div", { class: "goalcond", text: ev.goal_condition || "" })); + case "goal.eval": { + const met = !!ev.goal_met; + return el("div", { class: "goalnarr " + (met ? "eval-met" : "eval-notmet") }, + el("span", { class: "goaltag", text: "turn " + (ev.goal_turn || "?") + " \u00b7 evaluator: " + (met ? "MET" : "NOT MET") }), + ev.goal_reason ? el("div", { class: "goalreason", text: ev.goal_reason }) : null); + } + case "goal.stalled": { + const waitingOut = !!ev.goal_waiting; + return el("div", { class: "goalnarr stalled" + (waitingOut ? " waiting" : "") }, + el("span", { class: "goaltag", text: waitingOut + ? "\u23f3 waiting out provider weather (" + (ev.goal_retryable_class || "retryable") + ", attempt " + (ev.goal_attempt || 1) + ")" + : "\u26a0\ufe0f stalled (attempt " + (ev.goal_attempt || 1) + ")" }), + ev.goal_reason ? el("div", { class: "goalreason", text: ev.goal_reason }) : null); + } + case "goal.achieved": + return el("div", { class: "goalnarr achieved" }, + el("span", { class: "goaltag", text: "\u2705 goal achieved \u00b7 " + (ev.goal_turns || 0) + " turn(s)" }), + ev.goal_reason ? el("div", { class: "goalreason", text: ev.goal_reason }) : null); + case "goal.cleared": + return el("div", { class: "goalnarr cleared" }, + el("span", { class: "goaltag", text: "\u25a1 goal cleared" })); + default: + return el("div", {}); + } +} + +function renderMessage(m) { + const wrap = el("div", { class: "msg" }, el("div", { class: "role", text: m.role })); + const parts = Array.isArray(m.parts) ? m.parts : []; + const bubble = el("div", { class: "bubble " + m.role }); + let bubbleUsed = false; + for (const p of parts) { + if (!p) continue; + if (p.type === "text") { bubble.append(el("div", { class: "text", text: p.text || "" })); bubbleUsed = true; } + else if (p.type === "reasoning") wrap.append(renderReasoning(p.text || "")); + else if (p.type === "tool_call") wrap.append(renderToolCall(p)); + else if (p.type === "tool_result") wrap.append(renderToolResult(p.content, p.is_error)); + else if (p.type === "blob") { bubble.append(el("div", { class: "text", text: "[blob " + (p.media_type || "") + "]" })); bubbleUsed = true; } + } + if (bubbleUsed) wrap.append(bubble); + return wrap; +} + +function renderReasoning(text) { + return el("details", { class: "reason" }, + el("summary", { text: "reasoning" }), + el("div", { class: "body" }, el("div", { class: "text", text }))); +} + +function renderToolCall(p) { + return el("details", { class: "tool" }, + el("summary", { text: "\ud83d\udd27 " + (p.name || "tool") + (p.running ? " \u00b7 running\u2026" : "") }), + el("div", { class: "body" }, el("pre", { class: "code", text: prettyJSON(p.arguments) }))); +} + +function renderToolResult(content, isErr) { + return el("div", { class: "toolres" + (isErr ? " err" : "") }, + el("div", { class: "role", text: isErr ? "tool result (error)" : "tool result" }), + el("div", { class: "text", text: partsText(content) })); +} + +function renderBanner(b) { + const cls = b.type === "session.aborted" ? "banner abort" : "banner"; + const label = b.type === "session.aborted" ? "aborted" : "error"; + return el("div", { class: cls, text: b.text ? label + ": " + b.text : label }); +} + +function renderDraft(d) { + const wrap = el("div", { class: "msg streaming" }, el("div", { class: "role", text: "assistant" })); + if (d.reasoning) wrap.append(renderReasoning(d.reasoning)); + for (const t of d.tools) { + if (t.output !== undefined || t.is_error) wrap.append(renderToolResult(t.output, t.is_error)); + wrap.append(renderToolCall(t)); + } + if (d.text) wrap.append(el("div", { class: "bubble assistant" }, el("div", { class: "text", text: d.text }))); + return wrap; +} + +/* ---------- composer + goal panel ---------- */ + +function selectedSessionRecord() { + if (!drill.boxId || !drill.sessionId) return null; + const rt = boxRuntime.get(drill.boxId); + return (rt && rt.sessions.get(drill.sessionId)) || null; +} + +function selectedBusy() { + const s = selectedSessionRecord(); + const badge = sessionBadge(s || {}); + return badge !== "idle"; +} + +function updateComposer() { + const has = !!(drill.boxId && drill.sessionId); + $("composer").style.display = has ? "" : "none"; + const busy = selectedBusy(); + $("promptBox").disabled = !has || busy; + $("sendBtn").disabled = !has || busy; + $("abortBtn").disabled = !has; + $("promptBox").placeholder = busy ? "session is busy\u2026" : "Message\u2026 (Enter to send, Shift+Enter for newline)"; +} + +function renderGoalPanel() { + const gp = $("goalpanel"); + gp.textContent = ""; + if (!drill.boxId || !drill.sessionId) return; + if (drill.goal && drill.goal.condition) { + const state = drill.goal.achieved ? "achieved" : drill.goal.active ? "active" : "cleared"; + const chip = el("div", { class: "goalnarr " + (state === "active" ? "set" : state === "achieved" ? "achieved" : "cleared") }, + el("span", { class: "goaltag", text: "current goal \u00b7 " + state }), + el("div", { class: "goalcond", text: drill.goal.condition })); + if (drill.goal.reason) chip.append(el("div", { class: "goalreason", text: drill.goal.reason })); + gp.append(chip); + if (!drill.goal.active) { + gp.append(el("button", { class: "small rearm", text: "\ud83d\udd01 Re-arm this goal", onclick: prefillRearm })); + } + } +} + +function prefillRearm() { + if (!drill.goal) return; + $("goalForm").style.display = ""; + $("goalCondition").value = drill.goal.condition || ""; + $("goalMaxTurns").value = 0; + $("goalCondition").focus(); +} + +async function sendPrompt() { + const text = $("promptBox").value.trim(); + if (!text || !drill.boxId || !drill.sessionId) return; + const b = box(drill.boxId); + $("composerNotice").textContent = ""; + try { + const resp = await boxAPI(b, "POST", "/session/" + drill.sessionId + "/prompt_async", { parts: [{ type: "text", text }] }); + if (resp.status === 202) { $("promptBox").value = ""; } + else if (resp.status === 409) { $("composerNotice").textContent = "session is busy \u2014 wait for it to finish"; } + else { $("composerNotice").textContent = "send failed (" + resp.status + ")"; } + } catch (e) { $("composerNotice").textContent = "send failed: " + e.message; } +} + +async function submitGoal() { + const condition = $("goalCondition").value.trim(); + const maxTurns = parseInt($("goalMaxTurns").value, 10) || 0; + if (!condition || !drill.boxId || !drill.sessionId) return; + const b = box(drill.boxId); + $("composerNotice").textContent = ""; + try { + const resp = await boxAPI(b, "POST", "/session/" + drill.sessionId + "/goal", { condition, max_turns: maxTurns }); + if (resp.status === 202) { + $("goalForm").style.display = "none"; + } else { + const body = await resp.json().catch(() => ({})); + $("composerNotice").textContent = "goal failed (" + resp.status + "): " + (body.error || ""); + } + } catch (e) { $("composerNotice").textContent = "goal failed: " + e.message; } +} + +async function clearGoal() { + if (!drill.boxId || !drill.sessionId) return; + const b = box(drill.boxId); + try { await boxAPI(b, "DELETE", "/session/" + drill.sessionId + "/goal"); } + catch (e) { $("composerNotice").textContent = "clear goal failed: " + e.message; } +} + +async function abortSession() { + if (!drill.boxId || !drill.sessionId) return; + const b = box(drill.boxId); + try { await boxAPI(b, "POST", "/session/" + drill.sessionId + "/abort"); } + catch (e) { $("composerNotice").textContent = "abort failed: " + e.message; } +} + +async function quickNewSession(boxId) { + const b = box(boxId); + if (!b) return; + try { + const resp = await boxAPI(b, "POST", "/session", {}); + if (resp.status === 201) { + const s = await resp.json(); + const rt = ensureRuntime(boxId); + rt.sessions.set(s.id, s); + expandedBoxes.add(boxId); + selectSession(boxId, s.id); + } else { + $("headerNotice").textContent = "new session failed (" + resp.status + ")"; + } + } catch (e) { $("headerNotice").textContent = "new session failed: " + e.message; } +} + +function openRearmModal(boxId, s) { + selectSession(boxId, s.id); + prefillRearm(); +} + +/* ---------- box management: add / remove ---------- */ + +function randomID() { + return "box_" + Math.random().toString(16).slice(2) + Date.now().toString(16); +} + +function addBox({ name, base, token }) { + base = String(base || "").trim().replace(/\/+$/, ""); + token = String(token || "").trim(); + name = String(name || "").trim() || base; + if (!base || !token) return false; + const b = { id: randomID(), name, base, token }; + hs.boxes.push(b); + persist(); + expandedBoxes.add(b.id); + connectBox(b.id); + renderFleet(); + return true; +} + +function removeBox(id) { + disconnectBox(id); + hs.boxes = hs.boxes.filter(b => b.id !== id); + if (hs.view && hs.view.box === id) hs.view = {}; + if (drill.boxId === id) { closeDrillConn(); drill.boxId = null; drill.sessionId = null; renderDrill(); } + expandedBoxes.delete(id); + boxRuntime.delete(id); + persist(); + renderFleet(); +} + +/* ---------- modals ---------- */ + +function closeModal() { $("modalHost").textContent = ""; } + +function openAddBoxModal() { + const nameIn = el("input", { id: "mAddName", placeholder: "e.g. staging-1" }); + const baseIn = el("input", { id: "mAddBase", placeholder: "http://localhost:4096" }); + const tokIn = el("input", { id: "mAddToken", type: "password", placeholder: "run token" }); + const notice = el("div", { class: "notice" }); + const modal = el("div", { class: "modal" }, + el("h2", { text: "Add box" }), + el("div", { class: "field" }, el("label", { text: "Name" }), nameIn), + el("div", { class: "field" }, el("label", { text: "Base URL" }), baseIn), + el("div", { class: "field" }, el("label", { text: "Run token" }), tokIn), + notice, + el("div", { class: "row" }, + el("button", { class: "primary", text: "Add", onclick: () => { + if (addBox({ name: nameIn.value, base: baseIn.value, token: tokIn.value })) closeModal(); + else notice.textContent = "base URL and token are required"; + } }), + el("button", { text: "Cancel", onclick: closeModal }), + ), + ); + showModal(modal); + baseIn.focus(); +} + +function showModal(modal) { + const host = $("modalHost"); + host.textContent = ""; + const backdrop = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === backdrop) closeModal(); } }, modal); + host.append(backdrop); +} + +function openDispatchModal(boxId) { + const b = box(boxId); + if (!b) return; + const modelIn = el("input", { placeholder: "provider/model or alias (optional)" }); + const condIn = el("textarea", { class: "big", placeholder: "Completion condition\u2026 multi-paragraph is fine \u2014 this is the whole point." }); + const maxTurnsIn = el("input", { type: "number", min: "0", value: "0" }); + const notice = el("div", { class: "notice" }); + const modal = el("div", { class: "modal" }, + el("h2", { text: "Dispatch on " + b.name }), + el("div", { class: "field" }, el("label", { text: "Model (optional)" }), modelIn), + el("div", { class: "field" }, el("label", { text: "Completion condition" }), condIn), + el("div", { class: "field" }, el("label", { text: "Max turns (0 = unlimited)" }), maxTurnsIn), + notice, + el("div", { class: "row" }, + el("button", { class: "primary", text: "Dispatch", onclick: async () => { + const condition = condIn.value.trim(); + if (!condition) { notice.textContent = "a completion condition is required"; return; } + notice.textContent = "creating session\u2026"; + try { + const body = {}; + if (modelIn.value.trim()) body.model = modelIn.value.trim(); + const created = await boxAPI(b, "POST", "/session", body); + if (created.status !== 201) { notice.textContent = "create session failed (" + created.status + ")"; return; } + const sess = await created.json(); + const rt = ensureRuntime(boxId); + rt.sessions.set(sess.id, sess); + notice.textContent = "starting goal\u2026"; + const maxTurns = parseInt(maxTurnsIn.value, 10) || 0; + const goalResp = await boxAPI(b, "POST", "/session/" + sess.id + "/goal", { condition, max_turns: maxTurns }); + if (goalResp.status !== 202) { + const eb = await goalResp.json().catch(() => ({})); + notice.textContent = "goal failed (" + goalResp.status + "): " + (eb.error || ""); + return; + } + expandedBoxes.add(boxId); + closeModal(); + selectSession(boxId, sess.id); + } catch (e) { notice.textContent = "dispatch failed: " + e.message; } + } }), + el("button", { text: "Cancel", onclick: closeModal }), + ), + ); + showModal(modal); + condIn.focus(); +} + +/* ---------- spawn box: the hub's own (same-origin) SSE API ---------- */ + +function openSpawnModal() { + const nameIn = el("input", { placeholder: "name for the new box (once it's up)" }); + const log = el("div", { class: "spawnlog", text: "" }); + const notice = el("div", { class: "notice" }); + const startBtn = el("button", { class: "primary", text: "Spawn" }); + const modal = el("div", { class: "modal" }, + el("h2", { text: "Spawn a new box" }), + el("div", {}, "Runs this hub's configured -spawn-command / $HARNESS_HUB_SPAWN and streams its output below. See the contract in this page's header comment."), + el("div", { class: "field" }, el("label", { text: "Box name" }), nameIn), + notice, log, + el("div", { class: "row" }, startBtn, el("button", { text: "Close", onclick: closeModal })), + ); + showModal(modal); + startBtn.onclick = () => runSpawn(nameIn.value, log, notice, startBtn); + nameIn.focus(); +} + +async function runSpawn(name, log, notice, startBtn) { + startBtn.disabled = true; + notice.textContent = ""; + log.textContent = ""; + try { + const resp = await fetch("/spawn", { method: "POST" }); + if (!resp.body) throw new Error("no response body"); + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + let finished = false; + const parse = createSSEParser(frame => { + let ev; try { ev = JSON.parse(frame.data); } catch { return; } + if (ev.type === "stdout") { + log.textContent += (log.textContent ? "\n" : "") + ev.line; + log.scrollTop = log.scrollHeight; + } else if (ev.type === "done") { + finished = true; + if (ev.error) { + notice.textContent = "spawn failed: " + ev.error; + } else if (ev.tunnel_url && ev.run_token) { + addBox({ name: name || ev.tunnel_url, base: ev.tunnel_url, token: ev.run_token }); + notice.textContent = "box added: " + ev.tunnel_url; + } else { + notice.textContent = "spawn finished (exit " + ev.exit_code + ") but no TUNNEL_URL=/RUN_TOKEN= lines were found \u2014 add the box by hand."; + } + } + }); + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + parse(dec.decode(value, { stream: true })); + } + if (!finished) notice.textContent = "spawn stream ended unexpectedly"; + } catch (e) { + notice.textContent = "spawn failed: " + e.message; + } finally { + startBtn.disabled = false; + } +} + +/* ---------- notifications ---------- */ + +function notify(boxId, sessionId, notice) { + if (!hs.notify) return; + if (typeof Notification === "undefined" || Notification.permission !== "granted") return; + const b = box(boxId); + const title = notice.title + " \u00b7 " + (b ? b.name : boxId) + " / " + shortId(sessionId); + try { new Notification(title, { body: notice.body }); } catch { /* best-effort */ } +} + +function updateNotifyButton() { + const btn = $("notifyBtn"); + btn.classList.toggle("on", !!hs.notify); + btn.textContent = hs.notify ? "\ud83d\udd14 Notify: on" : "\ud83d\udd14 Notify"; +} + +async function toggleNotify() { + if (!hs.notify) { + if (typeof Notification !== "undefined" && Notification.permission !== "granted") { + try { await Notification.requestPermission(); } catch { /* ignore */ } + } + } + hs.notify = !hs.notify; + persist(); + updateNotifyButton(); +} + +/* ---------- copy hub link ---------- */ + +async function copyHubLink() { + const url = location.href; + try { + await navigator.clipboard.writeText(url); + $("headerNotice").textContent = "hub link copied"; + } catch { + $("headerNotice").textContent = url; + } + setTimeout(() => { $("headerNotice").textContent = ""; }, 4000); +} + +/* ---------- wiring + bootstrap ---------- */ + +$("addBoxBtn").onclick = openAddBoxModal; +$("spawnBtn").onclick = openSpawnModal; +$("notifyBtn").onclick = toggleNotify; +$("copyLinkBtn").onclick = copyHubLink; +$("sendBtn").onclick = sendPrompt; +$("abortBtn").onclick = abortSession; +$("clearGoalBtn").onclick = clearGoal; +$("goalSubmitBtn").onclick = submitGoal; +$("setGoalToggleBtn").onclick = () => { + const f = $("goalForm"); + f.style.display = f.style.display === "none" ? "" : "none"; +}; +$("promptBox").addEventListener("keydown", e => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (!$("sendBtn").disabled) sendPrompt(); } +}); + +function bootstrap() { + loadFromLocation(); + updateNotifyButton(); + for (const b of hs.boxes) { expandedBoxes.add(b.id); connectBox(b.id); } + renderFleet(); + updateComposer(); + const view = hs.view || {}; + if (view.box && view.session && box(view.box)) { + selectSession(view.box, view.session); + } else { + renderDrill(); + } +} + +bootstrap(); From fec040f86c8c97f54344b9d4805a64b1dc86a295 Mon Sep 17 00:00:00 2001 From: Andy Bonventre Date: Fri, 10 Jul 2026 22:43:26 +0000 Subject: [PATCH 04/39] =?UTF-8?q?docs(agents):=20document=20the=20Developm?= =?UTF-8?q?ent=20hub=20=E2=80=94=20state=20model,=20CORS,=20spawn=20contra?= =?UTF-8?q?ct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a public-safe AGENTS.md section for `harness hub`: what it is (a local, single-operator fleet dashboard, not a deployed product), the URL-fragment-only state model and why run tokens riding the URL is an accepted tradeoff, the -cors-origin requirement for every box, and the POST /spawn TUNNEL_URL=/RUN_TOKEN= contract that is the only coupling between this repo and any deployment-specific provisioning tool. Also applies gofmt's smart-quote comment normalization to hub.go. --- AGENTS.md | 33 +++++++++++++++++++++++++++++++++ tools/hub/hub.go | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 677d702..3998eb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,6 +168,39 @@ representation. - **A2A** — deliberately not implemented. Cross-org agent meshes are a different layer; revisit only if a concrete need appears. +## Development hub + +`harness hub` is a local, single-operator control surface over a FLEET of +`harness serve` boxes — a meeseeks-like dashboard for "what are my agents +doing right now" and for dispatching new goal-supervised sessions, not a +deployed product. It serves one embedded, single-file page +(`tools/hub/index.html`, `go:embed`, styled to match `tools/inspector/`) on +`localhost:7777` by default (`-addr` to change it). + +- **No server-side state.** The hub keeps no registry and reads no config + file: every box (name, base URL, run token) and the current selection + live only in that browser tab's URL fragment, base64-encoded JSON + (`#s=...`), kept in sync via `history.replaceState`. That makes a hub URL + bookmarkable and shareable between local tabs with zero persistence code + — and means **run tokens ride the URL by design**; treat a hub link like + a secret. +- **The page talks to boxes directly** from the browser, over each box's + normal HTTP+SSE API (`server/openapi.yaml`) — never proxied through the + hub's own server. Every box must therefore be started with `-cors-origin` + set to the hub's origin (or `*` for local hacking), e.g. `harness serve + -cors-origin http://localhost:7777`; a box without it will look + permanently unreachable from the hub. +- **The Go side is minimal on purpose**, exactly one API: `POST /spawn`. + It execs the command given by `-spawn-command` (or `$HARNESS_HUB_SPAWN`) + via `sh -c` and streams its combined stdout+stderr live to the page over + SSE. The **spawn-command contract** — the only coupling between this repo + and any deployment-specific provisioning tool — is two plain lines + anywhere in that output: `TUNNEL_URL=` and `RUN_TOKEN=`. Once + the command exits, the stream ends with a summary carrying those two + values (if found) and the exit code; the page adds the new box to its own + URL state itself. Nothing box-provisioning-specific lives in this repo. +- The hub binds loopback-only by default (`resolveAddr` in `tools/hub/hub.go`). + ## Startup Speed Rules - Nothing touches network, subprocesses, or disk beyond one config file before first paint. Provider auth validates on first message send, not at boot. diff --git a/tools/hub/hub.go b/tools/hub/hub.go index f379645..c56fdd6 100644 --- a/tools/hub/hub.go +++ b/tools/hub/hub.go @@ -110,7 +110,7 @@ func resolveAddr(addr string) string { // spawnCommandFromEnv resolves -spawn-command's fallback: the // HARNESS_HUB_SPAWN environment variable, consulted only when the flag was // not passed at all (flagSet, not merely flagValue == "", so an explicit -// -spawn-command '' can still disable spawning without env clobbering it). +// -spawn-command ” can still disable spawning without env clobbering it). func spawnCommandFromEnv(flagValue string, flagSet bool, getenv func(string) string) string { if flagSet { return flagValue From 59e17f0373bf8e6a5ac13d2e0a0b1667f02f3f53 Mon Sep 17 00:00:00 2001 From: Andy Bonventre Date: Fri, 10 Jul 2026 19:03:28 -0400 Subject: [PATCH 05/39] docs(hub): generic phrasing in hub descriptions --- AGENTS.md | 2 +- tools/hub/index.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3998eb5..27079da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,7 +171,7 @@ representation. ## Development hub `harness hub` is a local, single-operator control surface over a FLEET of -`harness serve` boxes — a meeseeks-like dashboard for "what are my agents +`harness serve` boxes — a fleet dashboard for "what are my agents doing right now" and for dispatching new goal-supervised sessions, not a deployed product. It serves one embedded, single-file page (`tools/hub/index.html`, `go:embed`, styled to match `tools/inspector/`) on diff --git a/tools/hub/index.html b/tools/hub/index.html index bf054bf..0040456 100644 --- a/tools/hub/index.html +++ b/tools/hub/index.html @@ -6,7 +6,7 @@ harness hub
-

🛰️ harness hub

+

harness hub

- - - + + +
@@ -444,7 +468,7 @@

🛰️ harness hub

- + -
+