diff --git a/packages/agent-driver/SPEC.md b/packages/agent-driver/SPEC.md index 95d3dce8..f44aecae 100644 --- a/packages/agent-driver/SPEC.md +++ b/packages/agent-driver/SPEC.md @@ -1,25 +1,26 @@ -The driver seam, as the npm package `agent-driver`: The Framework never calls a model of its own — it wraps a coding-agent CLI as a black box, and this package is that wrapper, published on its own because every product that drives a coding agent needs the same thing. A driver is started once per agent, bound to the agent's workspace; each prompt lets the wrapped CLI's own loop run one full turn on the user's own subscription, and The Framework gates on outcomes — the turn's final message, the code produced, the exit — never on the agent's individual tool calls. +The npm package `agent-driver`: a coding-agent CLI wrapped as a black box, for any product that drives coding agents without calling a model of its own. A driver is started once per agent, bound to the agent's workspace; each prompt lets the wrapped CLI's own loop run one full turn on the user's own subscription, and the caller gates on outcomes — the turn's final message, the code produced, the exit — never on the agent's individual tool calls. ## User story -- The user picks which coding-agent CLI does the work — Claude Code (`claude`) or Codex (`codex`) — and the run target: this device, a GitHub Actions runner, or a Claude Code cloud session. -- The user watches an agent live on the dashboard: its streamed text, which tools it reached for, its final message, what the turn spent. -- The user clicks Stop, or the daemon dies hard; no agent process keeps burning CPU afterwards. +- The user picks which coding-agent CLI does the work — Claude Code (`claude`) or Codex (`codex`) — and where it runs: this device, a GitHub Actions runner, or a Claude Code cloud session. +- The user watches an agent live in the caller's UI: its streamed text, which tools it reached for, its final message, what the turn spent. +- The user clicks Stop, or the caller's process dies hard; no agent process keeps burning CPU afterwards. - Unattended work stands down at the quota boundary, which needs an honest reading of where the account's quota stands. - The whole product can be demoed and tested offline, with no CLI installed and no model. ## Glossary +- **caller** — the product that embeds the package and drives agents through it. - **turn** — one prompt to the wrapped CLI: the CLI's own loop runs to completion, and the turn resolves with the CLI's final message, plus the CLI's session id and the turn's usage when the CLI reports them. - **framing** — the role text a driver delivers as the wrapped CLI's system prompt: fixed for the agent when the driver starts, optionally extended per turn. ## Business logic — TL;DR - **A black box, gated on outcomes** - drive by prompting, verify by result; a crashed turn never passes as a finished one, and the wrapped CLI's internal loop stays untouched and swappable. -- **One seam, five implementations** - Claude Code locally, Codex locally, a GitHub Actions workflow run per turn, and the scripted fake driver ship in the package; The Framework's own hand-off to a Claude Code cloud session (the `web` run target) implements the same contract from outside it — the proof that the contract is enough for a third party. -- **Bring your own subscription** - every driver runs on the user's own account and auth; The Framework holds no model API key and never reads the user's credentials. -- **Progress is visible, never load-bearing** - drivers stream progress events for the dashboard; control flow never branches on them. -- **No stray processes** - each spawned CLI runs as its own process group, stopped as a whole tree and reaped even on a hard daemon exit. +- **One seam, four drivers in the box** - Claude Code locally, Codex locally, a GitHub Actions workflow run per turn, and the scripted fake driver ship in the package; the contract also fixes an id for a fifth implementation built outside it, a Claude Code cloud session — the proof that the contract is enough for a third party. +- **Bring your own subscription** - every driver runs on the user's own account and auth; the caller holds no model API key and never reads the user's credentials. +- **Progress is visible, never load-bearing** - drivers stream progress events for the caller's UI; control flow never branches on them. +- **No stray processes** - each spawned CLI runs as its own process group, stopped as a whole tree and reaped even on a hard exit of the calling process. ## Business logic @@ -27,7 +28,7 @@ The driver seam, as the npm package `agent-driver`: The Framework never calls a #### User story -The user trusts The Framework to run agents unattended, so a turn that actually failed must never be treated as finished work. +The user trusts the caller to run agents unattended, so a turn that actually failed must never be treated as finished work. #### Business logic @@ -35,47 +36,47 @@ A driver does four things: start bound to a workspace, prompt for a turn, read a #### Rationale -Gating on individual tool calls would couple The Framework to each CLI's internals and break the subscription-auth story. Keeping the seam at the code and the outcome is what lets a second coding-agent CLI slot in behind the same four moves. +Gating on individual tool calls would couple the caller to each CLI's internals and break the subscription-auth story. Keeping the seam at the code and the outcome is what lets a second coding-agent CLI slot in behind the same four moves. -### One seam, five implementations +### One seam, four drivers in the box #### User story -The user picks the driver and the run target; everything above the seam behaves identically. +The user picks the CLI and where it runs; everything above the seam behaves identically. #### Business logic -Claude Code local and Codex each spawn their CLI afresh per turn and share one process engine — spawn in an own process group, prompt over stdin, stream the output through the CLI's own dialect, gate on the exit — differing only in command line and output dialect. The `actions` implementation runs each turn as a GitHub Actions workflow run, with continuity carried by the branch the previous run pushed. The fake driver replays scripted turns in memory for tests and offline demo runs. The `web` run target's implementation lives in The Framework, not here: it hands the whole task to a cloud session on claude.ai — hands-off, exactly one hand-off for the agent's life — and needs The Framework's daemon and browser bridge to do it. Each implementation carries a stable implementation id (`claude-code`, `codex`, `claude-web`, `github-actions`, `fake`), the set fixed by the package; one driver has an implementation per place it can run, and the product maps the id back to the user's driver choice. +Claude Code local and Codex each spawn their CLI afresh per turn and share one process engine — spawn in an own process group, prompt over stdin, stream the output through the CLI's own dialect, gate on the exit — differing only in command line and output dialect. The `actions` implementation runs each turn as a GitHub Actions workflow run, with continuity carried by the branch the previous run pushed. The fake driver replays scripted turns in memory for tests and offline demo runs. Each implementation carries a stable implementation id, and the contract fixes the set (`claude-code`, `codex`, `claude-web`, `github-actions`, `fake`): one CLI has an implementation per place it can run, and the caller maps the id back to the user's choice. `claude-web` is reserved for a driver built outside the package — one that hands the whole task to a Claude Code cloud session on claude.ai, which needs a browser the package does not have. ### Bring your own subscription #### User story -The user already pays for Claude or ChatGPT; The Framework must add no separate model bill and never handle their credentials. +The user already pays for Claude or ChatGPT; the caller must add no separate model bill and never handle their credentials. #### Business logic -Claude Code runs on the user's Claude subscription and Codex on their ChatGPT subscription; the CLI authenticates itself, so The Framework never reads or holds a token. A workflow run authenticates with an OAuth token the repo holds, minted by the user's own `claude setup-token`. What a turn spent is reported as usage (tokens always, a price only when the CLI prices its turns); where the account's quota stands is a separate account-wide read that only the Claude Code driver can answer — a driver that cannot answer omits the ability rather than fake a number. +Claude Code runs on the user's Claude subscription and Codex on their ChatGPT subscription; the CLI authenticates itself, so the caller never reads or holds a token. A workflow run authenticates with an OAuth token the repo holds, minted by the user's own `claude setup-token`. What a turn spent is reported as usage (tokens always, a price only when the CLI prices its turns); where the account's quota stands is a separate account-wide read that only the Claude Code driver can answer — a driver that cannot answer omits the ability rather than fake a number. ### Progress is visible, never load-bearing #### User story -The user follows an agent on the dashboard as it works. +The user follows an agent in the caller's UI as it works. #### Business logic -While a turn runs, a driver streams progress events: the prompt started, the CLI announced its session id, a chunk of text, a tool was used (its name only, never the arguments), the turn settled, where the account stands against its rate limit, an error, or a notice about something the driver worked around. Every surface renders them; nothing gates on them, and a listener that throws never breaks the agent. +While a turn runs, a driver streams progress events: the prompt started, the CLI announced its session id, a chunk of text, a tool was used (its name only, never the arguments), the turn settled, where the account stands against its rate limit, an error, or a notice about something the driver worked around. The caller renders them; nothing gates on them, and a listener that throws never breaks the agent. ### No stray processes #### User story -The user clicks Stop — or the daemon crashes — and expects a quiet machine. +The user clicks Stop — or the caller's process crashes — and expects a quiet machine. #### Business logic -A wrapped CLI spawns a deep subtree (workers, shell tool calls, MCP servers); stopping only the top process orphans the rest, which keeps burning CPU. Every long-lived child therefore runs as its own process-group leader and is stopped as a whole group — politely first, forcibly after a grace window — and every live child is registered so that even a hard daemon exit reaps every agent's tree on the way out. +A wrapped CLI spawns a deep subtree (workers, shell tool calls, MCP servers); stopping only the top process orphans the rest, which keeps burning CPU. Every long-lived child therefore runs as its own process-group leader and is stopped as a whole group — politely first, forcibly after a grace window — and every live child is registered so that even a hard exit of the calling process reaps every agent's tree on the way out. ## Before modifying/creating SPEC.md files diff --git a/packages/agent-driver/src/actions-zip.ts b/packages/agent-driver/src/actions-zip.ts index 1c002c90..d0a26997 100644 --- a/packages/agent-driver/src/actions-zip.ts +++ b/packages/agent-driver/src/actions-zip.ts @@ -2,7 +2,7 @@ import { inflateRawSync } from 'node:zlib' // A minimal zip reader, for one job: the GitHub artifact download API always returns a zip, // even for a single file, and that is the only REST-readable channel out of an Actions run -// (#610). Node ships deflate but no zip, and the framework has no runtime dependencies worth +// (#610). Node ships deflate but no zip, and the package has no runtime dependencies worth // adding for ~60 lines. Reading only, and only what upload-artifact writes: stored or // deflated entries, no zip64, no encryption. diff --git a/packages/agent-driver/src/actions.SPEC.md b/packages/agent-driver/src/actions.SPEC.md index 6bbf11e4..bc289909 100644 --- a/packages/agent-driver/src/actions.SPEC.md +++ b/packages/agent-driver/src/actions.SPEC.md @@ -1,4 +1,4 @@ -The driver for the `actions` run target: instead of running the coding-agent CLI on this device, it hands each turn to a GitHub Actions runner — dispatch the project's agent workflow, wait for the run to finish, then read back the transcript the run uploaded. From the rest of The Framework this looks exactly like any other driver; only the tempo differs. +The driver for the `actions` run target: instead of running the coding-agent CLI on this device, it hands each turn to a GitHub Actions runner — dispatch the project's agent workflow, wait for the run to finish, then read back the transcript the run uploaded. To the caller this looks exactly like any other driver; only the tempo differs. ## User story @@ -6,7 +6,7 @@ The user wants an agent to work a task without tying up their own machine — th ## Business logic — TL;DR -- **One turn is one workflow run** - every prompt dispatches a fresh runner with a fresh checkout; nothing survives on the runner between turns. +- **One turn is one workflow run** - every prompt dispatches the workflow the caller names, on a fresh runner with a fresh checkout; nothing survives on the runner between turns. - **The pushed branch is the continuity** - each run pushes to one branch that stays the same for the whole agent, and the next turn is dispatched from it, so later turns build on earlier work. - **A correlation id finds the run** - GitHub's dispatch reports no run identifier, so a unique per-turn tag is echoed into the run's name and its uploaded artifact and matched on. - **The transcript replays in a burst** - there is no live stream from a runner; the whole turn's events arrive at once when the run completes. @@ -24,7 +24,7 @@ See `## User story`. #### Business logic -Each prompt dispatches the project's agent workflow (`framework-agent.yml` unless the project names another) with the prompt text, then polls until that run completes. A run that concludes as anything other than success fails the turn and reports the run's URL. The wait gives up after an hour by default — GitHub's own job cap is six — and the user pressing Stop, at either the agent level or for the single turn, ends the wait immediately. +Each prompt dispatches the workflow the caller named when it configured the driver — one that echoes the correlation id into its run name and uploads the transcript — with the prompt text, then polls until that run completes. A run that concludes as anything other than success fails the turn and reports the run's URL. The wait gives up after an hour by default — GitHub's own job cap is six — and the user pressing Stop, at either the agent level or for the single turn, ends the wait immediately. The system prompt framing is prepended to the prompt text rather than passed as a separate input. @@ -40,11 +40,11 @@ See `## User story`. #### Business logic -The agent's session picks one branch name up front — `claude/framework-` by default — and asks every run to push to it. The first turn runs on the project's default ref; once a run reports the branch it pushed, every later turn is dispatched from that branch, so the runner's fresh checkout already contains the previous turns' work. The agent's own session id is carried across turns as well, so a turn can resume the CLI's conversation rather than starting cold. +The agent's session picks one branch name up front — `claude/` unless the caller sets another prefix — and asks every run to push to it. The first turn runs on the project's default ref; once a run reports the branch it pushed, every later turn is dispatched from that branch, so the runner's fresh checkout already contains the previous turns' work. The agent's own session id is carried across turns as well, so a turn can resume the CLI's conversation rather than starting cold. #### Rationale -The branch is named by The Framework and handed to the workflow rather than discovered afterwards, because a dispatched run reports no branch name of its own — there would be nothing to discover. +The branch is named by the driver and handed to the workflow rather than discovered afterwards, because a dispatched run reports no branch name of its own — there would be nothing to discover. ### A correlation id finds the run @@ -54,13 +54,13 @@ See `## User story`. #### Business logic -Dispatching a workflow returns no identifier for the run it creates. Each turn therefore carries a correlation id that the workflow writes into the run's display name and into the name of the artifact it uploads; polling matches recent dispatched runs on that id. The id mixes a random tag with a per-turn counter, so two agents — or the same agent after the daemon restarts — never match each other's runs. +Dispatching a workflow returns no identifier for the run it creates. Each turn therefore carries a correlation id that the workflow writes into the run's display name and into the name of the artifact it uploads; polling matches recent dispatched runs on that id. The id mixes a random tag with a per-turn counter, so two agents — or the same agent after the calling process restarts — never match each other's runs. ### The transcript replays in a burst #### User story -The dashboard shows an agent's tool calls and messages as an event stream, the same way for every run target. +The caller's UI shows an agent's tool calls and messages as an event stream, the same way for every run target. #### Business logic @@ -72,7 +72,7 @@ An artifact with no transcript file fails the turn and says so, naming what the #### User story -The framework reads files the agent produced — its plan, its notes, its config — as part of a turn's follow-up. +The caller reads files the agent produced — its plan, its notes, its config — as part of a turn's follow-up. #### Business logic @@ -92,7 +92,7 @@ The model name and the resumed session id are passed into the workflow and end u #### User story -The dashboard shows how much of the account's quota week is left, so unattended work can stand down near the quota boundary. +The caller's UI shows how much of the account's quota week is left, so unattended work can stand down near the quota boundary. #### Business logic diff --git a/packages/agent-driver/src/actions.test.SPEC.md b/packages/agent-driver/src/actions.test.SPEC.md index 6bc244f4..5bc71ae5 100644 --- a/packages/agent-driver/src/actions.test.SPEC.md +++ b/packages/agent-driver/src/actions.test.SPEC.md @@ -1,12 +1,12 @@ What the tests cover: an agent on the `actions` run target dispatches its workflow, keeps polling while the run is queued and in progress, and turns the run's uploaded transcript into a finished turn with the agent's final message, its session id, and its usage figures. -- The prompt is dispatched with the system prompt framing in front of the task text, plus a per-turn correlation id that is the only way back to a run GitHub reports no identifier for; two agents never share a correlation prefix, so a freshly started daemon cannot latch onto another agent's workflow run. +- The prompt is dispatched with the system prompt framing in front of the task text, plus a per-turn correlation id that is the only way back to a run GitHub reports no identifier for; two agents never share a correlation prefix, so a freshly started process cannot latch onto another agent's workflow run. - Each run is asked to push to the same branch for the whole agent, and every turn after the first is dispatched from the branch the previous run pushed, so later work builds on earlier work. - A follow-up turn asking to resume carries the agent's session id from the previous turn; the first turn carries none. - The chosen model is passed through, but a model name containing anything that could break out of the runner's shell refuses the turn instead of dispatching it. - A run that concludes red fails the turn and names the run's URL; a run that never finishes gives up rather than polling forever. - Files the agent produced are read from the branch the run pushed, not from the default branch, and asking before any run has pushed reports that plainly. -- The dashboard gets the same event stream as a local agent — the agent's text, its tool calls, and a link to the run — all arriving at once when the run completes. +- The caller gets the same event stream as a local agent — the agent's text, its tool calls, and a link to the run — all arriving at once when the run completes. - This driver reports no quota at all. - A transcript that is not a recognizable message list is rejected rather than passing as an agent that did nothing; a genuinely empty transcript is accepted as an empty turn. diff --git a/packages/agent-driver/src/actions.test.ts b/packages/agent-driver/src/actions.test.ts index a72946b6..c19a3f05 100644 --- a/packages/agent-driver/src/actions.test.ts +++ b/packages/agent-driver/src/actions.test.ts @@ -91,12 +91,12 @@ function fakeGitHub(opts: FakeOptions = {}): { fetch: FetchLike; calls: Call[] } const correlation = calls.filter(c => c.url.includes('/dispatches')).at(-1)?.body as { inputs?: { correlation_id?: string } } | undefined return json({ workflow_runs: [ - { id: 77, name: `framework-agent ${correlation?.inputs?.correlation_id}`, status: state.status, conclusion: state.conclusion ?? null, html_url: 'https://github.com/o/r/actions/runs/77' }, + { id: 77, name: `agent ${correlation?.inputs?.correlation_id}`, status: state.status, conclusion: state.conclusion ?? null, html_url: 'https://github.com/o/r/actions/runs/77' }, ], }) } - if (url.includes('/artifacts') && !url.endsWith('/zip')) return json({ artifacts: [{ id: 5, name: 'framework-run-actions-1-turn-1' }] }) + if (url.includes('/artifacts') && !url.endsWith('/zip')) return json({ artifacts: [{ id: 5, name: 'run-actions-1-turn-1' }] }) if (url.endsWith('/zip')) { const zip = makeZip([ @@ -127,6 +127,7 @@ function makeDriver(opts: FakeOptions = {}): { driver: ActionsDriver; calls: Cal owner: 'o', repo: 'r', token: 't', + workflow: 'agent.yml', fetch, pollIntervalMs: 1000, timeoutMs: 10_000, @@ -154,7 +155,7 @@ test('ActionsDriver dispatches the prompt with a correlation id and the framing await session.prompt('do the thing', { system: 'Also: be brief.' }) const dispatch = calls.find(c => c.url.includes('/dispatches'))! - assert.match(dispatch.url, /\/repos\/o\/r\/actions\/workflows\/framework-agent\.yml\/dispatches$/) + assert.match(dispatch.url, /\/repos\/o\/r\/actions\/workflows\/agent\.yml\/dispatches$/) const body = dispatch.body as { ref: string; inputs: Record } assert.equal(body.ref, 'main') // The action takes the prompt as an input, so the framing can ride in front of it safely. @@ -180,7 +181,7 @@ test('ActionsDriver names the branch each run pushes to, stable across turns (#1 const branches = calls.filter(c => c.url.includes('/dispatches')).map(c => (c.body as { inputs: Record }).inputs['branch']) // The driver names the branch instead of discovering it: the action leaves branch_name empty // for a workflow_dispatch run, so this is the only thing that lets readCode + continuity work. - assert.equal(branches[0], `claude/framework-${session.id}`) + assert.equal(branches[0], `claude/${session.id}`) // Same branch every turn, so a later turn's push builds on the earlier one. assert.equal(branches[0], branches[1]) }) @@ -235,7 +236,7 @@ test('ActionsDriver fails the turn when the run does, naming the run (#610)', as test('ActionsDriver gives up rather than polling a run forever (#610)', async () => { const { fetch } = fakeGitHub({ agents: [{ status: 'in_progress' }] }) let clock = 0 - const driver = new ActionsDriver({ owner: 'o', repo: 'r', token: 't', fetch, pollIntervalMs: 1000, timeoutMs: 5000, now: () => clock, sleep: async ms => void (clock += ms) }) + const driver = new ActionsDriver({ owner: 'o', repo: 'r', token: 't', workflow: 'agent.yml', fetch, pollIntervalMs: 1000, timeoutMs: 5000, now: () => clock, sleep: async ms => void (clock += ms) }) const session = await driver.start({ cwd: '/ws' }) await assert.rejects(() => session.prompt('go'), /Timed out waiting/) }) @@ -272,7 +273,7 @@ test('ActionsDriver replays the run events for the dashboard, in a burst at the test('ActionsDriver cannot report a quota, so it says so by omission (#610)', () => { // The quota belongs to whichever account's token the repo holds, and the runner that // could have answered is torn down before we ever read it. - const driver: Driver = new ActionsDriver({ owner: 'o', repo: 'r', token: 't' }) + const driver: Driver = new ActionsDriver({ owner: 'o', repo: 'r', token: 't', workflow: 'agent.yml' }) assert.equal(driver.readQuota, undefined) }) diff --git a/packages/agent-driver/src/actions.ts b/packages/agent-driver/src/actions.ts index 49e5e395..d41882ff 100644 --- a/packages/agent-driver/src/actions.ts +++ b/packages/agent-driver/src/actions.ts @@ -22,7 +22,7 @@ import type { Driver, DriverEvent, DriverPromptOptions, DriverSession, DriverSta * - **Minutes, not seconds.** Every `prompt` is a fresh runner and a fresh * checkout. Continuity comes from the branch the previous turn pushed, which the * session tracks and dispatches onto next time. - * - **No live stream.** The transcript arrives once, at the end, so the dashboard's + * - **No live stream.** The transcript arrives once, at the end, so the * {@link DriverStartOptions.onEvent} feed replays in a burst rather than trickling. * - **Quota is the account's, not the runner's.** Free minutes on a public repo * change nothing about the subscription window every run draws down. @@ -56,8 +56,8 @@ export interface ActionsDriverOptions { * in its `allowed_bots`. */ token: string - /** Workflow file to dispatch. Default `"framework-agent.yml"`. */ - workflow?: string + /** Workflow file to dispatch, e.g. `"agent.yml"`; it must echo the correlation id and upload the transcript. */ + workflow: string /** Git ref the first turn runs on. Later turns follow the branch the agent pushed. */ ref?: string /** How often to poll the run, in ms. Default 5000. */ @@ -113,9 +113,9 @@ export class ActionsSession implements DriverSession { ) { this.cwd = startOpts.cwd // The counter reads well in logs within one process; the random tag is what keeps the - // correlation id unique across processes, since the daemon spawns a fresh one per run. + // correlation id unique across processes, since a caller may spawn a fresh one per run. this.id = `actions-${++sessionCounter}-${(config.runTag ?? randomRunTag)()}` - this.runBranch = `${config.branchPrefix ?? 'claude/'}framework-${this.id}` + this.runBranch = `${config.branchPrefix ?? 'claude/'}${this.id}` this.lastSessionId = startOpts.resumeSessionId } @@ -168,13 +168,12 @@ export class ActionsSession implements DriverSession { /** Fire the workflow. Returns nothing useful: dispatch is 204 with no body, hence the correlation id. */ private async dispatch(prompt: string, correlationId: string, resume: string | undefined): Promise { - const workflow = this.config.workflow ?? 'framework-agent.yml' const inputs: Record = { prompt, correlation_id: correlationId, branch: this.runBranch } // These reach a shell on the runner as environment variables. They are ids and // model names, so anything outside that alphabet is a bug or an attack. if (this.startOpts.model) inputs['model'] = assertToken(this.startOpts.model, 'model') if (resume) inputs['resume_session_id'] = assertToken(resume, 'resume session id') - await this.api(`/repos/${this.owner}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`, { + await this.api(`/repos/${this.owner}/actions/workflows/${encodeURIComponent(this.config.workflow)}/dispatches`, { method: 'POST', body: JSON.stringify({ ref: this.branch ?? this.config.ref ?? 'main', inputs }), }) @@ -286,7 +285,7 @@ interface WorkflowRun { * difference between running locally and running on a runner is array-vs-JSONL. * * Events replay in a burst at the end rather than live — that is the honest cost of - * this driver, and the dashboard sees the same event stream either way. + * this driver, and a caller sees the same event stream either way. */ export function replayTranscript(json: string, emit: (event: DriverEvent) => void = () => {}): DriverTurn { let messages: unknown diff --git a/packages/agent-driver/src/child-registry.SPEC.md b/packages/agent-driver/src/child-registry.SPEC.md index 39a21cfb..f6bcca3c 100644 --- a/packages/agent-driver/src/child-registry.SPEC.md +++ b/packages/agent-driver/src/child-registry.SPEC.md @@ -1,9 +1,9 @@ -Makes sure that stopping a coding-agent CLI stops everything it started, and that no agent process outlives the daemon. +Makes sure that stopping a coding-agent CLI stops everything it started, and that no agent process outlives the process that spawned it. ## Business logic — TL;DR - **Stopping an agent stops its whole tree** - the wrapped CLI spawns a deep subtree of its own (workers, searches, shell tool calls, MCP servers); signalling only the CLI itself would leave that subtree reparented and still burning CPU, so the entire group is signalled at once. A tree that has already exited is not an error. -- **A hard daemon exit still reaps the agents** - every live CLI the daemon spawned is tracked, and any exit path that does not go through the normal shutdown — a crash, an unhandled error, an immediate exit — force-kills the tracked trees on the way out. This is the last-resort net; Ctrl-C is handled earlier by aborting each agent first. +- **A hard exit still reaps the agents** - every live CLI spawned is tracked, and any exit path of the calling process that does not go through its normal shutdown — a crash, an unhandled error, an immediate exit — force-kills the tracked trees on the way out. This is the last-resort net; Ctrl-C is the caller's to handle, by stopping each agent first. ## Before modifying/creating SPEC.md files diff --git a/packages/agent-driver/src/child-registry.ts b/packages/agent-driver/src/child-registry.ts index 6a15e0c4..bc41f464 100644 --- a/packages/agent-driver/src/child-registry.ts +++ b/packages/agent-driver/src/child-registry.ts @@ -1,7 +1,7 @@ /** * Process-tree kill registry. * - * The framework spawns the Claude Code CLI, which in turn spawns a deep subtree + * A driver spawns a coding-agent CLI, which in turn spawns a deep subtree * (node workers, ripgrep, bash tool calls, MCP servers). Signaling only the * top-level `claude` orphans that subtree: it reparents to init and keeps * burning CPU — the runaway-process leak we hit (a swarm of stray `claude` @@ -9,7 +9,7 @@ * * Fix: spawn each long-lived child as its own process-group leader (`detached`) * and signal the whole group at once via a negative pid. Register every live - * child here so a hard framework exit (crash, uncaught error, process.exit) + * child here so a hard exit of this process (crash, uncaught error, process.exit) * still reaps the trees on the way out. */ @@ -30,7 +30,7 @@ export function killTree(pid: number, signal: NodeJS.Signals): void { } } -/** Track a detached child so it is force-killed if the framework exits first. */ +/** Track a detached child so it is force-killed if this process exits first. */ export function registerChild(pid: number): void { installExitHook() live.add(pid) @@ -46,8 +46,8 @@ function installExitHook(): void { exitHookInstalled = true // `exit` fires on normal completion, process.exit(), and after an uncaught // error unwinds — sync-only, so a plain group SIGKILL is all we can (and need - // to) do. Signal deaths (SIGINT/SIGTERM) are handled by the CLI, which aborts - // the agent first; this is the last-resort net for every other exit path. + // to) do. Signal deaths (SIGINT/SIGTERM) are the caller's to handle, by aborting + // each session first; this is the last-resort net for every other exit path. process.on('exit', () => { for (const pid of live) killTree(pid, 'SIGKILL') }) diff --git a/packages/agent-driver/src/claude-code-quota.SPEC.md b/packages/agent-driver/src/claude-code-quota.SPEC.md index f6d183c6..e5623121 100644 --- a/packages/agent-driver/src/claude-code-quota.SPEC.md +++ b/packages/agent-driver/src/claude-code-quota.SPEC.md @@ -1,8 +1,8 @@ -Asks Claude Code where the account's subscription quota stands, and turns its answer into the reading the daemon uses to decide whether unattended work may keep spending. +Asks Claude Code where the account's subscription quota stands, and turns its answer into the reading a caller uses to decide whether unattended work may keep spending. ## User story -The user's coding-agent subscription is a fixed weekly allowance. The Framework spends it on the user's behalf while nobody is watching, so it has to know how much is left — and it must never spend the week dry because it guessed wrong. +The user's coding-agent subscription is a fixed weekly allowance. The caller spends it on the user's behalf while nobody is watching, so it has to know how much is left — and it must never spend the week dry because it guessed wrong. ## Glossary @@ -10,10 +10,10 @@ The user's coding-agent subscription is a fixed weekly allowance. The Framework ## Business logic — TL;DR -- **The CLI answers for itself** - the reading comes from Claude Code's own usage command, run non-interactively; The Framework never talks to Anthropic and never touches the user's credentials. +- **The CLI answers for itself** - the reading comes from Claude Code's own usage command, run non-interactively; the caller never talks to Anthropic and never touches the user's credentials. - **Reading the quota costs nothing** - the command is answered locally by the CLI, spending no tokens and no turns of the user's allowance. - **A failed read is never a zero** - an unreadable answer is reported as unreadable, never as "nothing used". -- **"No subscription" and "could not read it" are different answers** - an account with no subscription quota at all is distinguished from a subscription whose readout The Framework failed to understand. +- **"No subscription" and "could not read it" are different answers** - an account with no subscription quota at all is distinguished from a subscription whose readout the package failed to understand. - **The read always ends** - a missing CLI, a failing CLI, a hung CLI, or the user cancelling each produce their own distinct outcome rather than leaving the caller waiting. ## Business logic @@ -26,7 +26,7 @@ See `## User story`. #### Business logic -The reading is obtained by running the Claude Code CLI's own usage command in non-interactive mode and reading what it prints. The CLI reaches Anthropic with its own stored credentials, so The Framework never reads, stores, or forwards the user's token. The command must be run in the CLI's normal subscription mode; running it in the mode that forces API-key authentication would make the subscription quota disappear entirely. +The reading is obtained by running the Claude Code CLI's own usage command in non-interactive mode and reading what it prints. The CLI reaches Anthropic with its own stored credentials, so the caller never reads, stores, or forwards the user's token. The command must be run in the CLI's normal subscription mode; running it in the mode that forces API-key authentication would make the subscription quota disappear entirely. The command spends nothing — no tokens, no turns, no cost — because the CLI answers it locally instead of prompting a model. @@ -34,7 +34,7 @@ The command spends nothing — no tokens, no turns, no cost — because the CLI #### User story -Unattended work stands down once the quota boundary is reached. A reading of "0% used" would tell the daemon it has the whole week to spend. +Unattended work stands down once the quota boundary is reached. A reading of "0% used" would tell the caller it has the whole week to spend. #### Business logic diff --git a/packages/agent-driver/src/claude-code-quota.ts b/packages/agent-driver/src/claude-code-quota.ts index d85360d8..d114afe6 100644 --- a/packages/agent-driver/src/claude-code-quota.ts +++ b/packages/agent-driver/src/claude-code-quota.ts @@ -87,7 +87,7 @@ export interface ReadClaudeQuotaOptions { * Runs the CLI's own `/usage` command in print mode. Costs nothing: verified on * 2.1.210 as `total_cost_usd: 0` across zero turns and zero tokens, because the * CLI answers it locally rather than by prompting a model. It reaches Anthropic - * itself, with its own credentials, so The Framework never reads or handles the + * itself, with its own credentials, so the caller never reads or handles the * user's token. * * Never pass `--bare` here: it pins the CLI to API-key auth and never reads the diff --git a/packages/agent-driver/src/claude-code.SPEC.md b/packages/agent-driver/src/claude-code.SPEC.md index d459fafd..fabbd7aa 100644 --- a/packages/agent-driver/src/claude-code.SPEC.md +++ b/packages/agent-driver/src/claude-code.SPEC.md @@ -1,19 +1,19 @@ -The driver for Claude Code: wraps the `claude` CLI as a black box on the user's own subscription, drives one full turn per prompt, and turns what the CLI prints into the event stream every surface of The Framework is a projection of. +The driver for Claude Code: wraps the `claude` CLI as a black box on the user's own subscription, drives one full turn per prompt, and turns what the CLI prints into the event stream every driver produces. ## User story -The user runs an agent on their existing Claude Code subscription. They watch its text and its tool calls appear live in the dashboard, chat into it while it works, resume it later, and expect the whole thing to keep working without them ever supplying an API key. +The user runs an agent on their existing Claude Code subscription. They watch its text and its tool calls appear live in the caller's UI, chat into it while it works, resume it later, and expect the whole thing to keep working without them ever supplying an API key. ## Business logic — TL;DR -- **One prompt is one CLI invocation** - each turn spawns the CLI fresh, non-interactively, and the turn ends when the CLI's own loop has finished; The Framework never sees or gates the individual tool calls in between. +- **One prompt is one CLI invocation** - each turn spawns the CLI fresh, non-interactively, and the turn ends when the CLI's own loop has finished; the caller never sees or gates the individual tool calls in between. - **Writes go through without asking** - the CLI is run in a mode where file edits need no confirmation, and a fully autonomous agent that also installs and runs things can be given the mode that confirms nothing at all. - **The framing is added once per conversation** - the system prompt framing is appended when a turn starts a new conversation, and deliberately not when it continues an existing one, which already carries it. - **Chat and resume continue the same conversation** - the agent's own session id is carried from turn to turn, so a chat message or a resumed agent picks up its history instead of starting cold. - **A vanished conversation costs the history, never the message** - if the CLI no longer has the conversation being resumed, the same prompt is immediately re-sent as a fresh conversation, and the user is told the history is gone. - **The session id is announced immediately** - it is published from the CLI's first line of output, so a turn that is stopped or dies mid-flight still leaves behind the handle needed to resume it. - **Usage and quota both come for free** - the CLI's own end-of-turn accounting supplies what the turn spent, and its rate-limit notices supply where the account's subscription stands, with no extra call. -- **Extra tools merge, never replace** - tools The Framework wires in for a session (a real browser, for the browser preview) are added alongside the user's own configured tools. +- **Extra tools merge, never replace** - tools the caller wires in for a session (a real browser, say) are added alongside the user's own configured tools. ## Business logic @@ -25,11 +25,11 @@ See `## User story`. #### Business logic -Every prompt spawns the `claude` CLI non-interactively in its streaming mode, in the agent's own worktree, and lets its loop run to completion. The turn's outcome is the CLI's final message; if the CLI ends without one, the assistant text it produced along the way stands in for it. While the turn runs, the CLI's output is translated into the framework's event stream: the agent's prose becomes text events, and each tool the agent invokes becomes a labelled action event. Output that is not the CLI's structured stream — banners and other noise — is ignored rather than shown. +Every prompt spawns the `claude` CLI non-interactively in its streaming mode, in the agent's own worktree, and lets its loop run to completion. The turn's outcome is the CLI's final message; if the CLI ends without one, the assistant text it produced along the way stands in for it. While the turn runs, the CLI's output is translated into the driver event stream: the agent's prose becomes text events, and each tool the agent invokes becomes a labelled action event. Output that is not the CLI's structured stream — banners and other noise — is ignored rather than shown. #### Rationale -The CLI is treated as a black box: The Framework prompts it and reads the outcome, and the CLI owns its own loop, its own tools, and its own subscription authentication. That is what lets a second coding-agent CLI slot in behind the same seam without changing anything above it. +The CLI is treated as a black box: the caller prompts it and reads the outcome, and the CLI owns its own loop, its own tools, and its own subscription authentication. That is what lets a second coding-agent CLI slot in behind the same seam without changing anything above it. ### Writes go through without asking @@ -81,11 +81,11 @@ The agent's session id is published as soon as the CLI's first line of output ca #### User story -The dashboard shows what each agent spent, and unattended work stands down as the quota boundary approaches. +The caller's UI shows what each agent spent, and unattended work stands down as the quota boundary approaches. #### Business logic -The CLI's end-of-turn line carries what the turn cost and how many tokens it used — input, output, and both kinds of cache tokens — and that becomes the turn's usage. When the line carries no price at all, no cost is reported rather than a cost of zero: zero means "this was free" to the spending limits, while nothing at all means "unknown", and one coding-agent CLI reports tokens without prices. +The CLI's end-of-turn line carries what the turn cost and how many tokens it used — input, output, and both kinds of cache tokens — and that becomes the turn's usage. When the line carries no price at all, no cost is reported rather than a cost of zero: zero means "this was free" to a caller's spending limit, while nothing at all means "unknown", and one coding-agent CLI reports tokens without prices. Separately, the CLI emits a rate-limit notice each turn saying where the account's subscription stands and when it resets. That notice is passed through as-is, costing no extra call and no polling. A notice missing any of the parts that would be acted on is dropped silently rather than reported as a bogus reset time. @@ -95,11 +95,11 @@ Where the account's quota stands can also be asked for directly at any time, wit #### User story -The user turns on the browser preview, which needs the agent to drive a real Chrome — without losing the tools they configured for their own project. +The caller gives the agent a real browser to drive — without the user losing the tools they configured for their own project. #### Business logic -Tools The Framework wires in for an agent are written to a throwaway config that is added to the CLI's own configuration rather than substituted for it, so the user's configured tools stay available. The config is written once per agent, reused for all its turns, and deleted when the agent is disposed of. +Tools the caller wires in for an agent are written to a throwaway config that is added to the CLI's own configuration rather than substituted for it, so the user's configured tools stay available. The config is written once per agent, reused for all its turns, and deleted when the agent is disposed of. ## Before modifying/creating SPEC.md files diff --git a/packages/agent-driver/src/claude-code.test.SPEC.md b/packages/agent-driver/src/claude-code.test.SPEC.md index 883fcadb..875f1359 100644 --- a/packages/agent-driver/src/claude-code.test.SPEC.md +++ b/packages/agent-driver/src/claude-code.test.SPEC.md @@ -1,9 +1,9 @@ -What the tests cover: driving the Claude Code CLI and translating what it prints into the framework's event stream. +What the tests cover: driving the Claude Code CLI and translating what it prints into the driver event stream. - A turn's output becomes the agent's prose as text events and its tool calls as labelled action events, and the turn ends with the CLI's final message; if the CLI never produced one, the prose it streamed stands in instead. Output that is not the CLI's structured stream is ignored. - The agent's session id is published on the very first line that carries it, not held back until the turn ends, so a turn stopped or killed mid-flight keeps its resume handle. It is published again only when it actually changes. - The turn's usage — cost and input, output, and both cache token counts — is taken from the CLI's end-of-turn line. When the line reports tokens but no price, no cost is reported at all rather than a cost of zero, which spending limits would read as "free". A line reporting neither leaves the turn without usage. -- The CLI's per-turn rate-limit notice is passed through as where the account's quota stands and when it resets, converted to the framework's time unit, and it never disturbs the turn's own result. Values never seen before are passed through rather than dropped, while a notice missing any part that would be acted on stays silent, since a bogus reset time is worse than none. +- The CLI's per-turn rate-limit notice is passed through as where the account's quota stands and when it resets, converted to milliseconds, and it never disturbs the turn's own result. Values never seen before are passed through rather than dropped, while a notice missing any part that would be acted on stays silent, since a bogus reset time is worse than none. - A turn that exits with a failure is reported as failed even when the agent streamed text first — the partial text never passes as a result. - The CLI is invoked with file edits pre-approved, with the system prompt framing appended, and with the chosen model. - A chat turn resumes the conversation the previous turn reported and skips re-appending the framing the resumed conversation already carries; an agent started with a recorded session id resumes it on its very first turn; asking to resume with no conversation to resume simply runs a fresh turn with the framing. diff --git a/packages/agent-driver/src/claude-code.test.ts b/packages/agent-driver/src/claude-code.test.ts index e1da2277..6e861bc8 100644 --- a/packages/agent-driver/src/claude-code.test.ts +++ b/packages/agent-driver/src/claude-code.test.ts @@ -2,8 +2,8 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' import { Readable, Writable } from 'node:stream' import { existsSync, readFileSync } from 'node:fs' -import { ClaudeCodeDriver, StreamJsonParser, runClaude } from './claude-code.js' -import type { SpawnLike, SpawnedProcess } from './cli-session.js' +import { ClaudeCodeDriver, StreamJsonParser } from './claude-code.js' +import { runCliSession, type SpawnLike, type SpawnedProcess } from './cli-session.js' import type { DriverEvent } from './types.js' test('StreamJsonParser surfaces assistant text + tool names, keeps the result', () => { @@ -109,14 +109,14 @@ function fakeSpawn(lines: string[], code = 0, stderr = ''): SpawnLike { } } -test('runClaude drives a fake process and returns the final turn', async () => { +test('runCliSession with the Claude parser drives a fake process and returns the final turn', async () => { const events: DriverEvent[] = [] const lines = [ JSON.stringify({ type: 'system', subtype: 'init', session_id: 's9' }), JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash' }] } }), JSON.stringify({ type: 'result', subtype: 'success', result: 'built it', session_id: 's9' }), ] - const turn = await runClaude({ + const turn = await runCliSession({ bin: 'claude', args: ['-p'], cwd: '/ws', @@ -125,6 +125,8 @@ test('runClaude drives a fake process and returns the final turn', async () => { spawn: fakeSpawn(lines), emit: e => events.push(e), signals: [], + parser: new StreamJsonParser(), + driver: 'claude-code', }) assert.deepEqual(turn, { text: 'built it', sessionId: 's9' }) assert.equal(events[0]!.type, 'start') @@ -132,10 +134,10 @@ test('runClaude drives a fake process and returns the final turn', async () => { assert.equal(events.at(-1)!.type, 'result') }) -test('runClaude rejects on a non-zero exit with no result text', async () => { +test('runCliSession with the Claude parser rejects on a non-zero exit with no result text', async () => { await assert.rejects( () => - runClaude({ + runCliSession({ bin: 'claude', args: [], cwd: '/ws', @@ -144,17 +146,19 @@ test('runClaude rejects on a non-zero exit with no result text', async () => { spawn: fakeSpawn([], 1, 'boom'), emit: () => {}, signals: [], + parser: new StreamJsonParser(), + driver: 'claude-code', }), /boom/, ) }) -test('runClaude rejects on a non-zero exit even when the agent streamed text', async () => { +test('runCliSession with the Claude parser rejects on a non-zero exit even when the agent streamed text', async () => { const events: DriverEvent[] = [] const lines = [JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'started building' }] } })] await assert.rejects( () => - runClaude({ + runCliSession({ bin: 'claude', args: [], cwd: '/ws', @@ -163,6 +167,8 @@ test('runClaude rejects on a non-zero exit even when the agent streamed text', a spawn: fakeSpawn(lines, 1), emit: e => events.push(e), signals: [], + parser: new StreamJsonParser(), + driver: 'claude-code', }), /exited \(1\): started building/, ) diff --git a/packages/agent-driver/src/claude-code.ts b/packages/agent-driver/src/claude-code.ts index 2a120589..ecb6baba 100644 --- a/packages/agent-driver/src/claude-code.ts +++ b/packages/agent-driver/src/claude-code.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { readClaudeQuota } from './claude-code-quota.js' import { combineFraming, combineSignals, makeEmit, readWorkspaceFile } from './session-support.js' -import { runCliSession, type RunCliSessionOptions, type SpawnLike } from './cli-session.js' +import { runCliSession, type SpawnLike } from './cli-session.js' import type { Driver, DriverEvent, DriverPromptOptions, DriverQuota, DriverRateLimit, DriverSession, DriverStartOptions, DriverTurn, DriverUsage } from './types.js' /** Claude Code permission modes we pass through to the CLI. */ @@ -34,8 +34,7 @@ export interface ClaudeCodeDriverOptions { /** * MCP servers to expose to the agent for this session (#452). Written to a * temp config file passed via `--mcp-config`, so they merge with the user's - * own configured MCP servers rather than replacing them. Used by `--browser` - * to wire chrome-devtools-mcp (a real browser + DevTools tools) into the agent. + * own configured MCP servers rather than replacing them. */ mcpServers?: Record /** Environment for the child process. Default `process.env`. */ @@ -48,8 +47,8 @@ export interface ClaudeCodeDriverOptions { * The first real {@link Driver}: wraps the **Claude Code CLI** in print mode * (`claude -p --output-format stream-json`). Each {@link DriverSession.prompt} * spawns a fresh non-interactive invocation, so every loop pass gets fresh - * context (option A). We stream its JSON events to {@link DriverStartOptions.onEvent} - * for the dashboard and return the final `result` text as the turn. + * context. We stream its JSON events to {@link DriverStartOptions.onEvent} + * for the caller's UI and return the final `result` text as the turn. * * True black box: we prompt and read the result; Claude Code owns its own loop, * tools, and (subscription-based) auth. A second agent slots in behind the same @@ -106,7 +105,7 @@ export class ClaudeCodeSession implements DriverSession { const emit = makeEmit(this.startOpts.onEvent, 'claude-code') const signals = combineSignals(this.startOpts.signal, opts.signal) const agent = (id: string | undefined, emitFn: (event: DriverEvent) => void): Promise => - runClaude({ + runCliSession({ bin: this.config.bin ?? 'claude', args: this.buildArgs(system, id), cwd: this.cwd, @@ -115,6 +114,8 @@ export class ClaudeCodeSession implements DriverSession { spawn: this.config.spawn ?? (nodeSpawn as unknown as SpawnLike), emit: emitFn, signals, + parser: new StreamJsonParser(), + driver: 'claude-code', }) let turn: DriverTurn @@ -195,7 +196,7 @@ export class ClaudeCodeSession implements DriverSession { const servers = this.config.mcpServers if (!servers || Object.keys(servers).length === 0) return undefined if (!this.mcpConfigPath) { - const dir = mkdtempSync(join(tmpdir(), 'framework-mcp-')) + const dir = mkdtempSync(join(tmpdir(), 'agent-driver-mcp-')) this.mcpConfigPath = join(dir, 'mcp.json') writeFileSync(this.mcpConfigPath, JSON.stringify({ mcpServers: servers })) } @@ -211,11 +212,6 @@ function isConversationGone(err: unknown): boolean { return CONVERSATION_GONE.test(err instanceof Error ? err.message : String(err)) } -/** Spawn one Claude Code invocation and resolve with its final turn. */ -export function runClaude(opts: Omit): Promise { - return runCliSession({ ...opts, parser: new StreamJsonParser() }) -} - /** * Incremental parser for Claude Code's `stream-json` output: newline-delimited * JSON, one object per line. We surface assistant text + tool names as @@ -313,7 +309,7 @@ function parseRateLimit(obj: Record): DriverRateLimit | undefin const resetsAt = info['resetsAt'] if (typeof status !== 'string' || typeof window !== 'string') return undefined if (typeof resetsAt !== 'number' || !Number.isFinite(resetsAt)) return undefined - // The agent reports epoch seconds; the rest of the framework speaks millis. + // The agent reports epoch seconds; `resetsAt` is millis. return { status, window, resetsAt: resetsAt * 1000 } } @@ -329,7 +325,7 @@ function parseUsage(obj: Record): DriverUsage | undefined { if (typeof cost !== 'number' && !hasUsage) return undefined const usage = (hasUsage ? raw : {}) as Record const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0) - // Omit costUsd when there is no price, never 0: the budget gate reads 0 as "free" + // Omit costUsd when there is no price, never 0: a spending limit reads 0 as "free" // and undefined as "unknown" (#540), and Codex reports tokens without a price. return { ...(typeof cost === 'number' && Number.isFinite(cost) ? { costUsd: cost } : {}), diff --git a/packages/agent-driver/src/cli-session.SPEC.md b/packages/agent-driver/src/cli-session.SPEC.md index 5b3f5658..c08719b5 100644 --- a/packages/agent-driver/src/cli-session.SPEC.md +++ b/packages/agent-driver/src/cli-session.SPEC.md @@ -3,12 +3,12 @@ Runs one turn of a wrapped coding-agent CLI: start it, stream its output live, a ## Business logic — TL;DR - **The prompt goes in through the CLI's input, never as a command-line argument** - a task description, a plan, or a pasted stack trace can be arbitrarily long, and an argument would eventually be truncated by the operating system. -- **Output is streamed, not collected** - each line the CLI prints is turned into framework events as it arrives, so the dashboard shows the agent's prose and tool calls while the turn is still running. +- **Output is streamed, not collected** - each line the CLI prints is turned into driver events as it arrives, so the caller's UI shows the agent's prose and tool calls while the turn is still running. - **Stopping stops the whole tree** - the CLI is started as the leader of its own process group and the whole group is signalled at once, so the agent's own workers, searches, and shell commands go down with it instead of being left running. - **A hung agent is forced down** - a stop first asks politely, giving the agent five seconds to finish what it is doing, then kills it outright if it has not exited. -- **A failed exit is a failed turn, even after the agent spoke** - The Framework gates on outcomes, so an agent that streamed text and then crashed mid-build must not pass as a completed turn. The failure carries the CLI's own error output as its explanation, falling back to whatever text the agent had produced, and then to the bare exit status. +- **A failed exit is a failed turn, even after the agent spoke** - the caller gates on outcomes, so an agent that streamed text and then crashed mid-build must not pass as a completed turn. The failure names the driver and carries the CLI's own error output as its explanation, falling back to whatever text the agent had produced, and then to the bare exit status. - **Each turn is reported exactly once** - a turn already ended by the user stopping it, or by the CLI failing to start at all, is not reported a second time when the process finally closes. -- **A CLI that dies before reading its input does not take the daemon down** - the failed turn is reported through the normal path instead. +- **A CLI that dies before reading its input does not take the calling process down** - the failed turn is reported through the normal path instead. ## Before modifying/creating SPEC.md files diff --git a/packages/agent-driver/src/cli-session.test.SPEC.md b/packages/agent-driver/src/cli-session.test.SPEC.md index d7b1de1c..e7e218c8 100644 --- a/packages/agent-driver/src/cli-session.test.SPEC.md +++ b/packages/agent-driver/src/cli-session.test.SPEC.md @@ -1,4 +1,4 @@ -What the tests cover: running one turn of a wrapped coding-agent CLI. The CLI's output is turned into framework events as it arrives and the turn ends with the CLI's own result. A CLI that exits before it ever reads the prompt — including with a prompt too large to fit in one write — fails that turn cleanly and never brings the daemon down with it. A turn the user stopped reports itself as stopped and stays silent afterwards: the killed process reporting its own exit a moment later produces neither a result nor a failure. +What the tests cover: running one turn of a wrapped coding-agent CLI. The CLI's output is turned into driver events as it arrives and the turn ends with the CLI's own result. A CLI that exits before it ever reads the prompt — including with a prompt too large to fit in one write — fails that turn cleanly and never brings the calling process down with it. A turn the user stopped reports itself as stopped and stays silent afterwards: the killed process reporting its own exit a moment later produces neither a result nor a failure. ## Before modifying/creating SPEC.md files diff --git a/packages/agent-driver/src/cli-session.test.ts b/packages/agent-driver/src/cli-session.test.ts index ad93fe5d..bbf0a652 100644 --- a/packages/agent-driver/src/cli-session.test.ts +++ b/packages/agent-driver/src/cli-session.test.ts @@ -34,6 +34,7 @@ test('runCliSession streams the parser events and resolves the final turn', asyn spawn, emit: e => events.push(e), signals: [], + driver: 'agent', parser, }) assert.deepEqual(turn, { text: 'done', sessionId: 's1' }) @@ -68,6 +69,7 @@ test('an stdin write error fails the turn cleanly, not as an uncaught exception spawn, emit: () => {}, signals: [], + driver: 'agent', parser: { push: () => [], result: () => ({ text: '' }) }, }) // Let the stream's async 'error' emission land; with no listener it is an uncaught exception. @@ -88,6 +90,7 @@ test('a real CLI that exits before reading stdin does not crash the process (#94 spawn: spawn as unknown as SpawnLike, emit: () => {}, signals: [], + driver: 'agent', parser: { push: () => [], result: () => ({ text: 'ok' }) }, }) assert.equal(turn.text, 'ok') @@ -123,6 +126,7 @@ test('runCliSession emits no telemetry when the process closes after an abort', spawn, emit: e => events.push(e), signals: [controller.signal], + driver: 'agent', parser: { push: () => [], result: () => ({ text: '' }) }, }) controller.abort() diff --git a/packages/agent-driver/src/cli-session.ts b/packages/agent-driver/src/cli-session.ts index 27a64bda..8764d049 100644 --- a/packages/agent-driver/src/cli-session.ts +++ b/packages/agent-driver/src/cli-session.ts @@ -55,8 +55,8 @@ export interface RunCliSessionOptions { signals: AbortSignal[] /** The agent's own output dialect. */ parser: AgentCliParser - /** The agent's name, for error messages. Default `"claude-code"`. */ - driver?: string + /** The driver's id, for error messages, e.g. `"claude-code"`. */ + driver: string } /** @@ -73,7 +73,7 @@ export function runCliSession(opts: RunCliSessionOptions): Promise { return new Promise((resolvePromise, rejectPromise) => { for (const s of opts.signals) { if (s.aborted) { - rejectPromise(new Error(`[framework] ${opts.driver ?? 'claude-code'} prompt aborted`)) + rejectPromise(new Error(`${opts.driver} prompt aborted`)) return } } @@ -86,7 +86,7 @@ export function runCliSession(opts: RunCliSessionOptions): Promise { const pid = child.pid if (pid != null) registerChild(pid) const parser = opts.parser - const agent = opts.driver ?? 'claude-code' + const agent = opts.driver let settled = false let hardKillTimer: ReturnType | undefined // Raw bytes, decoded once at close: a per-chunk `String(chunk)` corrupts a multibyte @@ -123,7 +123,7 @@ export function runCliSession(opts: RunCliSessionOptions): Promise { const handler = () => { if (settled) return terminate() - finish(() => rejectPromise(new Error(`[framework] ${agent} prompt aborted`))) + finish(() => rejectPromise(new Error(`${agent} prompt aborted`))) } signal.addEventListener('abort', handler) return { signal, handler } @@ -156,7 +156,7 @@ export function runCliSession(opts: RunCliSessionOptions): Promise { if (code !== 0) { const detail = Buffer.concat(stderrChunks).toString('utf8').trim() || turn.text.trim() || `exit code ${code ?? 'null'}` opts.emit({ type: 'error', message: detail }) - finish(() => rejectPromise(new Error(`[framework] ${agent} exited (${code ?? 'null'}): ${detail}`))) + finish(() => rejectPromise(new Error(`${agent} exited (${code ?? 'null'}): ${detail}`))) return } opts.emit({ @@ -171,7 +171,7 @@ export function runCliSession(opts: RunCliSessionOptions): Promise { // Feed the prompt over stdin so long prompts never hit arg-length limits. if (child.stdin) { // A CLI that exits before reading stdin (bad flag, instant crash) surfaces an async - // EPIPE on the stream; with no listener that is an uncaught exception in the daemon + // EPIPE on the stream; with no listener that is an uncaught exception in the calling process // (#943). The close handler already reports the failed turn, so the error carries // nothing the caller needs. child.stdin.on('error', () => {}) diff --git a/packages/agent-driver/src/codex.SPEC.md b/packages/agent-driver/src/codex.SPEC.md index d6c87fe5..9681c3f7 100644 --- a/packages/agent-driver/src/codex.SPEC.md +++ b/packages/agent-driver/src/codex.SPEC.md @@ -2,17 +2,17 @@ The driver for Codex: wraps the `codex` CLI as a black box on the user's own Cha ## User story -The user prefers Codex, or already pays for it. They pick it as their driver and everything else about The Framework — the dashboard, the gates, the queue, the pull requests — works exactly as it does with Claude Code. +The user prefers Codex, or already pays for it. They pick it as their driver and everything else about the caller's product works exactly as it does with Claude Code. ## Business logic — TL;DR -- **The user's own subscription does the work** - the CLI is run non-interactively on the user's existing ChatGPT sign-in; The Framework never supplies an API key. +- **The user's own subscription does the work** - the CLI is run non-interactively on the user's existing ChatGPT sign-in; the caller never supplies an API key. - **The agent may edit its workspace and nothing else** - the CLI runs under a sandbox limited to the checkout it was pointed at. The mode that disables the sandbox entirely is never used. -- **The framing rides in front of the task** - Codex has no separate channel for a system prompt, so The Framework's framing is prepended to the prompt as its own block. The same words reach the agent. +- **The framing rides in front of the task** - Codex has no separate channel for a system prompt, so the framing is prepended to the prompt as its own block. The same words reach the agent. - **The last thing the agent says is the turn's answer** - Codex narrates as it works; each message is streamed as it arrives, and the final one is the turn's outcome. -- **Tool use surfaces as its kind, never its arguments** - what The Framework gates on is the code and the outcome, not the individual tool calls. +- **Tool use surfaces as its kind, never its arguments** - what the caller gates on is the code and the outcome, not the individual tool calls. - **Tokens without a price** - Codex reports token counts but never a cost, so usage carries the counts and reports no cost at all rather than a cost of zero, which would read as "free". -- **No quota reporting** - a driver that cannot say where the account's subscription stands simply does not, and the rest of the product copes. +- **No quota reporting** - a driver that cannot say where the account's subscription stands simply does not, and the caller copes. ## Business logic @@ -20,11 +20,11 @@ The user prefers Codex, or already pays for it. They pick it as their driver and #### User story -The dashboard shows what each agent spent, and a user can cap how much an agent may spend before it stops. +The caller's UI shows what each agent spent, and a user can cap how much an agent may spend before it stops. #### Business logic -The CLI's end-of-turn accounting yields token counts only. Those counts are reported as the turn's usage, and no cost is reported — never a cost of zero, which spending limits would read as "this turn was free". Since the spending cap is expressed as a price, it cannot apply to a Codex agent at all, and The Framework says so when it starts rather than leaving the user to assume the cap is protecting them. +The CLI's end-of-turn accounting yields token counts only. Those counts are reported as the turn's usage, and no cost is reported — never a cost of zero, which a caller's spending limit would read as "this turn was free". A spending limit expressed as a price therefore cannot apply to a Codex agent at all, and the caller must not pretend it does. #### Rationale diff --git a/packages/agent-driver/src/codex.test.SPEC.md b/packages/agent-driver/src/codex.test.SPEC.md index a32f2c33..df469329 100644 --- a/packages/agent-driver/src/codex.test.SPEC.md +++ b/packages/agent-driver/src/codex.test.SPEC.md @@ -1,11 +1,11 @@ -What the tests cover: driving the Codex CLI and translating a real Codex turn into the framework's event stream. +What the tests cover: driving the Codex CLI and translating a real Codex turn into the driver event stream. - Codex narrates as it works: every message it makes is streamed as it arrives, and the last one is the turn's answer. Tool use surfaces as its kind only, never its arguments. Output that is not one of the CLI's structured events is ignored. - The turn's usage carries token counts and no cost at all, since Codex prices nothing and a cost of zero would read as "free". - Token counting is exact: the reported input total already includes everything served from cache, so the genuinely new input is the difference; reasoning tokens are part of the reported output rather than additional to it, so they are never counted twice; no cache-creation count is invented. A missing or nonsensical accounting yields no usage rather than a wrong one, and a cache figure larger than the input total can never produce a negative count. - The CLI runs sandboxed to the agent's own checkout — never with the flag that disables the sandbox — and is told to run even when the checkout is not a git repository. - The prompt goes in through the CLI's input rather than as a command-line argument, so a long one is never truncated, and the system prompt framing is prepended to it since Codex has no separate channel for it. The chosen model is passed through. -- This driver reports no quota at all, which means spending limits simply do not apply to a Codex agent rather than being applied to a made-up figure. +- This driver reports no quota at all, which means a caller's spending limits simply do not apply to a Codex agent rather than being applied to a made-up figure. - A turn whose CLI exits with a failure is reported as failed, even though the agent streamed text first. ## Before modifying/creating SPEC.md files diff --git a/packages/agent-driver/src/codex.ts b/packages/agent-driver/src/codex.ts index e145b2e9..5330f5f2 100644 --- a/packages/agent-driver/src/codex.ts +++ b/packages/agent-driver/src/codex.ts @@ -41,8 +41,7 @@ export interface CodexDriverOptions { * framing is prepended to the prompt instead. Same words reach the agent. * - **Tokens, no price.** Codex reports token counts but never a price, so usage * carries the counts and omits `costUsd` rather than claim a turn cost `$0`, - * which would read as free (#540). The budget cap (#322) gates on a price, so - * it cannot fire here; the CLI says so at startup instead of implying it. + * which would read as free (#540). * - **No quota read.** No `readQuota`, for the same reason: the seam is optional * precisely so an agent that can't report one simply doesn't. */ diff --git a/packages/agent-driver/src/fake.SPEC.md b/packages/agent-driver/src/fake.SPEC.md index 84ee3a8b..f4d19037 100644 --- a/packages/agent-driver/src/fake.SPEC.md +++ b/packages/agent-driver/src/fake.SPEC.md @@ -1,14 +1,14 @@ -The fake driver: a driver that answers from a script instead of from a coding-agent CLI, so the whole product can run offline — no CLI installed, no model called, no cost, and the same outcome every time. +The fake driver: a driver that answers from a script instead of from a coding-agent CLI, so a whole product can run offline — no CLI installed, no model called, no cost, and the same outcome every time. ## User story -Someone wants to try The Framework, or to test it, without a coding-agent subscription. The dashboard, the agents, the gates, and the event streams all behave as they normally do; only the agent's answers are canned. +Someone wants to try the caller's product, or to test it, without a coding-agent subscription. Its UI, its agents, its gates, and its event streams all behave as they normally do; only the agent's answers are canned. ## Business logic — TL;DR - **Turns come from a script** - an agent replays a prepared list of answers, one per prompt, in order. Answers can also be produced from the prompt itself when a scenario needs to react to what was asked. - **A short script never starves a long agent** - once the script runs out, its last answer repeats, so an agent that takes more turns than the script anticipated still finishes. -- **The event stream is indistinguishable from a real one** - the same start, tool-call, text, and result events reach the dashboard, including any usage figures the script names, so every surface can be exercised end to end. +- **The event stream is indistinguishable from a real one** - the same start, tool-call, text, and result events reach the caller, including any usage figures the script names, so every surface can be exercised end to end. - **Files can be pre-seeded** - an agent can be given the file contents it will be asked to read, so flows that depend on reading what the agent wrote work with no agent at all. - **Stopping still works** - an agent already stopped refuses further prompts, exactly as a real one does. diff --git a/packages/agent-driver/src/fake.ts b/packages/agent-driver/src/fake.ts index efb501bb..8ea8e861 100644 --- a/packages/agent-driver/src/fake.ts +++ b/packages/agent-driver/src/fake.ts @@ -32,10 +32,10 @@ function asTurn(value: FakeTurn | string): FakeTurn { } /** - * An in-memory {@link Driver} for tests and `FRAMEWORK_FAKE` runs: it never spawns a + * An in-memory {@link Driver} for tests and offline runs: it never spawns a * process, replays scripted turns deterministically, and emits the same - * {@link DriverEvent} shape a real driver does. Mirrors `AiFake` / - * `FakeRunner`, so the whole flow runs offline with no CLI and no model. + * {@link DriverEvent} shape a real driver does, so a whole product can run + * with no CLI installed and no model called. */ export class FakeDriver implements Driver { readonly id = 'fake' @@ -65,7 +65,7 @@ export class FakeDriverSession implements DriverSession { prompt(text: string, opts: DriverPromptOptions = {}): Promise { if (this.startOpts.signal?.aborted || opts.signal?.aborted) { - return Promise.reject(new Error('[framework] fake prompt aborted')) + return Promise.reject(new Error('fake prompt aborted')) } const i = this.index++ this.prompts.push(text) @@ -81,7 +81,7 @@ export class FakeDriverSession implements DriverSession { readCode(path: string): Promise { const contents = this.config.files?.[path] - if (contents === undefined) return Promise.reject(new Error(`[framework] fake driver has no file ${path}`)) + if (contents === undefined) return Promise.reject(new Error(`fake driver has no file ${path}`)) return Promise.resolve(contents) } diff --git a/packages/agent-driver/src/index.SPEC.md b/packages/agent-driver/src/index.SPEC.md index ae3ff406..3fb0be07 100644 --- a/packages/agent-driver/src/index.SPEC.md +++ b/packages/agent-driver/src/index.SPEC.md @@ -1,4 +1,4 @@ -The package's main entry point: the driver contract itself and the set of implementation ids, the three drivers that satisfy it (Claude Code, Codex, the `actions` run target), the fake driver, reading where the account's quota stands, the process engine a further CLI-backed driver would be built on, and the event-stream helper a driver implemented outside the package (The Framework's cloud hand-off) builds its events with. No business logic of its own. The Actions run target's internal archive reader is deliberately not offered, since nothing outside that driver uses it. +The package's main entry point: the driver contract itself and the set of implementation ids, the three drivers that satisfy it (Claude Code, Codex, the `actions` run target), the fake driver, reading where the account's quota stands, the process engine a further CLI-backed driver would be built on, and the event-stream helper a driver implemented outside the package builds its events with. No business logic of its own. The Actions run target's internal archive reader is deliberately not offered, since nothing outside that driver uses it. ## Before modifying/creating SPEC.md files diff --git a/packages/agent-driver/src/index.ts b/packages/agent-driver/src/index.ts index d93e85a7..591a327d 100644 --- a/packages/agent-driver/src/index.ts +++ b/packages/agent-driver/src/index.ts @@ -20,14 +20,12 @@ export { ClaudeCodeDriver, ClaudeCodeSession, StreamJsonParser, - runClaude, type ClaudeCodeDriverOptions, type McpServerSpec, type PermissionMode, } from './claude-code.js' export { ActionsDriver, ActionsSession, replayTranscript, type ActionsDriverOptions, type FetchLike } from './actions.js' -// `makeEmit` is what a driver implemented outside this package (The Framework's cloud hand-off) builds -// its event stream with. +// `makeEmit` is what a driver implemented outside this package builds its event stream with. export { makeEmit } from './session-support.js' // `readZip`/`ZipEntry` are deliberately absent (#947): the Actions driver's internal zip reader // rode this barrel onto the published surface with no importer outside the driver and its own diff --git a/packages/agent-driver/src/session-support.SPEC.md b/packages/agent-driver/src/session-support.SPEC.md index 47febd44..0007b93a 100644 --- a/packages/agent-driver/src/session-support.SPEC.md +++ b/packages/agent-driver/src/session-support.SPEC.md @@ -1,6 +1,6 @@ The handful of behaviours every driver needs and none of them owns: publishing an agent's events, combining the agent-wide and per-turn framing into one block, combining the agent-wide and per-turn Stop signals, and reading a file out of the agent's own checkout. -The one rule worth stating: a surface that fails while handling an agent's event — a dashboard listener throwing, say — never takes the agent down with it. The failure is logged, naming which driver's event it happened on, and the agent carries on. +The one rule worth stating: a surface that fails while handling an agent's event — the caller's UI throwing, say — never takes the agent down with it. The failure is logged, naming which driver's event it happened on, and the agent carries on. ## Before modifying/creating SPEC.md files diff --git a/packages/agent-driver/src/session-support.ts b/packages/agent-driver/src/session-support.ts index 03785024..d8e7aa2c 100644 --- a/packages/agent-driver/src/session-support.ts +++ b/packages/agent-driver/src/session-support.ts @@ -10,7 +10,7 @@ import type { DriverEvent } from './types.js' /** * A {@link DriverStartOptions.onEvent} caller that never lets a listener throw into the - * driver — a throwing dashboard handler must not abort the agent. An absent `onEvent` + * driver — a throwing UI handler must not abort the agent. An absent `onEvent` * is a no-op. `driver` names it in the swallow log so a thrown handler stays traceable. */ export function makeEmit(onEvent: ((event: DriverEvent) => void) | undefined, driver: string): (event: DriverEvent) => void { @@ -19,7 +19,7 @@ export function makeEmit(onEvent: ((event: DriverEvent) => void) | undefined, dr try { onEvent(event) } catch (err) { - console.error(`[framework] ${driver} onEvent threw; ignoring:`, err) + console.error(`${driver} onEvent threw; ignoring:`, err) } } } diff --git a/packages/agent-driver/src/types.SPEC.md b/packages/agent-driver/src/types.SPEC.md index 93a0ed29..a65314ac 100644 --- a/packages/agent-driver/src/types.SPEC.md +++ b/packages/agent-driver/src/types.SPEC.md @@ -1,20 +1,21 @@ -The driver seam: the single contract every coding-agent CLI is wrapped behind, and the vocabulary the rest of The Framework speaks about agents, what they spend, and where the account's subscription stands. +The driver seam: the single contract every coding-agent CLI is wrapped behind, and the vocabulary the caller speaks about agents, what they spend, and where the account's subscription stands. ## User story -The user picks which coding agent does the work — Claude Code or Codex — and where it runs. Everything else about The Framework behaves identically either way: the same dashboard, the same live event stream, the same gates, the same pull requests. Adding another coding agent later must not change any of that. +The user picks which coding agent does the work — Claude Code or Codex — and where it runs. Everything else about the caller's product behaves identically either way: the same UI, the same live event stream, the same gates. Adding another coding agent later must not change any of that. ## Glossary -- **turn** - one prompt handed to the wrapped CLI and the whole loop it runs in response, ending with the agent's final message. A turn is the unit The Framework gates on. +- **caller** - the product that embeds the package and drives agents through it. +- **turn** - one prompt handed to the wrapped CLI and the whole loop it runs in response, ending with the agent's final message. A turn is the unit the caller gates on. - **quota window** - one named allowance the account's subscription is measured in: the current session, the current week across all models, or the current week for one model. ## Business logic — TL;DR -- **The CLI is a black box** - The Framework prompts it, lets its own loop run to completion, and then reads the code it produced. It never gates on which tool the agent reached for. +- **The CLI is a black box** - the caller prompts it, lets its own loop run to completion, and then reads the code it produced. It never gates on which tool the agent reached for. - **Every pass is a fresh prompt** - review, security, QA, UX: each is its own prompt with its own fresh context, and a role is framing added to the prompt rather than a separate kind of agent. - **Capabilities are optional, never faked** - reading the account's quota, reading a file from the agent's workspace, and continuing a previous conversation are each offered only by drivers that can genuinely do them; a driver that cannot simply does not, and the product copes. -- **Events are for looking, not for deciding** - everything the agent streams reaches the dashboard for visibility, and no control flow ever branches on it. +- **Events are for looking, not for deciding** - everything the agent streams reaches the caller's UI for visibility, and no control flow ever branches on it. - **Spend, traffic light, and proportion are three different things** - what an agent spent, whether the account may still spend, and how much of the allowance is gone are kept strictly apart and never substituted for one another. - **"We could not ask" is never "nothing is used"** - a quota reading is either a real reading or an explicit absence with a reason, so an unanswered question can never be read as an empty allowance. - **A failed reading says whether it is worth asking again** - failures that describe one attempt are distinguished from failures that describe the account or the installation. @@ -35,7 +36,7 @@ The contract is deliberately tiny: start an agent bound to a checkout, prompt it #### Rationale -Two identities are kept apart. The **driver** is the user's choice — Claude Code or Codex. Each driver has a separate implementation for each place it can run, since running on a GitHub Actions runner or in a cloud session is a different mechanism from running on this device, and the run target is a fact about *where*, not about *which agent*. The contract fixes the set of implementation ids (`claude-code`, `codex`, `claude-web`, `github-actions`, `fake`), because an agent's record names the implementation that ran it and every reader of that record must agree on the vocabulary; mapping an id back to the user's choice is the product's business, not the package's. +Two identities are kept apart. The **driver** is the user's choice — Claude Code or Codex. Each driver has a separate implementation for each place it can run, since running on a GitHub Actions runner or in a cloud session is a different mechanism from running on this device, and the run target is a fact about *where*, not about *which agent*. The contract fixes the set of implementation ids (`claude-code`, `codex`, `claude-web`, `github-actions`, `fake`), because an agent's record names the implementation that ran it and every reader of that record must agree on the vocabulary; mapping an id back to the user's choice is the caller's business, not the package's. ### Every pass is a fresh prompt @@ -47,29 +48,29 @@ An agent plans, then builds, then reviews its own work for quality, security, an Each pass is its own prompt, and a prompt is the unit of fresh context. Everything runs through the driver — there is no second execution path — and a role is delivered as framing prepended to the prompt rather than as a separate kind of agent. Framing exists at two levels: framing that applies to every prompt of an agent, and framing added for one prompt only. -The one deliberate exception is continuing a conversation: a prompt may ask to pick up the agent's previous turn instead of starting fresh, which is how a live chat message lands in the ongoing conversation with its full history, and how a finished agent is revived from the dashboard with everything it already knew. +The one deliberate exception is continuing a conversation: a prompt may ask to pick up the agent's previous turn instead of starting fresh, which is how a live chat message lands in the ongoing conversation with its full history, and how a finished agent is revived from the caller's UI with everything it already knew. ### Capabilities are optional, never faked #### User story -The dashboard shows a quota bar for a Claude Code agent and none for a Codex one, because Codex has no quota to report. A missing capability is honest; a made-up number is not. +The caller's UI shows a quota bar for a Claude Code agent and none for a Codex one, because Codex has no quota to report. A missing capability is honest; a made-up number is not. #### Business logic -Three capabilities are optional. Reading where the account's subscription quota stands belongs to the driver rather than to any one agent, because it is an account-wide fact — and a coding agent that cannot report it offers nothing rather than a guess, which is exactly what makes spending limits inapplicable rather than wrongly applied. Reading a file out of the agent's workspace is offered only when that workspace is on this machine. Continuing a previous conversation is best-effort: a driver that cannot resume runs a fresh prompt instead, which is the ordinary case and never an error. +Three capabilities are optional. Reading where the account's subscription quota stands belongs to the driver rather than to any one agent, because it is an account-wide fact — and a coding agent that cannot report it offers nothing rather than a guess, which is exactly what makes a caller's spending limits inapplicable rather than wrongly applied. Reading a file out of the agent's workspace is offered only when that workspace is on this machine. Continuing a previous conversation is best-effort: a driver that cannot resume runs a fresh prompt instead, which is the ordinary case and never an error. ### Events are for looking, not for deciding #### User story -The user watches an agent's prose and tool calls appear live in the dashboard while it works. +The user watches an agent's prose and tool calls appear live in the caller's UI while it works. #### Business logic An agent publishes, as it works: the prompt starting; its session id; each chunk of prose; each tool it reached for, by name only and never with its arguments; where the account's quota stands; the turn's final outcome; a failure; and a notice — something the driver worked around that the user should know about, such as a conversation whose history was lost. -None of this is ever branched on. It exists so every surface — the dashboard, the terminal — can show what is happening. A surface that fails while handling an event must not take the agent down with it. +None of this is ever branched on. It exists so every surface of the caller's — a UI, a terminal — can show what is happening. A surface that fails while handling an event must not take the agent down with it. The session id is published at the start of the turn as well as with the outcome, because a turn that never reaches its end — the user pressing Stop, a crash, a kill — would otherwise take the handle for resuming that conversation with it. @@ -83,7 +84,7 @@ The user wants to know what an agent cost them, whether unattended work should s #### Business logic -**Usage** is what one turn spent: tokens broken into new input, output, cache reads, and cache writes, plus a price when the coding agent prices its own turns. When it does not, no price is reported at all — never zero, which spending limits would read as "this was free" rather than "unknown". Tokens are reported by every coding agent; a price is reported by only some, which is why an agent that cannot price a turn still reports the tokens it does know. +**Usage** is what one turn spent: tokens broken into new input, output, cache reads, and cache writes, plus a price when the coding agent prices its own turns. When it does not, no price is reported at all — never zero, which a caller's spending limit would read as "this was free" rather than "unknown". Tokens are reported by every coding agent; a price is reported by only some, which is why an agent that cannot price a turn still reports the tokens it does know. The price is notional in any case: under a subscription the user pays a flat fee, and the figure is what the turn would have cost at metered rates. What a subscription actually consumes is quota. @@ -99,13 +100,13 @@ There are deliberately no daily allowances. The subscription is measured in a se #### User story -Unattended work stands down as the quota boundary approaches. A reading that silently came back empty would tell the daemon it has the whole week to spend. +Unattended work stands down as the quota boundary approaches. A reading that silently came back empty would tell the caller it has the whole week to spend. #### Business logic A quota reading is either a real reading with its windows, or an explicit absence carrying the reason — never an empty list of windows, which a caller could mistake for an unused allowance. -The reasons split into two kinds. Some describe *this attempt*: the agent's own lookup being refused upstream, the agent not answering in time, and an answer in a shape The Framework did not recognize — the last of which covers an update notice printed ahead of the answer, or an empty answer while the CLI replaces itself underneath a long-running daemon, both of which are gone by the next reading. A previous reading is still worth showing through those, and asking again may work. +The reasons split into two kinds. Some describe *this attempt*: the agent's own lookup being refused upstream, the agent not answering in time, and an answer in a shape the package did not recognize — the last of which covers an update notice printed ahead of the answer, or an empty answer while the CLI replaces itself underneath a long-running process, both of which are gone by the next reading. A previous reading is still worth showing through those, and asking again may work. The remaining reasons describe the account or the installation — no subscription quota exists, or the CLI is not installed — and are statements about the setup rather than about one attempt. A previously retained reading must not outlive them. diff --git a/packages/agent-driver/src/types.ts b/packages/agent-driver/src/types.ts index 62c5858e..ed747dc4 100644 --- a/packages/agent-driver/src/types.ts +++ b/packages/agent-driver/src/types.ts @@ -1,22 +1,20 @@ /** - * The **driver** seam: the one abstraction The Framework wraps a coding-agent - * CLI behind. A driver treats the agent (Claude Code today, Codex / opencode - * later) as a **black box**: we hand it a prompt, let its *own* loop run to - * completion, then read the code it produced and gate on the outcome ourselves. + * The **driver** seam: the one abstraction a coding-agent CLI is wrapped behind. + * A driver treats the CLI (Claude Code, Codex) as a **black box**: hand it a + * prompt, let its *own* loop run to completion, then read the code it produced + * and gate on the outcome. * * The seam is deliberately the **code and the outcome**, never the agent's - * individual tool calls (guardrail from #165). We drive by prompting and verify - * by result (builds / serves / review-passes), so the wrapped agent keeps its - * subscription-based auth and its internal loop stays untouched and swappable. + * individual tool calls (#165): drive by prompting, verify by result, so the + * wrapped CLI keeps its subscription-based auth and its internal loop stays + * untouched and swappable. * - * Decision (#166, option A): a single execution path. Everything runs *through* - * the driver; personas become prompt-framing ({@link DriverStartOptions.system}), - * and each loop pass (review / security / QA / UX) is a **fresh** {@link - * DriverSession.prompt} call, so `prompt` is the fresh-context unit. + * Everything runs *through* the driver: a role is prompt framing + * ({@link DriverStartOptions.system}), and each pass is a **fresh** + * {@link DriverSession.prompt} call, so `prompt` is the fresh-context unit. * * `Driver` is intentionally tiny: `start` a session, `prompt` it, read the code, - * `dispose`. It mirrors the runner seam's shape so a second agent slots in behind - * the same three methods. + * `dispose` — four moves a second CLI slots in behind. */ /** @@ -52,8 +50,8 @@ export interface DriverStartOptions { /** Absolute path to the workspace the agent reads and edits. */ cwd: string /** - * Role framing prepended to every prompt in this session (option A: personas - * are prompt-framing, not a separate agent). Maps to the agent's system prompt. + * Role framing prepended to every prompt in this session (a role is prompt + * framing, not a separate kind of agent). Maps to the agent's system prompt. */ system?: string /** Model id to pass through when the wrapped agent supports selecting one. */ @@ -63,14 +61,14 @@ export interface DriverStartOptions { /** * Resume a prior agent session id (#720): seed the session so its very first * prompt (with `resume`) continues that conversation instead of starting fresh. - * This is how a finished agent is revived from the dashboard — its captured session + * This is how a finished agent is revived from a UI — its captured session * id is threaded here so the opening message lands with the full prior context. * A driver that can't resume ignores it and runs fresh (the best-effort contract). */ resumeSessionId?: string /** * Observe the agent's *own* progress as it works. Black-box granularity: we - * forward these for visibility (the dashboard) but never branch control flow + * forward these for visibility (a UI) but never branch control flow * on them. Isolated: a throwing callback must not break the agent. */ onEvent?: (event: DriverEvent) => void @@ -117,11 +115,7 @@ export interface DriverPromptOptions { export interface DriverTurn { /** The agent's final assistant text for this prompt. */ text: string - /** - * The agent's session id for this turn, when it exposes one. The MVP - * persistence shortcut is to forward the agent's own transcript rather than - * keep our own store (#165), so this is the handle a UI links to. - */ + /** The agent's session id for this turn, when it exposes one: the handle a UI links to. */ sessionId?: string /** Token + cost accounting for this turn, when the agent reports it (#322). */ usage?: DriverUsage @@ -145,7 +139,7 @@ export interface DriverUsage { * Note this is a notional price under a subscription: the user pays a flat fee, * and the agent reports what the turn would have cost on metered API pricing. * What a subscription actually spends is quota, which {@link DriverQuota} - * carries and the consumption limits (#519) gate on. + * carries and a spending limit (#519) gates on. */ costUsd?: number /** Non-cached input tokens. */ @@ -239,7 +233,7 @@ export type DriverQuotaUnavailableReason = * * Transient, because it describes one answer rather than the install: an * update notice printed ahead of the JSON, or empty stdout while the CLI - * swaps itself under a long-lived daemon, both land here and both are gone by + * swaps itself under a long-lived process, both land here and both are gone by * the next read (#960). */ | 'unrecognized' @@ -260,9 +254,9 @@ export type DriverQuota = | { available: false; reason: DriverQuotaUnavailableReason } /** - * A black-box progress event from the wrapped agent. We forward these to the - * dashboard for visibility but never gate on them: the loop gates on the code / - * outcome, not on which tool the agent reached for. + * A black-box progress event from the wrapped agent. A caller shows these for + * visibility but never gates on them: the gate is the code and the outcome, not + * which tool the agent reached for. */ export type DriverEvent = /** A prompt was sent; the agent's loop is starting. */ @@ -270,8 +264,8 @@ export type DriverEvent = /** * The agent announced its session id, at the start of the turn (#1322). `result` repeats it, * but a turn that never settles — a manual Stop, an error, a kill — used to take the id down - * with it, and with it the agent's `claude --resume` handle. Telemetry consumes this one rather - * than forwarding it: the id is plumbing, not conversation. + * with it, and with it the agent's `claude --resume` handle. A caller records this one rather + * than showing it: the id is plumbing, not conversation. */ | { type: 'session'; sessionId: string } /** An assistant text chunk streamed out. */ @@ -280,8 +274,8 @@ export type DriverEvent = | { type: 'action'; label: string } /** * The turn settled with this final text. `sessionLink` is the real URL of the session, - * for a driver whose session has one of its own (#1317) — the cloud hand-off — so the - * meta can link there instead of the generic entry point; drivers without one omit it. + * for a driver whose session has one of its own (#1317) — a cloud session, say — so a + * caller can link there instead of the generic entry point; drivers without one omit it. * `anchorSha` is the hand-off anchor commit (#1601), for a driver whose session does its * work on a branch of its own naming that this machine can only recognize later by * ancestry; drivers whose work stays on the designated branch omit it. diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index 31ab86de..23ea60b4 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -1181,7 +1181,7 @@ async function driveAgent(opts: AgentOptions, io: CliIO): Promise { 'workflow refuses a bot-triggered run.', ) } - actionsConfig = { owner: slug.owner, repo: slug.repo, token } + actionsConfig = { owner: slug.owner, repo: slug.repo, token, workflow: 'framework-agent.yml' } io.out(`◆ run on: GitHub Actions (${slug.owner}/${slug.repo})`) } diff --git a/packages/framework/src/daemon-workspace.test.ts b/packages/framework/src/daemon-workspace.test.ts index a81eb77f..64c04228 100644 --- a/packages/framework/src/daemon-workspace.test.ts +++ b/packages/framework/src/daemon-workspace.test.ts @@ -411,7 +411,7 @@ test('the failed-start marker is not written where the checkout is gone (#1654)' test('isTransientAgentFailure names transport deaths, not work failures (#1281)', () => { assert.equal( - isTransientAgentFailure('[framework] claude-code exited (1): API Error: Connection closed mid-response. The response above may be incomplete.'), + isTransientAgentFailure('claude-code exited (1): API Error: Connection closed mid-response. The response above may be incomplete.'), true, ) assert.equal(isTransientAgentFailure('read ECONNRESET'), true) @@ -473,7 +473,7 @@ async function waitForSpawns(worktree: string, expected: number): Promise { const cwd = await initRepo('framework-transient-') - const detail = '[framework] claude-code exited (1): API Error: Connection closed mid-response. The response above may be incomplete.' + const detail = 'claude-code exited (1): API Error: Connection closed mid-response. The response above may be incomplete.' const runtime = createProjectRuntime({ driverPreflight: agentReady, cwd, env: {}, binPath: await writeFailingAgentStub(cwd, detail), retryDelayMs: 25 }) try { const result = (await runtime.onStart('build a thing', 'build')) as { ok: boolean; agentId?: string } diff --git a/packages/framework/src/target-driver.test.ts b/packages/framework/src/target-driver.test.ts index 01e1289f..ab0996d0 100644 --- a/packages/framework/src/target-driver.test.ts +++ b/packages/framework/src/target-driver.test.ts @@ -8,7 +8,7 @@ import { CloudDriver } from './driver/cloud.js' // `--run-on web` a CloudDriver; anything else falls through to the driver for the chosen CLI, // byte-identical to before. -const ACTIONS = { owner: 'gemstack-land', repo: 'gemstack', token: 't' } +const ACTIONS = { owner: 'gemstack-land', repo: 'gemstack', token: 't', workflow: 'framework-agent.yml' } test('createTargetDriver returns an ActionsDriver for target "actions"', () => { const driver = createTargetDriver({ driver: 'claude', target: 'actions', actionsConfig: ACTIONS })