Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion e2e/ui/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
},
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
7 changes: 5 additions & 2 deletions src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
7 changes: 6 additions & 1 deletion src/launcher/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,17 @@ export async function listResumableSessions(
/**
* `crew team resume <session>`: 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<LiveLaunchDeps, 'adapter' | 'delay' | 'relayBin'>,
deps: Pick<LiveLaunchDeps, 'adapter' | 'delay' | 'relayBin' | 'noAttach'>,
): Promise<void> {
const root = resolveWorkspaceRoot(io.cwd);
if (!(await deps.adapter.isPresent())) {
Expand Down
13 changes: 7 additions & 6 deletions src/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <you>
<task-id>\` — 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 <you> <worker> "Task <id>: landed, safe to clear your
context."\`) — advisory only, crew does not act on it. crew cannot detect a
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion src/ui/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 };
}
Expand Down
12 changes: 7 additions & 5 deletions src/ui/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 36 additions & 3 deletions tests/integration/ui-server-team.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
},
Expand All @@ -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 {
Expand Down Expand Up @@ -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<HttpReply> {
return httpSend(port, `/api/sessions?token=${TOKEN}`, '', {}, 'GET');
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/typecheck-projects.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}
).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([]);
});
});
15 changes: 14 additions & 1 deletion web/view-model.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { PARTICIPANT_IDS } from '../src/participants.js';
import type {
AgentSnapshotRecord,
MessageSnapshotRecord,
Expand All @@ -13,6 +14,7 @@ import {
canRequeue,
currentTaskFor,
describeEvent,
ENGINE_META,
engineMeta,
initials,
isUnreadToOperator,
Expand Down Expand Up @@ -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');
Expand Down
15 changes: 14 additions & 1 deletion web/view-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,13 @@ export interface EngineMeta {
readonly fg: string;
}

const ENGINE_META: Record<string, EngineMeta> = {
/**
* 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<string, EngineMeta> = {
'claude-code': {
label: 'Claude Code',
glyph: '✳',
Expand All @@ -157,6 +163,13 @@ const ENGINE_META: Record<string, EngineMeta> = {
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' },
};

Expand Down