agy-crew 0.0.0-stage → 0.9.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.
Files changed (66) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +315 -2
  3. package/README.zh-CN.md +314 -0
  4. package/companion/accounts.mjs +300 -0
  5. package/companion/agy-companion.mjs +2136 -0
  6. package/companion/identity.mjs +25 -0
  7. package/companion/install.mjs +443 -0
  8. package/companion/observation.mjs +147 -0
  9. package/companion/pool/env.mjs +14 -0
  10. package/companion/pool/lease.mjs +89 -0
  11. package/companion/pool/loans.mjs +46 -0
  12. package/companion/pool/quota.mjs +142 -0
  13. package/companion/pool/results.mjs +41 -0
  14. package/companion/pool/select.mjs +115 -0
  15. package/companion/pool/state.mjs +104 -0
  16. package/companion/pool/token.mjs +25 -0
  17. package/companion/state-lock.mjs +103 -0
  18. package/companion/stream-worker.mjs +361 -0
  19. package/docs/INSTALL_FOR_AGENTS.md +213 -0
  20. package/docs/REFERENCE.md +509 -0
  21. package/docs/REFERENCE.zh-CN.md +619 -0
  22. package/opencode-skills/agy-crew-accounts/SKILL.md +85 -0
  23. package/opencode-skills/agy-crew-ask/SKILL.md +46 -0
  24. package/opencode-skills/agy-crew-implementer/SKILL.md +56 -0
  25. package/opencode-skills/agy-crew-jobs/SKILL.md +99 -0
  26. package/opencode-skills/agy-crew-jobs/references/setup.md +41 -0
  27. package/opencode-skills/agy-crew-jobs/references/troubleshooting.md +35 -0
  28. package/opencode-skills/agy-crew-lead/SKILL.md +39 -0
  29. package/opencode-skills/agy-crew-researcher/SKILL.md +52 -0
  30. package/opencode-skills/agy-crew-reviewer/SKILL.md +62 -0
  31. package/opencode-skills/agy-crew-reviewer/references/code-review.md +46 -0
  32. package/opencode-skills/agy-crew-reviewer/references/general-review.md +22 -0
  33. package/opencode-skills/agy-crew-staffer/SKILL.md +54 -0
  34. package/opencode.mjs +14 -0
  35. package/package.json +53 -4
  36. package/pi-skills/agy-crew-accounts/SKILL.md +85 -0
  37. package/pi-skills/agy-crew-ask/SKILL.md +46 -0
  38. package/pi-skills/agy-crew-implementer/SKILL.md +56 -0
  39. package/pi-skills/agy-crew-jobs/SKILL.md +99 -0
  40. package/pi-skills/agy-crew-jobs/references/setup.md +41 -0
  41. package/pi-skills/agy-crew-jobs/references/troubleshooting.md +35 -0
  42. package/pi-skills/agy-crew-lead/SKILL.md +39 -0
  43. package/pi-skills/agy-crew-researcher/SKILL.md +52 -0
  44. package/pi-skills/agy-crew-reviewer/SKILL.md +62 -0
  45. package/pi-skills/agy-crew-reviewer/references/code-review.md +46 -0
  46. package/pi-skills/agy-crew-reviewer/references/general-review.md +22 -0
  47. package/pi-skills/agy-crew-staffer/SKILL.md +54 -0
  48. package/scripts/generate-skills.mjs +138 -0
  49. package/skills/accounts/SKILL.md +78 -0
  50. package/skills/ask/SKILL.md +40 -0
  51. package/skills/implementer/SKILL.md +50 -0
  52. package/skills/jobs/SKILL.md +93 -0
  53. package/skills/jobs/references/setup.md +39 -0
  54. package/skills/jobs/references/troubleshooting.md +33 -0
  55. package/skills/lead/SKILL.md +32 -0
  56. package/skills/researcher/SKILL.md +46 -0
  57. package/skills/reviewer/SKILL.md +56 -0
  58. package/skills/reviewer/references/code-review.md +44 -0
  59. package/skills/reviewer/references/general-review.md +20 -0
  60. package/skills/staffer/SKILL.md +48 -0
  61. package/templates/ask.md +5 -0
  62. package/templates/harness-compatibility.md +5 -0
  63. package/templates/implement.md +39 -0
  64. package/templates/research.md +37 -0
  65. package/templates/review.md +27 -0
  66. package/templates/staffer.md +19 -0
@@ -0,0 +1,2136 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agy-companion — the single brain of the agy-crew plugin.
4
+ *
5
+ * Wraps Google's Antigravity CLI (`agy`) so Claude Code and OpenAI Codex can
6
+ * delegate work to Gemini via five modes: staffer (general-purpose), research,
7
+ * review, implement, ask.
8
+ *
9
+ * Subcommands:
10
+ * staffer | research | review | implement
11
+ * run a task as a background job (staffer is
12
+ * the general-purpose mode: a minimal prompt
13
+ * with no role or output-format framing)
14
+ * ask --prompt <question> cheap zero-tool one-shot Q&A (foreground)
15
+ * continue --prompt <text> continue the most recent conversation (any mode)
16
+ * observe [job-id] immediate bounded snapshot in every job state
17
+ * restart <job-id> explicitly relaunch the stored task
18
+ * status [job-id] list background jobs / show one job
19
+ * wait [job-id] [--timeout 100s] block until the job finishes, then print
20
+ * its result (exit 2 = still running: call
21
+ * it again)
22
+ * result [job-id] print the stored output of a finished job
23
+ * cancel <job-id> kill a running background job
24
+ * setup [--apply] optional: install the evidence-gathering
25
+ * command allowlist used by restricted runs
26
+ * setup --restrict <modes|none> optional: per-repo policy — make the listed
27
+ * modes default to the restricted profile in
28
+ * this repository (.agy-crew/config.json)
29
+ * install [--hosts a,b | --all] [--dry-run] [--json] [--status]
30
+ * install the skills into the user-level skill
31
+ * directories of detected harnesses (claude,
32
+ * codex, omp, opencode, dsh) from one shared
33
+ * bundle; refuses to overwrite foreign content
34
+ * or coexist with upstream agy-staff
35
+ * uninstall [--hosts a,b | --all] [--json]
36
+ * remove only the links install created
37
+ * accounts <list|login|import|remove|enable|disable|cooldown clear|quota>
38
+ * manage the pool of private agy logins
39
+ * _worker <job-id> (internal) background job executor
40
+ *
41
+ * Uniform flags:
42
+ * --job <id> continue a specific job with its original configuration
43
+ * --conversation <id> resume a specific agy conversation
44
+ * --continue reuse the last conversation id for this mode
45
+ * --model <id> explicit agy model id (overrides --effort)
46
+ * --effort <l|m|h> low|medium|high → gemini-3.8-flash-<effort>
47
+ * --account <a> auto (default) | live | <pool account name>; with an empty
48
+ * pool every run uses your own (live) agy login
49
+ * --restricted hardening opt-in: keep agy's permission enforcement
50
+ * on (wants setup's evidence-gathering allowlist)
51
+ * --unrestricted pass --dangerously-skip-permissions (already the
52
+ * default for research/review/implement)
53
+ * --json (review) ask agy for schema-enforced JSON findings
54
+ * --timeout <dur> background hard limit (default 60m, maximum 120m); ask response timeout
55
+ * --prompt <text> the task text as one opaque argv value
56
+ * --prompt-file <path> read the task text from a file (long prompts)
57
+ * --stdin read the task text from stdin
58
+ *
59
+ * Task text for the run commands (staffer/research/review/implement/ask and
60
+ * continue) comes from exactly one of --prompt, --prompt-file, or --stdin.
61
+ * argv is parsed once, exactly as the shell delivered it, and the task value
62
+ * travels whole: never re-split, never scanned. Flag-like text inside a task
63
+ * (`--check`, `--json`, an unknown `--whatever`) is therefore ordinary prompt
64
+ * content and reaches agy byte for byte.
65
+ *
66
+ * Permissions: all tool-using modes (staffer/research/review/implement) run
67
+ * unrestricted by default, so they work out of the box with no setup;
68
+ * --restricted is the hardening opt-in that relies on the evidence-gathering
69
+ * allowlist installed by `setup`. ask is tool-free and always restricted.
70
+ * Profile precedence: CLI flag > project policy (.agy-crew/config.json,
71
+ * written by `setup --restrict`) > built-in default. The policy is a run
72
+ * policy for per-repo consistency, not a security boundary.
73
+ * The guardrails against irreversible side effects live in the prompt
74
+ * templates, backed by two tiered checks here: implement treats dirty
75
+ * workspaces as bounded prompt context, while staffer/review/research snapshot
76
+ * `git status --porcelain` around the run and report any delta with the result
77
+ * without ever blocking.
78
+ *
79
+ * Output split: stdout carries the deliverable — agy's response plus any guard
80
+ * warning about the working tree. The `[agy-crew]` telemetry line (mode,
81
+ * profile, model, duration, tokens, conversation id) goes to stderr, and for
82
+ * background jobs into `jobs/<id>.log`; it is metadata for the calling agent,
83
+ * never something to show the user.
84
+ *
85
+ * Execution style is fixed per mode and cannot be overridden: ask runs in the
86
+ * foreground; staffer/research/review/implement run as detached background
87
+ * jobs whose output is collected with wait/status/result/cancel.
88
+ *
89
+ * Job exit codes (`status <id>` and `wait`): 0 = done, 2 = running (for wait:
90
+ * still running when its own timeout expired — call it again), 3 = error or
91
+ * crashed, 4 = canceled, 5 = attention (resumable timeout). 1 stays the generic companion error (bad id, etc.),
92
+ * so a caller can loop on "exit code 2" with zero output parsing.
93
+ *
94
+ * Review is prompt-based: the subject ("Review PR #730", "Review changes
95
+ * against master") is described in the task text and agy gathers the evidence
96
+ * itself with its own tools.
97
+ *
98
+ * No dependencies beyond the Node standard library.
99
+ */
100
+
101
+ import fs from 'node:fs';
102
+ import os from 'node:os';
103
+ import path from 'node:path';
104
+ import { spawn, spawnSync } from 'node:child_process';
105
+ import { fileURLToPath } from 'node:url';
106
+ import { randomUUID } from 'node:crypto';
107
+ import { PRODUCT, STATE_DIR_NAME } from './identity.mjs';
108
+ import { cmdInstall, cmdUninstall } from './install.mjs';
109
+ import { boundSnapshot, excerpt } from './observation.mjs';
110
+ import { atomicJSON, runStreaming, processIdentity } from './stream-worker.mjs';
111
+ import { withStateLock, replaceFile, readTextRetry } from './state-lock.mjs';
112
+ import { cmdAccounts, poolSettingsFiles } from './accounts.mjs';
113
+ import { acquireRun, boundAccount, preflight, recordCooldown, markAuthError } from './pool/lease.mjs';
114
+ import { classifyFailureText, modelFamily } from './pool/results.mjs';
115
+ import { loadPool, updatePool, poolExists } from './pool/state.mjs';
116
+
117
+ const SELF = fileURLToPath(import.meta.url);
118
+ const TEMPLATES_DIR = path.join(path.dirname(SELF), '..', 'templates');
119
+ const AGY_BIN = process.env.AGY_BIN || 'agy';
120
+
121
+ /** How to launch agy. AGY_BIN normally names an executable; when it names a
122
+ * Node script (the test fake), run it through the current Node binary so the
123
+ * launch does not depend on shebang support (Windows has none: EFTYPE). */
124
+ function agyCommand(args) {
125
+ if (/\.(mjs|cjs|js)$/i.test(AGY_BIN)) return { cmd: process.execPath, args: [AGY_BIN, ...args] };
126
+ return { cmd: AGY_BIN, args };
127
+ }
128
+ const AGY_SETTINGS = path.join(os.homedir(), '.gemini', 'antigravity-cli', 'settings.json');
129
+
130
+ const MODES = ['staffer', 'research', 'review', 'implement', 'ask'];
131
+
132
+ const DEFAULTS = {
133
+ model: {
134
+ staffer: 'gemini-3.8-flash-medium',
135
+ research: 'gemini-3.8-flash-high',
136
+ review: 'gemini-3.8-flash-medium',
137
+ implement: 'gemini-3.8-flash-high',
138
+ ask: 'gemini-3.8-flash-low',
139
+ },
140
+ // Every tool-using mode is unrestricted by default: headless agy denies
141
+ // unlisted tool calls, so a restricted default made research/review come
142
+ // back empty until the user ran `setup`. --restricted is the opt-in.
143
+ // ask is tool-free, so its profile is irrelevant and stays restricted.
144
+ profile: {
145
+ staffer: 'unrestricted',
146
+ research: 'unrestricted',
147
+ review: 'unrestricted',
148
+ implement: 'unrestricted',
149
+ ask: 'restricted',
150
+ },
151
+ timeout: { staffer: '60m', research: '60m', review: '60m', implement: '60m', ask: '2m' },
152
+ // Background-first: only ask (seconds-long, tool-free) stays in the foreground.
153
+ // No flag overrides this; execution style is a property of the mode.
154
+ background: { staffer: true, research: true, review: true, implement: true, ask: false },
155
+ };
156
+
157
+ // agy only accepts effort-suffixed model ids; bare family names are rejected
158
+ // with status ERROR ("--model gemini-3.8-flash requires --effort").
159
+ // Known ids from `agy models` (v1.1.13):
160
+ const KNOWN_MODELS = new Set([
161
+ 'gemini-3.8-flash-high', 'gemini-3.8-flash-medium', 'gemini-3.8-flash-low',
162
+ 'gemini-3.7-flash-high', 'gemini-3.7-flash-medium', 'gemini-3.7-flash-low',
163
+ 'gemini-3.6-flash-high', 'gemini-3.6-flash-medium', 'gemini-3.6-flash-low',
164
+ 'gemini-3.5-flash-high', 'gemini-3.5-flash-medium', 'gemini-3.5-flash-low',
165
+ 'gemini-3.1-pro-high', 'gemini-3.1-pro-low',
166
+ 'claude-sonnet-4-6', 'claude-opus-4-6-thinking', 'gpt-oss-120b-medium',
167
+ ]);
168
+ const MODEL_FAMILIES = {
169
+ 'gemini-3.8-flash': ['low', 'medium', 'high'],
170
+ 'gemini-3.7-flash': ['low', 'medium', 'high'],
171
+ 'gemini-3.6-flash': ['low', 'medium', 'high'],
172
+ 'gemini-3.5-flash': ['low', 'medium', 'high'],
173
+ 'gemini-3.1-pro': ['low', 'high'],
174
+ };
175
+ const MODEL_ALIASES = { flash: 'gemini-3.8-flash', pro: 'gemini-3.1-pro' };
176
+
177
+ /** Normalize a user-supplied --model value to an id agy accepts, or die
178
+ * pre-flight with a helpful message. Never lets a bare family reach agy. */
179
+ function normalizeModel(raw, effort) {
180
+ const name = MODEL_ALIASES[raw] || raw;
181
+ if (KNOWN_MODELS.has(name)) return name;
182
+ const efforts = MODEL_FAMILIES[name];
183
+ if (efforts) {
184
+ let e = effort || 'medium';
185
+ if (!efforts.includes(e)) {
186
+ // e.g. gemini-3.1-pro has no medium: fall back to its highest tier
187
+ const fallback = efforts[efforts.length - 1];
188
+ process.stderr.write(`${PRODUCT}: ${name} has no "${e}" effort; using ${name}-${fallback}\n`);
189
+ e = fallback;
190
+ }
191
+ return `${name}-${e}`;
192
+ }
193
+ // future-tolerance: pass through anything already effort-suffixed
194
+ if (/-(low|medium|high|thinking)$/.test(name)) return name;
195
+ die(
196
+ `unknown model id "${raw}". agy needs effort-suffixed ids, e.g. ` +
197
+ `gemini-3.8-flash-low|medium|high, gemini-3.1-pro-low|high. ` +
198
+ `Aliases accepted here: "flash" (gemini-3.8-flash), "pro" (gemini-3.1-pro), ` +
199
+ `optionally combined with --effort. Run \`agy models\` for the full list.`
200
+ );
201
+ }
202
+
203
+ // Optional GLOBAL setup rules. AGY owns prefix matching and deny precedence;
204
+ // the small deny list prevents common mistakes, not every destructive action.
205
+ const EVIDENCE_ALLOWLIST = [
206
+ 'command(git)',
207
+ 'command(gh)',
208
+ 'command(cat)',
209
+ 'command(head)',
210
+ 'command(ls)',
211
+ 'command(grep)',
212
+ 'command(find)',
213
+ 'command(rg)',
214
+ 'command(wc)',
215
+ ];
216
+ const EVIDENCE_DENYLIST = [
217
+ 'command(git push)',
218
+ 'command(git reset --hard)',
219
+ 'command(git clean)',
220
+ 'command(gh pr merge)',
221
+ 'command(gh release delete)',
222
+ ];
223
+
224
+ // ~200KB task-text ceiling; macOS ARG_MAX is ~1MB and the prompt
225
+ // travels as a single argv entry.
226
+ const MAX_INLINE_BYTES = 200 * 1024;
227
+ const MAX_DIRTY_STATUS_LINES = 100;
228
+ const MAX_DIRTY_STATUS_BYTES = 16 * 1024;
229
+
230
+ const REVIEW_JSON_SCHEMA = JSON.stringify({
231
+ type: 'object',
232
+ properties: {
233
+ verdict: { type: 'string', enum: ['approve', 'request_changes', 'comment'] },
234
+ summary: { type: 'string' },
235
+ findings: {
236
+ type: 'array',
237
+ items: {
238
+ type: 'object',
239
+ properties: {
240
+ severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low', 'nit'] },
241
+ file: { type: 'string' },
242
+ line: { type: 'string' },
243
+ title: { type: 'string' },
244
+ detail: { type: 'string' },
245
+ },
246
+ required: ['severity', 'title', 'detail'],
247
+ },
248
+ },
249
+ could_not_verify: { type: 'array', items: { type: 'string' } },
250
+ },
251
+ required: ['verdict', 'summary', 'findings', 'could_not_verify'],
252
+ });
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // small utils
256
+ // ---------------------------------------------------------------------------
257
+
258
+ let inWorker = false;
259
+ function die(msg, code = 1) {
260
+ if (inWorker) throw new Error(msg);
261
+ process.stderr.write(`${PRODUCT} error: ${msg}\n`);
262
+ throw Object.assign(new Error(msg), { exitCode: code, alreadyPrinted: true });
263
+ }
264
+
265
+ function sh(cmd, args, opts = {}) {
266
+ const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, windowsHide: true, ...opts });
267
+ return { code: r.status ?? -1, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() };
268
+ }
269
+
270
+ const repoRootCache = new Map();
271
+ function repoRoot() {
272
+ const cwd = process.cwd();
273
+ if (repoRootCache.has(cwd)) return repoRootCache.get(cwd);
274
+ const r = sh('git', ['rev-parse', '--show-toplevel']);
275
+ // git prints forward slashes even on Windows; normalize so the root compares
276
+ // equal to process.cwd()-derived paths and reads naturally in agy arguments.
277
+ const root = r.code === 0 && r.out ? path.normalize(r.out) : cwd;
278
+ repoRootCache.set(cwd, root);
279
+ return root;
280
+ }
281
+
282
+ function stateDir() {
283
+ return path.join(repoRoot(), STATE_DIR_NAME);
284
+ }
285
+
286
+ function statePath() {
287
+ return path.join(stateDir(), 'state.json');
288
+ }
289
+
290
+ function configPath() {
291
+ return path.join(stateDir(), 'config.json');
292
+ }
293
+
294
+ // Modes whose default profile can be set per repo. ask is tool-free and
295
+ // always restricted, so it is not configurable.
296
+ const CONFIGURABLE_MODES = ['staffer', 'research', 'review', 'implement'];
297
+
298
+ /** Project policy (per-repo default profiles), written by `setup --restrict`.
299
+ * Missing file → null. Invalid file → die: a policy that is silently ignored
300
+ * is worse than an error. */
301
+ function loadProjectConfig() {
302
+ let raw;
303
+ try {
304
+ raw = fs.readFileSync(configPath(), 'utf8');
305
+ } catch {
306
+ return null;
307
+ }
308
+ let cfg;
309
+ try {
310
+ cfg = JSON.parse(raw);
311
+ } catch {
312
+ die(`project config is corrupt: ${configPath()} — fix or delete it, then retry`);
313
+ }
314
+ for (const [m, p] of Object.entries(cfg.profiles || {})) {
315
+ if (!CONFIGURABLE_MODES.includes(m)) {
316
+ die(
317
+ `project config: unknown mode "${m}" in ${configPath()} ` +
318
+ `(configurable: ${CONFIGURABLE_MODES.join(', ')}; ask is always restricted)`
319
+ );
320
+ }
321
+ if (p !== 'restricted' && p !== 'unrestricted') {
322
+ die(`project config: profile for ${m} must be "restricted" or "unrestricted", got "${p}" (${configPath()})`);
323
+ }
324
+ }
325
+ return cfg;
326
+ }
327
+
328
+ /** Create .agy-crew/ on first use and keep it out of `git status`.
329
+ * .git/info/exclude is repo-local and untracked — never the team's
330
+ * .gitignore. Best-effort: a read-only .git must not block a run. */
331
+ function ensureStateDir() {
332
+ const dir = stateDir();
333
+ if (!fs.existsSync(dir)) {
334
+ fs.mkdirSync(dir, { recursive: true });
335
+ if (sh('git', ['check-ignore', '-q', dir]).code !== 0) {
336
+ const p = sh('git', ['rev-parse', '--git-path', 'info/exclude']);
337
+ if (p.code === 0 && p.out) {
338
+ try {
339
+ fs.appendFileSync(path.resolve(p.out), STATE_DIR_NAME + '/\n');
340
+ } catch {}
341
+ }
342
+ }
343
+ }
344
+ return dir;
345
+ }
346
+
347
+ function loadState() {
348
+ let raw;
349
+ try {
350
+ raw = readTextRetry(statePath());
351
+ } catch (error) {
352
+ // Only a missing file means "no state yet". Anything else must not be
353
+ // mistaken for an empty state: callers write it back and would wipe jobs.
354
+ if (error.code === 'ENOENT') return { conversations: {}, last: null, jobs: [] };
355
+ die(`cannot read state file ${statePath()}: ${error.message}`);
356
+ }
357
+ try {
358
+ return JSON.parse(raw);
359
+ } catch {
360
+ // Never silently reset: every caller writes the state back, which would
361
+ // wipe all job records and conversation ids.
362
+ die(`state file is corrupt: ${statePath()} — fix or delete it, then retry`);
363
+ }
364
+ }
365
+
366
+ function saveState(state) {
367
+ ensureStateDir();
368
+ // Atomic replace: a detached worker and a status/result call can read this
369
+ // file at any moment; a plain truncate-then-write leaves a torn window.
370
+ const tmp = statePath() + `.tmp-${process.pid}`;
371
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n');
372
+ replaceFile(tmp, statePath());
373
+ }
374
+
375
+ function updateState(change) {
376
+ ensureStateDir();
377
+ return withStateLock(statePath() + '.lock', () => {
378
+ const state = loadState();
379
+ const value = change(state);
380
+ saveState(state);
381
+ return value;
382
+ });
383
+ }
384
+
385
+ function updateJob(id, fields, terminal = false) {
386
+ return updateState((state) => {
387
+ const job = state.jobs?.find((j) => j.id === id);
388
+ if (!job) throw new Error(`Missing job ${id}`);
389
+ if (terminal && job.status !== 'running') return job;
390
+ Object.assign(job, fields);
391
+ return job;
392
+ });
393
+ }
394
+
395
+ function finishJob(id, output, fields) {
396
+ return updateState((state) => {
397
+ const job = state.jobs?.find((j) => j.id === id);
398
+ if (!job) throw new Error(`Missing job ${id}`);
399
+ if (typeof fields === 'function') fields = fields(job);
400
+ if (job.status !== 'running' && !(job.status === 'canceled' && fields.status === 'canceled')) return job;
401
+ if (job.cancel_requested_at && fields.status !== 'canceled') throw Object.assign(new Error('Execution canceled.'), { reason: 'canceled' });
402
+ const final = { ...fields, finished_at: new Date().toISOString() };
403
+ const completed = { ...job, ...final };
404
+ fs.writeFileSync(job.result_file, typeof output === 'function' ? output(completed) : output);
405
+ atomicJSON(job.result_file + '.status.json', final);
406
+ Object.assign(job, final);
407
+ return job;
408
+ });
409
+ }
410
+
411
+ function rememberConversation(resolved, id, jobId, account = null) {
412
+ if (!id) return;
413
+ updateState((state) => {
414
+ state.conversations ||= {};
415
+ state.conversations[resolved.mode] = id;
416
+ state.last = { mode: resolved.mode, id, model: resolved.model, profile: resolved.profile };
417
+ state.conversation_configs ||= {};
418
+ state.conversation_configs[id] = { mode: resolved.mode, model: resolved.model,
419
+ profile: resolved.profile, cwd: process.cwd(), ...(account ? { account } : {}) };
420
+ const job = state.jobs?.find((j) => j.id === jobId);
421
+ if (job) job.conversation_id = id;
422
+ });
423
+ bindConversation(id, account);
424
+ }
425
+
426
+ /** A conversation lives in one account's HOME, so it is bound to that account
427
+ * the first time its id is seen. live conversations stay unbound (unbound
428
+ * means live). */
429
+ function bindConversation(id, account) {
430
+ if (!account || account === 'live') return;
431
+ updatePool((pool) => {
432
+ pool.conversations[id] ||= { account, recorded_at: new Date().toISOString() };
433
+ });
434
+ }
435
+
436
+ function pidAlive(pid) {
437
+ if (pid == null) return true; // registered, pid backfill pending — treat as running
438
+ try {
439
+ process.kill(pid, 0);
440
+ return true;
441
+ } catch (error) {
442
+ return error.code === 'EPERM';
443
+ }
444
+ }
445
+
446
+ const VALUE_FLAGS = new Set(['job', 'conversation', 'model', 'effort', 'timeout', 'restrict', 'prompt', 'prompt-file', 'hosts', 'account', 'family', 'home']);
447
+ const BOOL_FLAGS = new Set(['continue', 'restricted', 'unrestricted', 'json', 'apply', 'dry-run', 'stdin', 'all', 'status', 'yes', 'refresh']);
448
+
449
+ // Flags dropped in 0.2. They get their own error instead of falling through to
450
+ // "unknown flag", so a 0.1 caller learns what replaced them.
451
+ const REMOVED_REVIEW_FLAGS = new Set(['diff-file', 'pr', 'target']);
452
+ const REMOVED_EXEC_FLAGS = new Set(['background', 'wait']);
453
+
454
+ // Deprecated 0.1 spellings, kept for one release and undocumented.
455
+ const FLAG_ALIASES = { strict: 'restricted', loose: 'unrestricted' };
456
+ const warnedAliases = new Set();
457
+
458
+ function migrationDie(name) {
459
+ if (REMOVED_REVIEW_FLAGS.has(name)) {
460
+ die(
461
+ `--${name} was removed in 0.2: review is prompt-based now. Describe the subject in the prompt, ` +
462
+ `e.g. \`review --prompt "Review PR #730"\` or \`review --prompt "Review changes against master"\`.`
463
+ );
464
+ }
465
+ die(
466
+ `--${name} was removed in 0.2: execution style is fixed per mode (ask runs in the foreground; ` +
467
+ `research/review/implement run as background jobs). Use status/result/cancel to manage jobs.`
468
+ );
469
+ }
470
+
471
+ /** A flag name that still contains whitespace means the caller packed several
472
+ * arguments (and usually the task) into one quoted string. The companion does
473
+ * not split argument strings, so say so and name the fix. */
474
+ function packedArgumentDie(name, raw) {
475
+ const first = name.split(/\s+/)[0] || '';
476
+ const shown = raw.length > 60 ? `${raw.slice(0, 60)}…` : raw;
477
+ die(
478
+ `unknown flag --${first}: the whole string "${shown}" arrived as a single argument. ` +
479
+ `${PRODUCT} parses argv exactly as the shell delivers it and never splits an argument into flags — ` +
480
+ `pass each flag as its own argument and put the task text in --prompt, ` +
481
+ `e.g. \`review --restricted --prompt "Review PR #730"\`.`
482
+ );
483
+ }
484
+
485
+ /** Reject a value flag whose value is missing, empty, or itself flag-shaped.
486
+ * An empty value is a caller mistake worth surfacing rather than a default
487
+ * request, and a flag-shaped value almost always means the value was
488
+ * forgotten (`ask --prompt --json`). The one exception is
489
+ * --prompt with a value containing whitespace: that is a real sentence that
490
+ * happens to start with `--`, and prompt bytes are never second-guessed. */
491
+ function checkValue(name, v) {
492
+ if (v === undefined || v === '') die(`flag --${name} needs a value`);
493
+ if (!v.startsWith('--')) return;
494
+ if (name === 'prompt') {
495
+ if (/\s/.test(v)) return;
496
+ die(
497
+ `flag --prompt needs a value (if your prompt really starts with --, ` +
498
+ `quote the full sentence or use --prompt-file)`
499
+ );
500
+ }
501
+ die(`flag --${name} needs a value`);
502
+ }
503
+
504
+ /**
505
+ * Parse the shell's argv once. Values are taken verbatim: nothing is re-split,
506
+ * no quotes are interpreted, no byte of a value is inspected for flags.
507
+ *
508
+ * `taskCommand` only changes what a positional means. Run commands take their
509
+ * task from --prompt/--prompt-file/--stdin, so a positional there is a caller
510
+ * mistake and dies loudly. Management commands (status/wait/result/cancel/
511
+ * setup) keep collecting positionals as ids and values.
512
+ */
513
+ function parseFlags(argv, { taskCommand = false } = {}) {
514
+ const opts = { _: [] };
515
+ for (let i = 0; i < argv.length; i++) {
516
+ const t = argv[i];
517
+ if (t.startsWith('--')) {
518
+ let name = t.slice(2);
519
+ if (REMOVED_REVIEW_FLAGS.has(name) || REMOVED_EXEC_FLAGS.has(name)) migrationDie(name);
520
+ if (FLAG_ALIASES[name]) {
521
+ if (!warnedAliases.has(name)) {
522
+ warnedAliases.add(name);
523
+ process.stderr.write(`${PRODUCT}: --${name} is deprecated; use --${FLAG_ALIASES[name]}\n`);
524
+ }
525
+ name = FLAG_ALIASES[name];
526
+ }
527
+ if (VALUE_FLAGS.has(name)) {
528
+ const v = argv[++i];
529
+ checkValue(name, v);
530
+ opts[name] = v;
531
+ } else if (BOOL_FLAGS.has(name)) {
532
+ opts[name] = true;
533
+ } else if (/\s/.test(name)) {
534
+ packedArgumentDie(name, t);
535
+ } else {
536
+ die(`unknown flag --${name}`);
537
+ }
538
+ } else if (taskCommand) {
539
+ die(
540
+ `positional task text was removed; pass the task with --prompt <text>, ` +
541
+ `--prompt-file <path>, or --stdin`
542
+ );
543
+ } else {
544
+ opts._.push(t);
545
+ }
546
+ }
547
+ return opts;
548
+ }
549
+
550
+ function fmtTokens(usage) {
551
+ if (!usage) return 'n/a';
552
+ const parts = [`in ${usage.input_tokens ?? '?'}`, `out ${usage.output_tokens ?? '?'}`];
553
+ if (usage.thinking_tokens) parts.push(`think ${usage.thinking_tokens}`);
554
+ if (usage.cache_read_tokens) parts.push(`cache ${usage.cache_read_tokens}`);
555
+ return parts.join(', ');
556
+ }
557
+
558
+ // ---------------------------------------------------------------------------
559
+ // prompt building
560
+ // ---------------------------------------------------------------------------
561
+
562
+ function fillTemplate(mode, vars) {
563
+ const file = path.join(TEMPLATES_DIR, `${mode}.md`);
564
+ let text;
565
+ try {
566
+ text = fs.readFileSync(file, 'utf8');
567
+ } catch {
568
+ die(`template not found: ${file}`);
569
+ }
570
+ return text.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? '');
571
+ }
572
+
573
+ function gatherContext() {
574
+ const branch = sh('git', ['branch', '--show-current']).out || '(no git branch)';
575
+ return [
576
+ `Working directory: ${process.cwd()}`,
577
+ `Git branch: ${branch}`,
578
+ `Date: ${new Date().toISOString().slice(0, 10)}`,
579
+ ].join('\n');
580
+ }
581
+
582
+ function dirtyWorkspacePrompt() {
583
+ if (!inGitRepo()) return '';
584
+ const status = porcelainSnapshot();
585
+ if (!status?.length) return '';
586
+ const lines = [];
587
+ let bytes = 0;
588
+ for (const line of status) {
589
+ const next = Buffer.byteLength(`${line}\n`);
590
+ if (lines.length >= MAX_DIRTY_STATUS_LINES || bytes + next > MAX_DIRTY_STATUS_BYTES) break;
591
+ lines.push(line);
592
+ bytes += next;
593
+ }
594
+ const truncated = lines.length < status.length;
595
+ const limitNote = truncated
596
+ ? `\n\nThe status list was truncated to ${lines.length} of ${status.length} entries and ${bytes} bytes. Run \`git status --porcelain\` and inspect relevant diffs before editing or delivering changes.`
597
+ : '';
598
+ return (
599
+ '## Existing workspace changes\n\n' +
600
+ 'The workspace was already dirty before this implement run. Treat these paths as user-owned context. Build on them only when the task clearly includes them; otherwise pause and ask for confirmation before overwriting, cleaning, stashing, resetting, deleting, committing, pushing, or opening a PR with them. Use `git status --porcelain` and `git diff` as ground truth when path ownership is unclear.\n\n' +
601
+ '`git status --porcelain` before this run (bounded summary):\n' +
602
+ '```text\n' +
603
+ lines.join('\n') +
604
+ '\n```' +
605
+ limitNote
606
+ );
607
+ }
608
+
609
+ // ---------------------------------------------------------------------------
610
+ // agy invocation
611
+ // ---------------------------------------------------------------------------
612
+
613
+ function durationToMs(d) {
614
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(d);
615
+ if (!m) return null;
616
+ const mult = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[m[2]];
617
+ return Math.round(parseFloat(m[1]) * mult);
618
+ }
619
+
620
+
621
+ function queryAgyModels() {
622
+ try {
623
+ const agy = agyCommand(['models']);
624
+ const r = spawnSync(agy.cmd, agy.args, {
625
+ encoding: 'utf8',
626
+ timeout: 10_000,
627
+ windowsHide: true,
628
+ });
629
+ if (r.error) {
630
+ return { ok: false, error: r.error.message };
631
+ }
632
+ if (r.status !== 0) {
633
+ return { ok: false, error: (r.stderr || '').trim() || `exit ${r.status}` };
634
+ }
635
+ const stdout = (r.stdout || '').trim();
636
+ if (!stdout) {
637
+ return { ok: false, error: 'empty output from `agy models`' };
638
+ }
639
+ const lines = stdout.split('\n').map((l) => l.trim()).filter(Boolean);
640
+ const models = [];
641
+ for (const line of lines) {
642
+ if (/^fetching/i.test(line)) continue;
643
+ const parts = line.split(/\s+/);
644
+ const id = parts[0];
645
+ const desc = parts.slice(1).join(' ');
646
+ if (id) {
647
+ models.push({ id, desc });
648
+ }
649
+ }
650
+ if (models.length === 0) {
651
+ return { ok: false, error: 'no models found in `agy models` output' };
652
+ }
653
+ return { ok: true, models };
654
+ } catch (e) {
655
+ return { ok: false, error: e.message };
656
+ }
657
+ }
658
+
659
+ function recommendCompatibleModel(requestedModel, availableModelIds) {
660
+ const effortMatch = requestedModel ? requestedModel.match(/-(low|medium|high)$/) : null;
661
+ const effort = effortMatch ? effortMatch[1] : null;
662
+
663
+ if (effort) {
664
+ const sameEffort = availableModelIds.filter((id) => id.endsWith(`-${effort}`));
665
+ if (sameEffort.length > 0) {
666
+ const flashOrder = [
667
+ `gemini-3.8-flash-${effort}`,
668
+ `gemini-3.7-flash-${effort}`,
669
+ `gemini-3.6-flash-${effort}`,
670
+ `gemini-3.5-flash-${effort}`,
671
+ ];
672
+ for (const candidate of flashOrder) {
673
+ if (sameEffort.includes(candidate) && candidate !== requestedModel) {
674
+ return candidate;
675
+ }
676
+ }
677
+ const otherFlash = sameEffort.find((id) => id.includes('flash') && id !== requestedModel);
678
+ if (otherFlash) return otherFlash;
679
+
680
+ const proOrder = [`gemini-3.1-pro-${effort}`];
681
+ for (const candidate of proOrder) {
682
+ if (sameEffort.includes(candidate) && candidate !== requestedModel) {
683
+ return candidate;
684
+ }
685
+ }
686
+ const otherSameEffort = sameEffort.find((id) => id !== requestedModel);
687
+ if (otherSameEffort) return otherSameEffort;
688
+ }
689
+ }
690
+
691
+ const anyFlash = availableModelIds.find((id) => id.includes('flash') && id !== requestedModel);
692
+ if (anyFlash) return anyFlash;
693
+
694
+ return availableModelIds.find((id) => id !== requestedModel) || null;
695
+ }
696
+
697
+ function isUnsupportedModelError(errText) {
698
+ if (!errText) return false;
699
+ if (/auth|login|credential|unauthorized|401|403/i.test(errText)) return false;
700
+ if (/quota|rate.?limit|resource.?exhausted|429/i.test(errText)) return false;
701
+ if (/network|econnrefused|enotfound|fetch failed|socket|eai_again/i.test(errText)) return false;
702
+ if (/requires --effort/i.test(errText)) return false;
703
+ return (
704
+ /not recognized as a known model/i.test(errText) ||
705
+ /unknown model/i.test(errText) ||
706
+ /unsupported model/i.test(errText) ||
707
+ /invalid model selection/i.test(errText) ||
708
+ /model .* (not found|is not supported|is not available|is not recognized)/i.test(errText)
709
+ );
710
+ }
711
+
712
+ function handleUnsupportedModel({ requestedModel, errText, originalError, convNote }) {
713
+ const discovery = queryAgyModels();
714
+ let msg = `agy reported an unsupported-model error for requested model "${requestedModel}".\n`;
715
+ if (originalError) {
716
+ msg += `agy error: ${originalError}\n`;
717
+ }
718
+
719
+ if (!discovery.ok) {
720
+ msg +=
721
+ `Model discovery via \`agy models\` failed (${discovery.error}).\n` +
722
+ `Please run \`agy models\` to check available models.\n` +
723
+ `Updating agy is preferred to use the latest default (gemini-3.8-flash).`;
724
+ die(msg + (convNote || ''));
725
+ }
726
+
727
+ const availableIds = discovery.models.map((m) => m.id);
728
+ const best = recommendCompatibleModel(requestedModel, availableIds);
729
+
730
+ msg +=
731
+ `Available models (from \`agy models\`):\n` +
732
+ discovery.models.map((m) => ` ${m.id}${m.desc ? `\t${m.desc}` : ''}`).join('\n') +
733
+ '\n\n';
734
+
735
+ if (best) {
736
+ msg += `Best same-effort compatible recommendation: --model ${best}\n`;
737
+ }
738
+ msg += `Updating agy is preferred to use the latest default (gemini-3.8-flash).`;
739
+ die(msg + (convNote || ''));
740
+ }
741
+
742
+ function agyArgs({ prompt, model, timeout, conversation, unrestricted, jsonSchema, workspace }, format) {
743
+ const args = ['-p', prompt, '--model', model, '--output-format', format, '--print-timeout', timeout, '--add-dir', workspace];
744
+ if (conversation) args.push('--conversation', conversation);
745
+ if (unrestricted) args.push('--dangerously-skip-permissions');
746
+ if (jsonSchema) args.push('--json-schema', jsonSchema);
747
+ return args;
748
+ }
749
+
750
+ function runAgy(invoke) {
751
+ const { model, timeout } = invoke;
752
+ const args = agyArgs(invoke, 'json');
753
+
754
+ const budget = (durationToMs(timeout) ?? 600_000) + 60_000; // grace over agy's own timeout
755
+ const agy = agyCommand(args);
756
+ const r = spawnSync(agy.cmd, agy.args, {
757
+ encoding: 'utf8',
758
+ maxBuffer: 64 * 1024 * 1024,
759
+ timeout: budget,
760
+ windowsHide: true,
761
+ env: invoke.env, // undefined inherits; pool accounts pass a private HOME
762
+ });
763
+ if (r.error && r.error.code === 'ETIMEDOUT') {
764
+ die(`agy timed out: no result within ${timeout} plus 60s grace. Retry with a larger --timeout, or narrow the task.`);
765
+ }
766
+ if (r.error) die(`failed to launch agy (${AGY_BIN}): ${r.error.message}`);
767
+ if (r.signal) {
768
+ die(`agy was killed by signal ${r.signal} before returning a result (companion budget: ${timeout} + 60s grace).`);
769
+ }
770
+
771
+ const stdout = (r.stdout || '').trim();
772
+ const stderr = (r.stderr || '').trim();
773
+ // agy prints a single-line JSON object; be defensive about leading noise.
774
+ const start = stdout.indexOf('{');
775
+ let payload = null;
776
+ if (start >= 0) {
777
+ try {
778
+ payload = JSON.parse(stdout.slice(start));
779
+ } catch {
780
+ /* fall through */
781
+ }
782
+ }
783
+ if (!payload) {
784
+ const errText = `${stdout}\n${stderr}`;
785
+ if (isUnsupportedModelError(errText)) {
786
+ handleUnsupportedModel({
787
+ requestedModel: model,
788
+ errText,
789
+ originalError: stderr || stdout,
790
+ });
791
+ }
792
+ let msg =
793
+ `agy did not return parseable JSON (exit ${r.status}).\n` +
794
+ `stdout: ${stdout.slice(0, 800) || '(empty)'}\n` +
795
+ `stderr: ${stderr.slice(0, 800) || '(empty)'}`;
796
+ // EPERM on agy's own home files or on binding localhost is the signature of
797
+ // a harness command sandbox (e.g. Codex workspace-write). agy cannot run
798
+ // sandboxed: it binds a local port for its language server and reads its
799
+ // OAuth token file, which sandbox secret-protection hides — no
800
+ // writable_roots/network_access knob fixes the hidden token.
801
+ if (/operation not permitted/i.test(stderr)) {
802
+ msg +=
803
+ '\n\nThis looks like a harness command sandbox blocking agy (EPERM on its log/state files or on binding 127.0.0.1). ' +
804
+ 'agy cannot run inside a sandbox — it needs a localhost port and its OAuth token file, which sandboxes typically hide. ' +
805
+ 'Run this companion command unsandboxed: in Codex, grant the workspace full access or approve the command with escalated permissions.';
806
+ }
807
+ // A quota or auth failure is classified by executeRun before anything is
808
+ // printed; it re-raises through die() when no pool account handles it.
809
+ if (classifyFailureText(errText)) throw Object.assign(new Error(msg), { deferredDie: true, diagnosticText: errText });
810
+ die(msg);
811
+ }
812
+ return { payload, stderr, exit: r.status ?? 0 };
813
+ }
814
+
815
+ /** Triage the agy result into distinct classes with distinct guidance
816
+ * (never cross-suggested), or return the response text on success.
817
+ * 1. status ERROR / nonzero exit, but response text came back
818
+ * → return the text (exit 0) and put diagnostics on stderr.
819
+ * The orchestrator judges task completion; nonempty text is not proof.
820
+ * 2. status ERROR / nonzero exit, no response
821
+ * → agy's own error verbatim; NEVER suggest --unrestricted. Cause
822
+ * hints are appended only when the error text actually matches them.
823
+ * 3. response timeout → attention when the conversation is resumable.
824
+ * 4. status SUCCESS, empty body → permission fail-closed signature; only a
825
+ * --restricted run gets the setup hint
826
+ * (unrestricted runs have no rules to fix).
827
+ */
828
+ function triageResult({ payload, stderr, exit }, mode, profile, profileSource, requestedModel) {
829
+ const status = (payload.status || '').toUpperCase();
830
+ const response = (payload.response || '').trim();
831
+ const convNote = payload.conversation_id
832
+ ? `\nConversation id (you can still continue it): ${payload.conversation_id}`
833
+ : '';
834
+
835
+ // Match AGY's response deadline, not a tool/network timeout embedded in an
836
+ // unrelated error. Response text keeps the done-with-warnings delivery contract.
837
+ if (!response && (['TIMEOUT', 'TIMED_OUT', 'RESPONSE_TIMEOUT'].includes(status) ||
838
+ (status === 'ERROR' && /^timeout waiting for response[.!]?$/i.test(String(payload.error || '').trim())))) {
839
+ throw Object.assign(new Error(`agy timed out (status ${payload.status}) before finishing.` +
840
+ (payload.error ? `\nagy error: ${payload.error}` : '') + convNote), { reason: 'response_timeout' });
841
+ }
842
+
843
+ if ((status && status !== 'SUCCESS') || exit !== 0) {
844
+ if (response) {
845
+ // Preserve response text and diagnostics for the orchestrator to assess.
846
+ process.stderr.write(
847
+ `${PRODUCT} warning: agy reported status ${payload.status || 'unknown'} (exit ${exit}) ` +
848
+ 'but returned response text — delivering it for assessment.\n' +
849
+ (payload.error ? `agy error: ${payload.error}\n` : '') +
850
+ (stderr ? `agy stderr: ${stderr}\n` : '')
851
+ );
852
+ return response;
853
+ }
854
+ const errText = `${payload.error || ''}\n${stderr}`;
855
+ if (isUnsupportedModelError(errText)) {
856
+ handleUnsupportedModel({
857
+ requestedModel: requestedModel || DEFAULTS.model[mode],
858
+ errText,
859
+ originalError: payload.error || stderr,
860
+ convNote,
861
+ });
862
+ }
863
+
864
+ let msg = `agy reported an error (status ${payload.status || 'unknown'}, exit ${exit}).`;
865
+ if (payload.error) msg += `\nagy error: ${payload.error}`;
866
+ if (stderr) msg += `\nagy stderr: ${stderr}`;
867
+ const hints = [];
868
+ if (/model|effort/i.test(errText)) {
869
+ hints.push('invalid model id (agy needs effort-suffixed ids, e.g. gemini-3.8-flash-low — run `agy models`)');
870
+ }
871
+ if (/auth|login|credential|unauthorized|401|403/i.test(errText)) {
872
+ hints.push('expired auth (run `agy` interactively once to re-login)');
873
+ }
874
+ if (/quota|rate.?limit|resource.?exhausted|429/i.test(errText)) {
875
+ hints.push('exhausted quota');
876
+ }
877
+ if (hints.length) msg += `\nLikely cause: ${hints.join('; ')}.`;
878
+ die(msg + convNote);
879
+ }
880
+
881
+ if (response) return response;
882
+
883
+ // status SUCCESS but nothing came back
884
+ if (mode === 'ask') {
885
+ die(
886
+ 'unexpected: agy returned success with an empty answer, but ask uses no tools, so this cannot be a ' +
887
+ 'permission denial. Please report it (include the agy stderr below if any).' +
888
+ (stderr ? `\n\nagy stderr:\n${stderr}` : '') +
889
+ convNote
890
+ );
891
+ }
892
+ let msg = 'agy returned an empty response (status SUCCESS but no content).';
893
+ if (profile === 'restricted') {
894
+ const cause =
895
+ profileSource === 'project'
896
+ ? 'This run was restricted by the project policy in .agy-crew/config.json'
897
+ : profileSource === 'inherited' ? 'This continuation inherited the restricted profile' : 'This run used `--restricted`';
898
+ const relax =
899
+ profileSource === 'project'
900
+ ? 'relax the policy (`setup --restrict none`) or pass `--unrestricted` for this run'
901
+ : profileSource === 'inherited' ? 'pass `--unrestricted` explicitly for this continuation' : `drop \`--restricted\` — ${mode} runs unrestricted by default`;
902
+ msg +=
903
+ `\n${cause}, so agy kept its permission enforcement on: in headless mode every` +
904
+ ' unlisted tool call is auto-denied, which is the usual cause of an empty response.' +
905
+ `\nFix: run \`setup\` once to install the evidence-gathering command allowlist, or ${relax}.` +
906
+ '\nNote: some agy tools ignore allow-rules in headless mode entirely, so even a complete allowlist cannot' +
907
+ ' make them work; those need an unrestricted run.';
908
+ }
909
+ if (stderr) msg += `\n\nagy stderr:\n${stderr}`;
910
+ die(msg + convNote);
911
+ }
912
+
913
+ // ---------------------------------------------------------------------------
914
+ // A follow-up targets a conversation whose execution has stopped. While the
915
+ // job is still running, report its status and id instead of queueing: the
916
+ // orchestrator decides whether to wait or cancel first.
917
+ function refuseRunningFollowUp(state, conversation, jobId = null) {
918
+ if (!conversation && !jobId) return;
919
+ const active = (state.jobs || []).find(j =>
920
+ ((conversation && j.conversation_id === conversation) || (jobId && j.id === jobId)) &&
921
+ liveJobStatus(j) === 'running');
922
+ if (!active) return;
923
+ die(`job ${active.id} is still running (status: running); the follow-up was not accepted or queued. ` +
924
+ `Collect it with \`wait ${active.id}\` and continue afterwards, or \`cancel ${active.id}\` first for an immediate change of direction.`);
925
+ }
926
+
927
+ // run (research / review / implement / continue)
928
+ // ---------------------------------------------------------------------------
929
+
930
+ const ACCOUNT_REQUEST_RE = /^(auto|live|[a-z0-9][a-z0-9._-]{0,31})$/;
931
+
932
+ /** --account <auto|live|name>, defaulting to AGY_CREW_ACCOUNT, then auto. */
933
+ function accountRequest(opts) {
934
+ const value = opts.account || process.env.AGY_CREW_ACCOUNT || 'auto';
935
+ if (!ACCOUNT_REQUEST_RE.test(value)) die(`invalid --account "${value}": use auto, live, or a pool account name (see \`accounts list\`)`);
936
+ return value;
937
+ }
938
+
939
+ function resolveRun(mode, opts, priorJob = null) {
940
+ // likely a typo for --restricted; --restrict (per-repo policy) belongs to setup
941
+ if (opts.restrict !== undefined) {
942
+ die(`--restrict is a setup flag (per-repo policy: \`setup --restrict <modes|none>\`). For a single ${mode} run use --restricted.`);
943
+ }
944
+
945
+ // model / effort
946
+ if (opts.effort && !['low', 'medium', 'high'].includes(opts.effort)) {
947
+ die('--effort must be low|medium|high');
948
+ }
949
+ let model;
950
+ if (opts.model) {
951
+ model = normalizeModel(opts.model, opts.effort);
952
+ } else if (opts.effort) {
953
+ model = `gemini-3.8-flash-${opts.effort}`;
954
+ } else {
955
+ model = DEFAULTS.model[mode];
956
+ }
957
+
958
+ // profile: CLI flag > project policy (.agy-crew/config.json) > built-in default
959
+ if (opts.restricted && opts.unrestricted) die('--restricted and --unrestricted are mutually exclusive');
960
+ const policyProfile = mode === 'ask' ? null : loadProjectConfig()?.profiles?.[mode] || null;
961
+ let profile;
962
+ let profileSource; // 'flag' | 'project' | 'default' | 'inherited'
963
+ if (opts.restricted || opts.unrestricted) {
964
+ profile = opts.restricted ? 'restricted' : 'unrestricted';
965
+ profileSource = 'flag';
966
+ } else if (policyProfile) {
967
+ profile = policyProfile;
968
+ profileSource = 'project';
969
+ } else {
970
+ profile = DEFAULTS.profile[mode];
971
+ profileSource = 'default';
972
+ }
973
+ if (mode === 'ask' && (opts.unrestricted || opts.restricted)) {
974
+ if (opts.unrestricted) process.stderr.write(PRODUCT + ': ask is tool-free; --unrestricted ignored\n');
975
+ profile = 'restricted';
976
+ }
977
+
978
+ // execution style is a property of the mode; no flag overrides it
979
+ const background = DEFAULTS.background[mode];
980
+
981
+ const timeout = opts.timeout || DEFAULTS.timeout[mode];
982
+ const budget = durationToMs(timeout);
983
+ if (!Number.isFinite(budget) || budget <= 0 || (background && budget > 7200000)) die('invalid --timeout: use a positive duration, at most 120m for background jobs');
984
+
985
+ // conversation
986
+ const state = loadState();
987
+ let conversation = opts.conversation || null;
988
+ if (!conversation && opts.continue) {
989
+ conversation = state.conversations?.[mode] || null;
990
+ if (!conversation) die(`--continue given but no previous ${mode} conversation is recorded in state.json`);
991
+ }
992
+
993
+ const recorded = state.conversation_configs?.[conversation];
994
+ const prior = priorJob || (conversation ? [...(state.jobs || [])].reverse().find((j) => j.conversation_id === conversation && j.mode === mode) : null)
995
+ || (recorded?.mode === mode ? recorded : null)
996
+ || (state.last?.id === conversation && state.last?.mode === mode ? { model: state.last.model, profile: state.last.profile } : null);
997
+ // Configuration may come from an earlier job; occupancy belongs to the
998
+ // whole conversation, including jobs launched through another mode.
999
+ refuseRunningFollowUp(state, conversation);
1000
+ if (prior) {
1001
+ if (!opts.model && !opts.effort && prior.model) model = prior.model;
1002
+ if (!opts.restricted && !opts.unrestricted && prior.profile) { profile = prior.profile; profileSource = 'inherited'; }
1003
+ }
1004
+ if (profileSource === 'project') process.stderr.write(`${PRODUCT}: profile=${profile} set by project policy (${configPath()})\n`);
1005
+ return { mode, model, profile, profileSource, background, timeout, conversation, parentJobId: prior?.id || null, originalCwd: prior?.cwd || null,
1006
+ account_request: accountRequest(opts) };
1007
+ }
1008
+
1009
+ /** Task text comes from exactly one source: --prompt, --prompt-file, or
1010
+ * --stdin. Long prompts should use the latter two instead of shell quoting.
1011
+ * Whatever the source, the contents are opaque here: already a single string
1012
+ * by the time they arrive, and never scanned for companion flags. */
1013
+ function taskText(opts) {
1014
+ const sources = [
1015
+ opts.prompt !== undefined && '--prompt',
1016
+ opts['prompt-file'] !== undefined && '--prompt-file',
1017
+ opts.stdin && '--stdin',
1018
+ ].filter(Boolean);
1019
+ if (sources.length > 1) die(`task text given more than one way (${sources.join(', ')}) — use exactly one`);
1020
+ if (opts.prompt !== undefined) return opts.prompt.trim();
1021
+ if (opts['prompt-file'] !== undefined) {
1022
+ try {
1023
+ return fs.readFileSync(opts['prompt-file'], 'utf8').trim();
1024
+ } catch (e) {
1025
+ die(`cannot read --prompt-file ${opts['prompt-file']}: ${e.message}`);
1026
+ }
1027
+ }
1028
+ if (opts.stdin) {
1029
+ try {
1030
+ return fs.readFileSync(0, 'utf8').trim();
1031
+ } catch (e) {
1032
+ die(`cannot read task text from stdin: ${e.message}`);
1033
+ }
1034
+ }
1035
+ return '';
1036
+ }
1037
+
1038
+ function buildPrompt(mode, opts) {
1039
+ const task = taskText(opts);
1040
+ const context = gatherContext();
1041
+
1042
+ if (!task) {
1043
+ if (mode === 'ask') die('ask needs a question');
1044
+ if (mode === 'review') {
1045
+ die(
1046
+ 'review needs a subject description, e.g. review --prompt "Review PR #730" or review --prompt "Review the current working tree"'
1047
+ );
1048
+ }
1049
+ die(`${mode} needs a task description`);
1050
+ }
1051
+ if (Buffer.byteLength(task) > MAX_INLINE_BYTES) {
1052
+ die(`task text exceeds the ${MAX_INLINE_BYTES / 1024}KB inline limit`);
1053
+ }
1054
+ // ask is zero-tool by design: question only, no workspace context
1055
+ if (mode === 'ask') return fillTemplate('ask', { TASK: task });
1056
+ return fillTemplate(mode, {
1057
+ TASK: task,
1058
+ CONTEXT: context,
1059
+ WORKSPACE: mode === 'implement' ? dirtyWorkspacePrompt() : '',
1060
+ });
1061
+ }
1062
+
1063
+ // ---------------------------------------------------------------------------
1064
+ // tiered guards (unrestricted runs only; ask is forced restricted upstream)
1065
+ //
1066
+ // implement → it is meant to edit files. Dirty workspaces are prompt context,
1067
+ // not a hard companion refusal: agy can continue when the task
1068
+ // clearly includes the existing changes, and must ask when it
1069
+ // would overwrite or deliver unrelated user work.
1070
+ // review / → no gate at all, never blocked. They should not be touching
1071
+ // research files, so we snapshot `git status --porcelain` around the run
1072
+ // and report any delta with the result.
1073
+ // staffer → same snapshot/report, but neutrally worded: a general task may
1074
+ // legitimately edit files, so the delta is information for the
1075
+ // caller, not an accusation.
1076
+ // ---------------------------------------------------------------------------
1077
+
1078
+ function inGitRepo() {
1079
+ const r = sh('git', ['rev-parse', '--is-inside-work-tree']);
1080
+ return r.code === 0 && r.out === 'true';
1081
+ }
1082
+
1083
+ /** Porcelain lines as an array, or null when git can't tell us (no repo). */
1084
+ function porcelainSnapshot() {
1085
+ const r = sh('git', ['status', '--porcelain']);
1086
+ if (r.code !== 0) return null;
1087
+ return r.out ? r.out.split('\n') : [];
1088
+ }
1089
+
1090
+ /** Lines that appeared during the run, plus lines whose status changed for a
1091
+ * path that was already dirty (e.g. " M f" → "MM f"). */
1092
+ function porcelainDelta(before, after) {
1093
+ const seen = new Map();
1094
+ for (const line of before) seen.set(line.slice(3), line);
1095
+ return after.filter((line) => seen.get(line.slice(3)) !== line);
1096
+ }
1097
+
1098
+ function implementGuardApplies(resolved) {
1099
+ return resolved.profile === 'unrestricted' && resolved.mode === 'implement';
1100
+ }
1101
+
1102
+ function treeReportApplies(resolved) {
1103
+ return resolved.profile === 'unrestricted' && ['staffer', 'review', 'research'].includes(resolved.mode);
1104
+ }
1105
+
1106
+ function implementDispatchWarning() {
1107
+ if (!inGitRepo()) {
1108
+ process.stderr.write(
1109
+ PRODUCT + ' warning: not a git repository — agy\'s edits cannot be reviewed or rolled back via git.\n' +
1110
+ 'Proceeding anyway; back up anything you care about, or run implement from inside a repository.\n'
1111
+ );
1112
+ }
1113
+ }
1114
+
1115
+ function implementPostcondition(before) {
1116
+ if (!inGitRepo()) return '';
1117
+ const after = porcelainSnapshot() || [];
1118
+ if (!after.length) return '\n[unrestricted] Working tree clean after implement.';
1119
+ const diffStat = sh('git', ['diff', '--stat']).out;
1120
+ const delta = before ? porcelainDelta(before, after) : after;
1121
+ const untracked = delta
1122
+ .filter((l) => l.startsWith('??'))
1123
+ .map((l) => l.slice(3))
1124
+ .join(', ');
1125
+ let out;
1126
+ if (before?.length) {
1127
+ out =
1128
+ '\n[unrestricted] Working tree is dirty after implement. Existing pre-run changes may be part of the task context.\n' +
1129
+ 'Status entries that appeared or changed during the run:\n' +
1130
+ (delta.length ? delta.map((l) => ` ${l}`).join('\n') : ' (none detected by porcelain status)') +
1131
+ '\n`git diff --stat`:\n' +
1132
+ (diffStat || '(only new files or committed by agy)');
1133
+ } else {
1134
+ out =
1135
+ '\n[unrestricted] agy modified the working tree. `git diff --stat`:\n' +
1136
+ (diffStat || '(only new files or committed by agy)');
1137
+ }
1138
+ if (untracked) out += `\nNew untracked files: ${untracked}`;
1139
+ out += '\nACTION FOR THE CALLING AGENT: inspect the current workspace (`git status --short`, `git diff`) and distinguish pre-run dirty paths from this run\'s delta. Continue the same agy conversation for follow-up work. If committing or opening a PR, first verify the task explicitly authorized that delivery.';
1140
+ return out;
1141
+ }
1142
+
1143
+ /** Tree-delta warning for staffer/review/research: silent unless agy dirtied
1144
+ * the tree. review/research should never edit, so the report blames agy; a
1145
+ * staffer task may legitimately edit, so its wording is neutral. */
1146
+ function treeDeltaReport(mode, before, after) {
1147
+ if (!before || !after) return '';
1148
+ const delta = porcelainDelta(before, after);
1149
+ if (!delta.length) return '';
1150
+ const blame =
1151
+ mode === 'staffer'
1152
+ ? `agy modified the working tree during this ${mode} run — verify the task asked for it. `
1153
+ : `agy modified the working tree during this ${mode} — it should not have. `;
1154
+ return (
1155
+ `\n[unrestricted] ${blame}` +
1156
+ `Delta (\`git status --porcelain\` entries that appeared or changed during the run):\n` +
1157
+ delta.map((l) => ` ${l}`).join('\n') +
1158
+ `\nACTION FOR THE CALLING AGENT: inspect these changes (\`git diff\`) before trusting this ${mode}. ` +
1159
+ `Rollback: \`git checkout -- <path>\` for tracked files, delete the new untracked ones.`
1160
+ );
1161
+ }
1162
+
1163
+ // ---------------------------------------------------------------------------
1164
+ // account pool: acquire -> run -> classify -> fail over or loan
1165
+ // ---------------------------------------------------------------------------
1166
+
1167
+ const MAX_POOL_RETRIES = 3;
1168
+ const QUOTA_CONTINUE_PROMPT = 'Your previous turn stopped because the account hit a quota limit. ' +
1169
+ 'Continue the same task from where you stopped; do not repeat finished steps.';
1170
+
1171
+ /** With an empty pool and no --account the run is plain live, and stays silent
1172
+ * about accounts exactly as it did before the pool existed. */
1173
+ const showsAccount = (held, resolved) => !!held && (held.pooled || resolved.account_request === 'live');
1174
+
1175
+ function accountTelemetry(held, resolved) {
1176
+ if (!showsAccount(held, resolved)) return '';
1177
+ return ` account=${held.account}${held.lender ? ` credential=${held.lender}` : ''}`;
1178
+ }
1179
+
1180
+ /** Re-raise an agy failure that runAgy deferred so the pool could look at it. */
1181
+ function rethrow(error) {
1182
+ if (error.deferredDie) die(error.message);
1183
+ throw error;
1184
+ }
1185
+
1186
+ function resultFailure({ payload, stderr, exit }) {
1187
+ const status = (payload.status || '').toUpperCase();
1188
+ if ((payload.response || '').trim() || ((!status || status === 'SUCCESS') && exit === 0)) return null;
1189
+ const text = `${payload.error || ''}\n${stderr || ''}`;
1190
+ const kind = classifyFailureText(text);
1191
+ return kind ? { kind, text } : null;
1192
+ }
1193
+
1194
+ /** One agy call. A quota/auth failure comes back as data in both shapes agy
1195
+ * reports it (an ERROR payload, or a thrown error carrying the raw output);
1196
+ * anything else behaves exactly as before. */
1197
+ async function invokeOnce(invoke, execution) {
1198
+ let result;
1199
+ try {
1200
+ result = execution ? await execution(invoke) : runAgy(invoke);
1201
+ } catch (error) {
1202
+ const text = `${error.message}\n${error.diagnosticText || ''}`;
1203
+ const kind = error.reason === 'canceled' ? null : classifyFailureText(text);
1204
+ if (!kind) throw error;
1205
+ return { error, failure: { kind, text } };
1206
+ }
1207
+ return { result, failure: resultFailure(result) };
1208
+ }
1209
+
1210
+ function quotaFailure(resolved, opts, { message, retry_at, conversation }) {
1211
+ const when = retry_at || 'the account quota resets';
1212
+ const recovery = conversation
1213
+ ? `continue ${opts.jobId ? `--job ${opts.jobId}` : `--conversation ${conversation}`} after ${when}; ` +
1214
+ 'when another pool account is idle, a credential loan is used automatically'
1215
+ : `start a new run after ${when}, pass --account live, or choose another model family`;
1216
+ const text = `${message}\n${recovery}`;
1217
+ if (resolved.background) throw Object.assign(new Error(text), { reason: 'quota_exhausted', retry_at: retry_at || null, recovery });
1218
+ die(`${text}\n${JSON.stringify({ status: conversation ? 'attention' : 'error', reason: 'quota_exhausted',
1219
+ retry_at: retry_at || null, conversation_id: conversation || null, recovery }, null, 2)}`, conversation ? 5 : 1);
1220
+ }
1221
+
1222
+ function latestConversation(jobId) {
1223
+ return jobId ? loadState().jobs?.find((j) => j.id === jobId)?.conversation_id || null : null;
1224
+ }
1225
+
1226
+ /** Run agy on a pool account chosen under a lease. A quota failure records a
1227
+ * cooldown and then either fails over to another account (nothing started
1228
+ * yet) or continues the bound conversation on a borrowed credential. */
1229
+ async function runWithPool(resolved, invoke, opts, execution) {
1230
+ const family = modelFamily(resolved.model);
1231
+ for (let retries = 0; ; retries++) {
1232
+ const got = acquireRun({ requested: resolved.account_request, family, conversation: invoke.conversation, jobId: opts.jobId });
1233
+ if (got.error) {
1234
+ if (got.error.reason !== 'quota_exhausted') die(got.error.message);
1235
+ quotaFailure(resolved, opts, { ...got.error, conversation: invoke.conversation });
1236
+ }
1237
+ const held = got.held;
1238
+ const shown = showsAccount(held, resolved);
1239
+ invoke.env = held.env;
1240
+ invoke.recordAs = shown ? held.account : null;
1241
+ if (shown && opts.jobId) updateJob(opts.jobId, { account: held.account, ...(held.lender ? { credential_from: held.lender } : {}) });
1242
+
1243
+ let attempt;
1244
+ try {
1245
+ attempt = await invokeOnce(invoke, execution);
1246
+ if (attempt.result) rememberConversation(resolved, attempt.result.payload.conversation_id, opts.jobId, invoke.recordAs);
1247
+ } finally {
1248
+ held.release();
1249
+ }
1250
+ const { result, error, failure } = attempt;
1251
+ if (!failure || held.account === 'live') {
1252
+ if (error) rethrow(error);
1253
+ return { result, held };
1254
+ }
1255
+ const credit = held.lender || held.account; // the credential that made the request
1256
+ if (failure.kind === 'authentication_failed') {
1257
+ markAuthError(credit, failure.text);
1258
+ if (error) rethrow(error);
1259
+ return { result, held }; // triage reports it with its usual message
1260
+ }
1261
+ const cooldown = recordCooldown(credit, family, failure.text, opts.jobId);
1262
+ const conversation = result?.payload?.conversation_id || latestConversation(opts.jobId) || invoke.conversation;
1263
+ if (retries < MAX_POOL_RETRIES) {
1264
+ if (!conversation && held.auto) continue; // nothing started: just pick another account
1265
+ if (conversation && boundAccount(loadPool(), conversation) !== 'live') {
1266
+ invoke.conversation = conversation; // the next acquire finds the account cooling and borrows a credential
1267
+ invoke.prompt = QUOTA_CONTINUE_PROMPT;
1268
+ continue;
1269
+ }
1270
+ }
1271
+ quotaFailure(resolved, opts, { conversation, retry_at: cooldown.retry_at,
1272
+ message: `agy account "${credit}" hit a ${family} quota limit (quota_exhausted).` });
1273
+ }
1274
+ }
1275
+
1276
+ async function executeRun(resolved, prompt, opts, execution = null) {
1277
+ const workspaceBefore = resolved.mode === 'ask' ? null : porcelainSnapshot();
1278
+ const implementBefore = implementGuardApplies(resolved) ? workspaceBefore : null;
1279
+ const treeBefore = treeReportApplies(resolved) ? workspaceBefore : null;
1280
+
1281
+ const invoke = {
1282
+ prompt,
1283
+ workspace: repoRoot(),
1284
+ model: resolved.model,
1285
+ timeout: resolved.timeout,
1286
+ conversation: resolved.conversation,
1287
+ unrestricted: resolved.profile === 'unrestricted',
1288
+ jsonSchema: opts.json && resolved.mode === 'review' ? REVIEW_JSON_SCHEMA : null,
1289
+ };
1290
+ let result, response, held;
1291
+ try {
1292
+ ({ result, held } = await runWithPool(resolved, invoke, opts, execution));
1293
+ response = triageResult(result, resolved.mode, resolved.profile, resolved.profileSource, resolved.model);
1294
+ } catch (error) {
1295
+ error.workspace = {
1296
+ before: excerpt(workspaceBefore?.join('\n') ?? 'Git status unavailable', 3000),
1297
+ after: excerpt(porcelainSnapshot()?.join('\n') ?? 'Git status unavailable', 3000),
1298
+ note: 'Inspect git status --short, git diff and git diff --cached. Status cannot detect further edits to already dirty files; no workspace rollback was performed.' };
1299
+ if (error.reason === 'response_timeout' && !resolved.background) {
1300
+ const conversation = result.payload.conversation_id;
1301
+ const recovery = timeoutRecovery({ ...resolved, conversation_id: conversation });
1302
+ die(`${error.message}\n${JSON.stringify({
1303
+ status: conversation ? 'attention' : 'error', reason: error.reason,
1304
+ conversation_id: conversation || null, mode: resolved.mode, model: resolved.model,
1305
+ profile: resolved.profile, recovery,
1306
+ }, null, 2)}`, conversation ? 5 : 1);
1307
+ }
1308
+ throw error;
1309
+ }
1310
+ const payload = result.payload;
1311
+ const treeAfter = treeBefore ? porcelainSnapshot() : null;
1312
+ opts.warnings = !!(result.stderr || result.observationWarnings?.length || result.exit !== 0 || (payload.status && payload.status.toUpperCase() !== 'SUCCESS'));
1313
+
1314
+ // Telemetry is plumbing, not content: it goes to stderr so it never mixes
1315
+ // into the deliverable. Foreground runs put it on the caller's stderr;
1316
+ // background workers have stdout and stderr both wired to jobs/<id>.log, so
1317
+ // it lands there as the job's provenance record.
1318
+ process.stderr.write(
1319
+ `[${PRODUCT}] mode=${resolved.mode} profile=${resolved.profile} model=${resolved.model}${accountTelemetry(held, resolved)} ` +
1320
+ `agy_status=${payload.status || 'unknown'} agy_exit=${result.exit} ` +
1321
+ `duration=${payload.duration_seconds ?? '?'}s turns=${payload.num_turns ?? '?'} tokens(${fmtTokens(payload.usage)})\n` +
1322
+ `conversation: ${payload.conversation_id || 'unknown'} (follow up with --continue)\n`
1323
+ );
1324
+
1325
+ // Guard output is part of the body: the calling agent must act on it.
1326
+ let guard = '';
1327
+ if (implementGuardApplies(resolved)) guard += implementPostcondition(implementBefore);
1328
+ if (treeReportApplies(resolved)) guard += treeDeltaReport(resolved.mode, treeBefore, treeAfter);
1329
+ opts.warnings ||= !!guard;
1330
+ return guard ? response + '\n' + guard : response;
1331
+ }
1332
+
1333
+ function cmdRun(mode, opts) {
1334
+ const task = taskText(opts); // Resolve prompt-file/stdin in the caller's cwd.
1335
+ const resolved = resolveRun(mode, opts);
1336
+ enterOriginalWorkspace(resolved.originalCwd);
1337
+ if (resolved.parentJobId) {
1338
+ const prior = findJob(resolved.parentJobId);
1339
+ enterOriginalWorkspace(prior.cwd);
1340
+ if (prior.spec_file) { try { opts.json ||= JSON.parse(fs.readFileSync(prior.spec_file, 'utf8')).opts.json; } catch {} }
1341
+ }
1342
+ const prompt = buildPrompt(mode, { prompt: task });
1343
+ return dispatch(resolved, prompt, { ...opts, promptSource: { kind: 'template', task } });
1344
+ }
1345
+
1346
+ /** Fail fast (before a background job is registered) on an account request
1347
+ * that can never work: unknown or disabled name, or a conversation owned by
1348
+ * another account. Quota problems are left to the run itself. */
1349
+ function preflightAccount(resolved) {
1350
+ const problem = preflight({ requested: resolved.account_request, family: modelFamily(resolved.model), conversation: resolved.conversation });
1351
+ if (problem && problem.reason !== 'quota_exhausted') die(problem.message);
1352
+ }
1353
+
1354
+ async function dispatch(resolved, prompt, opts) {
1355
+ const mode = resolved.mode;
1356
+ preflightAccount(resolved);
1357
+ if (!resolved.background) {
1358
+ process.stdout.write(await executeRun(resolved, prompt, opts) + '\n');
1359
+ return;
1360
+ }
1361
+
1362
+ if (implementGuardApplies(resolved)) implementDispatchWarning();
1363
+
1364
+ // background: write a job spec, spawn ourselves detached as _worker
1365
+ const jobId = `${mode}-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
1366
+ const jobsDir = path.join(ensureStateDir(), 'jobs');
1367
+ fs.mkdirSync(jobsDir, { recursive: true });
1368
+ const logFile = path.join(jobsDir, `${jobId}.log`);
1369
+ const specFile = path.join(jobsDir, `${jobId}.spec.json`);
1370
+ const resultFile = path.join(jobsDir, `${jobId}.result.md`);
1371
+
1372
+ // Register the job BEFORE spawning: a fast worker's own state update must
1373
+ // find the record already present, or it gets lost in its read-modify-write.
1374
+ const record = {
1375
+ id: jobId, mode, pid: null, status: 'running', cwd: process.cwd(),
1376
+ model: resolved.model, profile: resolved.profile, profileSource: resolved.profileSource,
1377
+ timeout: resolved.timeout, conversation_id: resolved.conversation || null,
1378
+ parent_job_id: opts.parentJobId || resolved.parentJobId || null,
1379
+ started_at: new Date().toISOString(), log_file: logFile, result_file: resultFile,
1380
+ spec_file: specFile,
1381
+ events_file: path.join(jobsDir, `${jobId}.events.jsonl`),
1382
+ progress_file: path.join(jobsDir, `${jobId}.progress.json`),
1383
+ };
1384
+ updateState((state) => {
1385
+ // Two callers can both resolve an idle conversation. Recheck under the
1386
+ // registration lock before accepting either the job or its prompt file.
1387
+ refuseRunningFollowUp(state, resolved.conversation);
1388
+ fs.writeFileSync(specFile, JSON.stringify({ resolved, prompt, prompt_source: opts.promptSource || null, opts: { json: !!opts.json }, cwd: process.cwd() }, null, 2));
1389
+ state.jobs ||= [];
1390
+ state.jobs.push(record);
1391
+ });
1392
+ fs.appendFileSync(logFile, `[${PRODUCT}] dispatch registered ${jobId} at ${record.started_at}\n`);
1393
+
1394
+ const logFd = fs.openSync(logFile, 'a');
1395
+ // detached on every platform: on POSIX it isolates the process group; on
1396
+ // Windows it is DETACHED_PROCESS, so the worker has no console (with
1397
+ // windowsHide its own children get none either) and outlives the terminal
1398
+ // that dispatched it, including its Ctrl+C.
1399
+ const child = spawn(process.execPath, [SELF, '_worker', jobId], {
1400
+ cwd: process.cwd(),
1401
+ detached: true,
1402
+ windowsHide: true,
1403
+ stdio: ['ignore', logFd, logFd],
1404
+ });
1405
+ child.unref();
1406
+ fs.closeSync(logFd);
1407
+
1408
+ // Backfill the pid, preserving whatever status the worker may have written.
1409
+ updateJob(jobId, { pid: child.pid });
1410
+ child.on('error', (error) => {
1411
+ fs.writeFileSync(resultFile, `Job failed: worker launch: ${error.message}\n`);
1412
+ updateJob(jobId, { status: 'error', reason: 'worker_launch_error', finished_at: new Date().toISOString() }, true);
1413
+ });
1414
+
1415
+ process.stdout.write(
1416
+ `Started background ${mode} job.\n` +
1417
+ `job id: ${jobId} (pid ${child.pid})\n` +
1418
+ `model: ${resolved.model} profile: ${resolved.profile} timeout: ${resolved.timeout}\n` +
1419
+ `result file (written when the job finishes): ${resultFile}\n` +
1420
+ `Collect: run \`wait ${jobId} --timeout 10m\` as a background command ` +
1421
+ `(one background wait per job; exit 0 = result printed, 2 = still running — wait again for the same job, without extra progress checks).\n` +
1422
+ `Progress only if the user asks: \`observe ${jobId}\` Stop: \`cancel ${jobId}\`\n`
1423
+ );
1424
+ }
1425
+
1426
+ async function workerMain(jobId) {
1427
+ inWorker = true;
1428
+ const started = Date.now();
1429
+ const controller = new AbortController();
1430
+ const onSignal = () => controller.abort();
1431
+ process.on('SIGTERM', onSignal);
1432
+ process.on('SIGINT', onSignal);
1433
+ let job, cancelTimer;
1434
+ try {
1435
+ job = updateJob(jobId, { worker_started_at: new Date().toISOString(), worker_pid: process.pid, worker_identity: processIdentity(process.pid) });
1436
+ process.stderr.write(`[${PRODUCT}] worker started ${jobId} pid=${process.pid} at ${job.worker_started_at}\n`);
1437
+ if (job.status !== 'running') return;
1438
+ const checkCancellation = () => {
1439
+ if (job.cancel_requested_at || fs.existsSync(job.spec_file + '.cancel')) controller.abort();
1440
+ };
1441
+ checkCancellation();
1442
+ cancelTimer = setInterval(checkCancellation, 100);
1443
+ if (controller.signal.aborted) throw Object.assign(new Error('Execution canceled.'), { reason: 'canceled' });
1444
+ const spec = JSON.parse(fs.readFileSync(job.spec_file, 'utf8'));
1445
+ const opts = { ...spec.opts, jobId };
1446
+ const output = await executeRun(spec.resolved, spec.prompt, opts, (invoke) => {
1447
+ const agy = agyCommand(agyArgs(invoke, 'stream-json'));
1448
+ return runStreaming({ binary: agy.cmd, args: agy.args, job,
1449
+ budget: durationToMs(spec.resolved.timeout) - (Date.now() - started), signal: controller.signal,
1450
+ env: invoke.env,
1451
+ update: (fields) => updateJob(jobId, fields),
1452
+ conversation: (id) => rememberConversation(spec.resolved, id, jobId, invoke.recordAs),
1453
+ }).catch((error) => {
1454
+ if (error.reason === 'missing_result' && isUnsupportedModelError(error.diagnosticText || '')) {
1455
+ handleUnsupportedModel({ requestedModel: invoke.model, errText: error.diagnosticText, originalError: error.diagnosticText });
1456
+ }
1457
+ throw error;
1458
+ });
1459
+ });
1460
+ if (controller.signal.aborted) throw Object.assign(new Error('Execution canceled.'), { reason: 'canceled' });
1461
+ // Result and conversation metadata are durable before completion is visible.
1462
+ job = finishJob(jobId, output + '\n', { status: 'done', warnings: opts.warnings });
1463
+ if (job.status === 'done' && !job.warnings) {
1464
+ for (const file of [job.events_file, job.progress_file]) {
1465
+ try { fs.unlinkSync(file); } catch (error) { process.stderr.write(`cleanup: ${error.message}\n`); }
1466
+ }
1467
+ }
1468
+ } catch (error) {
1469
+ if (!job) throw error;
1470
+ job = loadState().jobs?.find((j) => j.id === jobId);
1471
+ if (!job) throw error;
1472
+ const reason = job.cancel_requested_at ? 'canceled' : error.reason || 'agy_error';
1473
+ const status = job.status === 'canceled' || reason === 'canceled' ? 'canceled'
1474
+ : (isTimeoutReason(reason) || reason === 'quota_exhausted') && job.conversation_id ? 'attention' : 'error';
1475
+ job = finishJob(jobId, (completed) => {
1476
+ const report = { ...diagnosticPacket(completed), result_exists: true, reason: completed.reason,
1477
+ workspace: error.workspace, last_snapshot: readObservation(completed) };
1478
+ return `${completed.status === 'attention' ? 'Job needs attention' : 'Job failed'}:\n${completed.reason === 'canceled' ? 'Execution canceled.' : error.message}\n\n${JSON.stringify(report, null, 2)}\n`;
1479
+ }, (current) => current.cancel_requested_at ? { status: 'canceled', reason: 'canceled' } : { status, reason, ...(error.retry_at ? { retry_at: error.retry_at } : {}) });
1480
+ process.exitCode = JOB_EXIT_CODES[job.status] ?? 1;
1481
+ } finally {
1482
+ clearInterval(cancelTimer);
1483
+ process.removeListener('SIGTERM', onSignal);
1484
+ process.removeListener('SIGINT', onSignal);
1485
+ }
1486
+ }
1487
+
1488
+ // ---------------------------------------------------------------------------
1489
+ // jobs: status / result / cancel
1490
+ // ---------------------------------------------------------------------------
1491
+
1492
+ const CRASH_SANDBOX_HINT =
1493
+ 'The worker pid is not visible from this process. If the job may have been started from a different harness permission or sandbox context, rerun wait/status/result from the same unsandboxed context before treating it as crashed.';
1494
+
1495
+ function refreshJobs(state) {
1496
+ for (const job of state.jobs || []) job.status = liveJobStatus(job);
1497
+ }
1498
+
1499
+ function liveJobStatus(job) {
1500
+ if (['done', 'canceled', 'error', 'attention'].includes(job.status)) return job.status;
1501
+ try {
1502
+ const final = JSON.parse(fs.readFileSync(job.result_file + '.status.json', 'utf8'));
1503
+ if (['done', 'error', 'canceled', 'attention'].includes(final.status)) return final.status;
1504
+ } catch {}
1505
+ if (pidAlive(job.pid)) return 'running';
1506
+ // New jobs publish an explicit result status; never infer success from an
1507
+ // error report left behind by a worker that died before updating state.
1508
+ return !job.spec_file && fs.existsSync(job.result_file) ? 'done' : 'crashed';
1509
+ }
1510
+
1511
+ // Machine-readable job exit codes shared by `status <id>` and `wait`.
1512
+ // 1 stays the generic companion error, so callers can loop on "code 2"
1513
+ // without parsing any output.
1514
+ const JOB_EXIT_CODES = { done: 0, running: 2, error: 3, crashed: 3, canceled: 4, attention: 5 };
1515
+
1516
+ function cmdStatus(opts) {
1517
+ const state = loadState();
1518
+ refreshJobs(state);
1519
+ const jobs = state.jobs || [];
1520
+ const id = opts._[0];
1521
+
1522
+ if (id) {
1523
+ const job = jobs.find((j) => j.id === id);
1524
+ if (!job) die(`no job ${id} in this repository`);
1525
+ process.stdout.write(JSON.stringify(job, null, 2) + '\n');
1526
+ if (job.status === 'running') {
1527
+ process.stdout.write(`\nStill running. Log tail:\n`);
1528
+ const log = readTail(job.log_file);
1529
+ process.stdout.write(log.split('\n').slice(-10).join('\n') + '\n');
1530
+ } else if (job.status === 'crashed' && !fs.existsSync(job.result_file)) {
1531
+ process.stdout.write(JSON.stringify(diagnosticPacket(job), null, 2) + '\n');
1532
+ process.stdout.write(`\n${CRASH_SANDBOX_HINT}\n`);
1533
+ }
1534
+ // machine-readable outcome so callers never have to parse the JSON
1535
+ process.exitCode = JOB_EXIT_CODES[job.status] ?? 1;
1536
+ return;
1537
+ }
1538
+
1539
+ if (!jobs.length) {
1540
+ process.stdout.write('No ' + PRODUCT + ' jobs recorded in this repository.\n');
1541
+ return;
1542
+ }
1543
+ process.stdout.write('id | mode | status | started | finished\n');
1544
+ for (const j of jobs.slice(-20)) {
1545
+ process.stdout.write(`${j.id} | ${j.mode} | ${j.status} | ${j.started_at} | ${j.finished_at || '-'}\n`);
1546
+ }
1547
+ process.stdout.write('\nDetails: `status <id>` Output: `result <id>`\n');
1548
+ if (jobs.slice(-20).some((j) => j.status === 'crashed' && !fs.existsSync(j.result_file))) {
1549
+ process.stdout.write(`\n${CRASH_SANDBOX_HINT}\n`);
1550
+ }
1551
+ }
1552
+
1553
+ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
1554
+
1555
+ /** Block until the job reaches a terminal state, then print its result —
1556
+ * `wait` + `result` in one call. Bounded by its own --timeout (default 100s,
1557
+ * chosen to sit under a typical harness per-command timeout); expiring is NOT
1558
+ * a failure: exit code 2 means "still running — call wait again". */
1559
+ async function cmdWait(opts) {
1560
+ const id = opts._[0] || null;
1561
+ const timeout = opts.timeout || '100s';
1562
+ const budget = durationToMs(timeout);
1563
+ if (!Number.isFinite(budget)) die(`invalid --timeout "${timeout}" (examples: 100s, 5m)`);
1564
+
1565
+ // Wait silently: callers use observe for progress, not periodic liveness text.
1566
+ // Read-only lookup: the poll loop must never write state.json, or it races
1567
+ // the worker's own final read-modify-write (see liveJobStatus).
1568
+ const findJob = () => {
1569
+ const jobs = loadState().jobs || [];
1570
+ if (id) return jobs.find((j) => j.id === id) || null;
1571
+ return jobs.length ? jobs[jobs.length - 1] : null;
1572
+ };
1573
+ let job = findJob();
1574
+ if (!job) die(id ? `no job ${id} in this repository` : `no ${PRODUCT} jobs recorded in this repository`);
1575
+
1576
+ const POLL_MS = 200;
1577
+ const start = Date.now();
1578
+ let status = liveJobStatus(job);
1579
+ while (status === 'running' && Date.now() - start < budget) {
1580
+ await sleepMs(Math.min(POLL_MS, budget - (Date.now() - start)));
1581
+ job = findJob();
1582
+ if (!job) die(`job record disappeared from state.json`);
1583
+ status = liveJobStatus(job);
1584
+ }
1585
+
1586
+ return renderJobResponse(job);
1587
+ }
1588
+
1589
+ function findJob(id) {
1590
+ const jobs = loadState().jobs || [];
1591
+ const job = id ? jobs.find((j) => j.id === id) : jobs.at(-1);
1592
+ if (!job) die(id ? `no job ${id} in this repository` : `no ${PRODUCT} jobs recorded in this repository`);
1593
+ return job;
1594
+ }
1595
+
1596
+ function readObservation(job) {
1597
+ let snapshot = { recent_activities: [], latest_text: null, last_event_at: null, warnings: ['No activity record is available for this job.'] };
1598
+ try { snapshot = JSON.parse(fs.readFileSync(job.progress_file, 'utf8')); } catch {}
1599
+ return boundSnapshot({ ...snapshot, job_id: job.id, mode: job.mode, status: liveJobStatus(job),
1600
+ started_at: job.started_at, observed_at: new Date().toISOString(),
1601
+ elapsed_seconds: Math.max(0, Math.round((Date.now() - Date.parse(job.started_at)) / 1000)),
1602
+ details: { raw_output: job.events_file || null, diagnostics: job.log_file, result: job.result_file },
1603
+ });
1604
+ }
1605
+
1606
+ function readTail(file, limit = 8192) {
1607
+ let fd;
1608
+ try {
1609
+ fd = fs.openSync(file, 'r');
1610
+ const size = fs.fstatSync(fd).size;
1611
+ const buffer = Buffer.alloc(Math.min(size, limit));
1612
+ fs.readSync(fd, buffer, 0, buffer.length, Math.max(0, size - buffer.length));
1613
+ return buffer.toString('utf8');
1614
+ } catch { return ''; } finally { if (fd !== undefined) fs.closeSync(fd); }
1615
+ }
1616
+
1617
+ function isTimeoutReason(reason) {
1618
+ return reason === 'response_timeout' || reason === 'hard_timeout';
1619
+ }
1620
+
1621
+ function shellArg(value) {
1622
+ return /^[a-zA-Z0-9_./:-]+$/.test(value) ? value : "'" + String(value).replaceAll("'", "'\\''") + "'";
1623
+ }
1624
+
1625
+ function timeoutRecovery(job) {
1626
+ const previous = durationToMs(job.timeout) || durationToMs(DEFAULTS.timeout[job.mode]) || 3600000;
1627
+ const next = job.mode === 'ask' ? previous * 2 : Math.min(previous * 2, 7200000);
1628
+ const suggested = next % 60000 === 0 ? `${next / 60000}m` : `${next / 1000}s`;
1629
+ const target = job.id ? `--job ${shellArg(job.id)}` : `--conversation ${shellArg(job.conversation_id || '')}`;
1630
+ return {
1631
+ inspect: 'git status --short; git diff; git diff --cached', spec_file: job.spec_file || null,
1632
+ requires_user_confirmation: true, suggested_timeout: suggested, at_timeout_ceiling: next <= previous,
1633
+ continue: job.conversation_id ? `continue ${target} --timeout ${suggested} --prompt "Continue after inspecting partial workspace changes"` : null,
1634
+ restart: job.id ? `restart ${shellArg(job.id)} --timeout ${suggested}` : null,
1635
+ note: job.conversation_id
1636
+ ? 'Ask the user whether to continue ' + (next <= previous ? 'with a narrower task at the 120m ceiling' : `with a larger timeout (suggested: ${suggested})`) +
1637
+ ' or stop and inspect the current workspace. No automatic retry or continuation.'
1638
+ : 'No conversation ID is available. Inspect partial workspace changes and ask the user before restarting. No retry was started.',
1639
+ };
1640
+ }
1641
+
1642
+ /** Recovery for a job that stopped on an exhausted account quota. */
1643
+ function quotaRecovery(job) {
1644
+ const when = job.retry_at || 'the account quota resets';
1645
+ return { retry_at: job.retry_at || null, spec_file: job.spec_file || null,
1646
+ continue: job.conversation_id ? `continue --job ${job.id} --prompt "Continue the task"` : null,
1647
+ restart: `restart ${job.id}`,
1648
+ note: job.conversation_id
1649
+ ? `continue --job ${job.id} after ${when}; when another pool account is idle, a credential loan is used automatically.`
1650
+ : `No conversation was started. Restart after ${when}, pass --account live, or choose another model family.` };
1651
+ }
1652
+
1653
+ function diagnosticPacket(job) {
1654
+ let logBytes = null;
1655
+ try { logBytes = fs.statSync(job.log_file).size; } catch {}
1656
+ return { job_id: job.id, mode: job.mode, cwd: job.cwd || process.cwd(), status: liveJobStatus(job),
1657
+ started_at: job.started_at, worker_started_at: job.worker_started_at || null, finished_at: job.finished_at || null,
1658
+ pid: job.pid, agy_pid: job.agy_pid || null, log_bytes: logBytes,
1659
+ log_state: logBytes === null ? 'missing' : logBytes === 0 ? 'empty' : 'present',
1660
+ result_exists: fs.existsSync(job.result_file), log_file: job.log_file, events_file: job.events_file || null,
1661
+ conversation_id: job.conversation_id || null, model: job.model || null, profile: job.profile || null,
1662
+ ...(job.account ? { account: job.account } : {}),
1663
+ ...(job.reason === 'quota_exhausted' ? { retry_at: job.retry_at || null } : {}),
1664
+ recovery: isTimeoutReason(job.reason) ? timeoutRecovery(job) : job.reason === 'quota_exhausted' ? quotaRecovery(job) : { inspect: 'git status --short; git diff', spec_file: job.spec_file || null,
1665
+ continue: job.conversation_id ? `continue --job ${job.id} --prompt "Continue after inspecting partial workspace changes"` : null,
1666
+ restart: `restart ${job.id}`, note: 'Inspect partial workspace changes first. Recovery creates a linked new job with a fresh budget; nothing is retried automatically.' },
1667
+ };
1668
+ }
1669
+
1670
+ /** Observation never reads the result body, even when completion races a read.
1671
+ * A separate wait/result owns delivery; observers cannot consume its output. */
1672
+ function readTerminalObservation(job, status) {
1673
+ // A result sidecar may be visible just before the shared registry commit.
1674
+ let final = {};
1675
+ try { final = JSON.parse(fs.readFileSync(job.result_file + '.status.json', 'utf8')); } catch {}
1676
+ const finishedAt = job.finished_at || final.finished_at || null;
1677
+ const snapshot = {
1678
+ job_id: job.id, mode: job.mode, status,
1679
+ started_at: job.started_at, finished_at: finishedAt, observed_at: new Date().toISOString(),
1680
+ elapsed_seconds: Math.max(0, Math.round(((Date.parse(finishedAt) || Date.now()) - Date.parse(job.started_at)) / 1000)),
1681
+ result_file: job.result_file, result_available: fs.existsSync(job.result_file),
1682
+ collection: {
1683
+ command: `result ${job.id}`,
1684
+ instruction: 'Collect the existing wait session if one is pending; otherwise use result for the full output.',
1685
+ },
1686
+ };
1687
+ if (status !== 'done') {
1688
+ const reason = job.reason || final.reason || (status === 'crashed' ? 'worker_crashed' : status === 'canceled' ? 'canceled' : 'job_error');
1689
+ const packet = diagnosticPacket({ ...job, ...final, reason });
1690
+ Object.assign(snapshot, {
1691
+ reason,
1692
+ summary: status === 'attention' ? 'Timeout with a resumable conversation; ask the user whether to continue.'
1693
+ : reason === 'hard_timeout' ? 'Execution stopped at its hard limit.' : `Job ${status}; inspect the retained report and diagnostics.`,
1694
+ conversation_id: job.conversation_id || null, model: job.model || null, profile: job.profile || null,
1695
+ ...(job.account ? { account: job.account } : {}),
1696
+ worker_started_at: job.worker_started_at || null, pid: job.pid, agy_pid: job.agy_pid || null,
1697
+ log_state: packet.log_state, log_bytes: packet.log_bytes,
1698
+ details: { diagnostics: job.log_file, raw_output: job.events_file || null, snapshot: job.progress_file || null },
1699
+ recovery: packet.recovery,
1700
+ });
1701
+ if (status === 'crashed') snapshot.liveness_note = CRASH_SANDBOX_HINT;
1702
+ }
1703
+ // Intermediate tool errors remain internal when the overall job succeeds.
1704
+ return boundSnapshot(snapshot);
1705
+ }
1706
+
1707
+ function renderJobResponse(initial, { observeOnly = false } = {}) {
1708
+ let job = findJob(initial.id);
1709
+ let status = liveJobStatus(job);
1710
+ if (status === 'running') {
1711
+ const snapshot = readObservation(job);
1712
+ // Success cleanup can race the snapshot read; terminal state wins.
1713
+ job = findJob(job.id);
1714
+ status = liveJobStatus(job);
1715
+ if (status === 'running') {
1716
+ process.stdout.write(JSON.stringify(snapshot) + '\n');
1717
+ process.exitCode = 2;
1718
+ return;
1719
+ }
1720
+ }
1721
+ if (observeOnly) {
1722
+ process.stdout.write(JSON.stringify(readTerminalObservation(job, status)) + '\n');
1723
+ process.exitCode = JOB_EXIT_CODES[status] ?? 1;
1724
+ return;
1725
+ }
1726
+ // Include warning metadata when the sidecar precedes the registry commit.
1727
+ if (job.status === 'running') {
1728
+ try { job = { ...job, ...JSON.parse(fs.readFileSync(job.result_file + '.status.json', 'utf8')) }; } catch {}
1729
+ }
1730
+ if (fs.existsSync(job.result_file)) {
1731
+ process.stdout.write(`# Job ${job.id} (${job.mode}, ${status})\n\n`);
1732
+ process.stdout.write(fs.readFileSync(job.result_file, 'utf8'));
1733
+ } else {
1734
+ process.stdout.write(`Job ${job.id} (${job.mode}) finished with status ${status} and no stored result. Log: ${job.log_file}\n`);
1735
+ process.stdout.write(JSON.stringify(diagnosticPacket(job), null, 2) + '\n');
1736
+ if (status === 'crashed') process.stdout.write(`\n${CRASH_SANDBOX_HINT}\n`);
1737
+ }
1738
+ if (status === 'done' && job.warnings) {
1739
+ process.stderr.write(`Job diagnostics (tail, up to 8192 bytes). Full log: ${job.log_file}\n${readTail(job.log_file)}\n`);
1740
+ }
1741
+ process.exitCode = JOB_EXIT_CODES[status] ?? 1;
1742
+ }
1743
+
1744
+
1745
+ function cmdResult(opts) {
1746
+ const state = loadState();
1747
+ refreshJobs(state);
1748
+ const jobs = state.jobs || [];
1749
+ let job;
1750
+ if (opts._[0]) {
1751
+ job = jobs.find((j) => j.id === opts._[0]);
1752
+ if (!job) die(`no job ${opts._[0]} in this repository`);
1753
+ } else {
1754
+ job = [...jobs].reverse().find((j) => j.status !== 'running');
1755
+ if (!job) die('no finished jobs in this repository');
1756
+ }
1757
+ if (job.status === 'running') {
1758
+ die(`job ${job.id} is still running — collect it with \`wait ${job.id}\` or peek with \`status ${job.id}\``);
1759
+ }
1760
+ if (!fs.existsSync(job.result_file)) {
1761
+ let msg = `job ${job.id} (${job.status}) has no stored result. Log: ${job.log_file}`;
1762
+ if (job.status === 'crashed') {
1763
+ msg += `\n${JSON.stringify(diagnosticPacket(job), null, 2)}\n\n${CRASH_SANDBOX_HINT}`;
1764
+ }
1765
+ die(msg);
1766
+ }
1767
+ renderJobResponse(job);
1768
+ // Preserve result's historical exit contract for non-attention terminal states.
1769
+ process.exitCode = job.status === 'attention' ? 5 : 0;
1770
+ }
1771
+
1772
+ async function cmdCancel(opts) {
1773
+ const id = opts._[0];
1774
+ if (!id) die('cancel needs a job id (see `status`)');
1775
+ let changed = false, status;
1776
+ const job = updateState((state) => {
1777
+ const job = state.jobs?.find((j) => j.id === id);
1778
+ if (!job) die(`no job ${id} in this repository`);
1779
+ status = liveJobStatus(job);
1780
+ if (status === 'running') {
1781
+ if (!job.spec_file) die('this legacy job has no cancellation request channel; cannot safely signal an unverified stored PID');
1782
+ const identity = job.worker_identity;
1783
+ const current = identity ? processIdentity(job.pid) : null;
1784
+ if (identity && (identity.pid !== job.pid || (current && identity.born !== current.born))) {
1785
+ die('worker identity no longer matches this job; refusing to signal a reused or unrelated PID');
1786
+ }
1787
+ // Keep running visible until the worker stores the cancellation report.
1788
+ job.cancel_requested_at ||= new Date().toISOString();
1789
+ fs.writeFileSync(job.spec_file + '.cancel', job.cancel_requested_at);
1790
+ changed = true;
1791
+ }
1792
+ return job;
1793
+ });
1794
+ if (!changed) { process.stdout.write(`Job ${id} is not running (status: ${status}).\n`); return; }
1795
+ // The worker polls the request even if PID inspection/signaling is blocked.
1796
+ // Never send signals to the stored AGY PID: the worker owns that child.
1797
+ // On Windows process.kill() is TerminateProcess: the worker would die without
1798
+ // running its cleanup and orphan the agy tree, so rely on the marker alone there.
1799
+ const current = job.worker_identity && process.platform !== 'win32' ? processIdentity(job.pid) : null;
1800
+ if (current && current.pid === job.worker_identity.pid && current.born === job.worker_identity.born) {
1801
+ try { process.kill(current.pid, 'SIGTERM'); } catch {}
1802
+ }
1803
+ const deadline = Date.now() + 10000;
1804
+ while (Date.now() < deadline) {
1805
+ const latest = findJob(id);
1806
+ status = liveJobStatus(latest);
1807
+ if (status === 'canceled') { process.stdout.write(`Canceled job ${id} (pid ${job.pid}).\n`); return; }
1808
+ if (status !== 'running') die(`Cancellation requested, but job is ${status}; inspect \`observe ${id}\` and the retained diagnostics.`);
1809
+ await new Promise((resolve) => setTimeout(resolve, 100));
1810
+ }
1811
+ die(`Cancellation requested but the worker has not published a terminal report; inspect \`observe ${id}\` from the original unsandboxed context.`);
1812
+ }
1813
+
1814
+ // ---------------------------------------------------------------------------
1815
+ // continue
1816
+ // ---------------------------------------------------------------------------
1817
+
1818
+ function enterOriginalWorkspace(cwd) {
1819
+ if (!cwd) return; // Legacy records did not store a cwd.
1820
+ const root = fs.realpathSync(repoRoot());
1821
+ let target;
1822
+ try { target = fs.realpathSync(cwd); } catch { die(`original workspace directory is unavailable: ${cwd}`); }
1823
+ const git = sh('git', ['rev-parse', '--show-toplevel'], { cwd: target });
1824
+ const targetRoot = fs.realpathSync(git.code === 0 && git.out ? git.out : target);
1825
+ if (root !== targetRoot) die(`recovery cannot switch worktrees; run from the original workspace: ${cwd}`);
1826
+ process.chdir(target);
1827
+ repoRootCache.set(target, git.code === 0 && git.out ? git.out : target);
1828
+ repoRootCache.set(process.cwd(), git.code === 0 && git.out ? git.out : target);
1829
+ }
1830
+
1831
+ function cmdContinue(opts) {
1832
+ const state = loadState();
1833
+ const targetId = opts.conversation || state.last?.id;
1834
+ const prior = opts.job ? findJob(opts.job) : [...(state.jobs || [])].reverse().find((j) => j.conversation_id === targetId)
1835
+ || state.conversation_configs?.[targetId];
1836
+ const legacyMode = Object.entries(state.conversations || {}).find(([, id]) => id === targetId)?.[0];
1837
+ const mode = prior?.mode || legacyMode || (state.last?.id === targetId ? state.last?.mode : null);
1838
+ const conversation = prior?.conversation_id || targetId;
1839
+ if (opts.job) refuseRunningFollowUp(state, prior?.conversation_id, prior?.id);
1840
+ if (!mode || !conversation) die(`no previous ${PRODUCT} conversation recorded in this repository for this target; use restart <job-id> when no conversation is available`);
1841
+ if (opts.job && opts.conversation && opts.conversation !== prior.conversation_id) die('--job and --conversation identify different conversations');
1842
+ if (opts.job && !prior.conversation_id) die('this job has no known conversation; use restart <job-id>');
1843
+ const task = taskText(opts);
1844
+ if (!task) die('continue needs follow-up text');
1845
+ enterOriginalWorkspace(prior?.cwd);
1846
+ const resolved = resolveRun(mode, { ...opts, conversation }, prior);
1847
+ const workspace = mode === 'implement' ? dirtyWorkspacePrompt() : '';
1848
+ const prompt = `${workspace ? `${workspace}\n\n` : ''}Follow-up in the same conversation:\n\n${task}`;
1849
+ let json = opts.json;
1850
+ if (prior?.spec_file) { try { json ||= JSON.parse(fs.readFileSync(prior.spec_file, 'utf8')).opts.json; } catch {} }
1851
+ return dispatch(resolved, prompt, { ...opts, json, parentJobId: prior?.id, promptSource: { kind: 'followup', task } });
1852
+ }
1853
+
1854
+ function cmdRestart(opts) {
1855
+ if (!opts._[0]) die('restart needs a job id');
1856
+ const job = findJob(opts._[0]);
1857
+ if (liveJobStatus(job) === 'running') die('job is still running; cancel it before restarting');
1858
+ if (!job.spec_file) die('this legacy job has no stored restart specification');
1859
+ const spec = JSON.parse(fs.readFileSync(job.spec_file, 'utf8'));
1860
+ enterOriginalWorkspace(spec.cwd);
1861
+ // A fresh conversation selects an account again; --account overrides the stored request.
1862
+ const resolved = { ...spec.resolved, conversation: null, timeout: DEFAULTS.timeout[job.mode],
1863
+ account_request: opts.account ? accountRequest(opts) : spec.resolved.account_request || accountRequest({}) };
1864
+ if (opts.timeout) {
1865
+ resolved.timeout = resolveRun(job.mode, { ...opts, model: resolved.model, [resolved.profile]: true }).timeout;
1866
+ }
1867
+ // Rebuild saved task sources with fresh context. For legacy prompts, label
1868
+ // historical snapshots and append current facts without parsing task text.
1869
+ const source = spec.prompt_source || { kind: 'legacy', text: spec.prompt };
1870
+ let prompt;
1871
+ if (source.kind === 'template') prompt = buildPrompt(job.mode, { prompt: source.task });
1872
+ else if (source.kind === 'followup') prompt = `${job.mode === 'implement' ? dirtyWorkspacePrompt() + '\n\n' : ''}Follow-up task in a fresh conversation:\n\n${source.task}`;
1873
+ else {
1874
+ const current = `${gatherContext()}\n\n${job.mode === 'implement' ? dirtyWorkspacePrompt() || 'Working tree is currently clean.' : ''}`;
1875
+ prompt = `Restart the original task below. Its embedded workspace/environment snapshots are historical. Use the current workspace section at the end for this execution; preserve existing partial work.\n\n${source.text}\n\n## Current workspace for this restart\n\n${current}`;
1876
+ }
1877
+ return dispatch(resolved, prompt, { ...spec.opts, parentJobId: job.id, promptSource: source });
1878
+ }
1879
+
1880
+ // ---------------------------------------------------------------------------
1881
+ // setup
1882
+ // ---------------------------------------------------------------------------
1883
+
1884
+ /** `setup --restrict <modes|none>`: write the per-repo policy. Declarative —
1885
+ * the listed modes become restricted-by-default, every unlisted mode falls
1886
+ * back to the built-in default. Written directly (no --apply): the file is
1887
+ * repo-local, git-ignored by convention, and trivially reversible. */
1888
+ function applyProjectPolicy(value) {
1889
+ const cfg = loadProjectConfig() || {};
1890
+ if (value === 'none') {
1891
+ delete cfg.profiles;
1892
+ if (Object.keys(cfg).length) {
1893
+ fs.writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + '\n');
1894
+ } else {
1895
+ try {
1896
+ fs.unlinkSync(configPath());
1897
+ } catch {}
1898
+ }
1899
+ process.stdout.write(`Project policy cleared — all modes use the built-in defaults again.\n\n`);
1900
+ return false;
1901
+ }
1902
+
1903
+ const modes = value.split(',').map((s) => s.trim()).filter(Boolean);
1904
+ if (!modes.length) die('--restrict needs a value: a comma-separated list of modes, or "none" to clear');
1905
+ for (const m of modes) {
1906
+ if (m === 'ask') die('ask is tool-free and always restricted; it cannot be configured');
1907
+ if (!CONFIGURABLE_MODES.includes(m)) {
1908
+ die(`--restrict: unknown mode "${m}" (configurable: ${CONFIGURABLE_MODES.join(', ')}, or "none" to clear)`);
1909
+ }
1910
+ }
1911
+ cfg.profiles = {};
1912
+ for (const m of modes) cfg.profiles[m] = 'restricted';
1913
+ ensureStateDir();
1914
+ fs.writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + '\n');
1915
+
1916
+ process.stdout.write(`Project policy written: ${configPath()}\n`);
1917
+ for (const m of modes) process.stdout.write(` ${m}: restricted (default for this repository)\n`);
1918
+ process.stdout.write(
1919
+ 'Unlisted modes keep the built-in default (unrestricted). A --restricted/--unrestricted flag on a\n' +
1920
+ 'call still overrides the policy. This is a per-repo, per-machine preference (.agy-crew/ is\n' +
1921
+ 'normally git-ignored, so it is not shared with the team) and a run policy, not a security\n' +
1922
+ 'boundary — for untrusted input use an isolated checkout.\n\n'
1923
+ );
1924
+ return true;
1925
+ }
1926
+
1927
+ function cmdSetup(opts) {
1928
+ // check agy availability
1929
+ const probe = agyCommand(['--version']);
1930
+ const v = sh(probe.cmd, probe.args);
1931
+ if (v.code !== 0) {
1932
+ die(
1933
+ `agy CLI not found or not working (tried \`${AGY_BIN} --version\`).\n` +
1934
+ 'Install Google Antigravity CLI and make sure `agy` is on PATH (expected at ~/.local/bin/agy).'
1935
+ );
1936
+ }
1937
+
1938
+ let policyWritten = false;
1939
+ if (opts.restrict !== undefined) policyWritten = applyProjectPolicy(opts.restrict);
1940
+
1941
+ let settings = {};
1942
+ let exists = false;
1943
+ try {
1944
+ settings = JSON.parse(fs.readFileSync(AGY_SETTINGS, 'utf8'));
1945
+ exists = true;
1946
+ } catch (error) {
1947
+ if (error.code !== 'ENOENT') die(`cannot read settings ${AGY_SETTINGS}: ${error.message}`);
1948
+ }
1949
+
1950
+ const current = settings.permissions?.allow || [];
1951
+ const denied = settings.permissions?.deny || [];
1952
+ const missing = EVIDENCE_ALLOWLIST.filter((r) => !current.includes(r));
1953
+ const missingDeny = EVIDENCE_DENYLIST.filter((r) => !denied.includes(r));
1954
+ const poolTargets = poolSettingsTargets();
1955
+ const poolPending = poolTargets.filter((t) => t.missing.length || t.missingDeny.length);
1956
+
1957
+ process.stdout.write(`agy CLI: OK (version ${v.out})\n`);
1958
+ const profiles = loadProjectConfig()?.profiles || {};
1959
+ const policyLine = Object.keys(profiles).length
1960
+ ? Object.entries(profiles)
1961
+ .map(([m, p]) => `${m}=${p}`)
1962
+ .join(' ')
1963
+ : '(none — built-in defaults apply)';
1964
+ process.stdout.write(`Project policy (${configPath()}): ${policyLine}\n`);
1965
+ process.stdout.write(`Global settings file: ${AGY_SETTINGS} ${exists ? '(exists)' : '(will be created)'}\n\n`);
1966
+ if (!missing.length && !missingDeny.length && !poolPending.length) {
1967
+ process.stdout.write('The evidence-gathering allow/deny rules are already installed. Nothing to do.\n');
1968
+ printSetupNotes();
1969
+ return;
1970
+ }
1971
+
1972
+ process.stdout.write(
1973
+ 'Setup is optional hardening: research/review/implement already run unrestricted by default.\n' +
1974
+ 'It only matters if you use `--restricted`, which keeps agy\'s permission enforcement on.\n'
1975
+ );
1976
+ process.stdout.write('Evidence-gathering rules for restricted runs — permissions.allow:\n\n');
1977
+ for (const r of EVIDENCE_ALLOWLIST) {
1978
+ process.stdout.write(` ${r}${current.includes(r) ? ' (already present)' : ''}\n`);
1979
+ }
1980
+ process.stdout.write('\npermissions.deny (AGY evaluates deny before ask before allow):\n\n');
1981
+ for (const r of EVIDENCE_DENYLIST) {
1982
+ process.stdout.write(` ${r}${denied.includes(r) ? ' (already present)' : ''}\n`);
1983
+ }
1984
+ process.stdout.write(`\nMissing rules will be appended to "permissions.allow" and "permissions.deny" in ${AGY_SETTINGS}.\n`);
1985
+ if (poolTargets.length) {
1986
+ process.stdout.write('The same rules are applied to each pool account settings file:\n');
1987
+ for (const t of poolTargets) process.stdout.write(` ${t.name}: ${t.file}${t.missing.length || t.missingDeny.length ? '' : ' (already present)'}\n`);
1988
+ }
1989
+ process.stdout.write(
1990
+ 'Scope: this file is GLOBAL — the rules apply to every agy run on this machine, not just this repository.\n' +
1991
+ 'Broad git/gh grants avoid enumerating every task\'s commands; five deny prefixes block common risky operations.\n' +
1992
+ 'These rules are NOT read-only: other command forms, scripts and APIs can still write or cause external effects.\n'
1993
+ );
1994
+
1995
+ if (!opts.apply) {
1996
+ process.stdout.write(
1997
+ policyWritten
1998
+ ? '\nRULES DRY RUN — the global settings file was not touched (only the project policy above was written).\n' +
1999
+ 'The settings file will be backed up first. To apply the rules: rerun with --apply after the user confirms.\n'
2000
+ : '\nDRY RUN — nothing written. The settings file will be backed up first.\n' +
2001
+ 'To apply: rerun with --apply after the user confirms.\n'
2002
+ );
2003
+ printSetupNotes();
2004
+ return;
2005
+ }
2006
+
2007
+ if (missing.length || missingDeny.length) {
2008
+ if (exists) {
2009
+ const backup = `${AGY_SETTINGS}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
2010
+ fs.copyFileSync(AGY_SETTINGS, backup);
2011
+ process.stdout.write(`\nBacked up settings to ${backup}\n`);
2012
+ } else {
2013
+ fs.mkdirSync(path.dirname(AGY_SETTINGS), { recursive: true });
2014
+ }
2015
+
2016
+ settings.permissions = settings.permissions || {};
2017
+ settings.permissions.allow = [...current, ...missing];
2018
+ settings.permissions.deny = [...denied, ...missingDeny];
2019
+ fs.writeFileSync(AGY_SETTINGS, JSON.stringify(settings, null, 2) + '\n');
2020
+ process.stdout.write(`Wrote ${missing.length} allow-rule(s) and ${missingDeny.length} deny-rule(s) to ${AGY_SETTINGS}. Setup complete.\n`);
2021
+ }
2022
+ for (const target of poolTargets) applyPoolRules(target);
2023
+ printSetupNotes();
2024
+ }
2025
+
2026
+ /** Settings files of every pool account, with the evidence rules each one lacks. */
2027
+ function poolSettingsTargets() {
2028
+ if (!poolExists()) return [];
2029
+ return poolSettingsFiles().map(({ name, file }) => {
2030
+ let settings = {};
2031
+ try { settings = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) {
2032
+ if (error.code !== 'ENOENT') die(`cannot read settings ${file}: ${error.message}`);
2033
+ }
2034
+ const allow = settings.permissions?.allow || [];
2035
+ const deny = settings.permissions?.deny || [];
2036
+ return { name, file, settings, allow, deny,
2037
+ missing: EVIDENCE_ALLOWLIST.filter((r) => !allow.includes(r)),
2038
+ missingDeny: EVIDENCE_DENYLIST.filter((r) => !deny.includes(r)) };
2039
+ });
2040
+ }
2041
+
2042
+ function applyPoolRules(t) {
2043
+ if (!t.missing.length && !t.missingDeny.length) return;
2044
+ if (fs.existsSync(t.file)) fs.copyFileSync(t.file, `${t.file}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`);
2045
+ else fs.mkdirSync(path.dirname(t.file), { recursive: true });
2046
+ t.settings.permissions = t.settings.permissions || {};
2047
+ t.settings.permissions.allow = [...t.allow, ...t.missing];
2048
+ t.settings.permissions.deny = [...t.deny, ...t.missingDeny];
2049
+ fs.writeFileSync(t.file, JSON.stringify(t.settings, null, 2) + '\n');
2050
+ process.stdout.write(`Wrote ${t.missing.length} allow-rule(s) and ${t.missingDeny.length} deny-rule(s) to pool account ${t.name}: ${t.file}\n`);
2051
+ }
2052
+
2053
+ function printSetupNotes() {
2054
+ process.stdout.write(
2055
+ '\nNotes:\n' +
2056
+ '- Scope: these rules live in the GLOBAL settings file above, so they apply to every agy run on\n' +
2057
+ ' this machine, in any repository — not only where you ran setup.\n' +
2058
+ '- Command rules are prefix-matched by AGY, with deny > ask > allow. Existing rules are preserved.\n' +
2059
+ ' Denied operations stay denied even when requested in the prompt; change the rules explicitly if needed.\n' +
2060
+ ' Other command forms, scripts and APIs are not covered. This is an evidence-gathering setup,\n' +
2061
+ ' not a read-only one or a guarantee that every irreversible action is blocked.\n' +
2062
+ '- Security-sensitive users can scope permissions per project instead: agy supports project-scoped\n' +
2063
+ ' permission rules (highest priority) tied to its --project system, but the exact project-settings\n' +
2064
+ ' file path is undocumented/unverified, so this setup only edits the global file above. If a rule\n' +
2065
+ ' seems ignored, check agy interactively for project-level overrides.\n' +
2066
+ '- This allowlist only affects restricted runs. research/review/implement are unrestricted by\n' +
2067
+ ' default (--dangerously-skip-permissions), and unrestricted runs ignore permission rules entirely.\n' +
2068
+ '- Per-repo policy: `setup --restrict <mode,...>` (e.g. review,research) makes those modes default\n' +
2069
+ ' to the restricted profile in THIS repository only; `setup --restrict none` clears it. The policy\n' +
2070
+ ' lives in .agy-crew/config.json (normally git-ignored — a personal preference, not shared with\n' +
2071
+ ' the team), and a --restricted/--unrestricted flag on a call always wins. It is a run policy for\n' +
2072
+ ' consistency, not a security boundary.\n' +
2073
+ '- Some agy tools ignore allow-rules in headless mode entirely and only work unrestricted. If a\n' +
2074
+ ' --restricted run keeps coming back empty even after setup, drop --restricted.\n'
2075
+ );
2076
+ }
2077
+
2078
+ // ---------------------------------------------------------------------------
2079
+ // main
2080
+ // ---------------------------------------------------------------------------
2081
+
2082
+ function main() {
2083
+ const [cmd, ...rest] = process.argv.slice(2);
2084
+ if (!cmd) {
2085
+ die(
2086
+ 'usage: agy-companion.mjs <staffer|research|review|implement|ask|continue|restart|observe|status|wait|result|cancel|setup|install|uninstall|accounts> [flags]\n' +
2087
+ 'flags: --restricted|--unrestricted --model <id> --effort <l|m|h> --timeout <dur> ' +
2088
+ '--prompt <text> --prompt-file <path> --stdin --conversation <id> --continue --json (review) ' +
2089
+ '--account <auto|live|name> (run commands, continue, restart) ' +
2090
+ '--apply --restrict <modes|none> (setup) --hosts <a,b> --all --dry-run --status (install/uninstall)\n' +
2091
+ 'accounts <list|login|import|remove|enable|disable|cooldown clear|quota> manages the pool of private agy logins ' +
2092
+ '(login <name>; import <name> --home <dir>; remove <name> --yes; cooldown clear <name> --family <f>; quota [<name>...] --refresh --json; list --json).\n' +
2093
+ 'task text for staffer/research/review/implement/ask/continue comes from exactly one of ' +
2094
+ '--prompt <text>, --prompt-file <path>, or --stdin.\n' +
2095
+ 'staffer/research/review/implement run unrestricted by default (no setup needed); --restricted is the ' +
2096
+ 'hardening opt-in that uses the evidence-gathering allowlist from `setup`. ask is always tool-free. ' +
2097
+ 'Per-repo policy: `setup --restrict review,research` makes those modes restricted by default here.'
2098
+ );
2099
+ }
2100
+ const opts = parseFlags(rest, { taskCommand: MODES.includes(cmd) || cmd === 'continue' });
2101
+
2102
+ if (MODES.includes(cmd)) return cmdRun(cmd, opts);
2103
+ switch (cmd) {
2104
+ case 'continue':
2105
+ return cmdContinue(opts);
2106
+ case 'restart':
2107
+ return cmdRestart(opts);
2108
+ case 'observe':
2109
+ return renderJobResponse(findJob(opts._[0]), { observeOnly: true });
2110
+ case 'status':
2111
+ return cmdStatus(opts);
2112
+ case 'wait':
2113
+ return cmdWait(opts);
2114
+ case 'result':
2115
+ return cmdResult(opts);
2116
+ case 'cancel':
2117
+ return cmdCancel(opts);
2118
+ case 'setup':
2119
+ return cmdSetup(opts);
2120
+ case 'install':
2121
+ return cmdInstall(opts);
2122
+ case 'uninstall':
2123
+ return cmdUninstall(opts);
2124
+ case 'accounts':
2125
+ return cmdAccounts(opts, { agyCommand, liveSettings: AGY_SETTINGS });
2126
+ case '_worker':
2127
+ return workerMain(rest[0]);
2128
+ default:
2129
+ die(`unknown subcommand: ${cmd}`);
2130
+ }
2131
+ }
2132
+
2133
+ Promise.resolve().then(main).catch((error) => {
2134
+ if (!error.alreadyPrinted) process.stderr.write(`${PRODUCT} error: ${error?.message || String(error)}\n`);
2135
+ process.exitCode = error.exitCode || 1;
2136
+ });