done-done-axi 0.3.1 → 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
3
  import { homedir, tmpdir } from 'node:os';
4
- import { dirname, isAbsolute, join } from 'node:path';
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,23 @@ 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
+ }
20
40
  // opencode prints this on stderr and auto-rejects the ask when a headless run reaches a permission a non-interactive
21
41
  // call can never answer. The drive then ends with no answer, so name the permission and paths it refused instead of
22
42
  // the bare "no JSON object in the answer" its empty output would give.
@@ -82,6 +102,7 @@ export function extractJsonObject(text, required = []) {
82
102
  // message chunks of acpx's JSON-RPC event stream. Every tool is denied, and a permission prompt is denied too, since
83
103
  // nobody can answer it. No model means the target's own default.
84
104
  const ACP = {
105
+ streams: true,
85
106
  argv: (c, dir) => ['--cwd', c.tools === 'none' ? dir : c.cwd, '--format', 'json', '--json-strict', '--deny-all',
86
107
  '--non-interactive-permissions', 'deny', ...(c.model ? ['--model', c.model] : []), c.agent.slice('acp:'.length), 'exec', '--file', '-'],
87
108
  answer: (out, c) => {
@@ -100,12 +121,21 @@ const PI_TOOL_LESS = ['--no-tools', '--no-extensions', '--no-skills', '--no-prom
100
121
  const ADAPTERS = {
101
122
  // claude -p: --json-schema puts the validated object in structured_output; --tools '' turns every tool off.
102
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.
103
126
  claude: {
104
- 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',
105
129
  ...(c.tools === 'edit' ? ['--permission-mode', 'acceptEdits', '--allowedTools', 'Bash', ...(c.dirs ?? []).flatMap((d) => ['--add-dir', d])] : ['--tools', c.tools === 'read' ? 'Read,Grep,Glob' : '']),
106
- ...(c.tools === 'edit' ? [] : ['--setting-sources', '']), ...(c.tools === 'read' ? ['--strict-mcp-config'] : [])],
107
- answer: (out) => {
108
- 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');
109
139
  if (r.is_error)
110
140
  throw new ProviderError(String(r.result || r.subtype));
111
141
  return r.structured_output;
@@ -115,6 +145,8 @@ const ADAPTERS = {
115
145
  // and the answer is the JSON object in the text parts of its JSON event stream.
116
146
  opencode: {
117
147
  file: true,
148
+ streams: true,
149
+ readsImages: true,
118
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)])],
119
151
  answer: (out, c) => {
120
152
  const events = out.split('\n').filter((l) => l.startsWith('{')).map((l) => JSON.parse(l));
@@ -151,14 +183,16 @@ const ADAPTERS = {
151
183
  return m ? `it asked for permission to ${m[1]} (${m[2]}), which a headless run cannot answer and auto-rejects` : '';
152
184
  },
153
185
  },
154
- // 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
155
187
  // message); --mode json streams JSONL events whose assistant message_end holds the answer text,
156
188
  // --no-session keeps the probe ephemeral. An @<file> argument attaches an image to that message.
157
- // --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).
158
190
  // A tool-less call also loads no extensions, skills, prompt templates or context files: they would add the user's own
159
- // 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.
160
192
  // Pi has no permission gate for edit steps, so doctor warns about it. Without a model, pi runs its own default.
161
193
  pi: {
194
+ streams: true,
195
+ readsImages: true,
162
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)}`)],
163
197
  answer: (out, c) => {
164
198
  let text = '';
@@ -187,11 +221,12 @@ const ADAPTERS = {
187
221
  // answer, and --ephemeral keeps no session. An edit step runs in codex's workspace-write sandbox, which writes only the
188
222
  // repo, the run's folders (--add-dir) and $TMPDIR; it may use the network, since the drive launches the app and talks
189
223
  // to it, and it keeps the user's config.toml. Without a model, codex runs its own default.
190
- // 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
191
225
  // canary file outside the folder, and --disable does not turn off its multi-agent tools.
192
226
  codex: {
227
+ streams: true,
193
228
  editOnly: 'codex cannot run tool-less: with your config.toml it keeps its MCP tools, which read files outside the folder',
194
- // 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
195
230
  // background by one command was gone by the next one, and a drive that launched the app and then browsed it captured nothing.
196
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).',
197
232
  argv: (c) => ['exec', '--json', '--ephemeral', ...(c.model ? ['-m', c.model] : []),
@@ -228,6 +263,7 @@ const ADAPTERS = {
228
263
  // policy folder and removes every tool, MCP ones too, from the model (its policy-engine.md). An @<file> in the -p text
229
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).
230
265
  gemini: {
266
+ readsImages: true,
231
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',
232
268
  ...(c.tools === 'none' ? ['--policy', join(dir, 'deny-all.toml'), '--skip-trust'] : []),
233
269
  ...(c.tools === 'edit' ? ['--allowed-tools', 'run_shell_command', ...(c.dirs ?? []).flatMap((d) => ['--include-directories', d])] : [])],
@@ -283,7 +319,6 @@ export function agentPath(agent, cfg) {
283
319
  acpxRuns.set(acpx, spawnSync(acpx, ['--version'], { stdio: 'ignore', timeout: 5_000 }).status === 0);
284
320
  return acpxRuns.get(acpx) ? acpx : '';
285
321
  }
286
- export const claudeBin = (cfg) => agentPath('claude', cfg);
287
322
  export const routeOf = (agent) => (agent === 'claude' ? 'claude-code' : `answerer:${agent}`);
288
323
  export const JUDGES = AGENTS.filter((a) => a === 'claude' || ANSWERER_JUDGES.includes(a));
289
324
  // Like the agent, the judge is exactly the one the user set; with none, done-done asks the user (in a terminal) or
@@ -309,14 +344,14 @@ export function selectJudge(cfg, found, fix = false) {
309
344
  if (fix && independence !== 'agent')
310
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>');
311
346
  if (independence === 'session-only' && !cfg.judge.allow_same)
312
- 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`);
313
348
  return { route, agent, model, independence, author: { agent: found.agent, model: found.model } };
314
349
  }
315
350
  const DRAIN_MS = 500;
316
351
  const OUTPUT_CAP = 64 << 20;
317
352
  // One agent process. An edit step gets its own process group, so whatever it started (a dev server, a browser) is
318
353
  // killed with it when it ends, times out or writes past the output cap.
319
- function start(bin, argv, c, cwd, input, env) {
354
+ function start(bin, argv, c, cwd, input, env, idleMs) {
320
355
  const group = c.tools === 'edit';
321
356
  return new Promise((done) => {
322
357
  const child = spawn(bin, argv, { cwd, env, detached: group });
@@ -324,6 +359,39 @@ function start(bin, argv, c, cwd, input, env) {
324
359
  let stderr = '';
325
360
  let error;
326
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
+ };
327
395
  const kill = () => {
328
396
  try {
329
397
  if (group)
@@ -345,18 +413,24 @@ function start(bin, argv, c, cwd, input, env) {
345
413
  }, c.timeoutMs);
346
414
  child.on('error', (e) => {
347
415
  clearTimeout(timer);
416
+ clearTimeout(idle);
348
417
  done({ status: null, stdout, stderr, error: e });
349
418
  });
350
419
  if (!child.pid)
351
420
  return;
352
421
  if (group)
353
422
  c.onSpawn?.(child.pid);
423
+ quiet();
354
424
  child.stdout.setEncoding('utf8').on('data', (d) => {
425
+ quiet();
426
+ limited(d, true);
355
427
  stdout += d;
356
428
  if (stdout.length > OUTPUT_CAP)
357
429
  overflow();
358
430
  });
359
431
  child.stderr.setEncoding('utf8').on('data', (d) => {
432
+ quiet();
433
+ limited(d, false);
360
434
  stderr += d;
361
435
  if (stderr.length > OUTPUT_CAP)
362
436
  overflow();
@@ -369,6 +443,7 @@ function start(bin, argv, c, cwd, input, env) {
369
443
  });
370
444
  child.on('close', (status) => {
371
445
  clearTimeout(timer);
446
+ clearTimeout(idle);
372
447
  clearTimeout(drain);
373
448
  if (group)
374
449
  kill();
@@ -395,8 +470,9 @@ export async function askAgent(c) {
395
470
  throw new Error(`no adapter for ${c.agent} yet`);
396
471
  if (adapter.editOnly && c.tools !== 'edit')
397
472
  throw new Error(adapter.editOnly);
398
- for (const i of c.images ?? [])
399
- 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 });
400
476
  for (const [name, text] of Object.entries(adapter.files?.(c) ?? {}))
401
477
  writeFileSync(join(dir, name), text, { mode: 0o600 });
402
478
  const bin = c.path;
@@ -406,13 +482,14 @@ export async function askAgent(c) {
406
482
  for (;;) {
407
483
  attempts++;
408
484
  // An ask-again names what was wrong with the last answer. The trailer ends on words, not the schema: a weak model
409
- // (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.
410
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`;
411
487
  const prompt = c.tools === 'edit' && adapter.editNote ? `${c.prompt}\n\n${adapter.editNote}` : c.prompt;
412
488
  const promptText = (attempts === 1 ? prompt : `${prompt}\n\nYour previous answer was not valid (${lastError}).`) + schemaTrailer;
413
489
  if (adapter.file)
414
490
  writeFileSync(join(dir, 'prompt.md'), promptText, { mode: 0o600 });
415
- 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);
416
493
  if (res.error)
417
494
  throw res.error;
418
495
  // A permission the run refused explains a call with no answer; name it rather than the exit code text.
@@ -457,8 +534,13 @@ export async function askAgent(c) {
457
534
  }
458
535
  }
459
536
  catch (e) {
460
- out = { ok: false, error: e.message };
461
- 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);
462
544
  }
463
545
  finally {
464
546
  if (dir)