Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
4cef011
feat(hub): scaffold harness hub subcommand — embed serve + spawn cont…
andybons Jul 10, 2026
64eedb0
feat(hub): fleet hub page skeleton — palette, layout, pure helpers
andybons Jul 10, 2026
bd1615d
feat(hub): fleet view, session drill-down, dispatch/goal workflow, no…
andybons Jul 10, 2026
fec040f
docs(agents): document the Development hub — state model, CORS, spawn…
andybons Jul 10, 2026
59e17f0
docs(hub): generic phrasing in hub descriptions
andybons Jul 10, 2026
19fb4f4
feat(hub): auto-generate adjective-noun-NN box names; drop the name f…
andybons Jul 11, 2026
9a47b87
fix(hub): hide the empty spawn-log box until output streams in
andybons Jul 11, 2026
09721a6
fix(hub): connect the fleet event stream from the journal tip, not seq 0
andybons Jul 11, 2026
ca9259a
fix(hub): coalesce event-driven renders to one per frame
andybons Jul 11, 2026
7814021
fix(hub): clamp the pinned goal condition to two lines with a toggle
andybons Jul 11, 2026
dc66bb8
test(hub): add pure helpers for incremental timeline/fleet rendering
andybons Jul 11, 2026
021ceee
fix(hub): keyed append-only timeline + per-card fleet reconciliation
andybons Jul 11, 2026
83a7023
fix(hub): sort fleet sessions by state, then a total recency order
andybons Jul 11, 2026
cd76472
test(hub): commit the real-backend end-to-end verification harness
andybons Jul 11, 2026
ada31f6
test(hub): make the real-backend e2e check self-installing, not opt-in
andybons Jul 11, 2026
d265eab
fix(hub): open sessions pinned to the bottom; stickiness is intent, n…
andybons Jul 11, 2026
c08e81b
feat(server): interactive PTY terminal over WebSocket at GET /term
andybons Jul 11, 2026
fda1269
feat(hub): POST /spawn accepts a name, passed to the spawn command as…
andybons Jul 11, 2026
73cda95
test(hub): pure helpers for lineage grouping, paused goals, and proce…
andybons Jul 11, 2026
5e654b9
feat(hub): lineage grouping, paused goals, process strip, respawn, an…
andybons Jul 11, 2026
2008dbf
fix(hub): guard ResizeObserver, make the real e2e scroll check fire a…
andybons Jul 11, 2026
239f0a5
style(hub): tactical telemetry redesign — committed dark archetype
andybons Jul 11, 2026
a1bbd49
docs(agents): document the hub's tactical-telemetry design language
andybons Jul 11, 2026
06c6133
refactor(hub): state-first information architecture; native menus and…
andybons Jul 11, 2026
6174100
fix(hub): label the box-card revision, stop uppercasing hex
andybons Jul 11, 2026
a85a9b6
fix(hub): hide the tunnel URL on unreachable boxes
andybons Jul 12, 2026
2d5537b
fix(hub): show the tunnel URL only when health is confirmed ok
andybons Jul 12, 2026
aa40ca8
fix(hub): dead boxes read 'inactive', not 'unreachable'
andybons Jul 12, 2026
823d71b
fix(hub): respawn dialog speaks operator, not design doc
andybons Jul 12, 2026
6e97ddf
fix(hub): spawn/respawn in-flight state — disable Close, label button…
andybons Jul 12, 2026
48dac2d
fix(hub): restore spawn/respawn in-flight state (lost to a build-chec…
andybons Jul 12, 2026
73c06a5
fix(hub): success notice is green, not error-red
andybons Jul 12, 2026
87aeb42
fix(hub): a11y contrast + no mobile overflow
andybons Jul 12, 2026
2715143
fix(hub): card overflow menu uses the Popover API to escape clipping
andybons Jul 12, 2026
5f241d7
fix(hub): stub the Popover API in the real e2e so the card menu handl…
andybons Jul 13, 2026
d123ea9
Merge remote-tracking branch 'origin/main' into feat/hub
andybons Jul 13, 2026
0b0d464
test(server): drain the in-flight prompt before TempDir cleanup
andybons Jul 13, 2026
aacc771
revert(server): remove the /term PTY endpoint and vendored xterm
andybons Jul 13, 2026
9c9d972
fix(hub): reject cross-origin POST /spawn (CSRF guard)
andybons Jul 13, 2026
fe28660
fix(hub): Content-Security-Policy on the served page (defense-in-depth)
andybons Jul 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 107 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,105 @@ 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 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`) 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 plain lines anywhere
in that output: `TUNNEL_URL=<url>` and `RUN_TOKEN=<token>` (required to
add the box), and any number of `PORT_URL_<port>=<url>` lines (optional —
one per exposed port's own tunnel/preview URL, collected into a
`port_urls` map; see the process strip in `tools/hub/index.html`'s header
comment). Once the command exits, the stream ends with a summary carrying
those 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.
- **Box name passthrough.** `POST /spawn`'s JSON body optionally carries
`{"name": "..."}` — the page's generated (or, on a Respawn/ADOPT, reused)
box name. The Go handler sets it as `HARNESS_HUB_BOX_NAME` in the spawn
command's own environment (`tools/hub/spawn.go`'s `runSpawn`), exactly
the deployment-environment contract `docs/design/fleet-model.md` §8
specifies: deployment tooling invoked by `-spawn-command` reads this
variable to derive per-name storage (typically setting
`HARNESS_SESSION_DIR` from it before `harness serve` starts) — harness's
own code never reads `HARNESS_HUB_BOX_NAME` at all. A request with no
body, or no `name` field, spawns exactly as before (no env var set).
- The hub binds loopback-only by default (`resolveAddr` in `tools/hub/hub.go`).
- **Browser-security hardening** (both in `tools/hub/hub.go`, tested in
`tools/hub/hub_test.go`). `POST /spawn` execs a real, costly provision
command, so `handleSpawn` rejects a browser cross-origin request before any
exec: if an `Origin` header is present it must match the request's `Host`
(OWASP verify-origin). Loopback binding alone does not stop this — any page
the operator visits can `fetch("http://localhost:7777/spawn",{method:
"POST"})` as a no-preflight CORS simple request — but the page's own
same-origin `fetch("/spawn")` (Origin == Host) and non-browser clients (no
Origin, so not a CSRF vector) pass unchanged. The served page also carries
a strict `Content-Security-Policy` (`default-src 'none'` + `'unsafe-inline'`
script/style — the page is a single no-build `go:embed`'d file with no
external resources and no per-response nonce hook — + `connect-src *`,
required because it fetches/streams from arbitrary operator-added box
origins the stateless hub cannot enumerate, + `frame-ancestors`/`base-uri`/
`form-action` pinned to `'none'`): defense-in-depth for a page holding run
tokens in its URL fragment.
- **Pure hub logic is unit-tested** by `tools/hub/hub_test.mjs` (run:
`node --test tools/hub/*_test.mjs`). **End-to-end, against a real backend**
is `tools/hub/e2e` (see its README): a `go test -race ./...` subtree that
starts an actual `server.Server` + `hub.NewHandler` and drives the real,
served `index.html` with Node + jsdom and an unmocked `fetch` — no manual
setup step; it installs its own `npm` dependency on first run.

### UI design language

The hub is styled as **tactical telemetry** — a committed dark-only
brutalist archetype (derived from the public
[taste-skill](https://github.com/Leonxlnx/taste-skill) brutalist +
anti-slop skills). Any new hub UI — and future passes on the inspector,
which still wears the older soft theme — follows these rules:

- **One substrate, no theme toggle**: `#0a0a0a` background, `#eaeaea`
phosphor foreground, `#2a2a2a` hairline borders. Never reintroduce a
light mode here; pick-one-and-commit is the point.
- **Two semantic colors only.** Hazard red (`--accent`, `#ff2a2a`) means
trouble or destructive action, nothing else. Terminal green (`--ok`,
`#4af626`) is reserved for exactly one semantic: live or succeeded goal
execution. Everything else is monochrome.
- **Monospace dominance**: body text is the `ui-monospace` stack;
headers are heavy uppercase system-ui. Micro-labels are uppercase with
`.06–.1em` tracking. No webfonts — the page is CSP-self-contained.
- **Geometry**: `border-radius: 0` absolutely everywhere; square status
markers; 1px compartment borders; inverted-video hover
(foreground/background swap). No gradients, soft shadows, or
translucency. The scanline overlay is static — motion requires a
stated purpose.
- **Copy discipline**: no emoji in UI strings, no em-dashes anywhere, and
every piece of "telemetry" displayed must be real data (vcs revisions,
seqs, PIDs, token counts) — never decorative or fabricated metadata.
- **Selectors are load-bearing**: the renderers create elements by class
name (`.sess`, `.box-card`, `.dot`, `.goalnarr`, …). Restyle classes;
never rename them in a styling pass.

## Fleet model (the deploy story)

The full build spec lives in `docs/design/fleet-model.md` — read it before
Expand All @@ -215,15 +314,14 @@ record) rather than a false "still running" reading. `parent_session`
re-dispatch to the task it continues from, so a fleet UI can group a box's
history by task across boxes.

**Hub spawn contract:** a hub (external orchestrator, not implemented in
this repo) that spawns boxes passes the generated box NAME to the spawn
command's environment as `HARNESS_HUB_BOX_NAME`, so deployment scripts can
derive per-name storage (e.g. mount/create a volume named after it) without
the hub and the box needing any other side channel to agree on identity.
Harness itself never reads this variable — it is a contract between the hub
and deployment tooling, documented in `docs/design/fleet-model.md` §8. Its
implementation is out of scope here; do not add code that reads or sets it
without checking whether that design has landed first.
**Hub spawn contract:** the hub that spawns boxes — `harness hub`, now
implemented in `tools/hub/` (see the Development hub above) — passes the
generated box NAME to the spawn command's environment as
`HARNESS_HUB_BOX_NAME`, so deployment scripts can derive per-name storage
(e.g. mount/create a volume named after it) without the hub and the box
needing any other side channel to agree on identity. Harness itself never
reads this variable — it is a contract between the hub and deployment
tooling, documented in `docs/design/fleet-model.md` §8.

## Startup Speed Rules

Expand Down
9 changes: 9 additions & 0 deletions cmd/harness/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
Empty file added go.sum
Empty file.
9 changes: 9 additions & 0 deletions server/goal_paused_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package server

import (
"context"
"encoding/json"
"fmt"
"net/http"
Expand Down Expand Up @@ -142,6 +143,14 @@ func TestGoalPausedRestartYieldsIdleAndUsable(t *testing.T) {
if resp.StatusCode != 202 {
t.Fatalf("prompt_async on a paused/idle session status = %d, want 202: %s", resp.StatusCode, data)
}

// The prompt above is admitted asynchronously (202): srv2 spawns a
// runPrompt goroutine, tracked by srv2.wg, that keeps writing the session
// journal into dir (== t.TempDir()). Drain blocks on wg.Wait until that
// goroutine finishes, so no writer survives into t.TempDir()'s RemoveAll
// cleanup — otherwise a journal write races the directory removal and the
// cleanup fails with "directory not empty" under load.
srv2.Drain(context.Background())
}

// TestGoalStalledProviderBackoffSurfacesPaused is deliverable 2(b)'s wire
Expand Down
1 change: 1 addition & 0 deletions tools/hub/e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
65 changes: 65 additions & 0 deletions tools/hub/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# tools/hub/e2e — real-backend verification for the hub page

`tools/hub/index.html` is a single, build-free HTML file with zero
dependencies (see its own header comment) — that does not change here.
This directory is a separate, isolated, npm-based verification *tool* that
proves the page's incremental-rendering behavior against a **real** running
harness backend, not hand-rolled mocks.

## What it checks

`real_e2e.mjs`, driven by `e2e_test.go`, starts:

- a real `server.Server` (`stub.go`'s `Start`, the same wiring as
`harness serve`) backed by a small scripted provider (no API key needed),
and
- a real `hub.NewHandler` (the same wiring as `harness hub`), serving the
*actual* embedded `tools/hub/index.html`,

then loads that real page in [jsdom](https://github.com/jsdom/jsdom) with
Node's own, **unmocked** `fetch` — real HTTP requests, real SSE streams,
real engine turns. It confirms:

1. the hub server serves `tools/hub/index.html` byte-for-byte (production
wiring, not a stale copy);
2. a populated URL fragment renders its box skeleton synchronously — no
"no boxes yet" flash;
3. a real `/health`+`/session` poll resolves to a healthy dot and a real
`vcs_revision`, and the box card's DOM node survives a real session
being created (no needless rebuild);
4. a real engine turn renders as a keyed, durable message; expanding its
reasoning block survives a **second** real, server-driven turn — the
keyed, append-only timeline, exercised over an actual SSE stream, not a
simulated one;
5. a scrolled-up timeline is left alone by a real subsequent render
(pinned-tail autoscroll).

## Running it

No manual setup step is required. Just run the same command already used to
verify this repo:

```sh
go test -race ./... # or narrower: go test ./tools/hub/e2e/...
```

`TestRealEndToEnd` installs its own dependency (`npm ci`, using the
package-lock.json committed here) the first time it runs if jsdom isn't
already present in this directory, then drives the real check. `node` (and
therefore `npm`, which ships with it) is already a hard requirement of this
repo's `node --test tools/hub/*_test.mjs` check, so this test only skips in
the one case where that other required command would ALSO be unrunnable —
no Node toolchain on `PATH` at all. It fails loudly (not a silent skip) if
`node`/`npm` ARE present but the dependency install itself fails (e.g. no
network access to npm's registry on first run) — an offline environment is
a real verification gap, not something to paper over.

To drive it by hand instead (e.g. to poke at the real backend from an
actual browser):

```sh
cd tools/hub/e2e && npm ci # only needed once, if not already run by the test above
go run ./tools/hub/e2e/hubverify # prints {"box_base":...,"hub_base":...,"token":...}, then blocks
node tools/hub/e2e/real_e2e.mjs <box_base> <hub_base> <token> # in another shell
# or open hub_base in a real browser and "+ Add box" with box_base + token by hand
```
121 changes: 121 additions & 0 deletions tools/hub/e2e/e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package e2e

import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"
)

// TestRealEndToEnd starts a REAL box server + REAL hub server (see Start)
// and drives the ACTUAL tools/hub/index.html against them with Node +
// jsdom, using Node's own unmocked fetch — real HTTP, real SSE, real engine
// turns. This is the automated counterpart to the header comment's
// hand-test checklist: it exists so plain `go test -race ./...` — the exact
// command already used to verify this repo, no extra step required —
// checks, without any manual browser session, that:
// - the hub server serves tools/hub/index.html byte-for-byte (production
// wiring, not a stale copy);
// - a populated URL fragment renders its box skeleton synchronously, no
// "no boxes yet" flash;
// - a real health/session poll resolves to a healthy dot, and the box
// card's DOM node survives a real session being created;
// - a real engine turn (via the scripted provider in this package) renders
// as a keyed durable message; expanding its reasoning block survives a
// second real, server-driven turn — the actual keyed append-only
// timeline behavior, exercised over a real SSE stream;
// - a scrolled-up viewport is left alone by a real subsequent render
// (pinned-tail autoscroll).
//
// Dependency setup is automatic, not a documented manual prerequisite: if
// jsdom isn't already installed in this directory, the test runs
// `npm ci` (falling back to `npm install` if there is no lockfile-clean
// install available) itself before driving real_e2e.mjs, using the
// package.json/package-lock.json committed alongside this file. `node` (and
// therefore `npm`, which ships with it) is already a hard requirement of
// this repo's own `node --test tools/hub/*_test.mjs` check, so the only way
// this test skips is the one case where that other required command would
// ALSO be unrunnable: no Node toolchain on PATH at all. Any environment
// that can run the three documented verification commands runs this test
// for real, every time.
func TestRealEndToEnd(t *testing.T) {
nodePath, err := exec.LookPath("node")
if err != nil {
t.Skip("node not found on PATH — this environment could not run `node --test tools/hub/*_test.mjs` either; skipping real end-to-end hub verification")
}
npmPath, err := exec.LookPath("npm")
if err != nil {
t.Skip("npm not found on PATH (normally ships with node); skipping real end-to-end hub verification")
}

_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("could not determine tools/hub/e2e directory")
}
dir := filepath.Dir(thisFile)
script := filepath.Join(dir, "real_e2e.mjs")

if _, err := os.Stat(filepath.Join(dir, "node_modules", "jsdom")); err != nil {
installDeps(t, npmPath, dir)
}

stub, err := Start()
if err != nil {
t.Fatalf("starting the real box/hub stub servers: %v", err)
}
defer stub.Close()

ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, nodePath, script, stub.BoxBase, stub.HubBase, stub.Token)
cmd.Dir = dir
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
runErr := cmd.Run()
t.Log(out.String())
if runErr != nil {
t.Fatalf("real_e2e.mjs failed: %v", runErr)
}
}

// installDeps runs `npm ci` (a clean, lockfile-exact install, using the
// package-lock.json committed in this directory) to fetch jsdom before the
// real end-to-end check needs it, so a fresh clone requires no manual setup
// step beyond having node/npm on PATH. Falls back to `npm install` if `npm
// ci` itself is unavailable in this npm version (older npm predates it).
// Requires network access to npm's registry; a genuinely offline CI run
// fails loudly here (t.Fatalf) rather than silently skipping the real
// check — an offline environment is a real gap in verification, not a
// reason to pretend everything passed.
func installDeps(t *testing.T, npmPath, dir string) {
t.Helper()
t.Logf("jsdom not present in %s; running npm ci to install it (see package.json/package-lock.json)", dir)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, npmPath, "ci", "--no-audit", "--no-fund")
cmd.Dir = dir
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
t.Logf("npm ci failed (%v), output:\n%s\nfalling back to npm install", err, out.String())
cmd = exec.CommandContext(ctx, npmPath, "install", "--no-audit", "--no-fund")
cmd.Dir = dir
out.Reset()
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
t.Fatalf("npm install failed too (%v); real end-to-end hub verification requires network access to npm's registry on first run:\n%s", err, out.String())
}
}
t.Log(out.String())
if _, err := os.Stat(filepath.Join(dir, "node_modules", "jsdom")); err != nil {
t.Fatalf("jsdom still missing from %s/node_modules after npm install; something is wrong with the dependency install", dir)
}
}
Loading