From 2b76c4d01779ee9e600fe5eacaba2ba6ffb652cf Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 13 Nov 2026 19:38:45 -0500 Subject: [PATCH 01/10] box: boot forgets the pane mark with the pane Co-Authored-By: Claude Opus 5.5 --- cloud/Dockerfile | 5 +++-- test/machine.test.mjs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/cloud/Dockerfile b/cloud/Dockerfile index f745f908..32cbd355 100644 --- a/cloud/Dockerfile +++ b/cloud/Dockerfile @@ -276,11 +276,12 @@ cd "$HOME" mkdir -p code note() { echo "$(date -u +%FT%TZ) $*" >> "$HOME/box.log"; } # Nothing runs git yet, so a lock in a repo is one a process killed by the restart left. Pane ids -# start again at %0, so one noted before this boot could be another program's by now. +# start again at %0, so one noted before this boot could be another program's by now; its mark +# (supervisor-pane-role) goes with it. find "$HOME/code" -path '*/.git/*' -name '*.lock' -type f -delete 2>/dev/null # so is a Postgres pid file: its old pid is often held by another process by now, and it wouldn't start rm -f "$HOME"/.pg/*/postmaster.pid -rm -f "$HOME"/code/*/docs/devteam/run/supervisor-pane +rm -f "$HOME"/code/*/docs/devteam/run/supervisor-pane "$HOME"/code/*/docs/devteam/run/supervisor-pane-role # tmux starts login shells, which read .profile, and Debian's reads .bashrc [ -f .profile ] || cp /etc/skel/.profile .profile [ -f .tmux.conf ] || printf 'set -g mouse on\nset -g history-limit 50000\nset -g default-terminal tmux-256color\nset -g default-shell /bin/bash\n' > .tmux.conf diff --git a/test/machine.test.mjs b/test/machine.test.mjs index 43f5b182..c0fd04a6 100644 --- a/test/machine.test.mjs +++ b/test/machine.test.mjs @@ -612,6 +612,18 @@ test('the box\'s boot keeps the list in the form recover reads', () => { assert.match(fs.readFileSync(`${up}.boot`, 'utf8'), /^100 \/r\/a\n\/r\/old\n\d{10} \/r\/a\n$/, 'b is back up: off the list'); }); +test('the box\'s boot forgets every repo\'s noted pane and its mark', () => { + const df = fs.readFileSync(path.join(REPO, 'cloud', 'Dockerfile'), 'utf8'); + const line = df.split('\n').find((l) => l.startsWith('rm -f "$HOME"/code/*/docs/devteam/run/supervisor-pane')); + assert.ok(line, 'the boot line'); + const home = fs.mkdtempSync(path.join(tmp, 'boot-')); + const run = path.join(home, 'code', 'a', 'docs', 'devteam', 'run'); + fs.mkdirSync(run, { recursive: true }); + for (const f of ['supervisor-pane', 'supervisor-pane-role', 'supervisor-up.json']) fs.writeFileSync(path.join(run, f), '%3\n'); + execFileSync('sh', ['-c', line], { env: { ...process.env, HOME: home } }); + assert.deepEqual(fs.readdirSync(run).sort(), ['supervisor-up.json'], 'the pane and its mark gone; the up record kept'); +}); + test('after the first deploy, a supervisor only an older image\'s ages still named stays stopped', { skip: !hasTmux || skipOld() }, () => { const dir = path.join(tmp, 'box-code-fold'); const kept = repo('fold-kept', { tag: 'v0.25.0', dir }); From 2ad06ed4ff176060043ee6e3867109729bc5a1ad Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 13 Nov 2026 19:41:24 -0500 Subject: [PATCH 02/10] machine: a repo's kit is asked whether it has recover once per process, and a failed help is never kept Co-Authored-By: Claude Opus 5.5 --- docs/devteam/scripts/devteam.mjs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 3dbba3d8..c4bfa679 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -12141,8 +12141,19 @@ function projectKit(root, args, { timeout = 60000 } = {}) { } const projectJson = (root, args) => { try { return JSON.parse(projectKit(root, args) ?? 'null'); } catch { return null; } }; // Whether a repo's kit has recover, from its own help: what it says it does, however a later kit -// is written inside. help reads nothing of the repo's and writes nothing. -const kitHasRecover = (root) => /^\s+recover \[/m.test(projectKit(root, ['help']) ?? ''); +// is written inside. help reads nothing of the repo's and writes nothing. Kept for this process by +// root and the kit file's size and time, so a kit is started once to say so; a help that failed or +// timed out is asked again next time, never kept. +const kitRecoverSeen = new Map(); +function kitHasRecover(root) { + let key = null; + try { const st = fs.statSync(kitAt(root)); key = `${root}\0${st.size}\0${st.mtimeMs}`; } catch {} + if (key && kitRecoverSeen.has(key)) return kitRecoverSeen.get(key); + const said = projectKit(root, ['help']); + const has = /^\s+recover \[/m.test(said ?? ''); + if (key && said !== null) kitRecoverSeen.set(key, has); + return has; +} // The supervisor's pane a repo noted, for the machine's commands: marked, on a kit with recover; // bare, as it is written, on a kit older than recover, which never marks it (nor does legacyRecover, // which writes only what that kit writes). Its supervisor has no role-less pane to take but by its From b06d5dc23fb52f2a84a7baa589159b484c33ab5c Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 13 Nov 2026 19:44:55 -0500 Subject: [PATCH 03/10] artifacts: a published tree is served only under the slug whose folder it was on main Co-Authored-By: Claude Opus 5.5 --- docs/devteam/scripts/devteam.mjs | 51 +++++++++++++++---- .../2026-11-10-supervisor-threads-design.md | 2 +- test/dashboard.test.mjs | 14 +++++ 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index c4bfa679..e3a81b8a 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -6946,20 +6946,49 @@ function artifactSource(at, slug) { function artifactAt(at, slug) { if (!String(at).startsWith('tree:')) return artifactSource(at, slug); const tree = at.slice(5); - if (!LINK_ID.test(slug ?? '') || !TREE_ID.test(tree) || !artifactTreeOkSync(tree)) return null; + if (!LINK_ID.test(slug ?? '') || !TREE_ID.test(tree) || !artifactTreeOkSync(tree, slug)) return null; const entries = treeIdEntries(tree); return entries.length ? { entries } : null; } -// Is this id an artifact folder's tree? A commit's id, or the root tree's, would let : name -// any committed file, so it must be a tree with an artifact.json at its top (decision 12; its slug -// can't be checked: a folder's tree doesn't know its name, and artifact.json doesn't say it). -// Remembered once true: a tree id never changes. +// Is this id an artifact folder's tree, and was it 's folder on main? A commit's id, or the +// root tree's, would let : name any committed file, so it must be a tree with an +// artifact.json at its top (decision 12). And another artifact's tree under this slug would show its +// files under this one's name, so it must be main's tree for that folder now, or have been, by main's +// history of the folder: only the commits that touch it are walked, a merge against main's previous +// state. Remembered once true (a tree id never changes, and main's history only grows); a no is asked +// again, since main can come to have it. A shallow clone's history stops short, so an older version +// there is no one's, and the viewer shows main. gitReadCache, when a read-only command turns it on, +// keeps these reads for that one process only. const artifactTrees = new Set(); -const artifactTreeOk = (id) => (artifactTrees.has(id) ? Promise.resolve(true) : new Promise((resolve) => execFile('git', ['cat-file', '-t', id], { cwd: mainRoot, encoding: 'utf8' }, (e1, type) => { - if (e1 || type.trim() !== 'tree') return resolve(false); - return execFile('git', ['cat-file', '-e', `${id}:artifact.json`], { cwd: mainRoot }, (e2) => { if (!e2) artifactTrees.add(id); resolve(!e2); }); -}))); -const artifactTreeOkSync = (id) => artifactTrees.has(id) || (git(['cat-file', '-t', id], mainRoot) === 'tree' && git(['cat-file', '-e', `${id}:artifact.json`], mainRoot) !== null && !!artifactTrees.add(id)); +const treeFolderArgs = (ref, slug) => ({ + now: ['ls-tree', ref, '--', `${ARTIFACTS_REL}/${slug}`], + log: ['log', '--format=', '--raw', '--no-abbrev', '-t', '-m', '--first-parent', '--no-renames', ref, '--', `${ARTIFACTS_REL}/${slug}`], +}); +const treeNow = (ls) => /^040000 tree ([0-9a-f]+)\t/.exec(ls ?? '')?.[1] ?? null; +const treeInHistory = (raw, slug, tree) => (raw ?? '').split('\n').some((l) => { const m = /^:\d+ \d+ [0-9a-f]+ ([0-9a-f]+) \S+\t(.*)$/.exec(l); return !!m && m[1] === tree && m[2] === `${ARTIFACTS_REL}/${slug}`; }); +const gitAsync = (args) => new Promise((resolve) => execFile('git', args, { cwd: mainRoot, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, (e, so) => resolve(e ? null : so))); +async function artifactTreeOk(id, slug) { + const key = `${slug}\0${id}`; + if (artifactTrees.has(key)) return true; + if ((await gitAsync(['cat-file', '-t', id]))?.trim() !== 'tree' || (await gitAsync(['cat-file', '-e', `${id}:artifact.json`])) === null) return false; + const ref = mainRef(); + if (!ref) return false; + const a = treeFolderArgs(ref, slug); + const ok = treeNow(await gitAsync(a.now)) === id || treeInHistory(await gitAsync(a.log), slug, id); + if (ok) artifactTrees.add(key); + return ok; +} +function artifactTreeOkSync(id, slug) { + const key = `${slug}\0${id}`; + if (artifactTrees.has(key)) return true; + if (git(['cat-file', '-t', id], mainRoot) !== 'tree' || git(['cat-file', '-e', `${id}:artifact.json`], mainRoot) === null) return false; + const ref = mainRef(); + if (!ref) return false; + const a = treeFolderArgs(ref, slug); + const ok = treeNow(git(a.now, mainRoot)) === id || treeInHistory(git(a.log, mainRoot), slug, id); + if (ok) artifactTrees.add(key); + return ok; +} // A broken manifest is listed by its slug and error, never its unvalidated title. function describeArtifact(slug, at, src) { const p = artifactProblems(slug, src.entries); @@ -13310,7 +13339,7 @@ async function cmdDashboard() { // an artifact folder's tree by its id, and nothing else (decision 12): : resolves // against a commit or the root tree as well, which would serve any committed file. A tree // never changes: what was published is what is shown. - if (await artifactTreeOk(am[2])) bytes = await blobAt(`${am[2]}:${rel}`); + if (await artifactTreeOk(am[2], am[3])) bytes = await blobAt(`${am[2]}:${rel}`); } else { // the real path stays inside the artifact's folder: a symlink out of it is not found try { diff --git a/docs/superpowers/specs/2026-11-10-supervisor-threads-design.md b/docs/superpowers/specs/2026-11-10-supervisor-threads-design.md index 12d24ed9..61e28956 100644 --- a/docs/superpowers/specs/2026-11-10-supervisor-threads-design.md +++ b/docs/superpowers/specs/2026-11-10-supervisor-threads-design.md @@ -184,7 +184,7 @@ In the dashboard's artifact viewer the overlay sits over the existing frame (z-i `https://public.///` serves exactly today's bytes to anyone without a session, under today's headers plus, on a page's index, `vary` and an opener policy (below). For a request with **`sec-fetch-dest: document`** (a top-level navigation; `navigating()`'s `sec-fetch-mode: navigate` would also match frame loads) carrying a valid owner session, the gateway serves the **owner wrapper** instead. It is its own small page under a policy of its own, `WRAP_CSP`: `default-src 'none'; style-src 'unsafe-inline'; frame-src https://.; frame-ancestors 'none'; base-uri 'none'; form-action 'none'` (no script and nothing to connect to, which `PAGE_CSP` would admit), and `cache-control: no-store, private`. It frames `https://./view/artifact/?at=tree:&w=.` from the instance it holds the project's binding for, full size (only an artifact's index page is wrapped: the viewer URL carries no page path), under a one-line bar ("Public · / · open as the public sees it"). That link adds `?plain` and gets the public bytes, also for the owner. Without fetch metadata (an old browser), the plain bytes. - **Failure falls back to plain.** `servePublic` runs today before any session code, inside a branch that turns errors into a 5xx. The session check is a separate step that can only choose the wrapper: a bad cookie or a missing binding serves the plain bytes. An unreadable public store is a 503 for everyone, as it is today, since the plain bytes come from the same store. -- **The kit serves `/artifact/tree///` from git** (`git ls-tree` and `cat-file`, as the manifest already does), so the viewer shows exactly the published version. Only an artifact folder's tree is served (a tree with an `artifact.json` at its top); a commit's or the root tree's id would name any committed file. A tree that isn't one answers 404, and the viewer falls back to main with a "not the published version" bar. Framed (the owner wrapper), it doesn't: the gateway grants a frame only the published tree, so main's would be blocked and empty. It stays on the tree and says "The published version isn't in this checkout; open it from the Library." The kit's artifact path pattern gains the `tree/` form, and so does the gateway's `ARTIFACT` pattern, which drives its capability redirect and CSP mapping. Without that, an artifact's relative subresources would fail `crossSiteOk` from the opaque origin. +- **The kit serves `/artifact/tree///` from git** (`git ls-tree` and `cat-file`, as the manifest already does), so the viewer shows exactly the published version. Only an artifact folder's tree is served (a tree with an `artifact.json` at its top); a commit's or the root tree's id would name any committed file. And only under the slug whose folder it was on main: main's tree for that folder now, or main's history of it (`git log --raw -t -m --first-parent -- /`, which walks only the commits that touch it, a merge against main's previous state). Another artifact's tree under this slug would show its files under this one's name, in the bar and in a thread's subject. A yes is remembered (a tree id never changes and main's history only grows); a no is asked again. A shallow clone's history stops short, so an older version there is no one's. A tree that isn't one, or isn't this slug's, answers 404, and the viewer falls back to main with a "not the published version" bar. Framed (the owner wrapper), it doesn't: the gateway grants a frame only the published tree, so main's would be blocked and empty. It stays on the tree and says "The published version isn't in this checkout; open it from the Library." The kit's artifact path pattern gains the `tree/` form, and so does the gateway's `ARTIFACT` pattern, which drives its capability redirect and CSP mapping. Without that, an artifact's relative subresources would fail `crossSiteOk` from the opaque origin. - **Every ancestor must allow the frame.** `frame-ancestors` is checked against every ancestor, so in wrapper → viewer → artifact the artifact itself must allow the public host. The gateway appends `https://public.` to `frame-ancestors` on two kinds of instance response, and on nothing else: - **the viewer**, `/view/artifact/`, only under the wrapper's grant: `w=.`, an HMAC with the gateway's per-process secret (the one `/c/` capabilities use) over the port, `/view/artifact/`, `at` and an expiry five minutes out. A missing, forged, expired or too-long grant, a second `at` or `w`, or any other path gets no public ancestor. PR 3's viewer must accept a `w` query parameter on `/view/artifact/` and ignore it (never treat it as an error), keep it in its own URL, and never reload or navigate itself to a URL without it (a `history.replaceState` that drops it is harmless, a reload after one isn't). It passes nothing on to its artifact frame: `/artifact/tree///` needs no parameter. - **an artifact at a published tree**, `/artifact/tree///…`, only when this port's binding published `` at ``. That content is public already, and the viewer's frame of it carries no grant, so keying on the published tree keeps PR 3 simpler than threading the MAC through the viewer. diff --git a/test/dashboard.test.mjs b/test/dashboard.test.mjs index f8983c0a..f336454d 100644 --- a/test/dashboard.test.mjs +++ b/test/dashboard.test.mjs @@ -1219,6 +1219,20 @@ test('the viewer: its route and headers, what /api/view says of an artifact, and assert.doesNotMatch(F.sup(['thread', 'show', on({ at: `tree:${T}`, fp: lib.fp })]), /changed since/, 'a published tree main still has'); assert.match(F.sup(['thread', 'show', on({ at: `tree:${T}`, fp: 'b'.repeat(64) })]), /^main has changed viewer-mock since this published version$/m); assert.match(F.sup(['thread', 'show', on({ at: 'demo-01-gone', fp: lib.fp })]), /^the artifact viewer-mock is no longer at "demo-01-gone"$/m, 'a draft whose worktree is gone'); + // a published tree is served only under the slug whose folder it was on main: another artifact's + // tree under this name would show its files as this one's, in the bar and in a thread's subject + const T2 = landArtifact(F, 'other-mock', { ...VIEWER_MOCK, 'artifact.json': JSON.stringify({ v: 1, title: 'Other mock', summary: 'Another page', by: 'supervisor', for: [] }) }); + assert.equal((await fetch(`${B}/artifact/tree/${T2}/viewer-mock/`)).status, 404, 'another artifact\'s tree under this slug'); + assert.equal((await view({ slug: 'viewer-mock', at: `tree:${T2}` })).status, 404); + assert.equal(F.run(F.old, ['artifact', 'describe', 'viewer-mock', `--at=tree:${T2}`]).status, 3, 'nothing there'); + assert.equal((await fetch(`${B}/artifact/tree/${T2}/other-mock/`)).status, 200, 'under its own'); + // a version main has moved on from is still its slug's, by main's history of that folder + write(path.join(F.old, 'docs/devteam/project/artifacts/viewer-mock/style.css'), '#art { color: rgb(4, 5, 6) }'); + const T3 = landArtifact(F, 'viewer-mock', {}); + assert.notEqual(T3, T); + assert.equal((await fetch(`${B}/artifact/tree/${T}/viewer-mock/`)).status, 200, 'the older version, by history'); + assert.equal(F.run(F.old, ['artifact', 'describe', 'viewer-mock', `--at=tree:${T}`]).status, 0, 'and in a process of its own, nothing remembered'); + assert.equal((await fetch(`${B}/artifact/tree/${T}/other-mock/`)).status, 404, 'still never under another slug'); // and once main no longer has it, a thread on a published version says so const pub = on({ at: `tree:${T}`, fp: lib.fp }); F.g(['rm', '-rq', 'docs/devteam/project/artifacts/viewer-mock'], F.old); From 8421d25bb5132f43f2a88ef74c3687f532ac1f6f Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 13 Nov 2026 19:45:21 -0500 Subject: [PATCH 04/10] test: /api/view says 500 when describe fails Co-Authored-By: Claude Opus 5.5 --- test/dashboard.test.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/dashboard.test.mjs b/test/dashboard.test.mjs index f336454d..f498e131 100644 --- a/test/dashboard.test.mjs +++ b/test/dashboard.test.mjs @@ -1206,6 +1206,18 @@ test('the viewer: its route and headers, what /api/view says of an artifact, and r = await view({ slug: 'no-such', at: 'main' }); assert.deepEqual([r.status, await r.json()], [404, { error: 'no artifact no-such at main' }], 'nothing there, in words of its own: never what describe printed'); assert.equal((await view({ slug: 'viewer-mock', at: 'demo-01-gone' })).status, 404, 'a worktree that is not there'); + // describe failing for another reason (a folder it can't read) is a 500 in words of its own, never + // taken for nothing there; as root a mode holds nothing back, so it is not asked + if (process.getuid?.() !== 0) { + const draft = path.join(F.old, '.worktrees/demo-01-locked/docs/devteam/project/artifacts/viewer-mock'); + for (const [rel, text] of Object.entries(VIEWER_MOCK)) write(path.join(draft, rel), text); + fs.mkdirSync(path.join(draft, 'locked')); + fs.chmodSync(path.join(draft, 'locked'), 0); + try { + r = await view({ slug: 'viewer-mock', at: 'demo-01-locked' }); + assert.deepEqual([r.status, await r.json()], [500, { error: 'the artifact could not be read' }]); + } finally { fs.chmodSync(path.join(draft, 'locked'), 0o755); } + } const many = await Promise.all([1, 2, 3].map(() => view({ slug: 'viewer-mock', at: 'main' }).then((x) => x.json()))); assert.deepEqual(many.map((x) => x.fp), [lib.fp, lib.fp, lib.fp], 'asks at once share one run, and each has its answer'); assert.equal((await view({ slug: 'viewer-mock', at: '../x' })).status, 400); From eb0d99bae66d159db781ee497dd582fa87892f43 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 13 Nov 2026 19:46:50 -0500 Subject: [PATCH 05/10] test: the focus and presses tests' hubs sit at ports nothing else in the file uses, and the port layout says the real rule Co-Authored-By: Claude Opus 5.5 --- test/dashboard.test.mjs | 4 ++-- test/lib/ports.mjs | 23 +++++++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/test/dashboard.test.mjs b/test/dashboard.test.mjs index f498e131..ab937c83 100644 --- a/test/dashboard.test.mjs +++ b/test/dashboard.test.mjs @@ -417,7 +417,7 @@ function artLensRun({ ancestor = 'http://127.0.0.1:4321', title = 'A mock', path test('presses: the page answers before the server, and a refusal puts it back', { skip: canDrive ? false : 'needs Chrome and a Node with WebSocket', timeout: 180000 }, async (t) => { const base = fs.mkdtempSync(path.join(os.tmpdir(), 'devteam-press-')); t.after(() => fs.rmSync(base, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })); - const env = { ...process.env, DEVTEAM_HUB_PORT: String(PORTS + 90), DEVTEAM_PORT_BASE: String(PORTS + 91), CLAUDE_CONFIG_DIR: path.join(base, 'claude'), CODEX_HOME: path.join(base, 'codex') }; + const env = { ...process.env, DEVTEAM_HUB_PORT: String(PORTS + 58), DEVTEAM_PORT_BASE: String(PORTS + 59), CLAUDE_CONFIG_DIR: path.join(base, 'claude'), CODEX_HOME: path.join(base, 'codex') }; for (const k of ['DEVTEAM_ROLE', 'DEVTEAM_TICKET', 'DEVTEAM_FEATURE', 'DEVTEAM_RUN', 'DEVTEAM_SUPERVISOR', 'TMUX', 'TMUX_PANE']) delete env[k]; const root = bareProject(base, 'press'); const dt = (args, role = {}) => { const r = spawnSync(process.execPath, [path.join(root, 'docs/devteam/scripts/devteam.mjs'), ...args], { cwd: root, env: { ...env, ...role }, encoding: 'utf8' }); assert.equal(r.status, 0, r.stderr); return r.stdout.trim(); }; @@ -530,7 +530,7 @@ test('typing in the dashboard: a refresh never takes the field, the caret or the const base = fs.mkdtempSync(path.join(os.tmpdir(), 'devteam-focus-')); // in t.after, each as it starts: they run on a failure and a timeout, where a finally may never be reached t.after(() => fs.rmSync(base, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })); - const env = { ...process.env, DEVTEAM_HUB_PORT: String(PORTS + 80), DEVTEAM_PORT_BASE: String(PORTS + 81), CLAUDE_CONFIG_DIR: path.join(base, 'claude'), CODEX_HOME: path.join(base, 'codex') }; + const env = { ...process.env, DEVTEAM_HUB_PORT: String(PORTS + 56), DEVTEAM_PORT_BASE: String(PORTS + 57), CLAUDE_CONFIG_DIR: path.join(base, 'claude'), CODEX_HOME: path.join(base, 'codex') }; for (const k of ['DEVTEAM_ROLE', 'DEVTEAM_TICKET', 'DEVTEAM_FEATURE', 'DEVTEAM_RUN', 'TMUX', 'TMUX_PANE']) delete env[k]; const root = bareProject(base, 'focus'); const ask = (q, ...more) => { diff --git a/test/lib/ports.mjs b/test/lib/ports.mjs index f0785192..f952a03a 100644 --- a/test/lib/ports.mjs +++ b/test/lib/ports.mjs @@ -1,8 +1,23 @@ // Each test process owns a block of 100 ports between 20000 and 32699 (below 32768, where Linux -// starts handing out ephemeral ports; macOS starts at 49152): the file-wide hub and instance block at PORTS, the ports test at -// PORTS+30, the hub test at PORTS+60. The dashboard test's preview relays listen at PORTS+62..79, inside the hub test's instance block: they share it, so those two never run at once (serial only). A block is claimed with a lock file in the temp dir, so the -// suite's files running side by side, and another checkout's suite, never share one. The lock goes -// when the process exits; one left by a process that died is cleared once it is an hour old. +// starts handing out ephemeral ports; macOS starts at 49152). A block is claimed with a lock file in +// the temp dir, so the suite's files running side by side, and another checkout's suite, never share +// one. The lock goes when the process exits; one left by a process that died is cleared once it is +// an hour old. +// +// Inside a block, a test names a hub port and an instance base. Only a dashboard that picks its own +// port, or a hub asked for its list (/instances.json), scans base..base+24, so such a base must be +// +75 or lower to stay in the block. A test that pins its dashboard with --port, and never asks the +// hub's list, scans nothing: only its hub port is bound, and that must be in the block. Tests in one +// file run one at a time, so their ranges may overlap, but a port one test binds should be one no +// test before it in that file can still be holding. +// The layout, by offset: +// - +0 the file-wide hub, +1..25 its instances (lib/kit.mjs's defaults, and the gateway test's) +// - +10/11 next-step's dashboard (pinned) +// - +30/31..55 the ports and viewer tests +// - +40/41, +42/43 the data-wait tests (pinned) +// - +56/57 focus, +58/59 presses (pinned, never asking the hub's list: their bases scan nothing) +// - +60/61..85 the hub test; the preview relays bind +62..79 inside it (never at once: serial only) +// - +70/71..95 accounts (its own file) import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; From 1ffe040078e9a158c7886f4900f1929ee328c3a1 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 13 Nov 2026 19:50:38 -0500 Subject: [PATCH 06/10] dashboard: a thread's question answered elsewhere shows its answer as soon as the page's data says so Co-Authored-By: Claude Opus 5.5 --- docs/devteam/dashboard/index.html | 17 ++++++++++++++--- test/dashboard.test.mjs | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index fc8c6dd1..5f207075 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -4606,13 +4606,21 @@

${linkify(k.statement)}

// A question asked in the thread: the Inbox's own card while it is open there (from /api/data), // answered from either place once; once it isn't open, its answer. A review or a secret is answered // in the Inbox, where its page or its form is: two forms that post values is one too many. +// A question as /api/data has it once it isn't open (only its 80 newest such: ooda.answered), or +// nothing: answered elsewhere (the terminal, another tab), the page's data can say so before the +// card's read of the threads does. +const dataAnswered = (id) => (DATA?.ooda?.answered || []).find((x) => x.id === id); function tlQuestion(q, reasonText) { if (ANSWERED.has(q.id)) return `
Answered: ${esc(ANSWERED.get(q.id))}
`; const open = (DATA?.questions || []).find((x) => x.id === q.id); if (q.status === 'open' && open && open.kind !== 'review' && !(open.secrets || []).length) return questionCard(open, DATA.ooda, reasonText, true); + const said = q.status === 'open' && !open ? dataAnswered(q.id) : null; + if (said) return tlDone(q, said); if (q.status === 'open') return `
${qinline(q.question, q.links)}
`; - return `
${qinline(q.question, q.links)}
${q.answer ? `
${esc(q.answer)}
` : ''}
${esc(q.status === 'answered' ? `answered ${CHANNEL_WORDS[q.channel] || ''}` : q.status)}
`; + return tlDone(q, q); } +// A question that isn't open, with its answer and where from as a (the thread's copy, or the page's data) has them. +const tlDone = (q, a) => `
${qinline(q.question, q.links)}
${a.answer ? `
${esc(a.answer)}
` : ''}
${esc(a.status === 'answered' ? `answered ${CHANNEL_WORDS[a.channel] || ''}` : a.status)}
`; // A turn's words, or the question it asks. function tlTurnBody(u, t) { const q = u.question ? t.questions.find((x) => x.id === u.question) : null; @@ -4643,7 +4651,7 @@

${linkify(k.statement)}

// seen only when the owner can see it: a reply landing in a hidden tab still gets its dot if (t && document.visibilityState === 'visible') tlSeen(t); const turns = tlEl('tl-c-turns'); - const sig = JSON.stringify([t, s, title, (t?.questions || []).map((q) => [q.id, (DATA?.questions || []).some((x) => x.id === q.id), ANSWERED.has(q.id)])]); + const sig = JSON.stringify([t, s, title, (t?.questions || []).map((q) => [q.id, (DATA?.questions || []).some((x) => x.id === q.id), !!dataAnswered(q.id), ANSWERED.has(q.id)])]); if (sig !== TL.sig && !turns.contains(document.activeElement)) { TL.sig = sig; const now = t ? tlHome(s) : null; @@ -5151,7 +5159,10 @@

${linkify(k.statement)}

render(shown); if (first) route(); inboxTitle(shown); - // the thread layer, which the redraw doesn't touch: its pins on what was just drawn, then its threads again + // the thread layer, which the redraw doesn't touch: the open card as this data has its questions + // (one answered elsewhere shows answered now, not at the next read), its pins on what was just + // drawn, then its threads again + tlCard(); tlPins(); tlRefresh(); } catch (e) { diff --git a/test/dashboard.test.mjs b/test/dashboard.test.mjs index ab937c83..2cd9a6dd 100644 --- a/test/dashboard.test.mjs +++ b/test/dashboard.test.mjs @@ -3188,6 +3188,32 @@ test('threads on the dashboard page: Shift and point, the card, the pins and the await p.ev('window.__obs.disconnect(); void 0'); }); + await t.test("answered elsewhere (the terminal), with the page's data newer than the card's read: its answer, never \"Answer it in the Inbox\"", async () => { + const Q = F.sup(['thread', 'ask', T, '--question', 'Ship to Canada too?', '--goal', F.goal, '--stake', '1', '--urgency', '1']); + await soon(`!!document.getElementById('t-q-${Q}') && !!document.getElementById('q-${Q}') && document.getElementById('tl-card').dataset.key === '${T}'`, 'both copies'); + // a read of the threads let through and held, so it says open; any Inbox link drawn noted + await p.ev(`window.__threadsQueue = []; window.__inboxLink = false; window.__obs = new MutationObserver((records) => { const link = 'a[href="#q/${Q}"]'; if (records.some((m) => [...m.addedNodes].some((n) => n.nodeType === 1 && (n.matches(link) || !!n.querySelector(link)))) || document.querySelector('#tl-c-turns ' + link)) window.__inboxLink = true; }); window.__obs.observe(document.getElementById('tl-card'), { childList: true, subtree: true }); tlRefresh(); void 0`); + await soon('window.__threadsQueue.length > 0', 'a read saying the question is open, held'); + await p.ev('window.__stale = window.__threadsQueue.length; void 0'); + try { + F.owner(['answer', Q, 'Not this quarter']); + // the page's data lands with the answer, while every read of the threads is still held + await p.ev('load({ force: true }); void 0'); + await soon(`(DATA?.ooda?.answered || []).some((x) => x.id === '${Q}') && !document.getElementById('q-${Q}')`, 'the page\'s data, with the answer'); + await soon(`/Not this quarter/.test(document.getElementById('tl-c-turns').textContent)`, 'the open card redrawn with it, before any read of the threads'); + // the stale reads land: the card draws a copy from before the answer + await p.ev('window.__threadsQueue.splice(0, window.__stale).forEach((go) => go())'); + await p.ev('window.__still()'); + await p.ev('window.__stir()'); + assert.equal(await p.ev(`TL.threads.find((x) => x.id === '${T}').questions.find((x) => x.id === '${Q}').status`), 'open', 'the card drew a read from before the answer'); + assert.equal(await p.ev('window.__inboxLink'), false, 'never "Answer it in the Inbox" for an answered question'); + assert.ok(await p.ev(`(() => { const s = document.getElementById('tl-c-turns').textContent; return /Not this quarter/.test(s) && /answered in your terminal/.test(s); })()`), 'its answer, and where from'); + } finally { await p.ev('(() => { const q = window.__threadsQueue || []; window.__threadsQueue = null; q.forEach((go) => go()); })()'); } + await soon(`TL.threads.find((x) => x.id === '${T}').questions.find((x) => x.id === '${Q}').status === 'answered'`, 'the answer as the log has it'); + assert.equal(await p.ev('window.__inboxLink'), false, 'nor once the threads are read again'); + await p.ev('window.__obs.disconnect(); void 0'); + }); + await t.test('a secrets question in the thread is a link to the Inbox, never a second form', async () => { const Q = F.sup(['thread', 'ask', T, '--kind', 'owner-action', '--question', 'Post the API key', '--secrets', JSON.stringify([{ name: 'KEY', sink: 'file:secrets/key.txt' }]), '--goal', F.goal, '--stake', '1', '--urgency', '1']); await soon(`!!document.querySelector('#q-${Q} [data-secret]') && /Post the API key/.test(document.getElementById('tl-c-turns').textContent)`, 'the Inbox form, the thread turn'); From cab2756a78d082f2d8cad7f896738a5b82f6c5c8 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 13 Nov 2026 19:50:54 -0500 Subject: [PATCH 07/10] spec: a click under the overlay is lost only with no pointer motion, and is kept so the lens stays read-only Co-Authored-By: Claude Opus 5.5 --- docs/superpowers/specs/2026-11-10-supervisor-threads-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-11-10-supervisor-threads-design.md b/docs/superpowers/specs/2026-11-10-supervisor-threads-design.md index 61e28956..bb93f261 100644 --- a/docs/superpowers/specs/2026-11-10-supervisor-threads-design.md +++ b/docs/superpowers/specs/2026-11-10-supervisor-threads-design.md @@ -126,7 +126,7 @@ One piece of `dashboard/index.html`, used by the dashboard and by the viewer (§ 1. Shift down, seen by the parent or reported by the frame's lens (`key {key, shift: true}`, taken only while the frame has the keyboard; the preview lens listens on the app frame's window in the capture phase and re-attaches after every load), raises the overlay over the frame (`pointer-events: auto`). 2. The overlay's `pointermove` sends `hit {id, x, y}` to the lens, at most once per animation frame, under an id minted for that pointer event, and draws the highlight from the `hit-reply`. A question is answered once: its id is let go of as its reply is taken, so a page that answers it again is dropped. An anchor is believed only when its rect holds the point asked about (a few pixels' slack); one without a rect, or elsewhere, is no anchor, and the thread keeps the point alone. 3. A click on the overlay starts a thread on the latest `hit-reply`. With no reply within 150 ms (no lens, an old kit, a frame that ignores it) it falls back to coordinates only: an `artifact` or `preview` subject with `point` (`{x, y}` in frame coordinates) and no `anchor`, which the pin draws in frame coordinates. -4. The parent's own evidence lowers the overlay again (`pointer-events: none`), and the frame gets its clicks back: its own Shift up, blur or Esc, or its pointer moving or pressing without Shift. A press without Shift on an overlay the page raised lowers it at the press, but that press and its click are consumed: the press landed on the overlay, in the parent, so the frame never saw it and can't be handed it, and the owner's next click reaches the page. A `key` from the frame only ever raises it, since the page's own scripts can send the same message at any time: a "Shift up" or an Esc from it lowers nothing while the owner may be holding Shift. An Esc from the frame ends pin mode only. +4. The parent's own evidence lowers the overlay again (`pointer-events: none`), and the frame gets its clicks back: its own Shift up, blur or Esc, or its pointer moving or pressing without Shift. A press without Shift on an overlay the page raised lowers it at the press, but that press and its click are consumed: the press landed on the overlay, in the parent, so the frame never saw it, and the owner's next click reaches the page. Only a click with no pointer motion at all is lost this way: any move without Shift lowers the overlay first, so the click that follows it reaches the page. It is kept deliberately. Handing the click to the lens to perform in the frame would turn the read-only bridge (§6), which only ever reports what is under a point, into one that acts on the page for the parent. A `key` from the frame only ever raises it, since the page's own scripts can send the same message at any time: a "Shift up" or an Esc from it lowers nothing while the owner may be holding Shift. An Esc from the frame ends pin mode only. **The pin button.** In the dock's header: a sticky pin mode for touch devices (`pointer: coarse`) and for a long pass. Touch places a pin under the finger with the highlight following; lifting drops it. Esc or the button again leaves the mode. From 935c585e969a0bce8f7720451e16bf5953b99c3e Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 13 Nov 2026 19:51:46 -0500 Subject: [PATCH 08/10] plan: supervisor threads follow-ups, amended after review Co-Authored-By: Claude Opus 5.5 --- ...2026-11-13-supervisor-threads-followups.md | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-13-supervisor-threads-followups.md diff --git a/docs/superpowers/plans/2026-11-13-supervisor-threads-followups.md b/docs/superpowers/plans/2026-11-13-supervisor-threads-followups.md new file mode 100644 index 00000000..d4e1bdbd --- /dev/null +++ b/docs/superpowers/plans/2026-11-13-supervisor-threads-followups.md @@ -0,0 +1,246 @@ +# Supervisor threads follow-ups Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Close the seven non-blocking follow-ups left after supervisor threads shipped (#185, #194, #205, #215). Nothing the owner sees changes except one edge case that now behaves: a thread's question no longer flashes "Answer it in the Inbox" after an answer from elsewhere. The swallowed click under the overlay (task 7) stays, and the spec says why. + +**Amended after an adversarial review** (the amendments override the first draft): task 2 keeps an in-process memo only, task 5 moves two tests' ports instead of adding an env var, task 6 redraws the card from the page's data directly, and task 7 is a spec note only. + +**Branch:** `threads-followups` off `origin/main` (c2b5ead), worktree `../devteam-threads-followups`. One PR, with one commit per task. Never rebase. + +**Rules:** +- Extend existing files; add no new source or test files. +- Run Node 24 locally on the affected test files only, and let CI cover the rest. +- **Process safety:** never `pkill`, `killall` or `kill` by pattern or name. Only stop pids or process groups you started. Other sessions on this Mac run the same test files. + +**Tech stack:** Node 18 built-ins only, vanilla browser JS, `node:test`, Chrome over CDP. + +**Spec:** `docs/superpowers/specs/2026-11-10-supervisor-threads-design.md`. §8 is edited by task 3, §4 by task 7. + +--- + +## Task 1: Boot clears the pane mark too (cloud/Dockerfile) + +**Why.** After a restart, pane ids start again at `%0`. Boot already removes `run/supervisor-pane` (`cloud/Dockerfile:283`). `run/supervisor-pane-role`, written beside it since #205, is left behind. A mark on its own counts for nothing (`markedPane` needs the two files to agree), so this is tidiness. It also stops a stale mark from ever agreeing with a newly written bare record that happens to reuse the same id. + +**Files:** +- `cloud/Dockerfile:283` +- `test/machine.test.mjs`, next to "the box's boot keeps the list in the form recover reads" (~line 582) + +- [x] **Step 1: Write the failing test.** In `test/machine.test.mjs`, after the existing boot test, add: + ```js + test('the box\'s boot forgets every repo\'s noted pane and its mark', () => { + const df = fs.readFileSync(path.join(REPO, 'cloud', 'Dockerfile'), 'utf8'); + const line = df.split('\n').find((l) => l.startsWith('rm -f "$HOME"/code/*/docs/devteam/run/supervisor-pane')); + assert.ok(line, 'the boot line'); + const home = fs.mkdtempSync(path.join(tmp, 'boot-')); + const run = path.join(home, 'code', 'a', 'docs', 'devteam', 'run'); + fs.mkdirSync(run, { recursive: true }); + for (const f of ['supervisor-pane', 'supervisor-pane-role', 'supervisor-up.json']) fs.writeFileSync(path.join(run, f), '%3\n'); + execFileSync('sh', ['-c', line], { env: { ...process.env, HOME: home } }); + assert.deepEqual(fs.readdirSync(run).sort(), ['supervisor-up.json'], 'the pane and its mark gone; the up record kept'); + }); + ``` +- [x] **Step 2:** Run `node --test --test-name-pattern="forgets every repo" test/machine.test.mjs`. Expect a FAIL: `supervisor-pane-role` is still there. +- [x] **Step 3: Implement.** In `cloud/Dockerfile:283`: + ```sh + rm -f "$HOME"/code/*/docs/devteam/run/supervisor-pane "$HOME"/code/*/docs/devteam/run/supervisor-pane-role + ``` + Extend the comment above it, at `:278-279`, which says pane ids restart at `%0`, to say that the mark goes with the record. +- [x] **Step 4:** Re-run. Expect PASS. +- [x] **Step 5:** Commit: `box: boot forgets the pane mark with the pane`. + +--- + +## Task 2: Memoize `kitHasRecover` in-process (devteam.mjs) + +**Why.** `kitHasRecover(root)` (`devteam.mjs:12145`) spawns the repo's own kit with `help` every time it is asked: `recover --machine` once per repo, `machine status` and `tell` through `notedPaneOf`. + +**Approach (amended).** An in-process `Map` only, keyed by `root` plus the kit file's size and mtime, so an upgraded kit in a long-lived process is asked again. No on-disk cache: a shared file in `~/.devteam` is one more thing every kit on the machine reads and writes, for a saving of one spawn per repo per command. A `help` that failed or timed out (`projectKit` returned null) is never kept, so a slow box asks again next time. If the kit can't be stat'ed, nothing is kept and the answer is today's. + +**Note.** Every current caller asks once per repo per process, so the memo saves nothing observable today; it guards a second ask in one command. There is no new test: none could see it. The existing legacy-kit cases in `test/machine.test.mjs` and `test/recovery.test.mjs` cover the answer itself. + +**Files:** `docs/devteam/scripts/devteam.mjs:12142-12145`. + +- [x] **Step 1: Implement** `kitRecoverSeen` and `kitHasRecover(root)` as above, keeping the comment's meaning ("from its own help: what it says it does"). +- [x] **Step 2:** `node --test test/machine.test.mjs test/recovery.test.mjs`. Expect PASS. +- [x] **Step 3:** Commit: `machine: a repo's kit is asked whether it has recover once per process, and a failed help is never kept`. + +--- + +## Task 3: A published tree is served only for its own slug (devteam.mjs) + +**Why.** `/artifact/tree///…` (`devteam.mjs:13285-13302`) and `artifactAt('tree:', slug)` (`:6946`) accept any tree with an `artifact.json` at its top, under any slug. A tree id and a slug from two different artifacts serve artifact B's files under artifact A's name, in the viewer's bar and in a thread's subject. +- It is not a leak. Only landed artifact folders are servable, all from one origin. +- It is a mislabel, and the spec §8 note says the slug "can't be checked". +- It can be checked: the tree has to have been `/` at some commit on main. + +**Approach (amended order).** `artifactTreeOk(id, slug)` and `artifactTreeOkSync(id, slug)`, memoized on `slug + '\0' + tree`, true answers only: +0. **First, the existing cheap check** (`cat-file -t` is `tree` and `:artifact.json` exists). Only then the slug's binding. +1. **Fast path.** Main's tree for the slug now (`git ls-tree -- /`, as `publicArtifact` reads it) is `tree`. That is the common case: the published version is what main has. +2. **Otherwise, main's history.** + ``` + git log --format= --raw --no-abbrev -t -m --first-parent --no-renames -- / + ``` + One process; it walks only the commits that touch that folder. + - `-t` lists the tree entries themselves. + - `-m --first-parent` diffs merges against main's previous state, so a tree that arrived by a merge is listed. + - Accept if any raw line `: \t` has `path === /` and `new === tree`. +3. A comment notes that `gitReadCache`, when a read-only command turns it on, keeps the sync reads for that one process, and that a shallow clone's history stops short, so an older version there is no one's (the viewer shows main). +4. Memoize a true answer only. A false one may become true once main gains the tree; a tree id is content-addressed, so a later landing can make it right. + +`artifactTreeOk(id)` and `artifactTreeOkSync(id)` become `(id, slug)`. Callers: +- `:13302` passes `am[3]`; +- `artifactAt` (`:6949`) passes `slug`; +- grep `artifactTreeOk` for any others. + +**What the gateway does.** It is untouched. Its `/artifact/tree/` grant is already keyed on "this port's binding published `` at ``" (spec §8), and it forwards to the kit, so the kit's check covers both the direct and the wrapped viewer. + +**Files:** +- `docs/devteam/scripts/devteam.mjs`, at `:6945-6962` and `:13299-13302` +- spec §8, the line at `:187` +- `test/dashboard.test.mjs`, in "the viewer: its route and headers…" (~`:1167`) + +- [x] **Step 1: Write the failing test.** Extend the viewer test. + - Land a second artifact `other-mock`: `landArtifact(F, 'other-mock', VIEWER_MOCK.replace(...))`, with a different title. + - Assert `fetch(\`${B}/artifact/tree/${T2}/viewer-mock/\`)` is 404, and that `view({ slug: 'viewer-mock', at: \`tree:${T2}\` })` is 404. + - Assert the same tree under its own slug is 200. + - Edit `viewer-mock` on main and land it, so main's tree moves to `T3`. Assert that the old `T` under `viewer-mock` is still 200, by history, and that `describe --at tree:` exits 0 in a fresh process. Ask this pair only after main moved and before the test's `git rm` of the folder. + - In the same test, `artifact describe viewer-mock --at tree:` must exit 3. +- [x] **Step 2:** Run `node --test --test-name-pattern="the viewer: its route" test/dashboard.test.mjs`. Expect FAIL: 200 for the crossed slug. +- [x] **Step 3: Implement.** Add a shared parser: + ```js + // Was ever 's folder on main? Main's tree now, else main's history of that folder: + // only those commits are walked, merges against main's previous state (decision 12, tightened). + const artifactTreeBound = new Set(); + const treeInHistory = (out, slug, tree) => out.split('\n').some((l) => { const m = /^:\d+ \d+ [0-9a-f]+ ([0-9a-f]+) \S+\t(.*)$/.exec(l); return m && m[1] === tree && m[2] === `${ARTIFACTS_REL}/${slug}`; }); + ``` + - Sync: `git(['log', '--format=', '--raw', '--no-abbrev', '-t', '-m', '--first-parent', ref, '--', `${ARTIFACTS_REL}/${slug}`], mainRoot)`. + - Async: `execFile` with the same args and `maxBuffer: 16 MB`. + - Check what `git()` does with paths that need quoting. Slugs are `LINK_ID` (lowercase words with `-`) and `ARTIFACTS_REL` is fixed, so git never quotes them. + - Update the comment at `:6953-6956`: "its slug is checked against main's history of its folder". +- [x] **Step 4:** Edit spec §8 (`:187`): "Only an artifact folder's tree is served, and only under the slug whose folder it was on main (main's tree now, or main's history of that folder)…" +- [x] **Step 5:** Re-run the viewer test, then the other `tree:` tests: `node --test --test-name-pattern="tree|viewer|published" test/dashboard.test.mjs test/kit.test.mjs`. Expect PASS. +- [x] **Step 6:** Commit: `artifacts: a published tree is served only under the slug it was on main`. + +--- + +## Task 4: Test `/api/view`'s 500 + +**Why.** `/api/view` (`devteam.mjs:13164-13182`) answers 500 when `artifact describe` fails for any reason other than exit 3 ("nothing there"). Only the page's handling of a 500 is tested (`dashboard.test.mjs:1826`, with a faked fetch). The server path is not tested. + +**How to make `describe` fail for real.** Use a draft worktree whose artifact folder has an unreadable subfolder. +- `draftWorktree` needs only a folder under `/.worktrees/` (`:6917`). +- `artifactSource` returns `{ entries: diskEntries(dir) }`, and `diskEntries` calls `readdirSync` on the subfolder, which throws EACCES. Nothing catches it, so `describe` exits 1 and the server answers 500. +- Skip the test as root (`process.getuid?.() === 0`), where permissions don't hold. Check the CI runner isn't root (GitHub's runner user isn't); the skip still keeps Docker runs honest. + +**Files:** `test/dashboard.test.mjs`, in the viewer test after the 404 cases (~`:1206`). + +- [x] **Step 1: Write the test.** + ```js + // describe failing (not "nothing there") is a 500 in words of its own, never mistaken for a 404 + if (process.getuid?.() !== 0) { + const dir = path.join(F.old, '.worktrees', 'demo-01-locked', 'docs/devteam/project/artifacts/viewer-mock'); + fs.mkdirSync(path.join(dir, 'locked'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'artifact.json'), '{}'); + fs.writeFileSync(path.join(dir, 'index.html'), '

x

'); + fs.chmodSync(path.join(dir, 'locked'), 0); + try { + const r500 = await view({ slug: 'viewer-mock', at: 'demo-01-locked' }); + assert.deepEqual([r500.status, await r500.json()], [500, { error: 'the artifact could not be read' }]); + } finally { fs.chmodSync(path.join(dir, 'locked'), 0o755); } + } + ``` + - Check that `demo-01-locked` matches `WT_NAME` and isn't `review-`/`tmp-`. + - (Amended) The mode is restored in the test body, right after the assertion: `node:test` runs `t.after` hooks in registration order, so one registered now would run after the fixture's `F.done` had already failed to delete the folder. +- [x] **Step 2:** Run `node --test --test-name-pattern="the viewer: its route" test/dashboard.test.mjs`. Expect PASS. The code exists; this is coverage. +- [x] **Step 3: Prove it would catch a regression.** Temporarily change the server's `r.code === 3` to `r.code !== 0`, see the test fail, then revert. +- [x] **Step 4:** Commit: `test: /api/view says 500 when describe fails`. + +--- + +## Task 5: Test dashboards' port layout, stated as it really is (tests only) + +**Why.** Each test process claims 100 ports, `PORTS..PORTS+99` (`test/lib/ports.mjs`). The focus and presses tests (`test/dashboard.test.mjs:533` and `:420`) set bases `+81` and `+91`, whose nominal 25-wide ranges run past the block. + +**What really scans (amended).** A dashboard pinned with `--port` never scans (`devteam.mjs` returns the pinned port before probing), and the hub probes `PORT_RANGE` only for `/instances.json`. Both tests pin their dashboard with `--port freePort()` and never ask the hub's list, so only their hub port is bound. No kit change and no `DEVTEAM_PORT_SPAN`. + +**Approach.** Move the two tests to spots nothing else in the file binds or scans: focus `+56/57`, presses `+58/59` (the viewer test scans `+31..55`, the hub test `+61..85`, the relays bind `+62..79`). Rewrite the `test/lib/ports.mjs` comment with the real rule and the layout: +- a dashboard that picks its own port, or a hub asked for its list, scans base..base+24, so that base must be `+75` or lower; +- a pinned test that never asks the list scans nothing, and only its hub port must be in the block and free of anything an earlier test in that file binds. + +- [x] **Step 1:** Move the bases; rewrite the comment. +- [x] **Step 2:** Run the focus and presses tests by name. Expect PASS. +- [x] **Step 3:** Commit: `test: the focus and presses tests' hubs sit at ports nothing else in the file uses, and the port layout says the real rule`. + +--- + +## Task 6: No "Answer it in the Inbox" after an answer from elsewhere (index.html) + +**Why.** The thread card's copy of a question comes from `/api/threads`, polled every 2 s while the card is open (`index.html:4715`) and every 5 s in a viewer. The Inbox's copy comes from `/api/data`, polled every 10 s (`:5574`). + +When the owner answers from the command line or another tab, two outcomes are possible: +- `/api/threads` sees it first: the card shows the answer. Fine. +- `/api/data` lands first: it no longer lists the question as open, while the card's copy still says `open`. `tlQuestion` (`:4609-4615`) then falls to the bare "Answer it in the Inbox" link (`:4613`) until the next threads poll, up to 2 s. + +(This tab's own answer is already covered by `ANSWERED`; #189 keeps it while a thread copy says open.) + +**Approach.** `/api/data` already carries the answer: `DATA.ooda.answered` (`devteam.mjs:11132`, the 80 newest that aren't open) has `answer`, `channel` and `status`. +- In `tlQuestion`: when the thread copy says `open` but `DATA` has the question in `ooda.answered`, draw it as answered from there, in the same markup as the not-open branch. +- Derive "answered" from `DATA.ooda.answered` through one helper, `dataAnswered(id)`, whose comment says it holds only the 80 newest. +- Add whether the question is in `ooda.answered` to the card's redraw signature (`:4646`). +- (Amended) `drawOrHold` ends in `tlPins(); tlRefresh();` and never redraws the card itself, so a signature change alone waits for the next threads read. Add a direct `tlCard()` before them, so `/api/data`'s answer redraws an open card. No second `tlRefresh()`. +- The viewer has no `DATA` and keeps today's behaviour: its Inbox link is the only way to answer there. + +**Files:** +- `docs/devteam/dashboard/index.html:4609-4615` and `:4646` +- the end of the data draw (after `drawOrHold`, ~`:5204`) +- `test/dashboard.test.mjs`, in the threads browser test, next to "a secrets question in the thread is a link to the Inbox" (`:3165`) + +- [x] **Step 1: Write the failing test (amended: the stale-read race).** After the "read of the threads begun before an answer" test (`:3165`), with the same `__threadsQueue` hold and `MutationObserver`, on a plain question (a secrets one shows the link while open, rightly): + 1. Let a threads read through and hold its answer (it says open). + 2. Answer from the command line: `F.owner(['answer', Q, …])`. + 3. Kick `load({ force: true })`, but wait on `DATA?.ooda?.answered` containing the id, not on the call. + 4. Assert the open card shows the answer before any threads read lands. + 5. Release the stale reads; assert the card drew the stale copy (`open`), the observer never saw the link, and the card says the answer and "answered in your terminal". + 6. Release the rest; assert the log's copy lands and still no link. +- [x] **Step 2:** Run the threads page test. Expect FAIL: the card isn't redrawn from the data. +- [x] **Step 3: Implement.** In `tlQuestion`, before the `q.status === 'open'` link line: + ```js + // answered elsewhere (the CLI, another tab): /api/data can say so before /api/threads does + const said = q.status === 'open' && !open ? (DATA?.ooda?.answered || []).find((x) => x.id === q.id) : null; + if (said) return `
${qinline(q.question, q.links)}
${said.answer ? `
${esc(said.answer)}
` : ''}
${esc(said.status === 'answered' ? `answered ${CHANNEL_WORDS[said.channel] || ''}` : said.status)}
`; + ``` + - Factor the not-open branch and this one into one helper, `tlDone(q, a)`, so they can't drift. + - Signature at `:4646`: add `(DATA?.ooda?.answered || []).some((x) => x.id === q.id)` to the per-question tuple. + - In `drawOrHold`, `tlCard();` before `tlPins(); tlRefresh();`. +- [x] **Step 4:** Re-run. Then run the neighbouring thread-question tests by name, `tlQuestion|Inbox|answered`, including `:3137`'s stale-read test. Expect PASS. +- [x] **Step 5:** Commit: `dashboard: a thread's question answered elsewhere shows its answer as soon as either read says so`. + +--- + +## Task 7: The click the overlay swallows stays, and the spec says why (spec §4 only) + +**Amended: the click hand-over is dropped.** Handing the owner's press to the lens as a `click {x, y}` order would turn the read-only bridge (§6), which only ever reports what is under a point, into one that acts on the page for the parent. The loss is narrow: any pointer move without Shift lowers the overlay first, so only a click with no pointer motion at all is consumed. + +- [x] **Step 1:** Spec §4 step 4 says plainly that the swallowed click needs zero pointer motion and is kept deliberately, because forwarding would make the §6 bridge one that acts. +- [x] **Step 2:** Commit: `spec: a click under the overlay is lost only with no pointer motion, and is kept so the lens stays read-only`. + +--- + +## Finish + +- [ ] Node 24: `node --test test/machine.test.mjs test/recovery.test.mjs test/gateway.test.mjs`, then `node --test --test-concurrency=1 test/dashboard.test.mjs`. That last run is heavy: run it once, at the end, without killing anything you didn't start. +- [ ] Update the memory follow-up list (`supervisor-threads-progress.md`): all seven closed. +- [ ] Open the PR "Supervisor threads: follow-ups", with one section per task. + - Note for the box operator: the Dockerfile's boot line changed (task 1), which takes effect at the next image deploy. The gateway is untouched. + - End the PR body with the attribution line. +- [ ] Fable adversarial review of the PR. Fix findings, re-run the affected tests, and stop at merge-ready. Merging is the upstream loop's job. + +## Risks and open questions + +- **Task 3, history size.** `git log -- ` is cheap: it walks only the commits that touch it, and the answer is cached once true. For a very old artifact with a huge history, the first ask after a restart takes one `git log`. The `/artifact/tree/` handler is async (`execFile`), so the server isn't blocked. `describe` is sync, but it runs in a child process. +- **Task 3, rewritten history.** A tree published from a commit that was later squashed out of main would 404, and the viewer falls back to main as it does for any missing tree. Main is never force-pushed in this workflow, so this is acceptable. +- **Task 5.** If either test ever stops pinning `--port`, or asks the hub's list, its base must drop to `+75` or lower (the rule in `test/lib/ports.mjs`). +- **Task 2.** Saves nothing today (each command asks once per repo); it costs one `stat` per ask. +- **Task 4.** It relies on POSIX permissions; it skips as root. From 95d3dc032c4dbd82e3198878dc97d9d0b50ee5d9 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 13 Nov 2026 20:04:23 -0500 Subject: [PATCH 09/10] machine: drop the kitHasRecover memo: every caller asks once per repo per process, so it never hit Co-Authored-By: Claude Opus 5.5 --- docs/devteam/scripts/devteam.mjs | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index e3a81b8a..03d8e7c2 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -12170,19 +12170,8 @@ function projectKit(root, args, { timeout = 60000 } = {}) { } const projectJson = (root, args) => { try { return JSON.parse(projectKit(root, args) ?? 'null'); } catch { return null; } }; // Whether a repo's kit has recover, from its own help: what it says it does, however a later kit -// is written inside. help reads nothing of the repo's and writes nothing. Kept for this process by -// root and the kit file's size and time, so a kit is started once to say so; a help that failed or -// timed out is asked again next time, never kept. -const kitRecoverSeen = new Map(); -function kitHasRecover(root) { - let key = null; - try { const st = fs.statSync(kitAt(root)); key = `${root}\0${st.size}\0${st.mtimeMs}`; } catch {} - if (key && kitRecoverSeen.has(key)) return kitRecoverSeen.get(key); - const said = projectKit(root, ['help']); - const has = /^\s+recover \[/m.test(said ?? ''); - if (key && said !== null) kitRecoverSeen.set(key, has); - return has; -} +// is written inside. help reads nothing of the repo's and writes nothing. +const kitHasRecover = (root) => /^\s+recover \[/m.test(projectKit(root, ['help']) ?? ''); // The supervisor's pane a repo noted, for the machine's commands: marked, on a kit with recover; // bare, as it is written, on a kit older than recover, which never marks it (nor does legacyRecover, // which writes only what that kit writes). Its supervisor has no role-less pane to take but by its From 665ac99ae9cc7ab517265332e293afdd89553689 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 13 Nov 2026 20:04:23 -0500 Subject: [PATCH 10/10] test: the 500 test removes its draft; the port layout says which file each line is Co-Authored-By: Claude Opus 5.5 --- test/dashboard.test.mjs | 5 ++++- test/lib/ports.mjs | 10 ++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/test/dashboard.test.mjs b/test/dashboard.test.mjs index 2cd9a6dd..58d641ce 100644 --- a/test/dashboard.test.mjs +++ b/test/dashboard.test.mjs @@ -1216,7 +1216,10 @@ test('the viewer: its route and headers, what /api/view says of an artifact, and try { r = await view({ slug: 'viewer-mock', at: 'demo-01-locked' }); assert.deepEqual([r.status, await r.json()], [500, { error: 'the artifact could not be read' }]); - } finally { fs.chmodSync(path.join(draft, 'locked'), 0o755); } + } finally { + fs.chmodSync(path.join(draft, 'locked'), 0o755); + fs.rmSync(path.join(F.old, '.worktrees/demo-01-locked'), { recursive: true, force: true }); + } } const many = await Promise.all([1, 2, 3].map(() => view({ slug: 'viewer-mock', at: 'main' }).then((x) => x.json()))); assert.deepEqual(many.map((x) => x.fp), [lib.fp, lib.fp, lib.fp], 'asks at once share one run, and each has its answer'); diff --git a/test/lib/ports.mjs b/test/lib/ports.mjs index f952a03a..00ba7956 100644 --- a/test/lib/ports.mjs +++ b/test/lib/ports.mjs @@ -10,14 +10,16 @@ // hub's list, scans nothing: only its hub port is bound, and that must be in the block. Tests in one // file run one at a time, so their ranges may overlap, but a port one test binds should be one no // test before it in that file can still be holding. -// The layout, by offset: -// - +0 the file-wide hub, +1..25 its instances (lib/kit.mjs's defaults, and the gateway test's) -// - +10/11 next-step's dashboard (pinned) +// The layout, by offset (each file has a block of its own; this is dashboard.test.mjs's, the most +// crowded, unless a line names another file): +// - +0 the file-wide hub, +1..25 its instances (lib/kit.mjs's defaults) +// - +10/11 next-step's dashboard (next-step.test.mjs; pinned) // - +30/31..55 the ports and viewer tests // - +40/41, +42/43 the data-wait tests (pinned) // - +56/57 focus, +58/59 presses (pinned, never asking the hub's list: their bases scan nothing) // - +60/61..85 the hub test; the preview relays bind +62..79 inside it (never at once: serial only) -// - +70/71..95 accounts (its own file) +// - +70/71..95 accounts (accounts.test.mjs) +// The gateway test claims no block: it picks ports of its own, outside 20000..32699. import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';