done-done-axi 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -101,7 +101,7 @@ cd your-app
101
101
  done-done init
102
102
  ```
103
103
 
104
- `init` writes a minimal `.done-done.yaml` (only if you don't already have one) that names your app, and a commented global `~/.done-done/config.yaml` (only if there is none). done-done never picks the coding agent it starts: `init` asks you in a terminal, or takes `--agent <agent> --model <model>`. It never picks the judge either, the agent of its own that `verify` starts to look at each capture (never the one that did the work): `init` asks in a terminal, or takes `--judge <agent> --judge-model <model>`. The config keys are in the [reference](docs/reference.md#config). A plain `init` never overwrites an existing config. It also scaffolds a verify skill at `.agents/skills/verify-<app>` from the repo's own files, for your agent to finish with one live drive. Evidence lives outside the repo, in `~/.done-done/evidence/<app>`, so it outlives the checkout that captured it. Then describe how to capture proof — swap the command for whatever drives your real app and saves screenshots, recordings or logs:
104
+ `init` writes a minimal `.done-done.yaml` (only if you don't already have one) that names your app, and a commented global `~/.done-done/config.yaml` (only if there is none). done-done never picks the coding agent it starts: `init` asks you in a terminal, or takes `--agent <agent> --model <model>`. It never picks the judge either, the agent of its own that `verify` starts to look at each capture: `init` asks in a terminal, or takes `--judge <agent> --judge-model <model>`. The judge must differ from the author by agent or by model: the same agent at another model is fine, the same agent and model is refused (`judge.allow_same: true` is an opt-out only). For example, with pi on DeepSeek as the author, `done-done init --agent pi --model commandcode/deepseek/deepseek-v4.1-flash --judge pi --judge-model zai/glm-5.3-flash` is accepted. The config keys are in the [reference](docs/reference.md#config). A plain `init` never overwrites an existing config. It also scaffolds a verify skill at `.agents/skills/verify-<app>` from the repo's own files, for your agent to finish with one live drive. Evidence lives outside the repo, in `~/.done-done/evidence/<app>`, so it outlives the checkout that captured it. Then describe how to capture proof — swap the command for whatever drives your real app and saves screenshots, recordings or logs:
105
105
 
106
106
  ```yaml
107
107
  journeys:
@@ -121,9 +121,9 @@ git add .done-done.yaml
121
121
  git commit -m "Add done-done"
122
122
  ```
123
123
 
124
- A `run -- <command>` whose command matches no recorded journey still runs, but its result hints that the recipe is not in the repo.
124
+ A `run -- <command>` whose command matches no recorded journey still runs, but its result hints that the recipe is not in the repo. For a proof you drive by hand or by your agent instead of a journey, `done-done run --open` prints a run id and a folder to capture into, and `done-done run --close <run-id>` seals it ([how](docs/reference.md#run-a-journey)).
125
125
 
126
- At any point, `done-done doctor` checks the setup and changes nothing except the `--probe` call record. Each item reports `ok`, `warning` or `missing` with the exact fix command; an agent you have not set yet is `missing`, with the command that saves your choice. `done-done doctor --probe` also asks the agent one question to prove it answers. The items are listed in the [reference](docs/reference.md#check-the-setup).
126
+ At any point, `done-done doctor` checks the setup and changes nothing except the `--probe` call record. Each item reports `ok`, `warning` or `missing` with the exact fix command; an agent you have not set yet is `missing`, with the command that saves your choice. `done-done doctor --probe` also asks the agent one question to prove it answers; on a new machine that probe fails until the agent is signed in (for pi, run `pi` and then `/login`). The quick start above never calls the agent, so `run` and `check` work before it. The judge also needs the optional `@byokit/decide@0.8.0` kit, which a plain npm install leaves out: until it is there, `doctor` reports the judge `missing`, and `verify`, `check --judge` and `design` cannot judge. Add it with the line `doctor` prints: `npx -y -p done-done-axi -p @byokit/decide@0.8.0 done-done-axi doctor`. The items are listed in the [reference](docs/reference.md#check-the-setup).
127
127
 
128
128
  `done-done maintain` keeps the verify skill true: it checks the skill's links and feature index, checks that every `file:line` it cites exists, and has your coding agent read the source for each feature, read-only, to report drift. It ends `clean`, `drift` with the findings to fix, or `blocked`. With `auto_fix: { maintain: <n> }` (as in no-mistakes; 0, the default, reports only), your judge first sorts each drift finding (a skill out of date, or a product that may be wrong, which stops and asks you), then your agent fixes the skill folder only, in a done-done worktree, and maintain runs again, up to n times. A clean end is `changed`: one commit on a `done-done/maintain-<run-id>` branch, your branch untouched ([reference](docs/reference.md#keep-the-verify-skill-true)).
129
129
 
@@ -147,6 +147,8 @@ No journey yet? `done-done verify --outcome "<what a user can now do>"` has your
147
147
 
148
148
  With `auto_fix: { verify: <n> }` (0, the default, reports only), a failed verify is fixed with your own agent: your judge first sorts each wrong screen (a product bug to fix, or a question that waits at the gate for you), then your agent fixes the product in a done-done worktree with your ignored files copied in, and verify runs again there, up to n times. A pass ends `changed`, with the fix on a `done-done/verify-<run-id>` branch and your branch untouched ([details](docs/reference.md#fix-the-product)).
149
149
 
150
+ While a `verify` or `maintain` run goes, `done-done status` shows its steps and next move, `done-done attach` streams its events, and `done-done logs --step <name>` shows one step's log. `done-done rerun` runs a failed verify run again from the failed step, `done-done abort` stops a live run, `done-done signoff` writes a verify run's sign-off message, and `done-done history` lists past runs.
151
+
150
152
  Run `done-done` with no arguments to see the current config and the next step.
151
153
 
152
154
  ## What it checks
@@ -157,9 +159,10 @@ Run `done-done` with no arguments to see the current config and the next step.
157
159
  - ♻️ **Not reused**: Proof made only of files already used for a different claim fails, and so does citing the same file twice. A claim is named by `--claim-id <id>`, which defaults to your branch, so rewording it keeps its proof. Several claims on one branch each need their own `--claim-id`.
158
160
  - 🧾 **Honest claims**: Reads a pull request's `## Checks` block — from a file, a URL, or stdin — and makes sure every line is filled in.
159
161
  - 📌 **Verifiable later**: `receipt` records a passing check against the exact commit and the sha256 of every file it cited; `receipt --verify` re-checks it from any checkout of that commit on the same machine (a merge gate or a fresh clone), or in CI once the evidence folder is copied there ([how](docs/reference.md#where-proof-lives)).
160
- - 👀 **Shows the outcome** (on by default in `verify`; `check` and `receipt` with `--judge`): `verify` starts done-done's own judge, a fresh, tools-off agent on your own plan or key (Claude Code, opencode, pi or Gemini CLI, or your own OpenAI API key), to say whether each capture is the right screen for the claim (and each text capture, such as a log, JSON or CLI output, the right output), and a wrong one fails. You choose the judge (`done-done init --judge <agent> --judge-model <model>`, or pick it from the list `verify` shows in a terminal); done-done never picks one, and it is never the agent and model that did the work. `--no-judge` or `judge.enabled: false` skips it when you choose to, and `check` and `receipt` judge only with `--judge`.
162
+ - 👀 **Shows the outcome** (on by default in `verify`; `check` and `receipt` with `--judge`): `verify` starts done-done's own judge, a fresh, tools-off agent on your own plan or key (Claude Code, opencode, pi or Gemini CLI, or your own OpenAI API key), to say whether each capture is the right screen for the claim (and each text capture, such as a log, JSON or CLI output, the right output), and a wrong one fails. You choose the judge (`done-done init --judge <agent> --judge-model <model>`, or pick it from the list `verify` shows in a terminal); done-done never picks one, and it must differ from the author by agent or by model (the same agent and model is refused). `--no-judge` or `judge.enabled: false` skips it when you choose to, and `check` and `receipt` judge only with `--judge`.
161
163
  - 🎨 **Looks right**: `done-done design <screens...> [--mock <screen>=<mock>]` has the same judge look at each captured screen, alone or next to its approved mock, and fails it on stretched or squashed images, clipped or squashed text, overlap, elements off-screen, blank or black strips and broken layout, naming each defect and where it is ([how](docs/reference.md#design-check)).
162
- - 🧩 **Page is clean** (with a `dom` block): each captured screen also needs its page-check record. A page that overflows the viewport, logs a console error, has a failed request or shows squashed text fails, unless an allow list names it ([how](docs/reference.md#dom-assertions)).
164
+ - 📐 **Fits the screen**: `done-done look <url>` opens a web page in headless Chrome at a 390x844 phone viewport, captures it, and fails the journey that calls it on squashed text, clipped text, elements cut off by an edge or a page wider than the screen, naming each element, with no model ([how](docs/reference.md#look-check)).
165
+ - 🧩 **Page is clean** (with a `dom` block): each captured screen also needs its page-check record. A page that overflows the viewport, logs a console error, has a failed request or shows squashed, clipped or cut-off UI fails, unless an allow list names it ([how](docs/reference.md#dom-assertions)).
163
166
  - 🔒 **Safe in CI**: `--trusted-ref origin/main` reads the rules from your main branch, so a pull request can't loosen them.
164
167
 
165
168
  Every answer is an exit code: **0** pass, **1** fail (or overridden at the gate), **2** usage or config error, **3** can't tell either way, **4** a `verify`, `maintain` or `rerun` run is still running, or a `verify` run waits at its gate.
@@ -185,12 +188,12 @@ Another fleet works the same way: add the include text to whatever your workers
185
188
  ## Learn more
186
189
 
187
190
  - [Vision](VISION.md): the three-tool family, the rules they share, and where done-done goes next.
188
- - [Full reference](docs/reference.md): every `init`, `doctor`, `run`, `check` and `receipt` option, the Checks block rules, each file check, and both config files.
191
+ - [Full reference](docs/reference.md): every command and option, the Checks block rules, each file check, and both config files.
189
192
  - [Report a problem or ask a question](https://github.com/umeranjum17/done-done-axi/issues)
190
193
 
191
194
  ## What's next
192
195
 
193
- So far: `init`, `doctor`, `run`, `check`, `receipt`, `verify` (by journey or by your coding agent, with a gate you answer with `respond`), the capture judge (on by default in `verify`), the `design` visual check and the opt-in session hooks for Claude Code, Codex, opencode, Gemini CLI and pi. Coming next: help writing the proof steps for your app.
196
+ So far: `init`, `doctor`, `run` (including `--open` and `--close`), `check`, `receipt`, `verify` (by journey or by your coding agent, with a gate you answer with `respond`), `maintain`, the capture judge (on by default in `verify`), the `design` visual check, the `look` step, the run tools (`status`, `attach`, `logs`, `rerun`, `abort`, `signoff`, `history`) and the opt-in session hooks for Claude Code, Codex, opencode, Gemini CLI and pi. Coming next: help writing the proof steps for your app.
194
197
 
195
198
  ## Develop
196
199
 
package/dist/agents.js CHANGED
@@ -1,13 +1,16 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { dirname, isAbsolute, join } from 'node:path';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { dirname, extname, isAbsolute, join } from 'node:path';
5
5
  import { stripVTControlCharacters } from 'node:util';
6
6
  import { AGENTS, ANSWERER_JUDGES, CHEAP, EFFORT_FLAGS, KEY_ROUTES, parseDuration, UsageError } from './config.js';
7
7
  import { isExecutable, onPath } from './judge.js';
8
8
  // argv never carries the prompt: it goes on stdin, or (file: true) into a 0600 prompt file the argv points at.
9
9
  // done-done parses the answer, validates it against the schema, and asks a tool-less call again, at most ASK_ATTEMPTS calls.
10
10
  const ASK_ATTEMPTS = 3;
11
+ // A streaming agent that prints nothing for this long is stopped, so a hung CLI ends in minutes, not at the agent_timeout.
12
+ // Only streaming adapters are watched: claude and gemini print their one answer at the end, so silence is their normal state.
13
+ export const AGENT_IDLE_MS = 5 * 60_000;
11
14
  // What a user sees of an agent's error: no colour codes, only the last line that names an error (not a
12
15
  // stack frame, an indented field or a lone brace), at most 200 characters. The call record keeps the raw stderr.
13
16
  const ERROR_WORD = /\b(error|failed|denied|cancelled|refused|not|invalid|unauthorized)\b/i;
@@ -17,6 +20,27 @@ function errorLine(text) {
17
20
  const line = (top.findLast((l) => ERROR_WORD.test(l)) ?? top.at(-1) ?? lines.at(-1) ?? '').trim();
18
21
  return line.length > 200 ? `${line.slice(0, 199)}…` : line;
19
22
  }
23
+ // A provider's rate limit (HTTP 429, a usage limit with its reset time). The CLI may retry it for many minutes, so the call stops at the first line that says so.
24
+ const RATE_LIMIT = /\b429\b|rate[ -]?limit|usage limit|too many requests/i;
25
+ const isRejectedRateLimit = (l) => {
26
+ try {
27
+ const e = JSON.parse(l);
28
+ return e.type === 'rate_limit_event' && e.rate_limit_info?.status === 'rejected';
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ };
34
+ // The first lines of an agent's stderr, one line: what it said before it failed or went quiet. Indented lines (stack frames, fields) are
35
+ // left out, as in errorLine. '' when it said nothing.
36
+ function stderrHead(stderr) {
37
+ const head = stripVTControlCharacters(stderr).split(/\r?\n/).filter((l) => /\w/.test(l) && !/^\s/.test(l)).slice(0, 2).map((l) => l.trim()).join(' | ');
38
+ return head.length > 300 ? `${head.slice(0, 299)}…` : head;
39
+ }
40
+ // opencode prints this on stderr and auto-rejects the ask when a headless run reaches a permission a non-interactive
41
+ // call can never answer. The drive then ends with no answer, so name the permission and paths it refused instead of
42
+ // the bare "no JSON object in the answer" its empty output would give.
43
+ const PERMISSION_REJECT = /permission requested:\s*(\S+)\s*\(([^)]*)\);\s*auto-rejecting/;
20
44
  class ProviderError extends Error {
21
45
  detail;
22
46
  constructor(text, prefix = '', suffix = '') {
@@ -78,6 +102,7 @@ export function extractJsonObject(text, required = []) {
78
102
  // message chunks of acpx's JSON-RPC event stream. Every tool is denied, and a permission prompt is denied too, since
79
103
  // nobody can answer it. No model means the target's own default.
80
104
  const ACP = {
105
+ streams: true,
81
106
  argv: (c, dir) => ['--cwd', c.tools === 'none' ? dir : c.cwd, '--format', 'json', '--json-strict', '--deny-all',
82
107
  '--non-interactive-permissions', 'deny', ...(c.model ? ['--model', c.model] : []), c.agent.slice('acp:'.length), 'exec', '--file', '-'],
83
108
  answer: (out, c) => {
@@ -96,12 +121,21 @@ const PI_TOOL_LESS = ['--no-tools', '--no-extensions', '--no-skills', '--no-prom
96
121
  const ADAPTERS = {
97
122
  // claude -p: --json-schema puts the validated object in structured_output; --tools '' turns every tool off.
98
123
  // An edit step accepts edits only in cwd and --add-dir folders, and may run commands (the drive launches the app).
124
+ // The judge's call runs with the user's own sign-in, and with --safe-mode when safeMode is set.
125
+ // A call with images sends them as stream-json content blocks, as claude's SDK does, and its result is the event of type result.
99
126
  claude: {
100
- argv: (c) => ['-p', '--output-format', 'json', '--json-schema', JSON.stringify(c.schema), '--model', c.model, '--no-session-persistence',
127
+ argv: (c) => ['-p', '--output-format', c.images?.length ? 'stream-json' : 'json', ...(c.images?.length ? ['--verbose', '--input-format', 'stream-json'] : []),
128
+ '--json-schema', JSON.stringify(c.schema), '--model', c.model, '--no-session-persistence',
101
129
  ...(c.tools === 'edit' ? ['--permission-mode', 'acceptEdits', '--allowedTools', 'Bash', ...(c.dirs ?? []).flatMap((d) => ['--add-dir', d])] : ['--tools', c.tools === 'read' ? 'Read,Grep,Glob' : '']),
102
- ...(c.tools === 'edit' ? [] : ['--setting-sources', '']), ...(c.tools === 'read' ? ['--strict-mcp-config'] : [])],
103
- answer: (out) => {
104
- const r = JSON.parse(out);
130
+ ...(c.tools === 'edit' ? [] : ['--setting-sources', '']), ...(c.tools === 'read' ? ['--strict-mcp-config'] : []), ...(c.safeMode ? ['--safe-mode'] : [])],
131
+ stdin: (c, prompt) => (c.images?.length ? `${JSON.stringify({ type: 'user', session_id: '', parent_tool_use_id: null, message: { role: 'user', content: [
132
+ ...c.images.flatMap((i) => [{ type: 'text', text: `Image: ${i.name.replace(/\.[^.]+$/, '')}` }, { type: 'image', source: { type: 'base64', media_type: `image/${extname(i.name).slice(1)}`, data: i.bytes.toString('base64') } }]),
133
+ { type: 'text', text: prompt }
134
+ ] } })}\n` : prompt),
135
+ answer: (out, c) => {
136
+ const r = c.images?.length ? out.split('\n').filter((l) => l.startsWith('{')).map((l) => JSON.parse(l)).findLast((e) => e.type === 'result') : JSON.parse(out);
137
+ if (!r)
138
+ throw new ProviderError('claude printed no result');
105
139
  if (r.is_error)
106
140
  throw new ProviderError(String(r.result || r.subtype));
107
141
  return r.structured_output;
@@ -111,6 +145,8 @@ const ADAPTERS = {
111
145
  // and the answer is the JSON object in the text parts of its JSON event stream.
112
146
  opencode: {
113
147
  file: true,
148
+ streams: true,
149
+ readsImages: true,
114
150
  argv: (c, dir) => ['run', 'Follow the attached instructions.', '--format', 'json', '-m', c.model, '-f', join(dir, 'prompt.md'), ...(c.images ?? []).flatMap((i) => ['-f', join(dir, i.name)])],
115
151
  answer: (out, c) => {
116
152
  const events = out.split('\n').filter((l) => l.startsWith('{')).map((l) => JSON.parse(l));
@@ -126,18 +162,37 @@ const ADAPTERS = {
126
162
  }
127
163
  return extractJsonObject(text, c.schema.required);
128
164
  },
129
- // An edit step keeps the user's own permissions, where a folder outside cwd asks and so is refused in `run`; only the
130
- // run's own folders are allowed.
131
- env: (c) => ({ OPENCODE_PERMISSION: JSON.stringify(c.tools === 'edit' ? { external_directory: Object.fromEntries((c.dirs ?? []).flatMap((d) => [[d, 'allow'], [`${d}/*`, 'allow']])) } : { '*': 'deny' }) }),
165
+ // A headless `run` cannot answer a permission ask, so any ask auto-rejects and the drive ends with no answer.
166
+ // Driving the app runs commands that read or execute paths outside the repo (a browser, a system binary), so an
167
+ // edit step allows every external directory except the user's home folder, where only the run's own folders are
168
+ // allowed again; opencode's data folder (its login and database) is denied even when it is outside the home folder.
169
+ // opencode takes the last rule that matches, so the run folders come after the denies. Tool-less calls deny all.
170
+ env: (c) => {
171
+ if (c.tools !== 'edit')
172
+ return { OPENCODE_PERMISSION: JSON.stringify({ '*': 'deny' }) };
173
+ const home = homedir();
174
+ const data = join(process.env.XDG_DATA_HOME || join(home, '.local', 'share'), 'opencode');
175
+ return { OPENCODE_PERMISSION: JSON.stringify({ external_directory: {
176
+ '*': 'allow', [home]: 'deny', [`${home}/*`]: 'deny', [data]: 'deny', [`${data}/*`]: 'deny',
177
+ ...Object.fromEntries((c.dirs ?? []).flatMap((d) => [[d, 'allow'], [`${d}/*`, 'allow']])),
178
+ } }) };
179
+ },
180
+ // A permission the run refused: the drive could not continue, so this is the reason, not a missing answer.
181
+ reject: (stderr) => {
182
+ const m = PERMISSION_REJECT.exec(stripVTControlCharacters(stderr));
183
+ return m ? `it asked for permission to ${m[1]} (${m[2]}), which a headless run cannot answer and auto-rejects` : '';
184
+ },
132
185
  },
133
- // pi -p reads the prompt on stdin (probed 12 Oct: with no positional message, stdin becomes the user
186
+ // pi -p reads the prompt on stdin (probed: with no positional message, stdin becomes the user
134
187
  // message); --mode json streams JSONL events whose assistant message_end holds the answer text,
135
188
  // --no-session keeps the probe ephemeral. An @<file> argument attaches an image to that message.
136
- // --no-tools turns every built-in and extension tool off (probed 13 Oct: a canary file outside the cwd went unread).
189
+ // --no-tools turns every built-in and extension tool off (probed: a canary file outside the cwd went unread).
137
190
  // A tool-less call also loads no extensions, skills, prompt templates or context files: they would add the user's own
138
- // context to a fresh judge, and extension loading took 2 minutes on a real setup (probed 13 Oct), past the judge's timeout.
191
+ // context to a fresh judge, and extension loading took 2 minutes on a real setup (probed), past the judge's timeout.
139
192
  // Pi has no permission gate for edit steps, so doctor warns about it. Without a model, pi runs its own default.
140
193
  pi: {
194
+ streams: true,
195
+ readsImages: true,
141
196
  argv: (c, dir) => ['-p', '--mode', 'json', '--no-session', ...(c.tools === 'edit' ? [] : PI_TOOL_LESS), ...(c.model ? ['--model', c.model] : []), ...(c.images ?? []).map((i) => `@${join(dir, i.name)}`)],
142
197
  answer: (out, c) => {
143
198
  let text = '';
@@ -166,11 +221,12 @@ const ADAPTERS = {
166
221
  // answer, and --ephemeral keeps no session. An edit step runs in codex's workspace-write sandbox, which writes only the
167
222
  // repo, the run's folders (--add-dir) and $TMPDIR; it may use the network, since the drive launches the app and talks
168
223
  // to it, and it keeps the user's config.toml. Without a model, codex runs its own default.
169
- // A tool-less call is refused: probed live 13 Oct, codex with the user's config.toml kept its MCP server, which read a
224
+ // A tool-less call is refused: codex with the user's config.toml kept its MCP server, which read a
170
225
  // canary file outside the folder, and --disable does not turn off its multi-agent tools.
171
226
  codex: {
227
+ streams: true,
172
228
  editOnly: 'codex cannot run tool-less: with your config.toml it keeps its MCP tools, which read files outside the folder',
173
- // Seen live 13 Oct: each sandboxed command runs in its own process namespace, so a server or browser bridge started in the
229
+ // Each sandboxed command runs in its own process namespace, so a server or browser bridge started in the
174
230
  // background by one command was gone by the next one, and a drive that launched the app and then browsed it captured nothing.
175
231
  editNote: 'Your shell sandbox ends every process a command started, background ones too, when that command ends. So start what a task needs (an app, a server, a browser bridge), use it, save what it produces and stop it within one shell command (a script in $TMPDIR is fine).',
176
232
  argv: (c) => ['exec', '--json', '--ephemeral', ...(c.model ? ['-m', c.model] : []),
@@ -207,6 +263,7 @@ const ADAPTERS = {
207
263
  // policy folder and removes every tool, MCP ones too, from the model (its policy-engine.md). An @<file> in the -p text
208
264
  // attaches an image: per Gemini CLI's source, its own @-command code reads it, outside the tool policy, and only inside the cwd, the call's scratch folder; not yet proven live (Gemini CLI was not signed in on the test machine).
209
265
  gemini: {
266
+ readsImages: true,
210
267
  argv: (c, dir) => ['-p', ['Follow the instructions on stdin.', ...(c.images ?? []).map((i) => `@${i.name}`)].join(' '), '-o', 'json', '-m', c.model, '--approval-mode', c.tools === 'edit' ? 'auto_edit' : 'plan',
211
268
  ...(c.tools === 'none' ? ['--policy', join(dir, 'deny-all.toml'), '--skip-trust'] : []),
212
269
  ...(c.tools === 'edit' ? ['--allowed-tools', 'run_shell_command', ...(c.dirs ?? []).flatMap((d) => ['--include-directories', d])] : [])],
@@ -262,7 +319,6 @@ export function agentPath(agent, cfg) {
262
319
  acpxRuns.set(acpx, spawnSync(acpx, ['--version'], { stdio: 'ignore', timeout: 5_000 }).status === 0);
263
320
  return acpxRuns.get(acpx) ? acpx : '';
264
321
  }
265
- export const claudeBin = (cfg) => agentPath('claude', cfg);
266
322
  export const routeOf = (agent) => (agent === 'claude' ? 'claude-code' : `answerer:${agent}`);
267
323
  export const JUDGES = AGENTS.filter((a) => a === 'claude' || ANSWERER_JUDGES.includes(a));
268
324
  // Like the agent, the judge is exactly the one the user set; with none, done-done asks the user (in a terminal) or
@@ -288,14 +344,14 @@ export function selectJudge(cfg, found, fix = false) {
288
344
  if (fix && independence !== 'agent')
289
345
  throw new UsageError(`judge: the judge ${agent} (model ${model}) is the fixer's own agent (${found.agent}) at any model, and the judge is never the agent that did the work; judge.allow_same does not apply to auto_fix`, 'Ask the user for another judge agent and save it: done-done init --judge <agent> --judge-model <model>');
290
346
  if (independence === 'session-only' && !cfg.judge.allow_same)
291
- throw new UsageError(`judge: the judge ${agent} (model ${model}) is the author's own agent and model (${found.agent}), and the judge is never the agent that did the work`, `Ask the user for another judge agent or model and save it: done-done init --judge <agent> --judge-model <model>; or set judge.allow_same: true in ~/.done-done/config.yaml to accept a fresh session of the same one`);
347
+ throw new UsageError(`judge: the judge ${agent} (model ${model}) is the author's own agent and model (${found.agent}), and the judge must differ from the author by agent or by model`, `done-done init --judge <agent> --judge-model <model>, with another agent or the same agent at another model; judge.allow_same: true in ~/.done-done/config.yaml is an opt-out only, for a fresh session of the same agent and model`);
292
348
  return { route, agent, model, independence, author: { agent: found.agent, model: found.model } };
293
349
  }
294
350
  const DRAIN_MS = 500;
295
351
  const OUTPUT_CAP = 64 << 20;
296
352
  // One agent process. An edit step gets its own process group, so whatever it started (a dev server, a browser) is
297
353
  // killed with it when it ends, times out or writes past the output cap.
298
- function start(bin, argv, c, cwd, input, env) {
354
+ function start(bin, argv, c, cwd, input, env, idleMs) {
299
355
  const group = c.tools === 'edit';
300
356
  return new Promise((done) => {
301
357
  const child = spawn(bin, argv, { cwd, env, detached: group });
@@ -303,6 +359,39 @@ function start(bin, argv, c, cwd, input, env) {
303
359
  let stderr = '';
304
360
  let error;
305
361
  let drain;
362
+ let idle;
363
+ // A rate-limit line ends the call with that line, whatever the agent does next. On stdout the line must also say when it resets or retries,
364
+ // so an answer that only quotes a 429 from the app's code does not stop the call. A claude call with images streams JSON lines,
365
+ // and only a rejected rate_limit_event on stdout stops it, so its complete lines are parsed one at a time.
366
+ let pendingStdout = '';
367
+ const limited = (chunk, stdoutLine) => {
368
+ let line;
369
+ if (c.agent === 'claude' && c.images?.length) {
370
+ if (!stdoutLine)
371
+ return;
372
+ const text = pendingStdout + chunk;
373
+ const end = text.lastIndexOf('\n') + 1;
374
+ pendingStdout = text.slice(end);
375
+ line = text.slice(0, end).split('\n').find(isRejectedRateLimit);
376
+ }
377
+ else {
378
+ line = stripVTControlCharacters(chunk).split(/\r?\n/).find((l) => RATE_LIMIT.test(l) && (!stdoutLine || /reset|retry/i.test(l)));
379
+ }
380
+ if (!line || error)
381
+ return;
382
+ error = new Error(`${c.agent} hit a rate limit and stopped: ${line.trim().slice(0, 200)}`);
383
+ kill();
384
+ };
385
+ // Any output (stdout or stderr) shows the agent is working; silence for idleMs is a hang.
386
+ const quiet = () => {
387
+ if (!idleMs)
388
+ return;
389
+ clearTimeout(idle);
390
+ idle = setTimeout(() => {
391
+ error = new Error(`${c.agent} printed nothing for ${Math.round(idleMs / 1000)}s`);
392
+ kill();
393
+ }, idleMs);
394
+ };
306
395
  const kill = () => {
307
396
  try {
308
397
  if (group)
@@ -324,18 +413,24 @@ function start(bin, argv, c, cwd, input, env) {
324
413
  }, c.timeoutMs);
325
414
  child.on('error', (e) => {
326
415
  clearTimeout(timer);
416
+ clearTimeout(idle);
327
417
  done({ status: null, stdout, stderr, error: e });
328
418
  });
329
419
  if (!child.pid)
330
420
  return;
331
421
  if (group)
332
422
  c.onSpawn?.(child.pid);
423
+ quiet();
333
424
  child.stdout.setEncoding('utf8').on('data', (d) => {
425
+ quiet();
426
+ limited(d, true);
334
427
  stdout += d;
335
428
  if (stdout.length > OUTPUT_CAP)
336
429
  overflow();
337
430
  });
338
431
  child.stderr.setEncoding('utf8').on('data', (d) => {
432
+ quiet();
433
+ limited(d, false);
339
434
  stderr += d;
340
435
  if (stderr.length > OUTPUT_CAP)
341
436
  overflow();
@@ -348,6 +443,7 @@ function start(bin, argv, c, cwd, input, env) {
348
443
  });
349
444
  child.on('close', (status) => {
350
445
  clearTimeout(timer);
446
+ clearTimeout(idle);
351
447
  clearTimeout(drain);
352
448
  if (group)
353
449
  kill();
@@ -374,8 +470,9 @@ export async function askAgent(c) {
374
470
  throw new Error(`no adapter for ${c.agent} yet`);
375
471
  if (adapter.editOnly && c.tools !== 'edit')
376
472
  throw new Error(adapter.editOnly);
377
- for (const i of c.images ?? [])
378
- writeFileSync(join(dir, i.name), i.bytes, { mode: 0o600 });
473
+ if (adapter.readsImages)
474
+ for (const i of c.images ?? [])
475
+ writeFileSync(join(dir, i.name), i.bytes, { mode: 0o600 });
379
476
  for (const [name, text] of Object.entries(adapter.files?.(c) ?? {}))
380
477
  writeFileSync(join(dir, name), text, { mode: 0o600 });
381
478
  const bin = c.path;
@@ -385,15 +482,20 @@ export async function askAgent(c) {
385
482
  for (;;) {
386
483
  attempts++;
387
484
  // An ask-again names what was wrong with the last answer. The trailer ends on words, not the schema: a weak model
388
- // (Go Muse, 13 Oct) copied a schema that closed the prompt back as its answer.
485
+ // (seen with one opencode model) copied a schema that closed the prompt back as its answer.
389
486
  const schemaTrailer = `\n\nAnswer with only one JSON object that matches this JSON schema:\n${JSON.stringify(c.schema)}\nThat object holds your own values, never the schema itself, and you send no other text.\n`;
390
487
  const prompt = c.tools === 'edit' && adapter.editNote ? `${c.prompt}\n\n${adapter.editNote}` : c.prompt;
391
488
  const promptText = (attempts === 1 ? prompt : `${prompt}\n\nYour previous answer was not valid (${lastError}).`) + schemaTrailer;
392
489
  if (adapter.file)
393
490
  writeFileSync(join(dir, 'prompt.md'), promptText, { mode: 0o600 });
394
- res = await start(bin, argv(dir), c, cwd, adapter.file ? '' : promptText, { ...process.env, ...c.env, ...adapter.env?.(c) });
491
+ const input = adapter.file ? '' : adapter.stdin ? adapter.stdin(c, promptText) : promptText;
492
+ res = await start(bin, argv(dir), c, cwd, input, { ...process.env, ...c.env, ...adapter.env?.(c) }, adapter.streams ? c.idleMs ?? AGENT_IDLE_MS : 0);
395
493
  if (res.error)
396
494
  throw res.error;
495
+ // A permission the run refused explains a call with no answer; name it rather than the exit code text.
496
+ const refused = adapter.reject?.(String(res.stderr ?? '')) ?? '';
497
+ if (res.status !== 0 && refused)
498
+ throw new ProviderError(refused, `${c.agent} exited ${res.status}: `);
397
499
  if (res.status !== 0) {
398
500
  // An agent that exits non-zero may still say why in its own output (claude: a usage limit, a bad model).
399
501
  let text = String(res.stderr || res.stdout).trim().slice(-500);
@@ -421,6 +523,9 @@ export async function askAgent(c) {
421
523
  catch (e) {
422
524
  if (e instanceof ProviderError)
423
525
  throw e;
526
+ // A refused permission is why there is no usable answer, not a bad answer: name it instead of "no JSON object".
527
+ if (refused)
528
+ throw new ProviderError(refused, `${c.agent} did not finish: `);
424
529
  // Only a tool-less call is asked again: an edit or read step may already have changed the repo or read it.
425
530
  lastError = e.message;
426
531
  if (c.tools !== 'none' || attempts >= (c.attempts ?? ASK_ATTEMPTS))
@@ -429,8 +534,13 @@ export async function askAgent(c) {
429
534
  }
430
535
  }
431
536
  catch (e) {
432
- out = { ok: false, error: e.message };
433
- failure = e instanceof ProviderError ? e.detail : e.message;
537
+ // A silent, hung or timed-out agent says why only on stderr, so its no-answer reason quotes that. A provider error already
538
+ // carries its own line, so it is left as it is.
539
+ const said = stderrHead(String(res?.stderr ?? ''));
540
+ const provider = e instanceof ProviderError;
541
+ const note = (text) => (!provider && said && !text.includes(said.split(' | ')[0]) ? `${text}; ${c.agent} stderr: ${said}` : text);
542
+ out = { ok: false, error: note(e.message), ...(!provider && said ? { stderr: said } : {}) };
543
+ failure = note(e instanceof ProviderError ? e.detail : e.message);
434
544
  }
435
545
  finally {
436
546
  if (dir)