46 KiB
Spoon Phase 2 — Runtime Unification: Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Collapse the three agent runtimes (Codex, OpenCode, Claude Code) behind one AgentRuntime adapter interface in the worker, run all three inside the per-user spoon-box-{username} container (no per-job containers), and let users pick the runtime per thread / per spoon with queue-time validation against their AI provider profile.
Architecture: The worker (apps/agent-worker) polls Convex, claims a job, acquires the user's persistent box (Phase 1), clones ~/Code/{spoon}/{branch} into the mounted home, then runs agent turns by docker exec-ing a CLI into the box. Today dispatch is implicit (Codex if the profile is a ChatGPT-login profile, otherwise a separate opencode serve container). Phase 2 makes job.runtime (codex|opencode|claude) authoritative: a registry maps it to an adapter; every adapter execs into the box via streamExecInContainer, tags its process group with a unique marker, and kills that group in-box on timeout/abort. The OpenCode server-in-a-side-container path is deleted. The Codex auth.json encrypted-snapshot pattern is generalized to Claude OAuth credentials.
Tech Stack: TypeScript, Bun, execa (docker/podman CLI), Convex (packages/backend/convex), Vitest (worker unit tests + convex-test backend tests), Next.js 16 (apps/next), Fedora 41 job image (docker/agent-job.Dockerfile) shipping opencode, @openai/codex, and (new) @anthropic-ai/claude-code.
Depends on: Phase 1 (box lifecycle: per-username mutex, idempotent acquire/release handles, --init on the box, heartbeat + cancelRequested teardown, startup reconcile of spoon-box-*). This plan assumes acquireUserBox/releaseUserBox are already concurrency-safe and the box runs with --init so orphaned in-box children are reaped. Where Phase-1 handle semantics are referenced, use whatever acquireUserBox returns after Phase 1; if Phase 1 kept the string box-name return used in worker.ts:1330, keep using it.
Global Constraints
- Worker unit tests live in
apps/agent-worker/tests/unit/*.test.ts; runcd apps/agent-worker && bun run test:unit(vitest run --project unit). Worker typecheck:cd apps/agent-worker && bun run typecheck. - Backend tests live in
packages/backend/tests/unit/*.test.tsusingconvex-test(seepackages/backend/tests/unit/harness.test.tsfor the exact fixture style:convexTest(schema, modules),authed(t, userId),spoonInput/githubSpoonInput). Runcd packages/backend && bun run test:unit. Alwayscd packages/backend && bun run codegenbeforebun run typecheck(codegen regenerates_generated/after schema/function changes). - Match the existing fixture/test style:
expect(normalizeX(...)).toContainEqual({ kind: ... })for event normalizers (seeapps/agent-worker/tests/unit/agent-events.test.ts);mkdtemp+afterEachcleanup for filesystem tests (seeapps/agent-worker/tests/unit/codex-runtime.test.ts). - Conventional commits; one commit per task. Run the relevant
test:unit+typecheck(andcodegenfor backend) before committing. Do not commit with a failing suite. - Three runtimes:
codex,opencode,claude— alldocker execintospoon-box-{username}. No per-job containers. Never leave aspoon-agent-job-*code path behind. - A real turn against a live CLI cannot run in unit tests. Adapters therefore take an injectable exec function so
runTurncan be driven with fixture stream lines; live behavior is covered by the extendedscripts/smoke-agent-containermanual step. Event parsing is covered by pure normalizer fixtures.
Task 1: Define the AgentRuntime adapter interface, shared types, and registry
Files:
- Create
apps/agent-worker/src/runtime/agent-runtime.ts(new). - Create
apps/agent-worker/src/runtime/provider.ts(new — pure helpers moved out ofworker.tsso adapters don't importworker.tsat runtime). - Edit
apps/agent-worker/src/worker.ts(extract theClaimtype and pure helpers; makeActiveWorkspaceextend the shared workspace shape; addruntime/turnMarkerfields). - Create
apps/agent-worker/tests/unit/agent-runtime.test.ts(new).
Interfaces:
Produces (runtime/agent-runtime.ts):
import type { NormalizedAgentEvent } from '../agent-events';
import type { streamExecInContainer } from './docker';
import type { AdapterWorkspace } from './provider';
export type AgentRuntimeName = 'codex' | 'opencode' | 'claude';
export type ExecStreamFn = typeof streamExecInContainer;
export type TurnResult = {
// Assistant text the adapter captured out-of-band (Codex --output-last-message
// file, Claude `result` event). Empty/undefined when everything streamed via onEvent.
finalMessage?: string;
// Runtime session id to persist for continuity (resume/--session/--resume).
sessionId?: string;
// Non-empty when the runtime reported a hard failure the caller must surface.
error?: string;
};
export type AgentRuntime = {
readonly name: AgentRuntimeName;
prepareAuth(workspace: AdapterWorkspace): Promise<void>;
runTurn(
workspace: AdapterWorkspace,
prompt: string,
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
): Promise<TurnResult>;
abort(workspace: AdapterWorkspace): Promise<void>;
};
export type AdapterFactory = (deps?: { execStream?: ExecStreamFn }) => AgentRuntime;
Produces (runtime/provider.ts) — AdapterWorkspace is the subset of ActiveWorkspace adapters read, plus the pure helpers currently in worker.ts:47-95, 350-446:
export type Claim = { /* moved verbatim from worker.ts:47-95, but runtime widened */ };
// job.runtime union becomes: runtime?: 'codex' | 'opencode' | 'claude';
export type AdapterWorkspace = {
claim: Claim;
workdir: string;
homeDir: string;
username: string;
containerHome: string; // e.g. /home/{username}
containerRepo: string; // e.g. /home/{username}/Code/{spoon}/{branch}
repoDir: string; // host path to the checkout
boxName: string; // spoon-box-{username}
redact: (value: string) => string;
runtime: AgentRuntimeName;
codexSessionId?: string;
opencodeSessionId?: string;
claudeSessionId?: string;
turnMarker?: string; // set per turn; used for in-box process-group kill
};
export const isCodexLoginProfile: (claim: Claim) => boolean; // moved from worker.ts:350
export const collectJsonStringValues: (value?: string) => string[]; // moved from worker.ts:354
export const providerEnvironment: (claim: Claim, workspaceRoot?: string) => Record<string, string>; // moved from worker.ts:379
export const opencodeModel: (claim: Claim) => string; // moved from worker.ts:425
export const codexModel: (claim: Claim) => string; // moved from worker.ts:440
export const codexModelArgs: (claim: Claim) => string[]; // moved from worker.ts:445
Consumes: NormalizedAgentEvent from agent-events.ts; streamExecInContainer from runtime/docker.ts.
Registry (runtime/agent-runtime.ts): a lazy map. Adapters register in later tasks; until then getAdapter throws for unknown names.
const factories = new Map<AgentRuntimeName, AdapterFactory>();
export const registerAdapter = (name: AgentRuntimeName, factory: AdapterFactory): void => { factories.set(name, factory); };
export const getAdapter = (name: AgentRuntimeName): AgentRuntime => {
const factory = factories.get(name);
if (!factory) throw new Error(`No agent runtime adapter registered for "${name}".`);
return factory();
};
Steps:
- Write a failing test
apps/agent-worker/tests/unit/agent-runtime.test.ts:registerAdapter('codex', () => fake)thenexpect(getAdapter('codex').name).toBe('codex'), andexpect(() => getAdapter('opencode')).toThrow(/No agent runtime adapter/). Also assertisCodexLoginProfile/opencodeModelre-exported fromruntime/provider.tsbehave as before (copy one existing expectation, e.g.opencodeModel({ aiProviderProfile: { provider: 'anthropic', model: 'claude-x', ... } } as Claim)→'anthropic/claude-x'). - Run
cd apps/agent-worker && bun run test:unit→ FAIL (module/exports missing). - Create
runtime/provider.ts: moveClaim(worker.ts:47-95),isCodexLoginProfile,collectJsonStringValues,providerEnvironment,opencodeModel,codexModel,codexModelArgs(worker.ts:350-446) verbatim; widenClaim['job']['runtime']to'codex' | 'opencode' | 'claude'; add and exportAdapterWorkspace. - Create
runtime/agent-runtime.tswith the interface,TurnResult,ExecStreamFn,AdapterFactory, and the registry above. - Edit
worker.ts: importClaim, the helpers, andAdapterWorkspacefromruntime/provider.ts(delete the now-moved definitions); changeActiveWorkspace(worker.ts:97) totype ActiveWorkspace = AdapterWorkspace & { githubToken: string; runtimeMode?: ...; agentTurnActive?: boolean; resolveTurn?: () => void; lastRecordedDiffSignature?: string; codexTurnError?: string; ... }; setruntimewhen the workspace is built inrunClaim(worker.ts:1348 — temporarilyruntime: 'codex'placeholder; real resolution lands in Task 3). - Run
cd apps/agent-worker && bun run test:unit→ PASS. Runbun run typecheck→ PASS. - Commit:
refactor(worker): extract AgentRuntime interface, shared provider helpers, and adapter registry.
Task 2: In-box process-group kill + marked-command helpers
Files:
- Edit
apps/agent-worker/src/runtime/docker.ts(add two exported helpers nearstreamExecInContainerat line 385). - Create
apps/agent-worker/tests/unit/box-process-kill.test.ts(new).
Interfaces:
Produces (runtime/docker.ts):
// Wraps a CLI argv so the process runs as a new session/process-group leader whose
// bash parent carries the marker in its argv (matchable by `pgrep -f`). We do NOT
// `exec` the CLI: bash tail-exec's a final simple command, so an `exec`-or-trailing
// CLI would replace the marker-carrying bash and `pgrep -f <marker>` would miss the
// running turn. Keep a trailing `rc=$?; exit "$rc"` so the CLI is no longer the final
// statement (bash survives as group leader) while still propagating its exit code
// (downstream `normalizeRunResult` relies on it; a bare `wait` would mask failures).
export const buildMarkedCommand = (marker: string, command: string[]): string[] => {
// assertMarker(marker) — reject anything but /^[A-Za-z0-9_-]+$/ so a newline can't
// break out of the `# comment` line.
const script = [
`# ${marker}`,
'exec 0</dev/null',
command.map(shellQuote).join(' '),
'rc=$?',
'exit "$rc"',
].join('\n');
return ['setsid', 'bash', '-lc', script];
};
// Kills every process group whose bash parent matches the marker, TERM then KILL.
// pgrep self-match: the kill script runs via `bash -lc <script>` whose own argv holds
// the marker literal, so pgrep matches the kill script (and its command-substitution
// subshells) too. Exclude its own pgid or it SIGTERMs itself on iteration one and
// leaks the target; kill the TARGET's pgid instead.
export const killBoxProcessesByMarker = async (args: {
containerName: string;
marker: string;
}): Promise<void> => {
// assertMarker(args.marker) — same /^[A-Za-z0-9_-]+$/ guard for the pgrep pattern.
const script = [
`self_pgid=$(ps -o pgid= -p $$ | tr -d ' ')`,
`pids=$(pgrep -f ${shellQuote(args.marker)} || true)`,
`for pid in $pids; do`,
` pgid=$(ps -o pgid= -p "$pid" | tr -d ' ')`,
` [ -z "$pgid" ] && continue`,
` [ "$pgid" = "$self_pgid" ] && continue`,
` kill -TERM -"$pgid" 2>/dev/null || true`,
`done`,
`sleep 2`,
`for pid in $pids; do`,
` pgid=$(ps -o pgid= -p "$pid" | tr -d ' ')`,
` [ -z "$pgid" ] && continue`,
` [ "$pgid" = "$self_pgid" ] && continue`,
` kill -KILL -"$pgid" 2>/dev/null || true`,
`done`,
].join('\n');
await execa(containerRuntime(), ['exec', args.containerName, 'bash', '-lc', script], {
reject: false, stdin: 'ignore',
});
};
Add a local const shellQuote = (v: string) => '${v.replaceAll("'", "'\''")}'; (mirror worker.ts:1049).
Consumes: execa, containerRuntime() (already in file).
Steps:
- Write failing
apps/agent-worker/tests/unit/box-process-kill.test.ts: importbuildMarkedCommand. AssertbuildMarkedCommand('spoon-turn-abc', ['codex', 'exec', '--json', 'hi'])returns['setsid', 'bash', '-lc', expect.stringContaining('# spoon-turn-abc')]and the script's last line containscodex exec --json 'hi'. (Do not unit-testkillBoxProcessesByMarkerend-to-end — it shells out; assert only the argv via a spy if you refactor it to a purebuildKillScript(marker)helper. Recommended: extractexport const buildKillScript = (marker: string): stringand test it containspgrep -f 'spoon-turn-abc',kill -TERM -"$pgid", andkill -KILL -"$pgid", plus the self-pgid exclusion ([ "$pgid" = "$self_pgid" ] && continue).) - Run
bun run test:unit→ FAIL. - Implement
buildMarkedCommand,buildKillScript, andkillBoxProcessesByMarkerindocker.ts. - Run
bun run test:unit→ PASS.bun run typecheck→ PASS. - Commit:
feat(worker): in-box process-group marker + kill helpers for agent turns.
Task 3: CodexAdapter — refactor runCodexTurn behind the interface, dispatch codex through the registry
Files:
- Create
apps/agent-worker/src/runtime/codex-adapter.ts(new). - Edit
apps/agent-worker/src/worker.ts(replacerunCodexTurncall path insendWorkspaceMessage; resolveworkspace.runtime; route abort). - Create
apps/agent-worker/tests/unit/codex-adapter.test.ts(new).
Interfaces:
Produces (runtime/codex-adapter.ts):
export const createCodexAdapter: AdapterFactory = (deps) => {
const execStream = deps?.execStream ?? streamExecInContainer;
return {
name: 'codex',
prepareAuth, // moves prepareCodexAuth (worker.ts:459) + prepareCodexWorkspaceFiles + writeJsonFile here
runTurn, // moves runCodexTurn body (worker.ts:712-853), using buildMarkedCommand + execStream
abort, // killBoxProcessesByMarker({ containerName: ws.boxName, marker: ws.turnMarker })
};
};
runTurn(ws, prompt, onEvent):
ws.runtime = 'codex';ws.turnMarker = 'spoon-turn-' + randomUUID();ws.codexSessionIdcontinuity preserved.- Build the same codex argv as
worker.ts:737-761(codex exec [resume <id>] --json ...codexModelArgs --dangerously-bypass-approvals-and-sandbox --output-last-message <containerPath> [--cd <repo>] <prompt>), thenconst command = buildMarkedCommand(ws.turnMarker, codexArgv). await execStream({ containerName: ws.boxName, containerCwd: ws.containerRepo, command, environment: { ...providerEnvironment(ws.claim, ws.containerHome), ...secretEnv }, redact: ws.redact, timeoutMs: env.jobTimeoutMs, onStdoutLine: (line) => forEach normalizeCodexJsonLine(line) → onEvent(event), onStderrLine: (line) => onEvent({ kind: 'status', status: line }) if not noise }).- On timeout: register
setTimeout(() => void killBoxProcessesByMarker(...), env.jobTimeoutMs)and clear it when execStream resolves (execa's owntimeoutonly kills the local client — the marker kill is what stops the in-box process). - Read
--output-last-messagefile (worker.ts:811-843) →finalMessage. Captureerrorevents into a local var →TurnResult.error. Return{ finalMessage, sessionId: capturedSessionId, error }.
Consumes: normalizeCodexJsonLine (agent-events.ts), providerEnvironment/codexModelArgs/isCodexLoginProfile (runtime/provider.ts), buildMarkedCommand/killBoxProcessesByMarker/streamExecInContainer (runtime/docker.ts), prepareCodexWorkspaceFiles (codex-runtime.ts), env.
Worker dispatch change (sendWorkspaceMessage, worker.ts:1624): replace the isCodexLoginProfile(claim) ? runCodexTurn : env.runtime==='docker' ? runOpenCodeTurn : localRun branching with:
const adapter = getAdapter(workspace.runtime);
const onEvent = (event: NormalizedAgentEvent) => handleAgentEvent({ workspace, event, assistantMessageId, assistantContent });
const result = await adapter.runTurn(workspace, prompt, onEvent);
if (result.sessionId) await persistRuntimeSession(workspace, result.sessionId); // generic setter, replaces setCodexSessionId call site
if (!assistantContent.value.trim() && result.finalMessage) { assistantContent.value = truncate(workspace.redact(result.finalMessage), 40_000); await updateMessage(...); }
if (!assistantContent.value.trim() && result.error) throw new Error(`${workspace.runtime} failed:\n${result.error}`);
Resolve workspace.runtime in runClaim (worker.ts:1348): for now set runtime: isCodexLoginProfile(claim) ? 'codex' : 'opencode' so behavior is byte-identical to today (job.runtime becomes authoritative in Task 8). Route abortWorkspaceAgent (worker.ts:1562) through getAdapter(workspace.runtime).abort(workspace).
Steps:
- Write failing
apps/agent-worker/tests/unit/codex-adapter.test.ts: build the adapter with a fakeexecStreamthat callsonStdoutLinewith fixture lines (reuse the exact JSON shapes fromagent-events.test.ts:110-172, e.g.{"type":"item.completed","item":{"id":"item-1","type":"agent_message","text":"done"}}and{"type":"turn.completed"}) then resolves{ exitCode: 0, output: '' }. AssertonEventreceived{ kind: 'assistant_delta', content: 'done\n\n', externalMessageId: 'item-1' }and{ kind: 'assistant_completed' }, and thatrunTurnreturns aTurnResult. Add a second case: fake execStream that emits{"type":"turn.failed","error":{"message":"boom"}}and returns exitCode 0 →result.errorcontainsboom. AssertbuildMarkedCommandwas used (spy the fake to captureargs.command[0] === 'setsid'). - Run
bun run test:unit→ FAIL. - Create
runtime/codex-adapter.ts; move Codex logic out ofworker.ts(prepareCodexAuth,runCodexTurn,writeJsonFile,readCodexTurnError). Register it: addregisterAdapter('codex', createCodexAdapter)in a newruntime/register.tsimported once fromworker.tstop-level. - Rewire
sendWorkspaceMessageandabortWorkspaceAgentper above; addpersistRuntimeSession(writessetCodexSessionId/opencodeSessionId/claudeSessionIdbased onworkspace.runtime). Keep the maintenance-decision parse, diff artifact,recordChangedFilestail (worker.ts:1731-1758) unchanged. - Run
bun run test:unit(all existing + new) → PASS.bun run typecheck→ PASS. - Commit:
refactor(worker): move Codex turn logic into CodexAdapter behind AgentRuntime.
Task 4: OpenCodeAdapter — run opencode run --format json inside the box
Files:
- Create
apps/agent-worker/src/runtime/opencode-adapter.ts(new). - Edit
apps/agent-worker/src/agent-events.ts(addnormalizeOpenCodeRunLineforopencode run --format jsonline output; may delegate tonormalizeOpenCodeEvent). - Edit
apps/agent-worker/src/runtime/register.ts(registeropencode). - Create
apps/agent-worker/tests/unit/opencode-adapter.test.ts(new). - Edit
apps/agent-worker/tests/unit/agent-events.test.ts(addnormalizeOpenCodeRunLinecases).
Interfaces:
Produces (runtime/opencode-adapter.ts):
export const createOpenCodeAdapter: AdapterFactory = (deps) => {
const execStream = deps?.execStream ?? streamExecInContainer;
return {
name: 'opencode',
prepareAuth: async (ws) => { /* opencode reads ANTHROPIC_API_KEY/OPENAI_API_KEY from env; for opencode_auth_json profiles, write ~/.local/share/opencode/auth.json 0600 (reuse logic from worker.ts:472-480) */ },
runTurn,
abort: (ws) => killBoxProcessesByMarker({ containerName: ws.boxName, marker: ws.turnMarker ?? '' }),
};
};
runTurn(ws, prompt, onEvent):
ws.runtime = 'opencode';ws.turnMarker = 'spoon-turn-' + randomUUID().- argv:
['opencode', 'run', '--format', 'json', '--model', opencodeModel(ws.claim), ...(ws.opencodeSessionId ? ['--session', ws.opencodeSessionId] : []), '--', prompt]. Verify the continuity flag againstopencode run --helpduring the smoke step (Task 11); if--sessionis unsupported, drop it and note that OpenCode turns are stateless (acceptable — Codex/Claude carry continuity). command = buildMarkedCommand(ws.turnMarker, argv);execStream({ containerName: ws.boxName, containerCwd: ws.containerRepo, command, environment: { ...providerEnvironment(ws.claim), ...secretEnv }, redact, timeoutMs, onStdoutLine: (line) => normalizeOpenCodeRunLine(line).forEach(onEvent), onStderrLine }).- Return
{ finalMessage, sessionId: capturedSessionId, error }(session id parsed from the run output if present).
Produces (agent-events.ts): export const normalizeOpenCodeRunLine = (line: string): NormalizedAgentEvent[] — JSON.parse each line; if it matches the SSE-style { type, properties } shape, delegate to normalizeOpenCodeEvent(parsed); otherwise map opencode run's result shape (capture sessionID, final text). Unparseable lines → [{ kind: 'status', status: line }].
Consumes: opencodeModel/providerEnvironment (runtime/provider.ts), buildMarkedCommand/killBoxProcessesByMarker/streamExecInContainer (runtime/docker.ts).
Steps:
- Write failing
agent-events.test.tscase:normalizeOpenCodeRunLine(JSON.stringify({ type: 'message.part.delta', properties: { part: { text: 'hi' }, messageID: 'm1' } }))→toContainEqual({ kind: 'assistant_delta', content: 'hi', externalMessageId: 'm1' }); and a non-JSON line →{ kind: 'status', status: '<line>' }. - Write failing
opencode-adapter.test.ts: fakeexecStreamemits the delta line + a final line, returns{ exitCode: 0, output: '' }; assertonEventgot the delta,runTurnresolves, and the capturedargs.command[0] === 'setsid'andargs.command[3](script) containsopencode run --format json. - Run
bun run test:unit→ FAIL. - Implement
normalizeOpenCodeRunLine,createOpenCodeAdapter, and register it. - Run
bun run test:unit→ PASS.bun run typecheck→ PASS. - Commit:
feat(worker): OpenCodeAdapter runs opencode in the per-user box (no side container).
Task 5: Delete the per-job OpenCode container path and dead docker helpers
Files:
- Edit
apps/agent-worker/src/worker.ts(removeensureOpenCodeSession,runOpenCodeTurn,workspaceContainerName,startWorkspaceContainerimport + usage,stopWorkspaceContainercalls,containerName/containerId/opencodePassword/opencodeSessionworkspace fields, the OpenCode-server branches ofabortWorkspaceAgent/replyToInteraction/getWorkspaceAgentStatus; retarget health/cleanup tospoon-box-). - Delete
apps/agent-worker/src/opencode-session.ts. - Edit
apps/agent-worker/src/runtime/docker.ts(deleterunInJobContainer,streamInJobContainer,execInWorkspaceContainer,inspectWorkspaceContainer,startWorkspaceContainer,getPublishedPort; keepensureUserContainer,streamExecInContainer,runExecInContainer,stopWorkspaceContainer,listWorkspaceContainerNames,jobWorkspaceVolumeSpec,normalizeRunResult,ensureJobImagePulled). - Edit
apps/agent-worker/src/env.ts(removecontainerAccess; remove now-deadterminalImage/terminalIdleMs/maxConcurrentJobsonly if not read anywhere else — grep first). - Edit
packages/backend/convex/*— none here. - Edit
apps/agent-worker/tests/unit/*as needed (no test imports the deleted symbols; if any do, update).
Interfaces: Consumes: now only box-scoped docker helpers. getWorkerHealth (worker.ts:1880) enumerates listWorkspaceContainerNames('spoon-box-'); cleanupOrphanedWorkspaces (worker.ts:1911) enumerates spoon-box- for the container list (do not touch the homes/ workdir removal here — that is Phase 1's layout-aware rewrite; leave whatever Phase 1 produced).
Steps:
grep -rn "spoon-agent-job\|startWorkspaceContainer\|ensureOpenCodeSession\|opencode-session\|containerAccess\|host_port\|runInJobContainer\|streamInJobContainer\|execInWorkspaceContainer\|inspectWorkspaceContainer" apps/agent-worker/src→ build the deletion checklist from the hits.grep -rn "env.terminalImage\|env.terminalIdleMs\|env.maxConcurrentJobs" apps/agent-worker/src→ only remove the env var if zero hits remain after Phase 1 (maxConcurrentJobsis surfaced ingetWorkerHealthat worker.ts:1903 — drop that line too, or keep the field; note it in the commit).- Remove the symbols above. In
abortWorkspaceAgent/replyToInteraction/getWorkspaceAgentStatus, drop theopencodeSessionbranches:abortWorkspaceAgent→await getAdapter(workspace.runtime).abort(workspace);replyToInteraction→throw new Error('Interactive replies are not supported by exec-based runtimes.')(no runtime generates interaction requests after this refactor);getWorkspaceAgentStatus→ return{ runtimeMode: workspace.runtime, codexSessionId, opencodeSessionId, claudeSessionId, active }. - In
openWorkspacePullRequest(worker.ts:1852-1855) andstopWorkspace(worker.ts:1869-1872), delete theworkspace.opencodeSession?.close()andstopWorkspaceContainer(workspace.containerName)lines (box lifecycle is now purelyreleaseUserBox). - Delete
apps/agent-worker/src/opencode-session.tsand its imports (worker.ts:31-35). - Run
cd apps/agent-worker && bun run typecheck→ PASS (fix any dangling refs). Runbun run test:unit→ PASS. - Commit:
refactor(worker): delete per-job OpenCode container path, host_port split, and dead docker helpers.
Task 6: ClaudeCodeAdapter — claude -p --output-format stream-json in the box
Files:
- Create
apps/agent-worker/src/runtime/claude-adapter.ts(new). - Edit
apps/agent-worker/src/agent-events.ts(addnormalizeClaudeJsonLine). - Edit
apps/agent-worker/src/runtime/register.ts(registerclaude). - Create
apps/agent-worker/tests/unit/claude-adapter.test.ts(new). - Edit
apps/agent-worker/tests/unit/agent-events.test.ts(add Claude normalizer cases).
Interfaces:
Produces (agent-events.ts): export const normalizeClaudeJsonLine = (line: string): NormalizedAgentEvent[]. Claude Code stream-json emits one JSON object per line:
{"type":"system","subtype":"init","session_id":"abc",...}→{ kind: 'session', sessionId: 'abc' }.{"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}→{ kind: 'assistant_delta', content: '...' }(concatenate text blocks);{"type":"content_block_delta",...}if present →assistant_delta.{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{...}}]}}→{ kind: 'tool_started', name, input: JSON.stringify(input) }.{"type":"user","message":{"content":[{"type":"tool_result",...}]}}→{ kind: 'tool_completed', name: 'tool', output }.{"type":"result","subtype":"success","result":"final text","session_id":"abc"}→{ kind: 'assistant_completed', content: 'final text' }(+ asessionevent if the id is new).{"type":"result","subtype":"error_max_turns"|"error_during_execution",...}→{ kind: 'error', message }.- Unparseable →
[{ kind: 'status', status: line }].
Produces (runtime/claude-adapter.ts):
export const createClaudeAdapter: AdapterFactory = (deps) => {
const execStream = deps?.execStream ?? streamExecInContainer;
return {
name: 'claude',
prepareAuth, // API-key kind: nothing on disk (ANTHROPIC_API_KEY comes from providerEnvironment). OAuth-json kind: write <homeDir>/.claude/.credentials.json 0600 from claim.aiProviderProfile.secret (reuse writeJsonFile).
runTurn,
abort: (ws) => killBoxProcessesByMarker({ containerName: ws.boxName, marker: ws.turnMarker ?? '' }),
};
};
runTurn(ws, prompt, onEvent):
ws.runtime = 'claude';ws.turnMarker = 'spoon-turn-' + randomUUID().- argv:
['claude', '-p', prompt, '--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions', '--model', claudeModel(ws.claim), ...(ws.claudeSessionId ? ['--resume', ws.claudeSessionId] : [])](cwd =containerRepo).--verboseis required forstream-json. Confirm exact flags in the smoke step against the pinned CLI (claude --help). command = buildMarkedCommand(ws.turnMarker, argv);execStream({ containerName: ws.boxName, containerCwd: ws.containerRepo, command, environment: { ...claudeEnv(ws.claim, ws.containerHome), ...secretEnv }, redact, timeoutMs, onStdoutLine: (line) => normalizeClaudeJsonLine(line).forEach(onEvent), onStderrLine }).claudeEnv: for API-key kind{ ANTHROPIC_API_KEY: secret, HOME: ws.containerHome }; for OAuth-json kind{ HOME: ws.containerHome }(CLI reads~/.claude/.credentials.json).- Return
{ finalMessage, sessionId, error }(finalMessagefrom theresultevent;sessionIdfromsystem:init/result).
Consumes: normalizeClaudeJsonLine (agent-events.ts), providerEnvironment (runtime/provider.ts), box helpers (runtime/docker.ts), writeJsonFile (move to runtime/provider.ts or a small runtime/auth.ts shared by Codex + Claude adapters).
Steps:
- Write failing
agent-events.test.tsClaude cases:system:init→ session;assistanttext block →assistant_delta;result:success→assistant_completedwith content;result:error_max_turns→error. - Write failing
claude-adapter.test.ts: fakeexecStreamemitssystem:init+assistanttext +result:successlines, returns{ exitCode: 0, output: '' }; assertonEventreceived the mapped events,runTurnreturns{ finalMessage: 'final text', sessionId: 'abc' }, andargs.command[0] === 'setsid'with the script containingclaude -p. - Run
bun run test:unit→ FAIL. - Implement
normalizeClaudeJsonLine,createClaudeAdapter, register it. MovewriteJsonFileto a sharedruntime/auth.ts. - Run
bun run test:unit→ PASS.bun run typecheck→ PASS. - Commit:
feat(worker): ClaudeCodeAdapter runs claude -p stream-json in the per-user box.
Task 7: Backend schema — add claude runtime, Anthropic OAuth auth kind, and profile→runtimes derivation
Files:
- Edit
packages/backend/convex/schema.ts(runtime unions + authType union). - Create
packages/backend/convex/runtimeSupport.ts(new — pure derivation helper, no'use node'). - Edit
packages/backend/convex/agentJobs.tsandpackages/backend/convex/spoonAgentSettings.tsandpackages/backend/convex/aiProviderProfiles.ts/aiProviderProfilesNode.ts(union constants). - Create
packages/backend/tests/unit/runtime-support.test.ts(new).
Interfaces:
Schema changes:
agentJobs.runtime(schema.ts:536):v.optional(v.union(v.literal('openai_direct'), v.literal('opencode'), v.literal('codex'), v.literal('claude')))— keepopenai_directfor legacy reads (deprecated, never written).spoonAgentSettings.runtime(schema.ts:472): same widened union.aiProviderProfiles.authType(schema.ts:389): addv.literal('anthropic_oauth_json').
Produces (runtimeSupport.ts):
import type { Doc } from './_generated/dataModel';
export type AgentRuntimeName = 'codex' | 'opencode' | 'claude';
// Which runtimes a provider profile can drive.
export const runtimesForProfile = (
profile: Pick<Doc<'aiProviderProfiles'>, 'provider' | 'authType'>,
): AgentRuntimeName[] => {
if (profile.provider === 'opencode_openai_login' || profile.authType === 'opencode_auth_json') {
return ['codex']; // ChatGPT-login snapshot → Codex CLI only
}
if (profile.provider === 'anthropic') {
return profile.authType === 'anthropic_oauth_json' ? ['claude'] : ['claude', 'opencode'];
}
if (profile.provider === 'openai') return ['opencode', 'codex'];
// Every remaining API-key provider is OpenAI-compatible → OpenCode.
return ['opencode'];
};
Steps:
- Write failing
packages/backend/tests/unit/runtime-support.test.ts:runtimesForProfile({ provider: 'anthropic', authType: 'api_key' })→['claude','opencode'];{ provider: 'opencode_openai_login', authType: 'opencode_auth_json' }→['codex'];{ provider: 'openrouter', authType: 'api_key' }→['opencode'];{ provider: 'openai', authType: 'api_key' }→['opencode','codex']. - Run
cd packages/backend && bun run test:unit→ FAIL. - Edit
schema.tsunions; createruntimeSupport.ts; widen theauthTypeunion constant inaiProviderProfiles.ts:24,aiProviderProfilesNode.ts:24. - Run
cd packages/backend && bun run codegenthenbun run typecheck→ PASS.bun run test:unit→ PASS. - Commit:
feat(backend): add claude runtime, anthropic OAuth auth kind, and profile→runtime derivation.
Task 8: Backend queue-time runtime validation + claim payload carries runtime
Files:
- Edit
packages/backend/convex/agentJobs.ts(insertJobat line 379, the threeruntimeunion constants at line 21,createFromRequest/createForThread/createForThreadInternalargs,claimNextInternalreturn). - Edit
packages/backend/convex/agentJobsNode.ts(mapjob.runtimeinto theWorkerClaim). - Edit
packages/backend/convex/threads.ts(createUserThreadpassesruntime). - Edit
packages/backend/convex/aiProviderProfiles.ts(publicProfileexposessupportedRuntimes). - Edit
apps/agent-worker/src/runtime/provider.ts(Claim.job.runtimealready widened in Task 1 — confirm) andworker.tsrunClaim(useclaim.job.runtimeas authoritative). - Edit
packages/backend/tests/unit/harness.test.tsor addpackages/backend/tests/unit/runtime-validation.test.ts(new).
Interfaces:
const runtime = v.union(v.literal('codex'), v.literal('opencode'), v.literal('claude'))(agentJobs.ts:21). The three create mutations gainruntime: v.optional(runtime).insertJobresolves and validates:
const profile = await getJobProfile(ctx, ownerId, aiProviderProfileId);
const supported = runtimesForProfile(profile);
const resolvedRuntime =
requestedRuntime ?? (settings.runtime as AgentRuntimeName | undefined) ?? supported[0];
if (!supported.includes(resolvedRuntime)) {
throw new ConvexError(
`Provider "${profile.name}" cannot run the "${resolvedRuntime}" runtime. Supported: ${supported.join(', ')}.`,
);
}
// insert agentJobs with runtime: resolvedRuntime
claimNextInternalalready returns the fulljobdoc (schema.ts row includesruntime), so no change there;agentJobsNode.claimNextForWorkermust forwardjob.runtime— it already returnsclaimed.jobverbatim inWorkerClaim.job, sojob.runtimeflows through. Confirm the worker'sClaim.job.runtimetype (widened in Task 1) matches.publicProfile(aiProviderProfiles.ts:45): addsupportedRuntimes: runtimesForProfile(profile)so the UI can filter.
Worker change: runClaim (worker.ts:1301) currently throws for non-opencode. Replace with: resolve const runtime = (claim.job.runtime ?? 'opencode') as AgentRuntimeName; set workspace.runtime = runtime; delete the legacy openai_direct throw (queue-time guard now prevents bad runtimes). prepareAuth for the resolved adapter runs in place of the isCodexLoginProfile special-case (worker.ts:1375): await getAdapter(runtime).prepareAuth(workspace).
Steps:
- Write failing
packages/backend/tests/unit/runtime-validation.test.ts(convex-test style from harness.test.ts): create a user + GitHub spoon + ananthropic/api_keyprofile set default; callcreateForThread/createUserThreadequivalent withruntime: 'codex'→ expect throw/cannot run the "codex" runtime/; withruntime: 'claude'→ resolves; with no runtime and anopencode_openai_loginprofile → job.runtime === 'codex'. - Run
cd packages/backend && bun run test:unit→ FAIL. - Implement the union widening,
insertJobvalidation, thread/request arg plumbing, andpublicProfile.supportedRuntimes. - Update
worker.tsrunClaimto setworkspace.runtimefromclaim.job.runtimeand callgetAdapter(runtime).prepareAuth(workspace); delete the legacy throw. - Run
cd packages/backend && bun run codegen && bun run typecheck && bun run test:unit→ PASS. Runcd apps/agent-worker && bun run typecheck && bun run test:unit→ PASS. - Commit:
feat(backend): queue-time runtime/profile validation; job.runtime authoritative in worker.
Task 9: Migrate/deprecate openai_direct
Files:
- Create
packages/backend/convex/migrations.ts(new internalMutation) OR add to an existing maintenance module — grep for an existing pattern first (grep -rn "internalMutation" packages/backend/convex | grep -i migrat; there is none, so createmigrations.ts). - Edit
packages/backend/tests/unit/(new test or extend).
Interfaces:
export const migrateOpenAiDirectRuntime = internalMutation({
args: {},
handler: async (ctx) => {
let patched = 0;
for (const job of await ctx.db.query('agentJobs').collect()) {
if (job.runtime === 'openai_direct') { await ctx.db.patch(job._id, { runtime: 'opencode', updatedAt: Date.now() }); patched++; }
}
for (const s of await ctx.db.query('spoonAgentSettings').collect()) {
if (s.runtime === 'openai_direct') { await ctx.db.patch(s._id, { runtime: 'opencode', updatedAt: Date.now() }); patched++; }
}
return { patched };
},
});
Run once post-deploy via npx convex run migrations:migrateOpenAiDirectRuntime (document in the smoke/deploy notes). openai_direct stays in the schema unions as a legacy-read literal; nothing writes it.
Steps:
- Write failing test: insert a
spoonAgentSettingsrow withruntime: 'openai_direct'(viat.run/raw ctx), run the migration, assert it becomes'opencode'andpatched >= 1. - Run
cd packages/backend && bun run test:unit→ FAIL. - Implement
migrations.ts. - Run
cd packages/backend && bun run codegen && bun run typecheck && bun run test:unit→ PASS. - Commit:
feat(backend): migration to retire openai_direct runtime.
Task 10: Next UI — runtime picker (thread create + spoon agent settings) and Anthropic OAuth profile kind
Files:
- Edit
apps/next/src/components/threads/thread-workspace-form.tsx(replace the disabledInput value='OpenCode workspace'at line 142-144 with a runtimeSelect; passruntimetocreateUserThread). - Edit
apps/next/src/components/spoons/spoon-agent-settings-form.tsx(replace the disabled Runtime input at line 187-190 with aSelect; passruntimetospoonAgentSettings.update). - Edit
apps/next/src/components/integrations/ai-provider-profiles-panel.tsx(add an Anthropic OAuth JSON option toproviderOptions/ auth-type handling at line 71-97). - Edit
packages/backend/convex/threads.tscreateUserThreadargs +spoonAgentSettings.tsupdateargs +agentJobs.tscreate mutations to acceptruntime(done in Task 8 for agentJobs; add tocreateUserThread/update).
Interfaces:
createUserThreadgainsruntime: v.optional(v.union(v.literal('codex'), v.literal('opencode'), v.literal('claude'))), forwarded tocreateForThreadInternal→insertJob.spoonAgentSettings.updateruntimearg union widens tocodex|opencode|claudeandpatch.runtime = args.runtime(drop the= 'opencode'hardcode at spoonAgentSettings.ts:126).- UI: both forms read
selectedProfile.supportedRuntimes(new field from Task 8) and render only thoseSelectItems; default the value to the settings runtime orsupportedRuntimes[0]; disable submit if the chosen runtime ∉ supportedRuntimes. - Profiles panel: add
{ value: 'anthropic', label: 'Anthropic OAuth (paste credentials)', authType: 'anthropic_oauth_json' }alongside the existinganthropicAPI-key option; whenanthropic_oauth_json, render aTextareafor the pasted~/.claude/.credentials.json(same UX as the Codexopencode_auth_jsontextarea).
Steps:
- Add
runtimetocreateUserThreadandspoonAgentSettings.updateargs + handlers;cd packages/backend && bun run codegen && bun run typecheck. - Edit the three Next components: runtime
Selects wired tosupportedRuntimes; Anthropic OAuth option in the profiles panel. - Typecheck Next:
cd apps/next && bun run typecheck(or the repo's Next typecheck script — confirm viaapps/next/package.json). If component tests exist for these forms, extend them; otherwise cover behavior in the Task 11 manual checklist (note: no jsdom test is required by the spec for the picker, but add one if a sibling form test already exists). - Commit:
feat(web): per-thread/per-spoon runtime picker and Anthropic OAuth provider profile.
Task 11: Add Claude Code CLI to the job image + extend the smoke test
Files:
- Edit
docker/agent-job.Dockerfile(line 50 npm install line). - Edit
scripts/smoke-agent-container(version checks + one turn per runtime). - Edit
README.md(the "Production agent runtime images"<details>at line 189-260: mention Claude Code CLI + theanthropic_oauth_jsoncredential snapshot pattern, mirroring the Codexauth.jsonparagraph at line 255-258).
Interfaces / concrete commands:
- Dockerfile line 50 becomes:
RUN npm install -g pnpm yarn bun@1.3.10 opencode-ai@1.17.9 @openai/codex@0.142.0 @anthropic-ai/claude-code@<PIN> \
&& npm cache clean --force
Pin <PIN> to the latest published @anthropic-ai/claude-code version at implementation time (npm view @anthropic-ai/claude-code version); record the exact version in the commit message.
scripts/smoke-agent-container: add to the in-container check block (aftercodex --versionat line 30):claude --version. Then add a documented manual per-runtime turn section (commented, runnable by hand) — build the image, start a throwaway box, exec one turn per runtime:
# Manual per-runtime smoke (requires creds in your shell env):
# docker build -f docker/agent-job.Dockerfile -t spoon-agent-job:latest .
# docker run -d --name spoon-box-smoke --init spoon-agent-job:latest sleep infinity
# docker exec -e OPENAI_API_KEY -w /workspace spoon-box-smoke \
# opencode run --format json --model openai/gpt-5.5 -- 'print hello'
# docker exec -e ANTHROPIC_API_KEY -w /workspace spoon-box-smoke \
# claude -p 'print hello' --output-format stream-json --verbose --dangerously-skip-permissions
# docker exec -e CODEX_HOME=/root/.codex -w /workspace spoon-box-smoke \
# codex exec --json --dangerously-bypass-approvals-and-sandbox 'print hello'
# docker rm -f spoon-box-smoke
Use these commands to also confirm the real flag names assumed in Tasks 4/6 (opencode run --help, claude --help) and fix the adapters if they differ.
Steps:
npm view @anthropic-ai/claude-code version→ choose the pin. Edit the Dockerfile.- Build the image and run
SPOON_AGENT_JOB_IMAGE=spoon-agent-job:latest ./scripts/smoke-agent-container→ all--versionchecks pass (claude --versionincluded). - Run the manual per-runtime turns; reconcile adapter flags with reality (patch Task 4/6 code if needed, keeping unit tests green).
- Update
README.md. - Commit:
build(agent-job): pin Claude Code CLI; smoke one turn per runtime.
Task 12: Secrets hygiene — 0600 env files, deleted on stop, re-materialized per run
Files:
- Edit
apps/agent-worker/src/worker.ts(materializeEnvFileat line 1167;stopWorkspace/openWorkspacePullRequestteardown; callmaterializeEnvFileper run). - Create
apps/agent-worker/tests/unit/materialize-env.test.ts(new).
Interfaces:
materializeEnvFile(worker.ts:1167): write with{ mode: 0o600 }(currently no mode). Extract the file path resolution + write into a pure-ish helperwriteMaterializedEnv(repoDir, envFilePath, secrets): Promise<string>returning the absolute path, so it is unit-testable withmkdtemp.- Track the materialized path on the workspace (
workspace.materializedEnvPath?: string). - On
stopWorkspace(worker.ts:1866) andopenWorkspacePullRequest(worker.ts:1851) and therunClaimcatch (worker.ts:1410) teardown: ifworkspace.materializedEnvPathexists,await rm(workspace.materializedEnvPath, { force: true }). - Re-materialize per run: call
materializeEnvFile(workspace)at the top ofsendWorkspaceMessage(worker.ts:1624) whenclaim.job.materializeEnvFileis set, before the adapter turn — so the env file is fresh each turn and can be deleted between turns. Keep the existingensureNoEnvFilesStagedstaging guard (worker.ts:1269) unchanged.
Steps:
- Write failing
materialize-env.test.ts:mkdtempa repo dir, callwriteMaterializedEnv(dir, '.env.local', [{ name: 'A', value: 'x' }]), assert the file exists,(await stat(path)).mode & 0o777 === 0o600, and content isA="x"\n(matches worker.ts:1172-1174 formatting). Mirror thecodex-runtime.test.tsmode()helper +afterEachcleanup. - Run
cd apps/agent-worker && bun run test:unit→ FAIL. - Implement
writeMaterializedEnv(0600), wire deletion on stop/PR/failure, re-materialize per run. - Run
bun run test:unit→ PASS.bun run typecheck→ PASS. - Commit:
fix(worker): materialize env files 0600, re-materialize per run, delete on stop.
Self-review against the Phase 2 spec
- Adapter interface, all exec into the box, process-group kill → Tasks 1–2 (
buildMarkedCommand=setsid+ marker;killBoxProcessesByMarker= in-boxpgrep+kill -TERM/-KILLof the group; never the local execa client). ✅ - Codex refactored behind the interface → Task 3. ✅
- OpenCode inside the box, per-job path deleted → Tasks 4–5 (
opencode run --format jsonin the box;startWorkspaceContainer/spoon-agent-job-*/containerAccess host_port|network/dead helpers all removed). ✅ - Claude Code adapter + new provider kind + pinned CLI + prepareAuth → Tasks 6, 7, 10, 11 (
anthropic_oauth_jsonsnapshot mirrors Codexauth.json; API-key path usesANTHROPIC_API_KEY). ✅ - Runtime picker + profiles declare supported runtimes + queue-time validation replacing run-time
openai_directdiscovery + migrate/deprecate → Tasks 7–10. ✅ - Secrets hygiene (0600, delete on stop, re-materialize; staging guard stays) → Task 12. ✅
- Testing approach → adapter-level fixture stream tests (injectable
execStream), normalizer fixtures matching the existing style, convex-test backend tests, extended manual smoke. ✅