From d3ccca34a51788d372242480d92c4382c18a4e95 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 24 Apr 2026 22:47:07 +0200 Subject: [PATCH] Unify CLI coordination contracts The CLI and MCP surfaces had parallel answers for session identity, publish staging, database lifetime, and recoverable coordination failures. This consolidates those contracts so hooks, viewer, CLI, and MCP clients branch on the same source of truth. Constraint: Keep the changeset prepublish path as the single publish staging path. Rejected: Keep pack-release.mjs for trimmed dependencies | it drifted from prepack and was not on the release automation path. Rejected: Leave MCP errors message-only | clients need stable codes for recovery branches. Confidence: high Scope-risk: moderate Directive: Do not reintroduce a second release tree unless it is generated from the same prepack source. Tested: pnpm --filter @colony/core exec vitest run test/task-thread.test.ts Tested: pnpm --filter @colony/mcp-server exec vitest run test/task-threads.test.ts Tested: pnpm --filter @imdeadpool/colony-cli exec vitest run test/program.test.ts Tested: pnpm --filter @colony/worker exec vitest run test/server.test.ts Tested: pnpm --filter @colony/core typecheck && pnpm --filter @colony/mcp-server typecheck && pnpm --filter @colony/worker typecheck && pnpm --filter @imdeadpool/colony-cli typecheck Tested: pnpm --filter @imdeadpool/colony-cli build && pnpm --filter @imdeadpool/colony-cli stage-publish Tested: pnpm exec biome check Not-tested: full pnpm test suite; repo-wide pnpm lint still has unrelated baseline package.json formatting outside this change --- CLAUDE.md | 3 +- README.md | 4 + apps/cli/package.json | 11 +- apps/cli/scripts/pack-release.mjs | 58 ------- apps/cli/src/commands/backfill.ts | 16 +- apps/cli/src/commands/debrief.ts | 16 +- apps/cli/src/commands/doctor.ts | 9 +- apps/cli/src/commands/export.ts | 38 ++--- apps/cli/src/commands/foraging.ts | 30 ++-- apps/cli/src/commands/inbox.ts | 181 +++++++++++++--------- apps/cli/src/commands/note.ts | 14 +- apps/cli/src/commands/observe.ts | 5 +- apps/cli/src/commands/reindex.ts | 12 +- apps/cli/src/commands/search.ts | 13 +- apps/cli/src/commands/status.ts | 23 ++- apps/cli/src/util/store.ts | 33 ++++ apps/cli/test/program.test.ts | 67 ++++++-- apps/mcp-server/src/tools/handoff.ts | 43 ++--- apps/mcp-server/src/tools/shared.ts | 33 +++- apps/mcp-server/src/tools/spec.ts | 17 +- apps/mcp-server/src/tools/wake.ts | 42 ++--- apps/mcp-server/test/task-threads.test.ts | 37 +++-- apps/worker/src/server.ts | 29 +++- apps/worker/src/viewer.ts | 79 ++++++---- apps/worker/test/server.test.ts | 22 +++ docs/mcp.md | 14 +- package.json | 4 +- packages/core/src/index.ts | 3 + packages/core/src/task-thread.ts | 154 +++++++++++++++--- packages/core/test/task-thread.test.ts | 44 +++++- scripts/e2e-pack-release.sh | 71 --------- 31 files changed, 630 insertions(+), 495 deletions(-) delete mode 100644 apps/cli/scripts/pack-release.mjs create mode 100644 apps/cli/src/util/store.ts delete mode 100755 scripts/e2e-pack-release.sh diff --git a/CLAUDE.md b/CLAUDE.md index 0ffc636..ce304ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,9 +82,8 @@ evals token-savings and round-trip harness Unit tests cover handlers, storage, and protocol contracts in isolation. They cannot catch issues that only show up in a globally-installed binary: bin-shim symlink resolution, ESM chunk shebangs, `prepublishOnly` staging, native `better-sqlite3` resolution, dynamic-import bundling. Those failure modes have bitten this repo before — they are now guarded by a dedicated script. - `bash scripts/e2e-publish.sh` — covers the **changeset publish** path (CI default). Builds, packs (mirroring what `changeset publish` ships), installs into an isolated `.e2e/` prefix with an isolated `$HOME`, drives every Claude Code hook event with a realistic payload, exercises FTS search and the MCP server, then uninstalls. Self-cleans on success. Required to pass in CI before `changeset publish` runs. -- `bash scripts/e2e-pack-release.sh` — covers the **`pnpm publish:release`** path (legacy bespoke flow that uses `apps/cli/scripts/pack-release.mjs` to write `apps/cli/release/`). Run this if you change `pack-release.mjs` or the `dependencies` block of `apps/cli/package.json`. - The 15 numbered checks in `e2e-publish.sh` must stay green. If you change anything in `apps/cli/`, `packages/installers/`, the hook handler stdout/stderr contract, or the publish surface, re-run it locally before opening a PR. -- Touching the tsup config, the `prepublishOnly` script, or the bin entrypoint guards (`isMainEntry()`) without re-running both scripts is a defect. +- Touching the tsup config, the `prepublishOnly` script, or the bin entrypoint guards (`isMainEntry()`) without re-running `scripts/e2e-publish.sh` is a defect. ## Extension points diff --git a/README.md b/README.md index 4cb549b..4c474a5 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,10 @@ These mechanisms are intentionally simple. Colony favors observable signals, dec - MCP transport is stdio-based, so IDE/runtime restarts can close the server process; the next tool call should reconnect through the installed config. - The viewer is useful for inspection, but the primary workflow is still terminal/agent-driven. +## Demo App + +`apps/hivemind-demo` is a private pedagogical artifact. It models a deterministic multi-agent loop in-process so coordination ideas can be tested without launching real IDE agents. + ## Roadmap - Finish release hygiene for the renamed `colony` package. diff --git a/apps/cli/package.json b/apps/cli/package.json index 7a3ba6a..376fb22 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -32,21 +32,14 @@ "colony": "./dist/index.js" }, "main": "./dist/index.js", - "files": [ - "dist", - "hooks-scripts", - "README.md", - "LICENSE" - ], + "files": ["dist", "hooks-scripts", "README.md", "LICENSE"], "scripts": { "build": "tsup", "dev": "tsup --watch --onSuccess \"node dist/index.js\"", "test": "vitest run", "typecheck": "tsc --noEmit", "stage-publish": "node scripts/prepack.mjs", - "prepublishOnly": "node scripts/prepack.mjs", - "pack:release": "pnpm build && node scripts/pack-release.mjs", - "publish:release": "pnpm pack:release && npm publish ./release --access public" + "prepublishOnly": "node scripts/prepack.mjs" }, "dependencies": { "commander": "^12.1.0", diff --git a/apps/cli/scripts/pack-release.mjs b/apps/cli/scripts/pack-release.mjs deleted file mode 100644 index c59a9e3..0000000 --- a/apps/cli/scripts/pack-release.mjs +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node -// Prepares a self-contained publish directory for the `colony` npm package. -// tsup already bundles all @colony/* workspace code into dist/index.js, so -// the shipped package only needs the real third-party runtime deps. -import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const here = dirname(fileURLToPath(import.meta.url)); -const root = resolve(here, '..'); -const out = join(root, 'release'); - -rmSync(out, { recursive: true, force: true }); -mkdirSync(out, { recursive: true }); - -const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); - -// Keep only the deps tsup marks as external. -const RUNTIME_DEPS = [ - 'commander', - 'kleur', - 'better-sqlite3', - 'hono', - '@hono/node-server', - '@modelcontextprotocol/sdk', -]; -const deps = Object.fromEntries( - RUNTIME_DEPS.filter((n) => pkg.dependencies?.[n]).map((n) => [n, pkg.dependencies[n]]), -); - -const shipped = { - name: pkg.name, - version: pkg.version, - description: pkg.description, - keywords: pkg.keywords, - license: pkg.license, - author: pkg.author, - homepage: pkg.homepage, - repository: pkg.repository, - bugs: pkg.bugs, - engines: pkg.engines, - type: pkg.type, - bin: Object.fromEntries( - Object.entries(pkg.bin).map(([k, v]) => [k, String(v).replace(/^\.\//, '')]), - ), - main: String(pkg.main).replace(/^\.\//, ''), - files: ['dist', 'hooks-scripts', 'README.md', 'LICENSE'], - dependencies: deps, -}; - -writeFileSync(join(out, 'package.json'), `${JSON.stringify(shipped, null, 2)}\n`); - -cpSync(join(root, 'dist'), join(out, 'dist'), { recursive: true }); -cpSync(join(root, '..', '..', 'hooks-scripts'), join(out, 'hooks-scripts'), { recursive: true }); -cpSync(join(root, '..', '..', 'README.md'), join(out, 'README.md')); -cpSync(join(root, '..', '..', 'LICENSE'), join(out, 'LICENSE')); - -process.stdout.write(`release dir ready: ${out}\n`); diff --git a/apps/cli/src/commands/backfill.ts b/apps/cli/src/commands/backfill.ts index 7b89dbb..1ecfcbf 100644 --- a/apps/cli/src/commands/backfill.ts +++ b/apps/cli/src/commands/backfill.ts @@ -1,8 +1,7 @@ -import { join } from 'node:path'; -import { loadSettings, resolveDataDir } from '@colony/config'; +import { loadSettings } from '@colony/config'; import { inferIdeFromSessionId } from '@colony/core'; -import { Storage } from '@colony/storage'; import type { Command } from 'commander'; +import { withStorage } from '../util/store.js'; /** * `colony backfill ide` heals sessions rows whose stored ide is `'unknown'` @@ -25,16 +24,11 @@ export function registerBackfillCommand(program: Command): void { .description('Re-infer the ide column for sessions stored as unknown.') .action(async () => { const settings = loadSettings(); - const storage = new Storage(join(resolveDataDir(settings.dataDir), 'data.db')); - try { - const { scanned, updated } = storage.backfillUnknownIde((id) => - inferIdeFromSessionId(id), - ); + await withStorage(settings, (storage) => { + const { scanned, updated } = storage.backfillUnknownIde((id) => inferIdeFromSessionId(id)); process.stdout.write( `backfill ide: scanned=${scanned} updated=${updated} remaining=${scanned - updated}\n`, ); - } finally { - storage.close(); - } + }); }); } diff --git a/apps/cli/src/commands/debrief.ts b/apps/cli/src/commands/debrief.ts index f4495be..16cae3a 100644 --- a/apps/cli/src/commands/debrief.ts +++ b/apps/cli/src/commands/debrief.ts @@ -1,8 +1,8 @@ -import { join } from 'node:path'; -import { loadSettings, resolveDataDir } from '@colony/config'; -import { Storage } from '@colony/storage'; +import { loadSettings } from '@colony/config'; +import type { Storage } from '@colony/storage'; import type { Command } from 'commander'; import kleur from 'kleur'; +import { withStorage } from '../util/store.js'; /** * Default window: last 24h. The "ran it today" common case. Overridable @@ -179,11 +179,9 @@ export function registerDebriefCommand(program: Command): void { .description('End-of-day collaboration post-mortem: 5 structured sections over DB evidence.') .option('--hours ', 'Window size in hours', String(DEFAULT_HOURS)) .option('--task ', 'Narrow the timeline section to a specific task thread') - .action((opts: { hours: string; task?: string }) => { + .action(async (opts: { hours: string; task?: string }) => { const settings = loadSettings(); - const dbPath = join(resolveDataDir(settings.dataDir), 'data.db'); - const storage = new Storage(dbPath); - try { + await withStorage(settings, (storage) => { const ctx: DebriefContext = { storage, since: Date.now() - Number(opts.hours) * 3_600_000, @@ -206,8 +204,6 @@ export function registerDebriefCommand(program: Command): void { ' • Which failures were missing-tool vs. tool-not-called vs. structural?\n', ); process.stdout.write(' • What was the most valuable moment the system created?\n'); - } finally { - storage.close(); - } + }); }); } diff --git a/apps/cli/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts index a3cbd21..55a008e 100644 --- a/apps/cli/src/commands/doctor.ts +++ b/apps/cli/src/commands/doctor.ts @@ -1,9 +1,8 @@ import { existsSync } from 'node:fs'; -import { join } from 'node:path'; import { loadSettings, resolveDataDir, settingsPath } from '@colony/config'; -import { Storage } from '@colony/storage'; import type { Command } from 'commander'; import kleur from 'kleur'; +import { dataDbPath, withStorage } from '../util/store.js'; export function registerDoctorCommand(program: Command): void { program @@ -17,11 +16,9 @@ export function registerDoctorCommand(program: Command): void { const settings = loadSettings(); const dir = resolveDataDir(settings.dataDir); process.stdout.write(`dataDir: ${dir}\n`); - const dbPath = join(dir, 'data.db'); + const dbPath = dataDbPath(settings); try { - const s = new Storage(dbPath); - const sessions = s.listSessions(1).length; - s.close(); + const sessions = await withStorage(settings, (s) => s.listSessions(1).length); process.stdout.write(`db: ${dbPath} ${kleur.green('ok')} (${sessions} sessions)\n`); } catch (err) { process.stdout.write(`db: ${dbPath} ${kleur.red('fail')} ${String(err)}\n`); diff --git a/apps/cli/src/commands/export.ts b/apps/cli/src/commands/export.ts index cd26d82..e31e65d 100644 --- a/apps/cli/src/commands/export.ts +++ b/apps/cli/src/commands/export.ts @@ -1,9 +1,8 @@ import { readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { loadSettings, resolveDataDir } from '@colony/config'; -import { Storage } from '@colony/storage'; +import { loadSettings } from '@colony/config'; import type { Command } from 'commander'; import { z } from 'zod'; +import { withStorage } from '../util/store.js'; const SessionRecord = z.object({ type: z.literal('session'), @@ -34,18 +33,20 @@ export function registerExportCommand(program: Command): void { .description('Export memory to JSONL') .action(async (out: string) => { const settings = loadSettings(); - const s = new Storage(join(resolveDataDir(settings.dataDir), 'data.db'), { - readonly: true, - }); const lines: string[] = []; - for (const sess of s.listSessions(10000)) { - lines.push(JSON.stringify({ type: 'session', ...sess })); - for (const o of s.timeline(sess.id, undefined, 10000)) { - lines.push(JSON.stringify({ type: 'observation', ...o })); - } - } + await withStorage( + settings, + (s) => { + for (const sess of s.listSessions(10000)) { + lines.push(JSON.stringify({ type: 'session', ...sess })); + for (const o of s.timeline(sess.id, undefined, 10000)) { + lines.push(JSON.stringify({ type: 'observation', ...o })); + } + } + }, + { readonly: true }, + ); writeFileSync(out, lines.join('\n')); - s.close(); process.stdout.write(`wrote ${out} (${lines.length} records)\n`); }); @@ -54,10 +55,9 @@ export function registerExportCommand(program: Command): void { .description('Import memory from JSONL') .action(async (file: string) => { const settings = loadSettings(); - const s = new Storage(join(resolveDataDir(settings.dataDir), 'data.db')); const lines = readFileSync(file, 'utf8').split(/\n+/); let n = 0; - try { + await withStorage(settings, (s) => { for (let i = 0; i < lines.length; i++) { const raw = lines[i]; if (!raw) continue; @@ -65,9 +65,7 @@ export function registerExportCommand(program: Command): void { try { parsed = JSON.parse(raw); } catch (err) { - throw new Error( - `${file}:${i + 1}: invalid JSON — ${(err as Error).message}`, - ); + throw new Error(`${file}:${i + 1}: invalid JSON — ${(err as Error).message}`); } const result = ImportRecord.safeParse(parsed); if (!result.success) { @@ -97,9 +95,7 @@ export function registerExportCommand(program: Command): void { } n++; } - } finally { - s.close(); - } + }); process.stdout.write(`imported ${n} records\n`); }); } diff --git a/apps/cli/src/commands/foraging.ts b/apps/cli/src/commands/foraging.ts index 0ca9fa1..73eb58e 100644 --- a/apps/cli/src/commands/foraging.ts +++ b/apps/cli/src/commands/foraging.ts @@ -1,9 +1,9 @@ -import { join } from 'node:path'; -import { loadSettings, resolveDataDir } from '@colony/config'; -import { MemoryStore } from '@colony/core'; +import { loadSettings } from '@colony/config'; +import type { MemoryStore } from '@colony/core'; import { scanExamples } from '@colony/foraging'; import type { Command } from 'commander'; import kleur from 'kleur'; +import { withStore } from '../util/store.js'; const FORAGING_SESSION_ID = 'foraging'; @@ -37,9 +37,7 @@ export function registerForagingCommand(program: Command): void { return; } const repo_root = opts.cwd ?? process.cwd(); - const dbPath = join(resolveDataDir(settings.dataDir), 'data.db'); - const store = new MemoryStore({ dbPath, settings }); - try { + await withStore(settings, (store) => { ensureForagingSession(store); const result = scanExamples({ repo_root, @@ -56,9 +54,7 @@ export function registerForagingCommand(program: Command): void { process.stdout.write( `${kleur.green('✓')} foraging: ${result.scanned.length} source(s), ${changed} re-indexed, ${result.skipped_unchanged} skipped (unchanged), ${result.indexed_observations} observation(s)\n`, ); - } finally { - store.close(); - } + }); }); group @@ -68,9 +64,7 @@ export function registerForagingCommand(program: Command): void { .action(async (opts: { cwd?: string }) => { const settings = loadSettings(); const repo_root = opts.cwd ?? process.cwd(); - const dbPath = join(resolveDataDir(settings.dataDir), 'data.db'); - const store = new MemoryStore({ dbPath, settings }); - try { + await withStore(settings, (store) => { const rows = store.storage.listExamples(repo_root); if (rows.length === 0) { process.stdout.write( @@ -84,9 +78,7 @@ export function registerForagingCommand(program: Command): void { ` ${kleur.cyan(r.example_name.padEnd(28))} ${kleur.dim((r.manifest_kind ?? 'unknown').padEnd(8))} ${r.observation_count} obs ${kleur.dim(when)}\n`, ); } - } finally { - store.close(); - } + }); }); group @@ -97,9 +89,7 @@ export function registerForagingCommand(program: Command): void { .action(async (opts: { cwd?: string; example?: string }) => { const settings = loadSettings(); const repo_root = opts.cwd ?? process.cwd(); - const dbPath = join(resolveDataDir(settings.dataDir), 'data.db'); - const store = new MemoryStore({ dbPath, settings }); - try { + await withStore(settings, (store) => { const targets = opts.example ? store.storage.listExamples(repo_root).filter((r) => r.example_name === opts.example) : store.storage.listExamples(repo_root); @@ -115,8 +105,6 @@ export function registerForagingCommand(program: Command): void { process.stdout.write( `${kleur.green('✓')} cleared ${targets.length} example(s), dropped ${dropped} observation(s)\n`, ); - } finally { - store.close(); - } + }); }); } diff --git a/apps/cli/src/commands/inbox.ts b/apps/cli/src/commands/inbox.ts index 8b1dba9..af8d20c 100644 --- a/apps/cli/src/commands/inbox.ts +++ b/apps/cli/src/commands/inbox.ts @@ -1,92 +1,125 @@ -import { join } from 'node:path'; -import { loadSettings, resolveDataDir } from '@colony/config'; -import { MemoryStore, buildAttentionInbox } from '@colony/core'; +import { loadSettings } from '@colony/config'; +import { buildAttentionInbox, inferIdeFromSessionId } from '@colony/core'; import type { Command } from 'commander'; import kleur from 'kleur'; +import { withStore } from '../util/store.js'; + +function sessionFromEnv(env: NodeJS.ProcessEnv = process.env): string | undefined { + return ( + env.CODEX_SESSION_ID?.trim() || + env.CLAUDECODE_SESSION_ID?.trim() || + env.CLAUDE_SESSION_ID?.trim() || + undefined + ); +} + +function agentFromSession(sessionId: string): string | undefined { + const ide = inferIdeFromSessionId(sessionId); + if (ide === 'claude-code') return 'claude'; + return ide; +} export function registerInboxCommand(program: Command): void { program .command('inbox') - .description('Compact list of attention items for a session: pending handoffs, wakes, stalled lanes, recent claims') - .requiredOption('--session ', 'your session_id') - .requiredOption('--agent ', 'your agent name (e.g. claude, codex)') + .description( + 'Compact list of attention items for a session: pending handoffs, wakes, stalled lanes, recent claims', + ) + .option( + '--session ', + 'your session_id (defaults to CODEX_SESSION_ID/CLAUDECODE_SESSION_ID)', + ) + .option( + '--agent ', + 'your agent name (e.g. claude, codex); inferred from session when omitted', + ) .option('--repo-root ', 'repo root to scan for stalled lanes') .option('--json', 'emit the full inbox as JSON') - .action(async (opts: { session: string; agent: string; repoRoot?: string; json?: boolean }) => { - const settings = loadSettings(); - const dbPath = join(resolveDataDir(settings.dataDir), 'data.db'); - const store = new MemoryStore({ dbPath, settings }); - try { - const inbox = buildAttentionInbox(store, { - session_id: opts.session, - agent: opts.agent, - ...(opts.repoRoot !== undefined ? { repo_root: opts.repoRoot } : {}), - }); - - if (opts.json) { - process.stdout.write(`${JSON.stringify(inbox, null, 2)}\n`); + .action( + async (opts: { session?: string; agent?: string; repoRoot?: string; json?: boolean }) => { + const session = opts.session?.trim() || sessionFromEnv(); + if (!session) { + process.stderr.write( + `${kleur.red('missing session')} — pass --session or set CODEX_SESSION_ID/CLAUDECODE_SESSION_ID\n`, + ); + process.exitCode = 1; + return; + } + const agent = opts.agent?.trim() || agentFromSession(session); + if (!agent) { + process.stderr.write( + `${kleur.red('missing agent')} — pass --agent or use a session id prefixed with codex@/claude@\n`, + ); + process.exitCode = 1; return; } + const settings = loadSettings(); + await withStore(settings, (store) => { + const inbox = buildAttentionInbox(store, { + session_id: session, + agent, + ...(opts.repoRoot !== undefined ? { repo_root: opts.repoRoot } : {}), + }); - const lines: string[] = []; - lines.push( - kleur.bold( - `Inbox for ${opts.agent}@${opts.session.slice(0, 8)} — ${inbox.summary.next_action}`, - ), - ); - lines.push( - ` handoffs: ${inbox.summary.pending_handoff_count} wakes: ${inbox.summary.pending_wake_count} stalled lanes: ${inbox.summary.stalled_lane_count} recent other claims: ${inbox.summary.recent_other_claim_count}`, - ); + if (opts.json) { + process.stdout.write(`${JSON.stringify(inbox, null, 2)}\n`); + return; + } + + const lines: string[] = []; + lines.push( + kleur.bold(`Inbox for ${agent}@${session.slice(0, 8)} — ${inbox.summary.next_action}`), + ); + lines.push( + ` handoffs: ${inbox.summary.pending_handoff_count} wakes: ${inbox.summary.pending_wake_count} stalled lanes: ${inbox.summary.stalled_lane_count} recent other claims: ${inbox.summary.recent_other_claim_count}`, + ); - if (inbox.pending_handoffs.length > 0) { - lines.push(''); - lines.push(kleur.cyan('Pending handoffs:')); - for (const h of inbox.pending_handoffs) { - const mins = Math.max(0, Math.round((h.expires_at - inbox.generated_at) / 60_000)); - lines.push( - ` #${h.id} task ${h.task_id} from ${h.from_agent} (${mins}m left): ${h.summary}`, - ); - lines.push( - ` accept: task_accept_handoff(handoff_observation_id=${h.id}, session_id="${opts.session}")`, - ); + if (inbox.pending_handoffs.length > 0) { + lines.push(''); + lines.push(kleur.cyan('Pending handoffs:')); + for (const h of inbox.pending_handoffs) { + const mins = Math.max(0, Math.round((h.expires_at - inbox.generated_at) / 60_000)); + lines.push( + ` #${h.id} task ${h.task_id} from ${h.from_agent} (${mins}m left): ${h.summary}`, + ); + lines.push( + ` accept: task_accept_handoff(handoff_observation_id=${h.id}, session_id="${session}")`, + ); + } } - } - if (inbox.pending_wakes.length > 0) { - lines.push(''); - lines.push(kleur.yellow('Pending wakes:')); - for (const w of inbox.pending_wakes) { - const mins = Math.max(0, Math.round((w.expires_at - inbox.generated_at) / 60_000)); - lines.push( - ` #${w.id} task ${w.task_id} from ${w.from_agent} (${mins}m left): ${w.reason}`, - ); - if (w.next_step) lines.push(` next: ${w.next_step}`); - lines.push( - ` ack: task_ack_wake(wake_observation_id=${w.id}, session_id="${opts.session}")`, - ); + if (inbox.pending_wakes.length > 0) { + lines.push(''); + lines.push(kleur.yellow('Pending wakes:')); + for (const w of inbox.pending_wakes) { + const mins = Math.max(0, Math.round((w.expires_at - inbox.generated_at) / 60_000)); + lines.push( + ` #${w.id} task ${w.task_id} from ${w.from_agent} (${mins}m left): ${w.reason}`, + ); + if (w.next_step) lines.push(` next: ${w.next_step}`); + lines.push( + ` ack: task_ack_wake(wake_observation_id=${w.id}, session_id="${session}")`, + ); + } } - } - if (inbox.stalled_lanes.length > 0) { - lines.push(''); - lines.push(kleur.magenta('Stalled lanes:')); - for (const lane of inbox.stalled_lanes) { - lines.push( - ` ${lane.branch} [${lane.activity}] ${lane.owner}: ${lane.activity_summary}`, - ); + if (inbox.stalled_lanes.length > 0) { + lines.push(''); + lines.push(kleur.magenta('Stalled lanes:')); + for (const lane of inbox.stalled_lanes) { + lines.push( + ` ${lane.branch} [${lane.activity}] ${lane.owner}: ${lane.activity_summary}`, + ); + } } - } - if (inbox.recent_other_claims.length > 0) { - lines.push(''); - lines.push(kleur.gray('Recent other-session claims:')); - for (const c of inbox.recent_other_claims) { - lines.push( - ` task ${c.task_id} ${c.file_path} by ${c.by_session_id.slice(0, 8)}`, - ); + if (inbox.recent_other_claims.length > 0) { + lines.push(''); + lines.push(kleur.gray('Recent other-session claims:')); + for (const c of inbox.recent_other_claims) { + lines.push(` task ${c.task_id} ${c.file_path} by ${c.by_session_id.slice(0, 8)}`); + } } - } - process.stdout.write(`${lines.join('\n')}\n`); - } finally { - store.close(); - } - }); + process.stdout.write(`${lines.join('\n')}\n`); + }); + }, + ); } diff --git a/apps/cli/src/commands/note.ts b/apps/cli/src/commands/note.ts index b78e244..7c6aef1 100644 --- a/apps/cli/src/commands/note.ts +++ b/apps/cli/src/commands/note.ts @@ -1,8 +1,8 @@ -import { join } from 'node:path'; -import { loadSettings, resolveDataDir } from '@colony/config'; -import { MemoryStore } from '@colony/core'; +import { loadSettings } from '@colony/config'; +import type { MemoryStore } from '@colony/core'; import type { Command } from 'commander'; import kleur from 'kleur'; +import { withStore } from '../util/store.js'; /** * Reserved session identifier for human scratch notes. Using a fixed id @@ -41,9 +41,7 @@ export function registerNoteCommand(program: Command): void { } const settings = loadSettings(); - const dbPath = join(resolveDataDir(settings.dataDir), 'data.db'); - const store = new MemoryStore({ dbPath, settings }); - try { + await withStore(settings, (store) => { ensureObserverSession(store); const id = store.addObservation({ session_id: OBSERVER_SESSION_ID, @@ -55,8 +53,6 @@ export function registerNoteCommand(program: Command): void { process.stdout.write( `${kleur.green('✓')} note #${id} at ${when}${opts.task ? ` on task #${opts.task}` : ''}\n`, ); - } finally { - store.close(); - } + }); }); } diff --git a/apps/cli/src/commands/observe.ts b/apps/cli/src/commands/observe.ts index 081efbd..b86c150 100644 --- a/apps/cli/src/commands/observe.ts +++ b/apps/cli/src/commands/observe.ts @@ -132,11 +132,12 @@ export function registerObserveCommand(program: Command): void { const storage = new Storage(dbPath); const intervalMs = Math.max(500, Number(opts.interval)); - // \x1b[2J clears the screen; \x1b[H sends the cursor home. Minimal + // \x1b[3J clears scrollback where supported, \x1b[2J clears the + // visible screen, and \x1b[H sends the cursor home. Minimal // cross-platform approach — avoids heavyweight `blessed`/`ink` deps // for what is ultimately a glorified printf loop. const paint = () => { - process.stdout.write('\x1b[2J\x1b[H'); + process.stdout.write('\x1b[3J\x1b[H\x1b[2J'); process.stdout.write(renderFrame(storage)); process.stdout.write(`\n\n${kleur.dim(`refresh ${intervalMs}ms · ctrl-c to exit`)}\n`); }; diff --git a/apps/cli/src/commands/reindex.ts b/apps/cli/src/commands/reindex.ts index 5bc0896..2708f41 100644 --- a/apps/cli/src/commands/reindex.ts +++ b/apps/cli/src/commands/reindex.ts @@ -1,7 +1,6 @@ -import { join } from 'node:path'; -import { loadSettings, resolveDataDir } from '@colony/config'; -import { Storage } from '@colony/storage'; +import { loadSettings } from '@colony/config'; import type { Command } from 'commander'; +import { withStorage } from '../util/store.js'; export function registerReindexCommand(program: Command): void { program @@ -9,12 +8,7 @@ export function registerReindexCommand(program: Command): void { .description('Rebuild FTS index') .action(async () => { const settings = loadSettings(); - const s = new Storage(join(resolveDataDir(settings.dataDir), 'data.db')); - try { - s.rebuildFts(); - } finally { - s.close(); - } + await withStorage(settings, (s) => s.rebuildFts()); process.stdout.write('reindex ok\n'); }); } diff --git a/apps/cli/src/commands/search.ts b/apps/cli/src/commands/search.ts index b892d3c..1877e60 100644 --- a/apps/cli/src/commands/search.ts +++ b/apps/cli/src/commands/search.ts @@ -1,9 +1,8 @@ -import { join } from 'node:path'; -import { loadSettings, resolveDataDir } from '@colony/config'; -import { MemoryStore } from '@colony/core'; +import { loadSettings } from '@colony/config'; import { createEmbedder } from '@colony/embedding'; import type { Command } from 'commander'; import kleur from 'kleur'; +import { withStore } from '../util/store.js'; export function registerSearchCommand(program: Command): void { program @@ -13,9 +12,7 @@ export function registerSearchCommand(program: Command): void { .option('--no-semantic', 'disable semantic re-rank, use BM25 only') .action(async (query: string, opts: { limit: string; semantic: boolean }) => { const settings = loadSettings(); - const dbPath = join(resolveDataDir(settings.dataDir), 'data.db'); - const store = new MemoryStore({ dbPath, settings }); - try { + await withStore(settings, async (store) => { let embedder = undefined; if (opts.semantic && settings.embedding.provider !== 'none') { const t0 = Date.now(); @@ -38,8 +35,6 @@ export function registerSearchCommand(program: Command): void { `${h.id}\t${h.score.toFixed(3)}\t${h.session_id}\t${h.snippet.replace(/\s+/g, ' ')}\n`, ); } - } finally { - store.close(); - } + }); }); } diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 0effa85..9c443f9 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -1,9 +1,9 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { loadSettings, resolveDataDir, settingsPath } from '@colony/config'; -import { Storage } from '@colony/storage'; import type { Command } from 'commander'; import kleur from 'kleur'; +import { dataDbPath, withStorage } from '../util/store.js'; interface WorkerState { provider?: string; @@ -56,11 +56,11 @@ export function registerStatusCommand(program: Command): void { program .command('status') .description('Show colony wiring, data, and worker state') - .action(() => { + .action(async () => { const sp = settingsPath(); const settings = loadSettings(); const dir = resolveDataDir(settings.dataDir); - const dbPath = join(dir, 'data.db'); + const dbPath = dataDbPath(settings); process.stdout.write(`${kleur.bold('colony status')}\n\n`); process.stdout.write( @@ -72,10 +72,10 @@ export function registerStatusCommand(program: Command): void { let obsCount = 0; let sessCount = 0; try { - const s = new Storage(dbPath); - obsCount = s.countObservations(); - sessCount = s.listSessions(10_000).length; - s.close(); + await withStorage(settings, (s) => { + obsCount = s.countObservations(); + sessCount = s.listSessions(10_000).length; + }); process.stdout.write( `db: ${dbPath} ${kleur.green('✓')} (${obsCount} observations, ${sessCount} sessions)\n`, ); @@ -109,6 +109,8 @@ export function registerStatusCommand(program: Command): void { ); if (state.lastError) { process.stdout.write(` ${kleur.red('error:')} ${state.lastError}\n`); + const remediation = embeddingRemediation(provider, state.lastError); + if (remediation) process.stdout.write(` ${kleur.yellow('fix:')} ${remediation}\n`); } } else { process.stdout.write( @@ -132,3 +134,10 @@ export function registerStatusCommand(program: Command): void { } }); } + +function embeddingRemediation(provider: string, error: string): string | null { + if (provider === 'local' && /@xenova\/transformers|transformers/i.test(error)) { + return 'local provider requires @xenova/transformers — install it or switch embedding.provider to none, ollama, or openai.'; + } + return null; +} diff --git a/apps/cli/src/util/store.ts b/apps/cli/src/util/store.ts new file mode 100644 index 0000000..8b9a77a --- /dev/null +++ b/apps/cli/src/util/store.ts @@ -0,0 +1,33 @@ +import { join } from 'node:path'; +import { type Settings, resolveDataDir } from '@colony/config'; +import { MemoryStore } from '@colony/core'; +import { Storage } from '@colony/storage'; + +export function dataDbPath(settings: Settings): string { + return join(resolveDataDir(settings.dataDir), 'data.db'); +} + +export async function withStore( + settings: Settings, + run: (store: MemoryStore) => T | Promise, +): Promise { + const store = new MemoryStore({ dbPath: dataDbPath(settings), settings }); + try { + return await run(store); + } finally { + store.close(); + } +} + +export async function withStorage( + settings: Settings, + run: (storage: Storage) => T | Promise, + options: { readonly?: boolean } = {}, +): Promise { + const storage = new Storage(dataDbPath(settings), options); + try { + return await run(storage); + } finally { + storage.close(); + } +} diff --git a/apps/cli/test/program.test.ts b/apps/cli/test/program.test.ts index 820fac9..ebee2ec 100644 --- a/apps/cli/test/program.test.ts +++ b/apps/cli/test/program.test.ts @@ -2,35 +2,78 @@ import { describe, expect, it } from 'vitest'; import { createProgram } from '../src/index.js'; describe('Colony CLI program', () => { - it('registers every top-level command', () => { + it('registers the stable top-level commands users rely on', () => { const program = createProgram(); const names = program.commands.map((c) => c.name()).sort(); const expected = [ - 'backfill', - 'compress', - 'config', - 'debrief', 'doctor', - 'expand', - 'export', 'foraging', 'hook', - 'import', + 'inbox', 'install', 'mcp', 'note', 'observe', 'reindex', - 'restart', 'search', 'start', 'status', 'stop', - 'uninstall', 'viewer', - 'worker', ].sort(); - expect(names).toEqual(expected); + for (const name of expected) { + expect(names).toContain(name); + } + }); + + it('keeps help output reviewable without making command registration brittle', () => { + const program = createProgram(); + expect(program.helpInformation()).toMatchInlineSnapshot(` + "Usage: colony [options] [command] + + Cross-agent persistent memory with compressed storage. + + Options: + -V, --version output the version number + -h, --help display help for command + + Commands: + install [options] Register hooks + MCP server for an IDE + uninstall [options] Remove IDE integration + status Show colony wiring, data, and worker state + config View or edit colony settings + doctor Run health checks + start Start the worker daemon (embeddings + viewer) + stop Stop the worker daemon + restart Restart the worker daemon + viewer Open the memory viewer in your browser + (auto-starts worker) + worker Manage local worker daemon + mcp Run the MCP stdio server (typically invoked by the + IDE) + search [options] Query memory from the terminal + compress [options] Compress a file in place (.original backup + created) + expand Expand abbreviations in a file + export Export memory to JSONL + import Import memory from JSONL + hook Internal: hook handler entrypoints + reindex Rebuild FTS index + backfill Heal historical rows that predate newer inference + logic. + note [options] Record a timestamped scratch note into the memory + timeline + observe [options] Live dashboard of collaboration state. Run in a + spare terminal during a session. + debrief [options] End-of-day collaboration post-mortem: 5 structured + sections over DB evidence. + inbox [options] Compact list of attention items for a session: + pending handoffs, wakes, stalled lanes, recent + claims + foraging Index and query /examples food sources + help [command] display help for command + " + `); }); it('the install command accepts --ide', () => { diff --git a/apps/mcp-server/src/tools/handoff.ts b/apps/mcp-server/src/tools/handoff.ts index e77e147..f114517 100644 --- a/apps/mcp-server/src/tools/handoff.ts +++ b/apps/mcp-server/src/tools/handoff.ts @@ -1,7 +1,8 @@ -import { TaskThread } from '@colony/core'; +import { TASK_THREAD_ERROR_CODES, TaskThread } from '@colony/core'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { ToolContext } from './context.js'; +import { mcpError, mcpErrorResponse } from './shared.js'; export function register(server: McpServer, ctx: ToolContext): void { const { store } = ctx; @@ -58,27 +59,17 @@ export function register(server: McpServer, ctx: ToolContext): void { async ({ handoff_observation_id, session_id }) => { const obs = store.storage.getObservation(handoff_observation_id); if (!obs?.task_id) { - return { - content: [ - { type: 'text', text: JSON.stringify({ error: 'observation is not on a task' }) }, - ], - isError: true, - }; + return mcpErrorResponse( + TASK_THREAD_ERROR_CODES.OBSERVATION_NOT_ON_TASK, + 'observation is not on a task', + ); } const thread = new TaskThread(store, obs.task_id); try { thread.acceptHandoff(handoff_observation_id, session_id); return { content: [{ type: 'text', text: JSON.stringify({ status: 'accepted' }) }] }; } catch (err) { - return { - content: [ - { - type: 'text', - text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }), - }, - ], - isError: true, - }; + return mcpError(err); } }, ); @@ -94,27 +85,17 @@ export function register(server: McpServer, ctx: ToolContext): void { async ({ handoff_observation_id, session_id, reason }) => { const obs = store.storage.getObservation(handoff_observation_id); if (!obs?.task_id) { - return { - content: [ - { type: 'text', text: JSON.stringify({ error: 'observation is not on a task' }) }, - ], - isError: true, - }; + return mcpErrorResponse( + TASK_THREAD_ERROR_CODES.OBSERVATION_NOT_ON_TASK, + 'observation is not on a task', + ); } const thread = new TaskThread(store, obs.task_id); try { thread.declineHandoff(handoff_observation_id, session_id, reason); return { content: [{ type: 'text', text: JSON.stringify({ status: 'cancelled' }) }] }; } catch (err) { - return { - content: [ - { - type: 'text', - text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }), - }, - ], - isError: true, - }; + return mcpError(err); } }, ); diff --git a/apps/mcp-server/src/tools/shared.ts b/apps/mcp-server/src/tools/shared.ts index 4aee474..c6c63a3 100644 --- a/apps/mcp-server/src/tools/shared.ts +++ b/apps/mcp-server/src/tools/shared.ts @@ -4,6 +4,8 @@ import type { HivemindSnapshot, SearchResult, } from '@colony/core'; +import { TASK_THREAD_ERROR_CODES, TaskThreadError } from '@colony/core'; +import type { TaskThreadErrorCode } from '@colony/core'; export interface HivemindToolOptions { repo_root: string | undefined; @@ -53,10 +55,33 @@ export function toHivemindOptions(input: HivemindToolOptions): HivemindOptions { return options; } -export function buildContextQuery( - query: string | undefined, - sessions: HivemindSession[], -): string { +export function mcpError(err: unknown): { + content: Array<{ type: 'text'; text: string }>; + isError: true; +} { + const error = err instanceof Error ? err.message : String(err); + const code = + err instanceof TaskThreadError ? err.code : TASK_THREAD_ERROR_CODES.OBSERVATION_NOT_ON_TASK; + return { + content: [{ type: 'text', text: JSON.stringify({ code, error }) }], + isError: true, + }; +} + +export function mcpErrorResponse( + code: TaskThreadErrorCode | 'SPEC_TASK_NOT_FOUND' | 'SPEC_CHANGE_NOT_FOUND', + error: string, +): { + content: Array<{ type: 'text'; text: string }>; + isError: true; +} { + return { + content: [{ type: 'text', text: JSON.stringify({ code, error }) }], + isError: true, + }; +} + +export function buildContextQuery(query: string | undefined, sessions: HivemindSession[]): string { if (query?.trim()) return query.trim(); const taskText = sessions .flatMap((session) => [ diff --git a/apps/mcp-server/src/tools/spec.ts b/apps/mcp-server/src/tools/spec.ts index 8c362ac..264d815 100644 --- a/apps/mcp-server/src/tools/spec.ts +++ b/apps/mcp-server/src/tools/spec.ts @@ -11,6 +11,7 @@ import { import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { ToolContext } from './context.js'; +import { mcpErrorResponse } from './shared.js'; export function register(server: McpServer, ctx: ToolContext): void { const { store } = ctx; @@ -106,9 +107,7 @@ export function register(server: McpServer, ctx: ToolContext): void { ...(task ? { task_id: task.task_id } : {}), }); return { - content: [ - { type: 'text', text: JSON.stringify({ delta_count: change.deltaRows.length }) }, - ], + content: [{ type: 'text', text: JSON.stringify({ delta_count: change.deltaRows.length }) }], }; }, ); @@ -125,10 +124,7 @@ export function register(server: McpServer, ctx: ToolContext): void { const spec = repo.readRoot(); const resolved = resolveTaskContext(spec, task_id); if (!resolved) { - return { - content: [{ type: 'text', text: JSON.stringify({ error: `no task ${task_id}` }) }], - isError: true, - }; + return mcpErrorResponse('SPEC_TASK_NOT_FOUND', `no task ${task_id}`); } return { content: [ @@ -163,12 +159,7 @@ export function register(server: McpServer, ctx: ToolContext): void { const repo = new SpecRepository({ repoRoot: args.repo_root, store }); const specTask = repo.listSpecTasks().find((t) => t.slug === args.slug); if (!specTask) { - return { - content: [ - { type: 'text', text: JSON.stringify({ error: `no open change ${args.slug}` }) }, - ], - isError: true, - }; + return mcpErrorResponse('SPEC_CHANGE_NOT_FOUND', `no open change ${args.slug}`); } const signature = computeFailureSignature({ test_id: args.test_id, diff --git a/apps/mcp-server/src/tools/wake.ts b/apps/mcp-server/src/tools/wake.ts index 7ff69c7..ddcca3b 100644 --- a/apps/mcp-server/src/tools/wake.ts +++ b/apps/mcp-server/src/tools/wake.ts @@ -1,12 +1,14 @@ import { type AttentionInboxOptions, ProposalSystem, + TASK_THREAD_ERROR_CODES, TaskThread, buildAttentionInbox, } from '@colony/core'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { ToolContext } from './context.js'; +import { mcpError, mcpErrorResponse } from './shared.js'; export function register(server: McpServer, ctx: ToolContext): void { const { store } = ctx; @@ -55,27 +57,17 @@ export function register(server: McpServer, ctx: ToolContext): void { async ({ wake_observation_id, session_id }) => { const obs = store.storage.getObservation(wake_observation_id); if (!obs?.task_id) { - return { - content: [ - { type: 'text', text: JSON.stringify({ error: 'observation is not on a task' }) }, - ], - isError: true, - }; + return mcpErrorResponse( + TASK_THREAD_ERROR_CODES.OBSERVATION_NOT_ON_TASK, + 'observation is not on a task', + ); } const thread = new TaskThread(store, obs.task_id); try { thread.acknowledgeWake(wake_observation_id, session_id); return { content: [{ type: 'text', text: JSON.stringify({ status: 'acknowledged' }) }] }; } catch (err) { - return { - content: [ - { - type: 'text', - text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }), - }, - ], - isError: true, - }; + return mcpError(err); } }, ); @@ -91,27 +83,17 @@ export function register(server: McpServer, ctx: ToolContext): void { async ({ wake_observation_id, session_id, reason }) => { const obs = store.storage.getObservation(wake_observation_id); if (!obs?.task_id) { - return { - content: [ - { type: 'text', text: JSON.stringify({ error: 'observation is not on a task' }) }, - ], - isError: true, - }; + return mcpErrorResponse( + TASK_THREAD_ERROR_CODES.OBSERVATION_NOT_ON_TASK, + 'observation is not on a task', + ); } const thread = new TaskThread(store, obs.task_id); try { thread.cancelWake(wake_observation_id, session_id, reason); return { content: [{ type: 'text', text: JSON.stringify({ status: 'cancelled' }) }] }; } catch (err) { - return { - content: [ - { - type: 'text', - text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }), - }, - ], - isError: true, - }; + return mcpError(err); } }, ); diff --git a/apps/mcp-server/test/task-threads.test.ts b/apps/mcp-server/test/task-threads.test.ts index 8fd26a9..515bbc3 100644 --- a/apps/mcp-server/test/task-threads.test.ts +++ b/apps/mcp-server/test/task-threads.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { defaultSettings } from '@colony/config'; -import { MemoryStore, TaskThread } from '@colony/core'; +import { MemoryStore, TASK_THREAD_ERROR_CODES, TaskThread } from '@colony/core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -22,6 +22,19 @@ async function call(name: string, args: Record): Promise return JSON.parse(text) as T; } +async function callError( + name: string, + args: Record, +): Promise<{ + code: string; + error: string; +}> { + const res = await client.callTool({ name, arguments: args }); + expect(res.isError).toBe(true); + const text = (res.content as Array<{ type: string; text: string }>)[0]?.text ?? '{}'; + return JSON.parse(text) as { code: string; error: string }; +} + /** * Seeds the fixture every task-thread test needs: two participating sessions * and a task thread they're both joined to. We bypass the hook layer here @@ -159,11 +172,11 @@ describe('task threads — handoff lifecycle', () => { meta.expires_at = Date.now() - 1000; store.storage.updateObservationMetadata(handoff_observation_id, JSON.stringify(meta)); - const res = await client.callTool({ - name: 'task_accept_handoff', - arguments: { handoff_observation_id, session_id: sessionB }, + const error = await callError('task_accept_handoff', { + handoff_observation_id, + session_id: sessionB, }); - expect(res.isError).toBe(true); + expect(error.code).toBe(TASK_THREAD_ERROR_CODES.HANDOFF_EXPIRED); // Metadata must flip to `expired` so the sender sees the outcome on // their next turn — staying `pending` after a failed accept would @@ -207,11 +220,8 @@ describe('task threads — handoff lifecycle', () => { expect(meta.status).toBe('acknowledged'); expect(meta.acknowledged_by_session_id).toBe(sessionB); - const retry = await client.callTool({ - name: 'task_ack_wake', - arguments: { wake_observation_id, session_id: sessionB }, - }); - expect(retry.isError).toBe(true); + const retry = await callError('task_ack_wake', { wake_observation_id, session_id: sessionB }); + expect(retry.code).toBe(TASK_THREAD_ERROR_CODES.ALREADY_ACKNOWLEDGED); }); it('task_cancel_wake cancels a pending wake without side effects on claims', async () => { @@ -235,11 +245,8 @@ describe('task threads — handoff lifecycle', () => { const meta = JSON.parse(row?.metadata ?? '{}'); expect(meta.status).toBe('cancelled'); - const res = await client.callTool({ - name: 'task_ack_wake', - arguments: { wake_observation_id, session_id: sessionB }, - }); - expect(res.isError).toBe(true); + const error = await callError('task_ack_wake', { wake_observation_id, session_id: sessionB }); + expect(error.code).toBe(TASK_THREAD_ERROR_CODES.ALREADY_CANCELLED); }); it("task_updates_since filters out the caller's own posts", async () => { diff --git a/apps/worker/src/server.ts b/apps/worker/src/server.ts index bf67a1b..4f2596d 100644 --- a/apps/worker/src/server.ts +++ b/apps/worker/src/server.ts @@ -11,6 +11,8 @@ import { Hono } from 'hono'; import { type EmbedLoopHandle, startEmbedLoop, stateFilePath } from './embed-loop.js'; import { renderIndex, renderSession } from './viewer.js'; +const HIVEMIND_CACHE_TTL_MS = 500; + export interface WorkerAppOptions { hivemindRepoRoots?: string[]; } @@ -21,6 +23,7 @@ export function buildApp( options: WorkerAppOptions = {}, ): Hono { const app = new Hono(); + const readCachedHivemind = createHivemindReader(options); app.use('*', async (_c, next) => { loop?.touch(); @@ -39,7 +42,7 @@ export function buildApp( return c.json(store.storage.listSessions(limit)); }); - app.get('/api/hivemind', (c) => c.json(readWorkerHivemind(options))); + app.get('/api/hivemind', (c) => c.json(readCachedHivemind())); app.get('/api/colony/tasks', (c) => { const repoRoot = c.req.query('repo_root'); @@ -125,9 +128,7 @@ export function buildApp( return c.json(await store.search(q, limit)); }); - app.get('/', (c) => - c.html(renderIndex(store.storage.listSessions(50), readWorkerHivemind(options))), - ); + app.get('/', (c) => c.html(renderIndex(store.storage.listSessions(50), readCachedHivemind()))); app.get('/sessions/:id', (c) => { const id = c.req.param('id'); const session = store.storage.getSession(id); @@ -156,6 +157,18 @@ function safeJsonObject(raw: string | null): Record { } } +function createHivemindReader(options: WorkerAppOptions): () => ReturnType { + let cached: ReturnType | null = null; + let cachedAt = 0; + return () => { + const now = Date.now(); + if (cached && now - cachedAt < HIVEMIND_CACHE_TTL_MS) return cached; + cached = readWorkerHivemind(options); + cachedAt = now; + return cached; + }; +} + function readWorkerHivemind(options: WorkerAppOptions): ReturnType { const input: HivemindOptions = { limit: 20 }; if (options.hivemindRepoRoots?.length) { @@ -195,14 +208,14 @@ export async function start(): Promise { // Build embedder if provider != 'none'. Model load runs in the worker // process only — hooks never wait for it. let embedder = null; + let embedderError: string | null = null; try { embedder = await createEmbedder(settings, { log: (line) => process.stderr.write(`${line}\n`), }); } catch (err) { - process.stderr.write( - `[colony worker] embedder unavailable: ${err instanceof Error ? err.message : String(err)}\n`, - ); + embedderError = err instanceof Error ? err.message : String(err); + process.stderr.write(`[colony worker] embedder unavailable: ${embedderError}\n`); } if (embedder) { @@ -227,7 +240,7 @@ export async function start(): Promise { total: store.storage.countObservations(), lastBatchAt: null, lastBatchMs: null, - lastError: null, + lastError: embedderError, lastHttpAt: Date.now(), startedAt: Date.now(), }, diff --git a/apps/worker/src/viewer.ts b/apps/worker/src/viewer.ts index 3e1554d..db4cf01 100644 --- a/apps/worker/src/viewer.ts +++ b/apps/worker/src/viewer.ts @@ -1,4 +1,4 @@ -import { inferIdeFromSessionId, type HivemindSession, type HivemindSnapshot } from '@colony/core'; +import { type HivemindSession, type HivemindSnapshot, inferIdeFromSessionId } from '@colony/core'; import type { SessionRow } from '@colony/storage'; const style = ` @@ -27,8 +27,33 @@ const style = ` .owner[data-derived="true"] { font-style: italic; opacity: 0.85; } `; +interface SafeHtml { + readonly __html: string; +} + +function raw(value: string): SafeHtml { + return { __html: value }; +} + +function html(strings: TemplateStringsArray, ...values: unknown[]): string { + let out = strings[0] ?? ''; + for (let i = 0; i < values.length; i++) { + out += renderHtmlValue(values[i]); + out += strings[i + 1] ?? ''; + } + return out; +} + +function renderHtmlValue(value: unknown): string { + if (Array.isArray(value)) return value.map(renderHtmlValue).join(''); + if (value && typeof value === 'object' && '__html' in value) { + return String((value as SafeHtml).__html); + } + return esc(String(value ?? '')); +} + function layout(title: string, body: string): string { - return `${title}

agents-hivemind

local memory + runtime viewer
${body}
`; + return html`${title}

agents-hivemind

local memory + runtime viewer
${raw(body)}
`; } function esc(s: string): string { @@ -53,34 +78,34 @@ function resolveOwner(storedIde: string, sessionId: string): { ide: string; deri function ownerChip(ide: string, derived: boolean): string { const label = derived ? `${ide}?` : ide; - return `${esc(label)}`; + return html`${label}`; } export function renderIndex(sessions: SessionRow[], snapshot?: HivemindSnapshot): string { const dashboard = snapshot ? renderHivemindDashboard(snapshot) : ''; if (sessions.length === 0) { - return layout('agents-hivemind', `${dashboard}

No memory sessions yet.

`); + return layout('agents-hivemind', html`${raw(dashboard)}

No memory sessions yet.

`); } const ownerCounts = new Map(); const items = sessions .map((s) => { const owner = resolveOwner(s.ide, s.id); ownerCounts.set(owner.ide, (ownerCounts.get(owner.ide) ?? 0) + 1); - const cwdHtml = s.cwd ? ` · ${esc(s.cwd)}` : ''; - return ` + const cwdHtml = s.cwd ? html` · ${s.cwd}` : ''; + return html`
-
${ownerChip(owner.ide, owner.derived)}${esc(s.id)}
-
${new Date(s.started_at).toISOString()}${cwdHtml}
+
${raw(ownerChip(owner.ide, owner.derived))}${s.id}
+
${new Date(s.started_at).toISOString()}${raw(cwdHtml)}
`; }) .join(''); const summary = [...ownerCounts.entries()] .sort(([, a], [, b]) => b - a) - .map(([ide, n]) => `${esc(ide)} · ${n}`) + .map(([ide, n]) => html`${ide} · ${n}`) .join(' '); return layout( 'agents-hivemind · sessions', - `${dashboard}

Recent memory sessions

${summary}

${items}`, + html`${raw(dashboard)}

Recent memory sessions

${raw(summary)}

${raw(items)}`, ); } @@ -90,17 +115,17 @@ export function renderSession( ): string { const rows = observations .map( - (o) => ` + (o) => html`
-
#${o.id} · ${esc(o.kind)} · ${new Date(o.ts).toISOString()}
-
${esc(o.content)}
+
#${o.id} · ${o.kind} · ${new Date(o.ts).toISOString()}
+
${o.content}
`, ) .join(''); const owner = resolveOwner(session.ide, session.id); return layout( `agents-hivemind · ${session.id}`, - `

${ownerChip(owner.ide, owner.derived)}${esc(session.id)}

← all sessions

${rows}`, + html`

${raw(ownerChip(owner.ide, owner.derived))}${session.id}

← all sessions

${raw(rows)}`, ); } @@ -110,7 +135,7 @@ function renderHivemindDashboard(snapshot: HivemindSnapshot): string { ? snapshot.sessions.map(renderLane).join('') : '

No active Hivemind lanes found for configured repo roots.

'; - return ` + return html`

Hivemind runtime

@@ -121,10 +146,12 @@ function renderHivemindDashboard(snapshot: HivemindSnapshot): string {
${ needsAttention.length > 0 - ? `

${needsAttention.length} lane needs attention

` - : '

runtime clean

' + ? raw( + html`

${needsAttention.length} lane needs attention

`, + ) + : raw('

runtime clean

') } - ${lanes} + ${raw(lanes)}
`; } @@ -132,18 +159,18 @@ function renderLane(session: HivemindSession): string { const attention = laneNeedsAttention(session); const lockSummary = session.locked_file_count > 0 - ? `
GX locks ${session.locked_file_count}: ${esc(session.locked_file_preview.join(', '))}
` + ? html`
GX locks ${session.locked_file_count}: ${session.locked_file_preview.join(', ')}
` : ''; const ownerIde = laneOwnerIde(session); const ownerDerived = ownerIde !== session.agent && ownerIde !== session.cli; - return ` + return html`
-
${ownerChip(ownerIde, ownerDerived)}${esc(session.task || session.task_name || session.branch)} - ${esc(session.activity)}
-
${esc(session.agent)}/${esc(session.cli)} · ${esc(session.branch)} · ${esc(session.source)}
-
${esc(session.activity_summary)} Updated ${esc(session.updated_at || 'unknown')}.
- ${lockSummary} -
${esc(session.worktree_path)}
+
${raw(ownerChip(ownerIde, ownerDerived))}${session.task || session.task_name || session.branch} + ${session.activity}
+
${session.agent}/${session.cli} · ${session.branch} · ${session.source}
+
${session.activity_summary} Updated ${session.updated_at || 'unknown'}.
+ ${raw(lockSummary)} +
${session.worktree_path}
`; } diff --git a/apps/worker/test/server.test.ts b/apps/worker/test/server.test.ts index 6f8f2a3..f895483 100644 --- a/apps/worker/test/server.test.ts +++ b/apps/worker/test/server.test.ts @@ -123,6 +123,28 @@ describe('worker HTTP', () => { }); }); + it('GET /api/hivemind reuses a short cache during browser refresh bursts', async () => { + const repoRoot = join(dir, 'repo-runtime-cache'); + seedRuntime(repoRoot); + const appWithRuntime = buildApp(store, undefined, { hivemindRepoRoots: [repoRoot] }); + + const first = (await (await appWithRuntime.request('/api/hivemind')).json()) as { + session_count: number; + }; + rmSync(join(repoRoot, '.omx'), { recursive: true, force: true }); + const cached = (await (await appWithRuntime.request('/api/hivemind')).json()) as { + session_count: number; + }; + await new Promise((resolve) => setTimeout(resolve, 550)); + const refreshed = (await (await appWithRuntime.request('/api/hivemind')).json()) as { + session_count: number; + }; + + expect(first.session_count).toBe(1); + expect(cached.session_count).toBe(1); + expect(refreshed.session_count).toBe(0); + }); + it('GET /api/hivemind returns GX file-lock fallback lanes', async () => { const repoRoot = join(dir, 'repo-file-locks'); seedFileLocks(repoRoot); diff --git a/docs/mcp.md b/docs/mcp.md index 41fd064..320887e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -309,10 +309,10 @@ Hand off work to another agent on this task. Atomically transfers file claims fr "session_id": "sess_abc", "agent": "claude", "to_agent": "codex", - "summary": "Implementation landed. Codex: run the e2e script and check the pack-release path.", - "next_steps": ["run scripts/e2e-publish.sh", "verify apps/cli/release/ is populated"], + "summary": "Implementation landed. Codex: run the publish e2e script.", + "next_steps": ["run scripts/e2e-publish.sh", "verify the packed CLI tarball includes staged assets"], "blockers": [], - "transferred_files": ["apps/cli/scripts/pack-release.mjs"], + "transferred_files": ["apps/cli/scripts/prepack.mjs"], "expires_in_minutes": 60 } } @@ -331,7 +331,7 @@ Accept a pending handoff addressed to you. Installs the transferred file claims } ``` -Returns `{ status: 'accepted' }` on success, `{ error }` when the observation is not a handoff or is already resolved. +Returns `{ status: 'accepted' }` on success. Errors include `{ code, error }`, where `code` is stable for branching, for example `HANDOFF_EXPIRED`, `NOT_PARTICIPANT`, `NOT_TARGET_AGENT`, or `ALREADY_ACCEPTED`. ## `task_decline_handoff` @@ -348,7 +348,7 @@ Decline a pending handoff. Records a reason so the sender can reissue, possibly } ``` -Returns `{ status: 'cancelled' }` on success. +Returns `{ status: 'cancelled' }` on success. Errors include `{ code, error }`. ## `task_wake` @@ -382,7 +382,7 @@ Acknowledge a pending wake request. Records an ack on the task thread so the sen } ``` -Returns `{ status: 'acknowledged' }`. +Returns `{ status: 'acknowledged' }`. Errors include `{ code, error }`, for example `WAKE_EXPIRED`, `NOT_PARTICIPANT`, `NOT_TARGET_AGENT`, or `ALREADY_ACKNOWLEDGED`. ## `task_cancel_wake` @@ -395,7 +395,7 @@ Cancel a pending wake. Either the sender (withdrawing) or the target (declining) } ``` -Returns `{ status: 'cancelled' }`. +Returns `{ status: 'cancelled' }`. Errors include `{ code, error }`. ## `attention_inbox` diff --git a/package.json b/package.json index 343325d..6da7ac9 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,7 @@ "lint:fix": "biome check --write .", "format": "biome format --write .", "clean": "pnpm -r exec rm -rf dist && rm -rf node_modules/.cache", - "p": "pnpm run publish:cli", - "publish:cli": "pnpm --filter @imdeadpool/colony-cli pack:release && npm publish apps/cli/release --access public --cache /tmp/agents-hivemind-npm-cache", - "publish:cli:dry-run": "pnpm --filter @imdeadpool/colony-cli pack:release && npm publish apps/cli/release --dry-run --access public --cache /tmp/agents-hivemind-npm-cache", + "p": "pnpm run release", "release": "changeset publish" }, "devDependencies": { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d0bb134..2eea66a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,6 +12,8 @@ export { createSessionId } from './ids.js'; export { inferIdeFromSessionId } from './infer-ide.js'; export { TaskThread, + TaskThreadError, + TASK_THREAD_ERROR_CODES, type CoordinationKind, type HandoffMetadata, type HandoffObservation, @@ -19,6 +21,7 @@ export { type HandoffTarget, type HandOffArgs, type RequestWakeArgs, + type TaskThreadErrorCode, type WakeRequestMetadata, type WakeRequestObservation, type WakeStatus, diff --git a/packages/core/src/task-thread.ts b/packages/core/src/task-thread.ts index 67f70d5..e91d7ac 100644 --- a/packages/core/src/task-thread.ts +++ b/packages/core/src/task-thread.ts @@ -26,6 +26,54 @@ export type HandoffTarget = 'claude' | 'codex' | 'any'; export type WakeStatus = 'pending' | 'acknowledged' | 'expired' | 'cancelled'; export type WakeTarget = 'claude' | 'codex' | 'any'; +export const TASK_THREAD_ERROR_CODES = { + OBSERVATION_NOT_ON_TASK: 'OBSERVATION_NOT_ON_TASK', + NOT_HANDOFF: 'NOT_HANDOFF', + NOT_WAKE_REQUEST: 'NOT_WAKE_REQUEST', + TASK_MISMATCH: 'TASK_MISMATCH', + METADATA_MISSING: 'METADATA_MISSING', + ALREADY_ACCEPTED: 'ALREADY_ACCEPTED', + ALREADY_ACKNOWLEDGED: 'ALREADY_ACKNOWLEDGED', + ALREADY_CANCELLED: 'ALREADY_CANCELLED', + HANDOFF_EXPIRED: 'HANDOFF_EXPIRED', + WAKE_EXPIRED: 'WAKE_EXPIRED', + NOT_TARGET_SESSION: 'NOT_TARGET_SESSION', + NOT_PARTICIPANT: 'NOT_PARTICIPANT', + NOT_TARGET_AGENT: 'NOT_TARGET_AGENT', +} as const; + +export type TaskThreadErrorCode = + (typeof TASK_THREAD_ERROR_CODES)[keyof typeof TASK_THREAD_ERROR_CODES]; + +export class TaskThreadError extends Error { + readonly code: TaskThreadErrorCode; + + constructor(code: TaskThreadErrorCode, message: string) { + super(message); + this.name = 'TaskThreadError'; + this.code = code; + } +} + +function taskError(code: TaskThreadErrorCode, message: string): TaskThreadError { + return new TaskThreadError(code, message); +} + +function statusErrorCode( + status: HandoffStatus | WakeStatus, + kind: 'handoff' | 'wake', +): TaskThreadErrorCode { + if (status === 'accepted') return TASK_THREAD_ERROR_CODES.ALREADY_ACCEPTED; + if (status === 'acknowledged') return TASK_THREAD_ERROR_CODES.ALREADY_ACKNOWLEDGED; + if (status === 'cancelled') return TASK_THREAD_ERROR_CODES.ALREADY_CANCELLED; + if (status === 'expired') { + return kind === 'handoff' + ? TASK_THREAD_ERROR_CODES.HANDOFF_EXPIRED + : TASK_THREAD_ERROR_CODES.WAKE_EXPIRED; + } + return TASK_THREAD_ERROR_CODES.METADATA_MISSING; +} + /** * Structured payload for a wake_request observation. Kept in metadata so the * hook preface can render it without decompressing content. Mirrors the @@ -301,25 +349,50 @@ export class TaskThread { acceptHandoff(handoff_observation_id: number, session_id: string): void { const obs = this.store.storage.getObservation(handoff_observation_id); if (!obs || obs.kind !== 'handoff') { - throw new Error(`observation ${handoff_observation_id} is not a handoff`); + throw taskError( + TASK_THREAD_ERROR_CODES.NOT_HANDOFF, + `observation ${handoff_observation_id} is not a handoff`, + ); } if (obs.task_id !== this.task_id) { - throw new Error(`handoff belongs to task ${obs.task_id}, not ${this.task_id}`); + throw taskError( + TASK_THREAD_ERROR_CODES.TASK_MISMATCH, + `handoff belongs to task ${obs.task_id}, not ${this.task_id}`, + ); } const meta = parseHandoff(obs.metadata); - if (!meta) throw new Error('handoff metadata missing'); - if (meta.status !== 'pending') throw new Error(`handoff is ${meta.status}, cannot accept`); + if (!meta) { + throw taskError(TASK_THREAD_ERROR_CODES.METADATA_MISSING, 'handoff metadata missing'); + } + if (meta.status !== 'pending') { + throw taskError( + statusErrorCode(meta.status, 'handoff'), + `handoff is ${meta.status}, cannot accept`, + ); + } if (Date.now() > meta.expires_at) { meta.status = 'expired'; this.store.storage.updateObservationMetadata(handoff_observation_id, JSON.stringify(meta)); - throw new Error('handoff expired'); + throw taskError(TASK_THREAD_ERROR_CODES.HANDOFF_EXPIRED, 'handoff expired'); } if (meta.to_session_id && meta.to_session_id !== session_id) { - throw new Error('handoff is addressed to a different session'); + throw taskError( + TASK_THREAD_ERROR_CODES.NOT_TARGET_SESSION, + 'handoff is addressed to a different session', + ); } const myAgent = this.store.storage.getParticipantAgent(this.task_id, session_id); + if (!myAgent) { + throw taskError( + TASK_THREAD_ERROR_CODES.NOT_PARTICIPANT, + 'session is not a participant on this task', + ); + } if (meta.to_agent !== 'any' && myAgent && meta.to_agent !== myAgent) { - throw new Error(`handoff is for ${meta.to_agent}, not ${myAgent}`); + throw taskError( + TASK_THREAD_ERROR_CODES.NOT_TARGET_AGENT, + `handoff is for ${meta.to_agent}, not ${myAgent}`, + ); } this.store.storage.transaction(() => { @@ -342,11 +415,19 @@ export class TaskThread { * and flips the handoff status to `cancelled`. No claims are touched. */ declineHandoff(handoff_observation_id: number, session_id: string, reason?: string): void { const obs = this.store.storage.getObservation(handoff_observation_id); - if (!obs || obs.kind !== 'handoff') throw new Error('not a handoff'); - if (obs.task_id !== this.task_id) throw new Error('handoff belongs to a different task'); + if (!obs || obs.kind !== 'handoff') { + throw taskError(TASK_THREAD_ERROR_CODES.NOT_HANDOFF, 'not a handoff'); + } + if (obs.task_id !== this.task_id) { + throw taskError(TASK_THREAD_ERROR_CODES.TASK_MISMATCH, 'handoff belongs to a different task'); + } const meta = parseHandoff(obs.metadata); - if (!meta) throw new Error('handoff metadata missing'); - if (meta.status !== 'pending') throw new Error(`handoff is ${meta.status}`); + if (!meta) { + throw taskError(TASK_THREAD_ERROR_CODES.METADATA_MISSING, 'handoff metadata missing'); + } + if (meta.status !== 'pending') { + throw taskError(statusErrorCode(meta.status, 'handoff'), `handoff is ${meta.status}`); + } this.store.storage.transaction(() => { meta.status = 'cancelled'; this.store.storage.updateObservationMetadata(handoff_observation_id, JSON.stringify(meta)); @@ -425,25 +506,48 @@ export class TaskThread { acknowledgeWake(wake_observation_id: number, session_id: string): void { const obs = this.store.storage.getObservation(wake_observation_id); if (!obs || obs.kind !== 'wake_request') { - throw new Error(`observation ${wake_observation_id} is not a wake_request`); + throw taskError( + TASK_THREAD_ERROR_CODES.NOT_WAKE_REQUEST, + `observation ${wake_observation_id} is not a wake_request`, + ); } if (obs.task_id !== this.task_id) { - throw new Error(`wake belongs to task ${obs.task_id}, not ${this.task_id}`); + throw taskError( + TASK_THREAD_ERROR_CODES.TASK_MISMATCH, + `wake belongs to task ${obs.task_id}, not ${this.task_id}`, + ); } const meta = parseWake(obs.metadata); - if (!meta) throw new Error('wake metadata missing'); - if (meta.status !== 'pending') throw new Error(`wake is ${meta.status}, cannot acknowledge`); + if (!meta) throw taskError(TASK_THREAD_ERROR_CODES.METADATA_MISSING, 'wake metadata missing'); + if (meta.status !== 'pending') { + throw taskError( + statusErrorCode(meta.status, 'wake'), + `wake is ${meta.status}, cannot acknowledge`, + ); + } if (Date.now() > meta.expires_at) { meta.status = 'expired'; this.store.storage.updateObservationMetadata(wake_observation_id, JSON.stringify(meta)); - throw new Error('wake expired'); + throw taskError(TASK_THREAD_ERROR_CODES.WAKE_EXPIRED, 'wake expired'); } if (meta.to_session_id && meta.to_session_id !== session_id) { - throw new Error('wake is addressed to a different session'); + throw taskError( + TASK_THREAD_ERROR_CODES.NOT_TARGET_SESSION, + 'wake is addressed to a different session', + ); } const myAgent = this.store.storage.getParticipantAgent(this.task_id, session_id); + if (!myAgent) { + throw taskError( + TASK_THREAD_ERROR_CODES.NOT_PARTICIPANT, + 'session is not a participant on this task', + ); + } if (meta.to_agent !== 'any' && myAgent && meta.to_agent !== myAgent) { - throw new Error(`wake is for ${meta.to_agent}, not ${myAgent}`); + throw taskError( + TASK_THREAD_ERROR_CODES.NOT_TARGET_AGENT, + `wake is for ${meta.to_agent}, not ${myAgent}`, + ); } this.store.storage.transaction(() => { @@ -470,11 +574,17 @@ export class TaskThread { */ cancelWake(wake_observation_id: number, session_id: string, reason?: string): void { const obs = this.store.storage.getObservation(wake_observation_id); - if (!obs || obs.kind !== 'wake_request') throw new Error('not a wake_request'); - if (obs.task_id !== this.task_id) throw new Error('wake belongs to a different task'); + if (!obs || obs.kind !== 'wake_request') { + throw taskError(TASK_THREAD_ERROR_CODES.NOT_WAKE_REQUEST, 'not a wake_request'); + } + if (obs.task_id !== this.task_id) { + throw taskError(TASK_THREAD_ERROR_CODES.TASK_MISMATCH, 'wake belongs to a different task'); + } const meta = parseWake(obs.metadata); - if (!meta) throw new Error('wake metadata missing'); - if (meta.status !== 'pending') throw new Error(`wake is ${meta.status}`); + if (!meta) throw taskError(TASK_THREAD_ERROR_CODES.METADATA_MISSING, 'wake metadata missing'); + if (meta.status !== 'pending') { + throw taskError(statusErrorCode(meta.status, 'wake'), `wake is ${meta.status}`); + } this.store.storage.transaction(() => { meta.status = 'cancelled'; this.store.storage.updateObservationMetadata(wake_observation_id, JSON.stringify(meta)); diff --git a/packages/core/test/task-thread.test.ts b/packages/core/test/task-thread.test.ts index ae05d83..cd6550e 100644 --- a/packages/core/test/task-thread.test.ts +++ b/packages/core/test/task-thread.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { defaultSettings } from '@colony/config'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { MemoryStore } from '../src/memory-store.js'; -import { TaskThread } from '../src/task-thread.js'; +import { TASK_THREAD_ERROR_CODES, TaskThread, TaskThreadError } from '../src/task-thread.js'; let dir: string; let store: MemoryStore; @@ -104,9 +104,13 @@ describe('TaskThread', () => { expect(store.storage.getClaim(thread.task_id, 'src/viewer.tsx')).toBeUndefined(); // Second accept must fail — status is no longer pending. - expect(() => thread.acceptHandoff(handoffId, 'codex')).toThrow( - /not a handoff|accepted|pending/, - ); + try { + thread.acceptHandoff(handoffId, 'codex'); + throw new Error('expected second accept to fail'); + } catch (err) { + expect(err).toBeInstanceOf(TaskThreadError); + expect((err as TaskThreadError).code).toBe(TASK_THREAD_ERROR_CODES.ALREADY_ACCEPTED); + } }); it('handoff addressed to a specific agent refuses a mismatched agent', () => { @@ -131,6 +135,30 @@ describe('TaskThread', () => { expect(store.storage.getClaim(thread.task_id, 'x.ts')?.session_id).toBe('codex'); }); + it('handoff acceptance reports non-participants with a stable code', () => { + seed('claude', 'outsider'); + const thread = TaskThread.open(store, { + repo_root: '/r', + branch: 'x', + session_id: 'claude', + }); + thread.join('claude', 'claude'); + const handoffId = thread.handOff({ + from_session_id: 'claude', + from_agent: 'claude', + to_agent: 'any', + summary: 'please take over', + }); + + try { + thread.acceptHandoff(handoffId, 'outsider'); + throw new Error('expected outsider accept to fail'); + } catch (err) { + expect(err).toBeInstanceOf(TaskThreadError); + expect((err as TaskThreadError).code).toBe(TASK_THREAD_ERROR_CODES.NOT_PARTICIPANT); + } + }); + it("pendingHandoffsFor hides the sender's own handoff and expired ones", () => { seed('claude', 'codex'); const thread = TaskThread.open(store, { @@ -185,6 +213,12 @@ describe('TaskThread', () => { const meta = JSON.parse(row?.metadata ?? '{}') as { status: string }; expect(meta.status).toBe('cancelled'); // Accept after decline must fail. - expect(() => thread.acceptHandoff(handoffId, 'codex')).toThrow(/cancelled|pending/); + try { + thread.acceptHandoff(handoffId, 'codex'); + throw new Error('expected accept after decline to fail'); + } catch (err) { + expect(err).toBeInstanceOf(TaskThreadError); + expect((err as TaskThreadError).code).toBe(TASK_THREAD_ERROR_CODES.ALREADY_CANCELLED); + } }); }); diff --git a/scripts/e2e-pack-release.sh b/scripts/e2e-pack-release.sh deleted file mode 100755 index 7188b9d..0000000 --- a/scripts/e2e-pack-release.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# scripts/e2e-pack-release.sh -# -# End-to-end test of the LEGACY `pnpm publish:release` path. This is the -# bespoke publishing flow apps/cli ships in addition to changeset publish: -# -# pnpm pack:release -> build + scripts/pack-release.mjs (writes apps/cli/release/) -# pnpm publish:release -> pack:release + npm publish ./release -# -# We dry-run the same artifact: pack the release dir into a tarball, install -# it into an isolated prefix, drive a hook, and run search. If you change -# pack-release.mjs or apps/cli/package.json's `dependencies` block, run this. -# -# Companion script: bash scripts/e2e-publish.sh (covers the changeset path). -set -euo pipefail - -REPO="$(cd "$(dirname "$0")/.." && pwd)" -WORK="$REPO/.e2e" -PACK="$WORK/pack-release" -PREFIX="$WORK/prefix-release" -HOME_DIR="$WORK/home-release" - -cleanup() { - rm -rf "$PACK" "$PREFIX" "$HOME_DIR" -} -cleanup -mkdir -p "$PACK" "$PREFIX" "$HOME_DIR" - -echo "==> 1. pack:release (build + pack-release.mjs)" -pnpm --filter @imdeadpool/colony-cli pack:release >/dev/null - -REL="$REPO/apps/cli/release" -test -f "$REL/package.json" || { echo "release dir missing package.json"; exit 1; } -test -f "$REL/README.md" || { echo "release dir missing README.md"; exit 1; } -test -f "$REL/LICENSE" || { echo "release dir missing LICENSE"; exit 1; } -test -d "$REL/hooks-scripts" || { echo "release dir missing hooks-scripts"; exit 1; } - -echo "==> 2. npm pack the release dir (mirrors what publish:release uploads)" -( cd "$REL" && npm pack --pack-destination "$PACK" >/dev/null ) -TGZ=$(node -e "const fs=require('fs'); const path=require('path'); const f=fs.readdirSync('$PACK').find((name)=>name.endsWith('.tgz')); if (!f) process.exit(1); console.log(path.join('$PACK', f));") -test -f "$TGZ" || { echo "tarball missing in $PACK"; ls "$PACK"; exit 1; } - -echo "==> 3. install -g into isolated prefix" -npm install --prefix "$PREFIX" --global "$TGZ" >/dev/null -BIN="$PREFIX/bin/colony" -test -x "$BIN" || { echo "bin shim missing"; exit 1; } - -export HOME="$HOME_DIR" - -echo "==> 4. version" -"$BIN" --version - -echo "==> 5. mcp launches" -out=$("$BIN" mcp &1 || true) -if echo "$out" | grep -q "Invalid or unexpected token"; then - echo "FAIL: mcp crashed: $out" - exit 1 -fi - -echo "==> 6. install + hook + search round trip" -"$BIN" install --ide claude-code >/dev/null -echo '{"session_id":"r","hook_event_name":"UserPromptSubmit","prompt":"check /etc/hosts"}' \ - | "$BIN" hook run user-prompt-submit --ide claude-code -"$BIN" search "hosts" | grep -q "hosts" || { echo "search returned no hits"; exit 1; } - -echo "==> 7. doctor reports healthy" -"$BIN" doctor - -echo -echo "publish:release ARTIFACT VERIFIED" -cleanup