Skip to content

The hub: one page for every devteam instance on a machine - #97

Merged
hhff merged 15 commits into
mainfrom
devteam-hub
Nov 5, 2026
Merged

hhff merged 15 commits into
mainfrom
devteam-hub

Conversation

@hhff

@hhff hhff commented Nov 5, 2026

Copy link
Copy Markdown
Member

Spec: docs/superpowers/specs/2026-11-04-devteam-hub-design.md. Plan: docs/superpowers/plans/2026-11-04-devteam-hub.md.

What

  • scripts/devteam.mjs, tmux: tmux_session defaults to null. The resolved name is devteam-<project>, saved to run/tmux-session when the kit first creates the session; read-only commands and CI write nothing. A project whose live sessions already sit in a devteam session keeps that name. Every tmux target matches the session exactly (=name), because tmux otherwise takes a prefix (devteam would find devteam-stacks). settings --get tmux_session and whoami print the resolved name.
  • scripts/devteam.mjs, dashboard port: dashboard_port defaults to null. A checkout reuses the port it saved in run/dashboard-port. Otherwise it takes the one after the highest instance answering in 4321–4345, skipping ports held by anything else, and falls back to the lowest free one. A pinned port outside the block warns that the hub won't list it.
  • scripts/devteam.mjs, hello and /api/hello: hello is a light fold (open questions, fleet mode, live sessions, and the kit version, with +local when the running kit differs from main's, by blob, offline). The dashboard refreshes it every 30 s, and /api/hello answers from the last run at once.
  • scripts/devteam.mjs, startHub, and dashboard/hub.html: every dashboard tries to serve 127.0.0.1:4320. The one that gets it scans the block for /api/hello on each page load and lists what answers, each card linking to that instance's own origin. The others retry every 30 s, so the hub moves when its dashboard stops.
  • Dashboard Host check: it now runs first, for every route. Before this, GET / and GET /api/data answered any Host, so a page that rebound its own name to 127.0.0.1 could read the whole dashboard. Owner actions were already guarded.
  • README, supervisor.md: one session and port per project, the hub, and restart commands that name the project's own session.
  • test/kit.test.mjs: tests for the per-project session (including the legacy case and a foreign devteam session), the ports (save and reuse, +1, held ports, pinned outside the block), hello with the Host guard, and the hub (list, +1, +local, CSP, own Host, GET only, failover). Every test dashboard uses test-owned ports (DEVTEAM_HUB_PORT, DEVTEAM_PORT_BASE), never the machine's.

Why

The owner wants to run several devteam projects on one machine and reach them all from one page. Two projects on one machine collided: they shared the devteam tmux session, so send <id> could type into the other project's worker and a restart could kill the other's dashboard, and they fought over port 4321, so one could show the other's Inbox.

Authority

The hub can't act for the owner. It has no POST route and no proxy, answers only to its own Host, and its page has connect-src 'self' and frame-ancestors 'none'. Each card is a link to the instance's own origin, where the dashboard's existing owner checks apply. Instance discovery reads /api/hello, which carries counts, not log content. The Host fix narrows what any page can read.

Migration

None for a running project. Stacks resolves devteam, because its workers' windows are there, and, with no dashboard answering in the block when it restarts, takes 4321 and saves it. New projects get devteam-<project> and the next port. The owner's new bookmark is http://127.0.0.1:4320.

Tests

See the PR comments for the local runs on Node 18.20.4, 20.19.6 and 24.14.1. CI runs 18/20/22/24.

🤖 Generated with Claude Code

hhff and others added 15 commits November 4, 2026 19:32
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…needs no migration; tests never touch real ports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eam session kept, exact targets

Two projects on one machine shared the default devteam session, so window names clashed and send
could type into the other project's worker. tmux_session defaults to devteam-<project> now, saved
to run/tmux-session when the kit first creates the session (never by a read-only command). A
project whose live sessions already sit in a devteam session keeps it. Every target matches the
session exactly (=name): tmux would otherwise take a prefix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in 4321-4345, held ports skipped

Two projects' dashboards fought over 4321. dashboard_port defaults to null: a checkout reuses the
port it saved in run/dashboard-port, else takes the one after the highest instance answering in
the block (skipping ports held by anything else), else the lowest free one. A pinned port outside
the block warns that the hub won't list it. The move checklist points at the hub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hello is a light fold (open questions, fleet mode, live sessions, kit version with +local when the
running kit differs from main's) that the dashboard refreshes every 30s; /api/hello answers from
the last run at once, for the hub's 300ms probe. The dashboard's Host check moves to the top of
the handler: before, GET / and /api/data answered any Host, so a page that rebound its own name
to 127.0.0.1 could read the whole dashboard. Owner actions were already guarded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… to each one's own dashboard

Every dashboard tries to serve the hub port; the one that gets it scans the 25-port block for
/api/hello on each page load and lists what answers (root, name, kit, Inbox, fleet), each linking
to the instance's own origin. No state, no proxy, GET only, its own Host only, and a page that can
talk only to itself. The others retry, so the hub moves when its dashboard stops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ache the name per process

liveSessions() lists tmux windows, which asks tmuxSession(), so the legacy check recursed until the
stack ran out. A window is this project's when it is named after one of its tickets or planners in
the log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ktrees; lost-Enter test uses the project's session

A planner's feature may not be in the main log yet, so plan-<feature> is matched by the checkout's
worktree of that name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#S:#W), not the exact-match target

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, a light hello, a hub that can't be driven

From the PR review:
- A port is held when anything answers on 127.0.0.1 or ::1 (a dev server on localhost), not only
  when binding 127.0.0.1 fails. Two dashboards that pick one port: the loser picks again.
- The session name carries a hash of the checkout's path, so two clones of one repo never share.
  A legacy devteam name is saved the moment it is found, so it can't flip when its last window
  closes.
- hello counts sessions with one tmux call and checks the kit's files once per dashboard; the
  dashboard runs it one at a time with a timeout.
- The hub probes the block at most every 2s however often it is asked, refuses another site's
  requests, retries when the hub port's holder is merely slow, and lists an instance speaking a
  newer hello by root and port only.
- Printed tmux targets are quoted (zsh expands =word) and session targets are exact.
- kitVersion: a kit file main doesn't have counts as +local.
- Tests: the ports test now tells +1 from lowest-free and a saved port from lowest-free; a
  squatter on the hub port; a cross-site request; each run owns its own port block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… opens from any link

- A window counts as this project's only when it started under this checkout's .worktrees/ (the
  kit starts every worker and planner there with -c), never by its name. A name match let another
  project adopt Stacks' devteam session for good, now that a legacy name is saved on detection.
  hello counts sessions the same way.
- The cross-site refusal guards /instances.json only: the page itself opens from a README link.
- kitVersion: untracked kit files from git ls-files --others --exclude-standard (an editor's swap
  file isn't a local kit).
- Docs name the session devteam-<project>-<hash>; the tests' port block stays below the
  ephemeral range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A worker window the owner split, with the split left active, reported the split's start path and
stopped counting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ate/var)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hhff

hhff commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

Review (upstream loop). The author's PR, so a fresh reviewer agent reviewed it adversarially, in three passes:

  • Pass 1, CHANGES, 3 blockers:

    • tmuxSession() recursed through liveSessions();
    • lost-Enter recovery looked up the wrong key;
    • the tmux tests couldn't be trusted.

    Also 13 should-fixes and nits. All were fixed: ports held on ::1 or wildcard addresses, the start-up race, the session name hashed per checkout, the legacy name saved on detection, a light hello, coalesced hub scans with a cross-site refusal, the hub port's holder classified, quoted targets, untracked kit files, the version rule, and stronger tests.

  • Pass 2, CHANGES, 1 blocker: matching windows by name let one project adopt another's devteam session permanently. Windows are now ours only when a pane started under this checkout's .worktrees/, compared after resolving symlinks. Also fixed: the hub page opens from any link, and only /instances.json refuses cross-site requests.

  • Pass 3, APPROVE. Nits addressed: every pane counts, not only the active one. tmux without pane_start_path is noted as a limit.

  • Authority: the hub has no POST and no proxy, answers only to its own Host, and its page has connect-src 'self'. The dashboard's Host check now covers every route; before, GET / and /api/data were readable through DNS rebinding.

  • Tests at 352565d: CI 18/20/22/24 green. Local 24.14.1 47/47, 20.19.6 47/47. Local 18.20.4: one failure, in the review test's review-answer POST ('fetch failed'). The same intermittent macOS + Node 18 dashboard death hit this exact test before this PR (in v0.18.1-era runs); I'm tracking it separately.

@hhff
hhff merged commit 076c391 into main Nov 5, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant