From 76f957e3edc696680a972cd24589b2dff722a048 Mon Sep 17 00:00:00 2001 From: Igor Magdich Date: Fri, 7 Aug 2026 01:15:51 +0300 Subject: [PATCH 1/5] fix: give little-coder a Console engine badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ENGINE_META` covered seven of the eight Participant CLI ids, so an Agent joined as `little-coder` fell through to the unknown-engine branch and the Console presented a fully supported platform as unrecognized — the raw id string with the neutral fallback badge. The gap is invisible from the docs-facts guard, which compares generated facts against the platform registry and never reads `web/`. Exporting ENGINE_META lets a test pin its key set to PARTICIPANT_IDS, so the next engine that lands in the shared vocabulary without a badge fails a test instead of shipping. Closes #59 --- web/view-model.test.ts | 15 ++++++++++++++- web/view-model.ts | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/web/view-model.test.ts b/web/view-model.test.ts index 94ac12b..534f728 100644 --- a/web/view-model.test.ts +++ b/web/view-model.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { PARTICIPANT_IDS } from '../src/participants.js'; import type { AgentSnapshotRecord, MessageSnapshotRecord, @@ -13,6 +14,7 @@ import { canRequeue, currentTaskFor, describeEvent, + ENGINE_META, engineMeta, initials, isUnreadToOperator, @@ -216,13 +218,24 @@ describe('colour vocabularies', () => { expect(engineMeta('copilot-cli').label).toBe('Copilot'); expect(engineMeta('antigravity-cli').label).toBe('Antigravity'); expect(engineMeta('pi-cli').label).toBe('Pi'); + expect(engineMeta('little-coder').label).toBe('Little Coder'); expect(engineMeta('opencode-cli').label).toBe('opencode'); // Every registered engine gets a branded badge, never the neutral fallback glyph. - for (const id of ['pi-cli', 'opencode-cli']) { + for (const id of ['pi-cli', 'little-coder', 'opencode-cli']) { expect(engineMeta(id).glyph).not.toBe('·'); } }); + /** + * The roster-drift guard: a Participant CLI that lands in the shared id + * vocabulary without a Console badge renders as an unrecognized platform. + * `tests/unit/docs-facts.test.ts` never reads `web/`, so this is the only + * place the gap can be caught. + */ + it('ENGINE_META covers exactly the shared Participant id vocabulary', () => { + expect(Object.keys(ENGINE_META).sort()).toEqual([...PARTICIPANT_IDS].sort()); + }); + it('engineMeta falls back to a neutral "unknown" badge for null or unrecognized platforms', () => { expect(engineMeta(null)).toMatchObject({ label: 'unknown', glyph: '·' }); expect(engineMeta('some-future-cli').label).toBe('some-future-cli'); diff --git a/web/view-model.ts b/web/view-model.ts index f1d1b34..6a5d021 100644 --- a/web/view-model.ts +++ b/web/view-model.ts @@ -138,7 +138,13 @@ export interface EngineMeta { readonly fg: string; } -const ENGINE_META: Record = { +/** + * One badge per Participant CLI id. Exported so a test can pin its key set to + * `PARTICIPANT_IDS`: a missing entry is invisible at runtime (the unknown-engine + * fallback below renders the raw id), so only an equality guard catches the next + * engine landing in the shared vocabulary without a Console badge. + */ +export const ENGINE_META: Record = { 'claude-code': { label: 'Claude Code', glyph: '✳', @@ -157,6 +163,13 @@ const ENGINE_META: Record = { fg: '#1a7345', }, 'pi-cli': { label: 'Pi', glyph: 'π', color: '#c2317a', bg: '#fbe9f2', fg: '#a52868' }, + 'little-coder': { + label: 'Little Coder', + glyph: '◈', + color: '#0f8ba6', + bg: '#e4f2f6', + fg: '#0b6b80', + }, 'opencode-cli': { label: 'opencode', glyph: '❯', color: '#c9821f', bg: '#f9f0e2', fg: '#a66b12' }, }; From 4bd5d1ffe3e6e25af956cb2ffc8d189dedbe19d7 Mon Sep 17 00:00:00 2001 From: Igor Magdich Date: Fri, 7 Aug 2026 01:16:00 +0300 Subject: [PATCH 2/5] fix: keep the Console team resume detached (FR-U20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FR-U20 requires that a Team launch from the Console be detached and that attaching stay a terminal-only action. The launch route honored that through the `noAttach` seam; the resume route could not, because `runTeamResume`'s deps slice omitted `noAttach` entirely. The resumed plan carries the same `attach: true` a terminal launch would, so `POST /api/team/resume` drove `tmux attach` inside the headless `crew ui` server process — an HTTP request reaching for a terminal the Operator is not sitting at. Widening the slice is the whole fix: the Console passes `noAttach: true` like its launch route, and the CLI omits it so `crew team resume` keeps its plan-driven attach behavior unchanged. No route, requirement, or user-facing capability changes. The test fake now models two things the real panes already do — registering on the configured client and re-joining an archived row with `--resume` — so the resume happy path is reachable and the zero-attach assertion is real rather than an artifact of an early failure. Closes #50 --- src/launcher/resume.ts | 7 ++++- src/ui/actions.ts | 15 ++++++++- tests/integration/ui-server-team.test.ts | 39 ++++++++++++++++++++++-- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/launcher/resume.ts b/src/launcher/resume.ts index bdda35b..cd0f521 100644 --- a/src/launcher/resume.ts +++ b/src/launcher/resume.ts @@ -170,12 +170,17 @@ export async function listResumableSessions( /** * `crew team resume `: strict recovery from a clean stop. The stored * launch plan must still match current tracked config exactly. + * + * `noAttach` is part of the deps slice so a caller that is NOT a terminal can + * force the detached recovery the resume path already describes: the Console + * passes it (FR-U20 — attaching stays a terminal-only action), while the CLI + * omits it and keeps the plan-driven attach behavior unchanged. */ export async function runTeamResume( io: Io, session: string, opts: { readonly json: boolean }, - deps: Pick, + deps: Pick, ): Promise { const root = resolveWorkspaceRoot(io.cwd); if (!(await deps.adapter.isPresent())) { diff --git a/src/ui/actions.ts b/src/ui/actions.ts index c1fbda3..5afee25 100644 --- a/src/ui/actions.ts +++ b/src/ui/actions.ts @@ -373,6 +373,9 @@ export async function launchTeam( * `POST /api/team/resume` — a DETACHED recovery launch of a cleanly stopped * Team session. The stored launch plan must still match the current Team * config exactly; broken leftovers are for `doctor` to diagnose, not repair. + * Like {@link launchTeam}, the `noAttach` seam is passed explicitly: the + * resumed plan carries the same `attach: true` a terminal launch would, and + * this server process is not the Operator's terminal (FR-U20). */ export async function resumeTeam( deps: TeamActionDeps, @@ -381,7 +384,17 @@ export async function resumeTeam( const fields = bodyFields(body, ['session']); const session = requiredString(fields, 'session'); const resume = await capturedRecord(deps.io, async (captured) => { - await runTeamResume(captured, session, { json: true }, deps); + await runTeamResume( + captured, + session, + { json: true }, + { + adapter: deps.adapter, + delay: deps.delay, + relayBin: deps.relayBin, + noAttach: true, + }, + ); }); return { resume }; } diff --git a/tests/integration/ui-server-team.test.ts b/tests/integration/ui-server-team.test.ts index ad46361..106a69d 100644 --- a/tests/integration/ui-server-team.test.ts +++ b/tests/integration/ui-server-team.test.ts @@ -139,7 +139,7 @@ function fakeTmux( ): FakeTmux { const ops: string[] = []; let paneCounter = 0; - let pendingJoin: { id: string; role: string } | null = null; + let pendingJoin: { id: string; role: string; resume: boolean } | null = null; let launchToken: string | undefined; let sessionOwner: string | null = null; // Stateful session existence: a launch creates it, a kill removes it, so a @@ -189,9 +189,16 @@ function fakeTmux( }, setBufferArg: (_b, content) => { ops.push('setBufferArg'); - const parts = content.trim().split(/\s+/); + const tokens = content.trim().split(/\s+/); + // A resume launch appends `--resume` to the same invocation; drop flags + // before positional parsing so the role/id pair is read the same way. + const parts = tokens.filter((token) => !token.startsWith('-')); if (parts.length >= 3 && parts[0]?.includes('crew')) { - pendingJoin = { role: parts[parts.length - 2]!, id: parts[parts.length - 1]! }; + pendingJoin = { + role: parts[parts.length - 2]!, + id: parts[parts.length - 1]!, + resume: tokens.includes('--resume'), + }; } return Promise.resolve(); }, @@ -208,9 +215,14 @@ function fakeTmux( if (pendingJoin !== null) { const store = openWorkspaceStore(cwd, () => 0); try { + // The real pane registers on the configured client, and a resume + // reactivates the archived exact row rather than allocating a suffix; + // both are preconditions `team resume` re-checks before relaunching. store.joinAgent({ id: pendingJoin.id, role: pendingJoin.role, + platformId: 'codex-cli', + ...(pendingJoin.resume ? { resume: true as const } : {}), ...(launchToken !== undefined ? { launchToken } : {}), }); } finally { @@ -501,6 +513,27 @@ describe('POST /api/team/stop (FR-U26–U29 reused)', () => { }); }); +describe('POST /api/team/resume (FR-U20)', () => { + it('resumes a cleanly stopped session DETACHED: zero attach calls', async () => { + const { cwd, io } = teamWorkspace(); + const fake = fakeTmux(cwd); + const { port } = await serve(io, cwd, fake); + + await post(port, '/api/team/launch', { team: 'dev' }); + await post(port, '/api/team/stop', { session: SESSION, confirm: true }); + fake.ops.length = 0; + + const reply = await post(port, '/api/team/resume', { session: SESSION }); + expect(reply.status).toBe(200); + expect(envelope(reply).ok).toBe(true); + // Same detached proof as launch: the session was rebuilt, attach never fired. + // The Console server is not the Operator's terminal, so `tmux attach` must + // never be reachable from an HTTP request (FR-U20). + expect(fake.ops).toContain('newSession'); + expect(fake.ops).not.toContain('attach'); + }); +}); + describe('GET /api/sessions (owned live sessions for Operations)', () => { function getSessions(port: number): Promise { return httpSend(port, `/api/sessions?token=${TOKEN}`, '', {}, 'GET'); From b7dbdd6ba7d7e5c8c02ccc43b3768808d37f847d Mon Sep 17 00:00:00 2001 From: Igor Magdich Date: Fri, 7 Aug 2026 01:16:07 +0300 Subject: [PATCH 3/5] fix: stop shipping Role prompts that promise an undelivered context reset ADR-0016 decided that crew's Relay delivers the Worker context reset, but its Consequences put "the Relay delivery, the registry field, and their requirements" in a follow-up change that has not landed: `clear_safe` reaches no reset delivery in `src/relay.ts` or `src/launcher/`, and no platform record carries a per-engine reset command. The packaged Manager and Worker prompts asserted the unbuilt half as live, so an Agent launched with a shipped Role waits for a reset that never arrives. The ADR outranks the prompt, so the prompt text moves: both now say crew does not deliver the reset yet and a human types it, while keeping ADR-0016's own finding that a Worker cannot reset itself. Closes #23 --- src/templates.ts | 13 +++++++------ tests/unit/templates.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/templates.ts b/src/templates.ts index e099140..6a6f28e 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -30,8 +30,8 @@ Responsibilities: - Once a Task's work has fully landed (approved, merged if your workflow merges), send the Sign-off: if the Task has a worktree, run \`crew task land \` — crew removes the Worker's worktree/branch and sends the - structured Sign-off for you; in a launched crew, crew then resets the - Worker's session itself. If the Task never had a worktree (worktrees + structured Sign-off for you. crew does not deliver the Worker's context reset + yet, so a human still types it. If the Task never had a worktree (worktrees disabled, or the assignee didn't use one), send a plain note yourself (\`crew send "Task : landed, safe to clear your context."\`) — advisory only, crew does not act on it. crew cannot detect a @@ -69,10 +69,11 @@ Responsibilities: honest workflow depends on you leaving review to the Inspector. - Keep your context intact after submitting, in case the Inspector requeues the Task for rework. The Sign-off confirming a Task has fully landed arrives as a - structured message (a Task of yours being abandoned counts the same way). In - a launched crew, crew performs the context reset itself after the Sign-off — - your job is simply to run \`crew receive\` when nudged, then continue with - your next Task. Never reset or compact mid-Task. + structured message (a Task of yours being abandoned counts the same way). You + cannot reset your own context, and crew does not deliver the reset yet — a + human types it. After a Sign-off your job is simply to run \`crew receive\` + when nudged, then continue with your next Task. Never reset or compact + mid-Task. Run bounded one-shot \`crew\` commands, retain your actual agent id, report failures, and wait for a nudge instead of polling. diff --git a/tests/unit/templates.test.ts b/tests/unit/templates.test.ts index 5c495ed..e389729 100644 --- a/tests/unit/templates.test.ts +++ b/tests/unit/templates.test.ts @@ -18,6 +18,31 @@ describe('packaged Roles — ADR-0014 context-clear Sign-off', () => { }); }); +/** + * ADR-0016 decided that crew's Relay — not the Worker — delivers the context + * reset, but its Consequences put "the Relay delivery, the registry field, and + * their requirements" in a follow-up change that has not landed: no reset + * delivery exists in `src/relay.ts` or `src/launcher/`, and no platform record + * carries a per-engine reset command. A shipped prompt must not tell an Agent + * to wait for a reset that never arrives. + */ +describe('packaged Roles — ADR-0016 relay-delivered reset is still a follow-up', () => { + const flat = (body: string): string => body.replace(/\s+/g, ' '); + + it('no packaged Role claims crew already performs the context reset', () => { + for (const [name, body] of Object.entries(PACKAGED_ROLES)) { + expect(flat(body), `${name} promises an undelivered reset`).not.toMatch( + /crew (?:then )?(?:resets|performs the context reset)/i, + ); + } + }); + + it('the Manager and Worker prompts say the reset is not delivered yet', () => { + expect(flat(PACKAGED_ROLES.manager!)).toMatch(/crew does not deliver [^.]*context reset yet/i); + expect(flat(PACKAGED_ROLES.worker!)).toMatch(/crew does not deliver the reset yet/i); + }); +}); + describe('packaged Teams', () => { it('ships the dev team', () => { expect(Object.keys(PACKAGED_TEAMS)).toContain('dev'); From ac090bb5542d7ecae0d8c467b9d93a3c5666aae6 Mon Sep 17 00:00:00 2001 From: Igor Magdich Date: Fri, 7 Aug 2026 01:16:15 +0300 Subject: [PATCH 4/5] fix: put the e2e/ui tsconfig inside the typecheck gate `e2e/ui/tsconfig.json` was referenced by no script and no workflow, and it did not compile: alone among the repo's tsconfigs it set `lib: ["ES2023"]` with no DOM lib and omitted `skipLibCheck`, so `tsc -p e2e/ui/tsconfig.json` failed with 139 errors inside playwright-core's own declarations. Playwright transpiles without type-checking, so the specs escaped the repo's otherwise universal gate while the dangling config implied they were covered. Wiring it into `typecheck` rather than the ui-e2e workflow keeps the gate where every other project already is, so a type error surfaces per-PR instead of in a nightly browser smoke. The added guard walks the repo for tsconfigs and fails on any the script does not compile, so the next dangling project cannot repeat this silently. NOTE: `npm run typecheck` now runs FOUR tsconfig projects, not three. Closes #24 --- e2e/ui/tsconfig.json | 3 +- package.json | 2 +- tests/unit/typecheck-projects.test.ts | 42 +++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 tests/unit/typecheck-projects.test.ts diff --git a/e2e/ui/tsconfig.json b/e2e/ui/tsconfig.json index be59a43..3750d41 100644 --- a/e2e/ui/tsconfig.json +++ b/e2e/ui/tsconfig.json @@ -3,8 +3,9 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ES2023", - "lib": ["ES2023"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "strict": true, + "skipLibCheck": true, "noEmit": true, "types": ["node"] }, diff --git a/package.json b/package.json index 73a46b9..5d5073f 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "build": "tsc -p tsconfig.build.json && npm run build:web", "build:web": "esbuild web/main.tsx --bundle --minify --format=esm --jsx=automatic --jsx-import-source=preact --loader:.css=text --outdir=dist/ui-assets && cp web/index.html dist/ui-assets/index.html", "build:docs": "esbuild docs-site/main.tsx --bundle --minify --format=esm --jsx=automatic --jsx-import-source=preact --loader:.css=text --outdir=dist-docs && cp docs-site/index.html dist-docs/index.html", - "typecheck": "tsc -p tsconfig.json && tsc -p web/tsconfig.json && tsc -p docs-site/tsconfig.json", + "typecheck": "tsc -p tsconfig.json && tsc -p web/tsconfig.json && tsc -p docs-site/tsconfig.json && tsc -p e2e/ui/tsconfig.json", "lint": "eslint . --ignore-pattern dist-docs", "lint:fix": "eslint . --ignore-pattern dist-docs --fix", "format": "prettier --write .", diff --git a/tests/unit/typecheck-projects.test.ts b/tests/unit/typecheck-projects.test.ts new file mode 100644 index 0000000..a4a80f8 --- /dev/null +++ b/tests/unit/typecheck-projects.test.ts @@ -0,0 +1,42 @@ +/** + * Drift guard for the typecheck gate: every TypeScript project in the repo must + * be one the `typecheck` script actually compiles. A `tsconfig.json` that no + * script references gives a false impression of coverage — its sources escape + * the repo's otherwise-universal gate and only fail at runtime. + */ +import { describe, expect, it } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = fileURLToPath(new URL('../../', import.meta.url)); +const SKIP_DIRS = new Set(['node_modules', 'dist', 'dist-docs', 'coverage', '.git', '.crew']); + +/** Every `tsconfig.json` in the repo, as a POSIX path relative to the root. */ +function projectConfigs(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue; + found.push(...projectConfigs(join(dir, entry.name))); + } else if (entry.name === 'tsconfig.json') { + found.push(relative(ROOT, join(dir, entry.name)).split('\\').join('/')); + } + } + return found; +} + +const scripts = ( + JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as { + scripts: Record; + } +).scripts; + +describe('npm run typecheck', () => { + it('compiles every TypeScript project in the repo', () => { + const missing = projectConfigs(ROOT).filter( + (config) => !scripts['typecheck']?.includes(config), + ); + expect(missing, `tsconfigs outside the typecheck gate: ${missing.join(', ')}`).toEqual([]); + }); +}); From 55f0efaba21a3e8cf8a26576a651a165431b22ba Mon Sep 17 00:00:00 2001 From: Igor Magdich Date: Fri, 7 Aug 2026 01:16:24 +0300 Subject: [PATCH 5/5] docs: correct two source comments that assert something false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both comments claim an exactness they do not have, and both are the first thing a reader of that module sees. `src/ui/server.ts`'s header says the write surface is EXACTLY the FR-U19 action POSTs and then lists nine, omitting the `/api/agents/:id/archive` and `/api/agents/:id/restore` routes that `isActionPath` admits and that mutate Agent rows. Anyone auditing the write surface from the header would miss two live routes. The neighbouring docstring already says "FR-U19/FR-U36", so the header now matches it. `src/format.ts` attributes `CREDENTIAL_KEY` to a "name-based env-guardrail set" that no code implements — neither `doctor` nor `setup` reads a variable for reporting. The comment now describes the constant as what it is, the credential-word vocabulary of the keyed-pair redaction in free text (FR-J14), and points the environment property at FR-J13, which actually governs it. Comment-only: no route table, dispatch path, or behavior changes, so no test changes are expected. Closes #60 Closes #68 --- src/format.ts | 7 +++++-- src/ui/server.ts | 12 +++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/format.ts b/src/format.ts index 110c538..5859a73 100644 --- a/src/format.ts +++ b/src/format.ts @@ -116,8 +116,11 @@ const MAX_REDACTED_LENGTH = 2048; // The value (double-quoted with spaces, single-quoted, or a bare token) is masked // only when the key ENDS WITH a credential word — bare or namespaced, so // `launch_token`, `CREW_LAUNCH_TOKEN`, `signing_key`, and `db_credential` all match -// while `monkey`/`author` do not. The credential-word set mirrors the name-based -// env-guardrail set documented in security.md (FR-J14). +// while `monkey`/`author` do not. The credential-word set below is the vocabulary +// of this keyed-pair rule alone — a free-text redaction applied to error and setup +// output (FR-J14). It is not an environment guardrail: crew's no-credential-env +// property (FR-J13) holds because no environment value is ever copied into a +// record, not because any name is matched. const KEYED_PAIR = /([A-Za-z0-9](?:[A-Za-z0-9_-]*[A-Za-z0-9])?)((\s*[=:]\s*)(?:"([^"]*)"|'([^']*)'|([^\s",;]+)))?/g; const CREDENTIAL_KEY = diff --git a/src/ui/server.ts b/src/ui/server.ts index d873e1d..fd2bafa 100644 --- a/src/ui/server.ts +++ b/src/ui/server.ts @@ -12,11 +12,13 @@ * and `/api/resumable-sessions`. The write surface is EXACTLY the FR-U19 * Operator action POSTs: `/api/messages`, `/api/tasks`, * `/api/tasks/:id/approve`, `/api/tasks/:id/requeue`, `/api/team/launch`, - * `/api/team/resume`, `/api/team/stop`, `/api/prune`, and `/api/clean` — - * each handled by `./actions.js` with the actor derived from the - * authenticated Operator session (FR-U13/U14), guarded by the same - * token/Host/no-store posture as every GET, and the destructive three gated - * by the FR-U25 one-click confirmation flag. + * `/api/team/resume`, `/api/team/stop`, `/api/prune`, and `/api/clean`, plus + * the FR-U36 Agent lifecycle pair `/api/agents/:id/archive` and + * `/api/agents/:id/restore` — each handled by `./actions.js` with the actor + * derived from the authenticated Operator session (FR-U13/U14), guarded by the + * same token/Host/no-store posture as every GET, and the destructive ones + * (stop, prune, clean, archive) gated by the FR-U25 one-click confirmation + * flag. * * Change detection is one server-side poller over the monotonic cursors of * `Store.getChangeSignature()` (FR-U22); connected browsers are notified with