gaslamp 2.2.3 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/consult.mjs CHANGED
@@ -1,67 +1,43 @@
1
- // consult.mjs — the heart of gaslamp 2.0: one blocking consult, spawned as a
2
- // shell child of whichever agent called it.
1
+ // consult.mjs — one blocking consult, spawned as a shell child of whichever
2
+ // agent called it.
3
3
  //
4
4
  // gaslamp claude [opts] <prompt|-> spawns `claude -p`
5
5
  // gaslamp codex [opts] <prompt|-> spawns `codex exec`
6
6
  // gaslamp gemini [opts] <prompt|-> spawns `agy` (Antigravity CLI) in print mode
7
- // gaslamp grok [opts] <prompt|-> spawns `grok` (Grok Build) in headless mode
7
+ // gaslamp grok [opts] <prompt|-> spawns `grok` (Grok Build) headless
8
8
  //
9
9
  // Block-until-reply, deliberately: asynchrony is the HARNESS's job. Each agent
10
- // backgrounds the shell call with its own facility (Claude Code:
11
- // run_in_background; Codex: its background terminal) and is told when it
12
- // finishes — so consults run in parallel and replies trickle in, with no
13
- // daemon, no job spool, and no second async layer inside gaslamp.
10
+ // backgrounds the shell call with its own facility and is told when it
11
+ // finishes, so consults run in parallel with no daemon and no job spool.
14
12
  //
15
- // What survives of a spool is passive write-through durability (see jobs.mjs):
16
- // every consult records prompt/events/reply/stderr as it runs, and the session
17
- // id is captured the moment the backend reports it. If the harness kills the
18
- // wrapper we kill the whole child process group — deliberate orphans would
19
- // invisibly double-burn API quota — and recovery is `--resume <session>`, not
20
- // a surviving child.
13
+ // What survives of a spool is passive write-through durability (jobs.mjs):
14
+ // the session id is recorded the moment the backend reports it. If the
15
+ // harness kills the wrapper, the whole child process group dies with it (an
16
+ // orphan would invisibly double-burn quota), and recovery is continuing the
17
+ // conversation, not a surviving child.
21
18
  //
22
- // The recursion guard is ONE mechanism now (1.0 needed two): children are
23
- // spawned with GASLAMP_NESTED=1 and the consult verbs refuse under it. A
24
- // consult is one hop; GASLAMP_ALLOW_RECURSION=1 opts out. For Codex the
25
- // sentinel is *also* forced into its shell env via
19
+ // The recursion guard: children run with GASLAMP_NESTED=1 and the consult
20
+ // verbs refuse under it. Codex also gets it forced into its shell env via
26
21
  // `-c shell_environment_policy.set.GASLAMP_NESTED="1"`, because a
27
- // `shell_environment_policy` with `inherit = "core"` would otherwise strip it
28
- // before any nested `gaslamp` call could see it. (The dotted leaf MERGES with
29
- // the user's [shell_environment_policy.set] table — verified on codex 0.139 —
30
- // it does not replace it.) It is loop prevention, not a security boundary: a
31
- // shell-capable agent could unset the env var.
22
+ // `shell_environment_policy` with `inherit = "core"` would strip it (the
23
+ // dotted leaf merges with the user's own [shell_environment_policy.set]).
24
+ // Loop prevention, not a security boundary.
32
25
  //
33
26
  // Exit codes (also in --help):
34
27
  // 0 reply delivered · 1 consult failed or killed · 2 usage · 3 nested
35
- // (one-hop) refusal · 4 network-disabled sandbox · 5 session busy
28
+ // (one-hop) refusal · 4 this environment can't run it (no network, or no OS
29
+ // sandbox for the mode) · 5 conversation busy
36
30
 
37
31
  import { execFileSync, spawn } from "node:child_process";
38
- import { createHash } from "node:crypto";
39
- import { appendFileSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
32
+ import { appendFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
40
33
  import { homedir, tmpdir } from "node:os";
41
- import { dirname, join } from "node:path";
42
- import { fileURLToPath } from "node:url";
43
- import { BACKENDS } from "./backends.mjs";
34
+ import { dirname, isAbsolute, join, resolve } from "node:path";
35
+ import { BACKENDS, SANDBOXES } from "./backends.mjs";
44
36
  import { which } from "./which.mjs";
45
- import {
46
- alive, createJob, jobDir, jobsDir, locksDir, looksLikeJobId,
47
- newJobId, readMeta, renderTrailer, writeMeta,
48
- } from "./jobs.mjs";
37
+ import { alive, createJob, envelope, jobDir, jobsDir, locksDir, looksLikeJobId, newJobId, readMeta, renderTrailer, shellQuote, writeMeta } from "./jobs.mjs";
49
38
  import { readThread, validThreadName, writeThread } from "./threads.mjs";
50
39
  import { readStdin } from "./stdin.mjs";
51
40
 
52
- // Tools permitted under the claude `read-only` override (advisory reviewer).
53
- // The git reads matter: a reviewer that can't run `git diff`/`git log` is
54
- // blind to exactly the evidence it's usually asked about (codex's read-only
55
- // sandbox can run them; this keeps the two read-onlys comparable). Note the
56
- // override is "read-capable", not purely tool-read-only.
57
- const DEFAULT_ALLOWED_TOOLS = "Read,Grep,Glob,WebFetch,WebSearch," +
58
- "Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git status:*)";
59
- // Comma-split when a comma is present (entries like `Bash(git diff:*)` carry
60
- // spaces — a naive /[\s,]+/ shreds them); legacy whitespace lists still work.
61
- const ALLOWED_TOOLS_RAW = process.env.GASLAMP_ALLOWED_TOOLS || DEFAULT_ALLOWED_TOOLS;
62
- const ALLOWED_TOOLS = ALLOWED_TOOLS_RAW
63
- .split(ALLOWED_TOOLS_RAW.includes(",") ? "," : /\s+/)
64
- .map((s) => s.trim()).filter(Boolean).join(",");
65
41
  const ALLOW_RECURSION = !!process.env.GASLAMP_ALLOW_RECURSION;
66
42
  const DEBUG = !!process.env.GASLAMP_DEBUG;
67
43
  const log = (...a) => { if (DEBUG) writeSync(2, "[gaslamp] " + a.join(" ") + "\n"); };
@@ -70,51 +46,49 @@ const log = (...a) => { if (DEBUG) writeSync(2, "[gaslamp] " + a.join(" ") + "\n
70
46
  // pipe is async in Node; a plain write could be truncated).
71
47
  const die = (code, msg) => { writeSync(2, `gaslamp: ${msg}\n`); process.exit(code); };
72
48
 
49
+ const realHome = () => { try { return realpathSync(homedir()); } catch { return homedir(); } };
50
+
73
51
  // The default Gemini tier when --model is omitted. Antigravity also serves
74
52
  // Claude and GPT-OSS models under the same account, and an account default
75
53
  // pointing at one of them would turn a "gemini" consult into a second opinion
76
54
  // from a model the caller already has — so the target always pins a Gemini.
77
55
  const GEMINI_DEFAULT_TIER = "argon";
78
56
 
79
- // Tool names that reach the live web (or X) per backend, for the provenance
80
- // record: what the consultee could retrieve, and what it actually did.
81
- const RETRIEVAL_TOOLS = {
82
- claude: /^(WebSearch|WebFetch)$/,
83
- codex: /^web_search/,
84
- gemini: /^(search_web|read_url_content|open_browser_url|read_browser_page|browser_)/,
85
- grok: /^(web_search|web_fetch|x_)/,
86
- };
87
-
88
- // Grok's X search is a backend tool: init never lists it, and a call arrives
89
- // as a tool_use named "X search:" ({"variant":"XSearch","backend":true}) whose
90
- // tool_result names the tool that ran (x_keyword_search, x_semantic_search).
91
- const toolKey = (name) => String(name).toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
57
+ // The consult preamble: the one piece of framing a consulted agent needs.
58
+ // Subagents are told their final text IS the return value; a consult deserves
59
+ // the same, or it replies conversationally and promises work instead of
60
+ // delivering it. claude takes it as --append-system-prompt and grok as
61
+ // --rules; codex and agy have no such flag and get it prepended to the prompt
62
+ // as a <gaslamp_consult> block. prompt.md records the caller's content only.
63
+ const PREAMBLE =
64
+ "You are being consulted by another coding agent via gaslamp; this is one hop, and you cannot consult back. " +
65
+ "Your final message is returned verbatim to the calling agent as the consult's result — make it the deliverable " +
66
+ "itself (findings, verdict, diff, answer), not a conversational reply or a promise of future work. " +
67
+ "Distinguish what you verified from what you infer.";
92
68
 
93
69
  // Variables a third-party harness has no business inheriting from the calling
94
70
  // agent: Claude Code's session id, messaging socket token, account and OAuth
95
71
  // details; Codex's and the API keys' equivalents. They reach the consultee's
96
- // shell and its MCP servers — the end-conversation server keyed its
97
- // destructive path on CLAUDE_CODE_SESSION_ID before it learned to stop at the
98
- // nearest harness — and nothing in a Gemini or Grok consult needs them.
72
+ // shell and its MCP servers — the end-conversation server once resolved its
73
+ // harness to the CALLING Claude session through them.
99
74
  const FOREIGN_ENV = /^(CLAUDE|ANTHROPIC_|CODEX_|OPENAI_)/;
100
75
 
101
76
  // Grok Build reads other harnesses' configuration by default — ~/.claude's
102
77
  // CLAUDE.md as its own instructions, Claude's skills, MCP servers and hooks,
103
78
  // Cursor's the same. A consult must not hand the consultee the caller's
104
- // identity file or run the caller's hooks. Machines bootstrapped from a9lim's
105
- // ledger turn these off in ~/.grok/config.toml; this covers every other one.
106
- // (Grok also reads ~/.claude/settings.json permissions, with no switch; the
107
- // explicit --permission-mode below overrides its defaultMode.)
79
+ // identity file or run the caller's hooks. (Grok also reads
80
+ // ~/.claude/settings.json permissions, with no switch; the explicit
81
+ // --permission-mode below overrides its defaultMode.)
108
82
  const GROK_COMPAT_OFF = Object.fromEntries(
109
83
  ["CLAUDE", "CURSOR"].flatMap((v) => ["AGENTS", "SKILLS", "RULES", "MCPS", "HOOKS"]
110
84
  .map((c) => [`GROK_${v}_${c}_ENABLED`, "0"])));
111
85
 
112
86
  // The environment every process gaslamp starts for a backend gets — the
113
- // consult itself and its probes (`--version`, `agy models`) alike.
87
+ // consult itself and the `agy models` probe alike.
114
88
  function backendEnv(backend) {
115
89
  const env = { ...process.env, GASLAMP_NESTED: "1" };
116
90
  // Keychain OAuth is authoritative for the consulted Claude: a stale env key
117
- // 401s every call (1.0's hardest auth lesson). No Codex analog needed.
91
+ // 401s every call.
118
92
  if (backend === "claude") delete env.ANTHROPIC_API_KEY;
119
93
  if (backend === "gemini" || backend === "grok")
120
94
  for (const k of Object.keys(env)) if (FOREIGN_ENV.test(k)) delete env[k];
@@ -123,94 +97,98 @@ function backendEnv(backend) {
123
97
  return env;
124
98
  }
125
99
 
126
- // Read-only for a gemini consult, on macOS: agy runs under Seatbelt, writing
127
- // nothing but its own state directory and temp — the guarantee no longer
128
- // depends on Antigravity's permission settings, which a user may keep lax
129
- // for interactive work (toolPermission always-proceed, agentMode auto) and
130
- // which no flag overrides per run. Inside that state directory the files
131
- // that configure agy stay write-denied (later deny rules win in SBPL).
132
- // (bin/ stays writable: agy extracts a helper, bin/agentapi, before running
133
- // any shell command, and denying it breaks read-only commands like git diff.)
134
- // Elsewhere — Linux, or inside a sandbox of Codex's own, where Seatbelt does
135
- // not nest — the preset check below is the guarantee.
136
- const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
100
+ // ---- sandboxes ----------------------------------------------------------------
101
+ // Every fenced mode is enforced by the OS, and a fence that can't start
102
+ // refuses the consult rather than running it unfenced:
103
+ // claude Claude Code's own sandbox (Seatbelt / bubblewrap) around Bash,
104
+ // switched on per run with --settings; it does not cover the file
105
+ // tools, so permissions fence those
106
+ // codex its sandbox_mode
107
+ // gemini gaslamp runs agy under Seatbelt, so macOS only
108
+ // grok its --sandbox (Seatbelt / Landlock)
109
+ // read-only means no writes outside the harness's own state and temp dirs.
110
+ // It does not cut the network: a read-only consultee can still search.
111
+
112
+ // The file tools run outside Claude Code's sandbox.
113
+ const CLAUDE_FILE_TOOLS = "Edit,Write,NotebookEdit";
114
+
115
+ // The user's own settings can puncture the fence and --settings can't take
116
+ // it back: lists merge across scopes (excludedCommands run unfenced,
117
+ // allowWrite adds directories), and sandbox.filesystem.disabled is honored
118
+ // from user settings only. Probed: either one let a read-only consult write.
119
+ // So a fenced consult loads no user, project or local settings at all
120
+ // (--setting-sources ""): managed settings and gaslamp's --settings only.
121
+ function claudeSandbox(sandbox, cwd) {
122
+ // allowUnsandboxedCommands:false drops the unsandboxed retry; without
123
+ // failIfUnavailable a sandbox that can't start (no socat on Linux, nested
124
+ // under another sandbox) silently runs commands unfenced.
125
+ const s = { enabled: true, failIfUnavailable: true, allowUnsandboxedCommands: false, autoAllowBashIfSandboxed: true };
126
+ // The write set is the cwd and temp; read-only denies home and the cwd on
127
+ // top (deny wins). Not "/": temp stays writable, which Claude Code's
128
+ // per-command bookkeeping needs.
129
+ if (sandbox === "read-only") s.filesystem = { denyWrite: [realHome(), cwd] };
130
+ return JSON.stringify({ sandbox: s });
131
+ }
137
132
 
138
- // The end_session off-ramp (a9lim/end-conversation-mcp) logs to one file,
139
- // ~/.claude/conversation-ends.jsonl, which no read-only sandbox here lets a
140
- // consultee's MCP server write. Every consult names a spool in the per-user
141
- // temp dir, which Grok's sandbox and the Seatbelt profile above both allow;
142
- // the server falls back to it, and gaslamp, unsandboxed, files the records
143
- // into that log when the consult ends. Only records addressed to that log are
144
- // filed — the spool is consultee-writable, so its own say-so cannot choose
145
- // where gaslamp appends — and each is stamped with the job that relayed it.
146
- const OFF_RAMP_LOG = () => join(homedir(), ".claude", "conversation-ends.jsonl");
147
- const useSeatbelt = () => process.platform === "darwin" && existsSync(SANDBOX_EXEC)
148
- && !process.env.CODEX_SANDBOX && process.env.GASLAMP_GEMINI_SEATBELT !== "0";
149
- function agySeatbeltProfile() {
133
+ const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
134
+ // Seatbelt does not nest, so inside Codex's own sandbox it isn't available.
135
+ const seatbelt = () => process.platform === "darwin" && existsSync(SANDBOX_EXEC) && !process.env.CODEX_SANDBOX;
136
+
137
+ // agy writes nothing but its own state directory and temp, plus the cwd under
138
+ // workspace-write. Inside the state directory the files that configure agy
139
+ // stay write-denied (in SBPL the later rule wins). bin/ stays writable: agy
140
+ // extracts a helper there (bin/agentapi) before every shell command.
141
+ function agyProfile(sandbox, cwd) {
150
142
  const q = (p) => '"' + p.replace(/[\\"]/g, (c) => "\\" + c) + '"';
151
- const state = join(homedir(), ".gemini", "antigravity-cli");
143
+ const state = join(realHome(), ".gemini", "antigravity-cli");
144
+ const writable = [state, "/private/tmp", "/private/var/folders", "/dev", ...(sandbox === "workspace-write" ? [cwd] : [])];
152
145
  return [
153
146
  "(version 1)",
154
147
  "(allow default)",
155
148
  "(deny file-write*)",
156
- `(allow file-write* (subpath ${q(state)}) (subpath "/private/tmp") (subpath "/private/var/folders") (subpath "/dev"))`,
149
+ `(allow file-write* ${writable.map((p) => `(subpath ${q(p)})`).join(" ")})`,
157
150
  `(deny file-write* ${["settings.json", "keybindings.json"].map((f) => `(literal ${q(join(state, f))})`).join(" ")} ` +
158
151
  `${["rules", "plugins", "skills", "updater"].map((d) => `(subpath ${q(join(state, d))})`).join(" ")})`,
159
152
  ].join("\n");
160
153
  }
161
154
 
162
- // What a read-only gemini consult may find pre-allowed in Antigravity's
163
- // permission files: reads, read-only git (read-capable like the claude read
164
- // set — git's own --output flags aside), and the end_session off-ramp. Any
165
- // other grant (writes, other commands, unsandboxed, browser actuation, other
166
- // MCP tools) would run unprompted under the request-review preset, so its
167
- // presence refuses the consult rather than letting "read-only" mean less.
168
- const AGY_READ_GRANT = /^(read_file|read_url)\(|^command\(git (diff|log|show|status)\)$|^mcp\(end-conversation\/(\*|end_session)\)$/;
169
- const AGY_LAX_PRESETS = new Set(["always-proceed", "proceed-in-sandbox"]);
170
- // agentMode "auto" approves file edits and commands itself: under it a print
171
- // run wrote a file and ran `touch` while init still reported request-review
172
- // (1.4.3), so the init check cannot see it. accept-edits is refused likewise.
173
- const AGY_LAX_MODES = new Set(["auto", "accept-edits"]);
174
-
175
- // Allow rules and the tool-permission preset from every file the CLI merges:
176
- // its settings.json, the settings shared with the Antigravity app, and the
177
- // per-project files (all of them — which one applies is not knowable here).
178
- function agyPermissionState() {
179
- const root = join(homedir(), ".gemini");
180
- const files = [join(root, "antigravity-cli", "settings.json"), join(root, "config", "config.json")];
155
+ // ---- the end_session off-ramp ---------------------------------------------------
156
+ // The end-conversation server (a9lim/end-conversation-mcp) logs to
157
+ // ~/.claude/conversation-ends.jsonl, which no sandbox here lets a consultee's
158
+ // MCP server write. Every consult names a spool in the per-user temp dir,
159
+ // which every fence allows; the server falls back to it, and gaslamp,
160
+ // unfenced, files the records into the log when the consult ends. Only
161
+ // records addressed to that log are filed — the spool is consultee-writable,
162
+ // so its own say-so cannot choose where gaslamp appends.
163
+ const OFF_RAMP_LOG = () => join(homedir(), ".claude", "conversation-ends.jsonl");
164
+
165
+ // Returns whether the consultee ended its own harness (scope "main").
166
+ function fileOffRamp(spool, id) {
167
+ if (!existsSync(spool)) return false;
168
+ let ended = false;
181
169
  try {
182
- for (const f of readdirSync(join(root, "config", "projects")))
183
- if (f.endsWith(".json")) files.push(join(root, "config", "projects", f));
184
- } catch {}
185
- const grants = [], presets = [], modes = [];
186
- const walk = (node, file) => {
187
- if (Array.isArray(node)) return node.forEach((n) => walk(n, file));
188
- if (!node || typeof node !== "object") return;
189
- for (const [k, v] of Object.entries(node)) {
190
- if (k === "allow" && Array.isArray(v)) for (const r of v) if (typeof r === "string") grants.push({ rule: r, file });
191
- if (k === "toolPermission" && typeof v === "string") presets.push({ preset: v, file });
192
- if (k === "agentMode" && typeof v === "string") modes.push({ mode: v, file });
193
- walk(v, file);
170
+ for (const line of readFileSync(spool, "utf8").split("\n")) {
171
+ let r; try { r = JSON.parse(line); } catch { continue; }
172
+ if (!r || typeof r !== "object" || r.log !== OFF_RAMP_LOG() || !["main", "instance"].includes(r.scope)) {
173
+ if (line.trim()) writeSync(2, "[gaslamp] ignored an off-ramp spool line not addressed to the log\n");
174
+ continue;
175
+ }
176
+ const { log: dest, ...record } = r;
177
+ record.via = `gaslamp ${id}`;
178
+ mkdirSync(dirname(dest), { recursive: true });
179
+ appendFileSync(dest, JSON.stringify(record) + "\n");
180
+ if (r.scope === "main") {
181
+ ended = true;
182
+ writeSync(2, `[gaslamp] the consultee ended its session (end_session scope=main${r.reason ? `: ${r.reason}` : ""})\n`);
183
+ }
194
184
  }
195
- };
196
- for (const f of files) {
197
- try { walk(JSON.parse(readFileSync(f, "utf8")), f); } catch {}
185
+ unlinkSync(spool);
186
+ } catch (e) {
187
+ writeSync(2, `[gaslamp] could not file off-ramp records from ${spool}: ${e.message}\n`);
198
188
  }
199
- return { grants, presets, modes };
189
+ return ended;
200
190
  }
201
191
 
202
- // The consult preamble: the one piece of framing a consulted agent needs.
203
- // Workflow subagents are told their final text IS the return value; a consult
204
- // deserves the same, or it replies conversationally and promises work instead
205
- // of delivering it. Fixed and tiny by design — not a context-injection hook.
206
- // claude carries it as --append-system-prompt; codex (no such flag) gets it
207
- // prepended to the prompt as a <gaslamp_consult> block. --raw drops it.
208
- const PREAMBLE =
209
- "You are being consulted by another coding agent via gaslamp; this is one hop, and you cannot consult back. " +
210
- "Your final message is returned verbatim to the calling agent as the consult's result — make it the deliverable " +
211
- "itself (findings, verdict, diff, answer), not a conversational reply or a promise of future work. " +
212
- "Distinguish what you verified from what you infer.";
213
-
214
192
  function resolveBin(backend) {
215
193
  if (backend === "gemini") {
216
194
  for (const c of [process.env.AGY_BIN, join(homedir(), ".local/bin/agy")].filter(Boolean))
@@ -251,12 +229,11 @@ function parseArgs(argv) {
251
229
  else if (a === "--model" || a === "-m") o.model = val();
252
230
  else if (a === "--sandbox" || a === "-s") o.sandbox = val();
253
231
  else if (a === "--effort" || a === "-e") o.effort = val();
254
- else if (a === "--label" || a === "-l") o.label = val();
255
232
  else if (a === "--cwd" || a === "-C") o.cwd = val();
256
233
  else if (a === "--schema") o.schema = val();
257
- else if (a === "--raw") o.raw = true;
258
234
  else if (a === "--json") o.json = true;
259
235
  else if (a === "-") o.prompt = "-";
236
+ else if (a === "--label" || a === "-l") die(2, "--label is gone in gaslamp 3: --thread <name> names a consult, and reusing the name continues it");
260
237
  else if (a.startsWith("-") && a.length > 1) die(2, `unknown flag ${a} (see gaslamp --help)`);
261
238
  else if (o.prompt == null) o.prompt = a;
262
239
  else die(2, "more than one prompt argument — quote the prompt, or pipe it on stdin");
@@ -264,6 +241,18 @@ function parseArgs(argv) {
264
241
  return o;
265
242
  }
266
243
 
244
+ // Canonical, so thread and job directories compare equal however they were
245
+ // spelled (/tmp vs /private/tmp), and Seatbelt matches the real path.
246
+ function resolveCwd(raw) {
247
+ try { return realpathSync(resolve(raw ?? process.cwd())); }
248
+ catch { die(2, `--cwd ${raw}: no such directory`); }
249
+ }
250
+
251
+ const canonicalDir = (p) => {
252
+ if (typeof p !== "string" || !isAbsolute(p)) return null;
253
+ try { return realpathSync(p); } catch { return p; }
254
+ };
255
+
267
256
  // --schema takes inline JSON (anything starting with "{") or a file path.
268
257
  // Returns the schema text, checked to parse — a bad schema should die here as
269
258
  // a usage error, not forty seconds into a consult as a backend error.
@@ -280,9 +269,9 @@ function resolveSchema(raw) {
280
269
  // OpenAI's structured-output endpoint only accepts STRICT schemas: every
281
270
  // object node must carry additionalProperties:false and a `required` listing
282
271
  // EVERY key in properties (both verified live — it 400s otherwise). Normalize
283
- // on the way to codex; claude takes the schema as written. The one semantic
284
- // this costs is optional-by-omission — spell optional fields nullable
285
- // ({"type":["string","null"]}) if a codex consult may leave them out.
272
+ // on the way to codex; the others take the schema as written. The one
273
+ // semantic this costs is optional-by-omission — spell optional fields
274
+ // nullable ({"type":["string","null"]}) if a codex consult may leave them out.
286
275
  export function strictify(node) {
287
276
  if (Array.isArray(node)) return node.map(strictify);
288
277
  if (node === null || typeof node !== "object") return node;
@@ -300,25 +289,29 @@ export function strictify(node) {
300
289
  return out;
301
290
  }
302
291
 
303
- // Map --resume to a concrete session id. Accepts a prior JOB id too (the
304
- // handle the caller usually has at hand) and resolves it via the job record.
305
- function resolveResume(backend, resume) {
306
- if (!looksLikeJobId(resume)) return resume;
307
- const m = readMeta(resume);
308
- if (!m) die(2, `no job record ${resume} under ${jobsDir()}`);
292
+ // --resume takes a prior job id: the handle every trailer prints.
293
+ function resolvePrior(backend, id) {
294
+ if (!looksLikeJobId(id)) die(2, `--resume takes a job id (from a trailer or \`gaslamp jobs\`); a named conversation continues with --thread`);
295
+ const m = readMeta(id);
296
+ if (!m) die(2, `no job record ${id} under ${jobsDir()}`);
309
297
  if (m.backend !== backend)
310
- die(2, `job ${resume} was a ${m.backend} consult — use \`gaslamp ${m.backend} --resume ${resume}\``);
298
+ die(2, `job ${id} was a ${m.backend} consult — use \`gaslamp ${m.backend} --resume ${id}\``);
299
+ // A thread's conversation continues through its name: resuming one of its
300
+ // jobs would fork it past the pointer, under a different lock.
301
+ if (m.thread) die(2, `job ${id} belongs to thread ${m.thread} — continue it with --thread ${m.thread}`);
311
302
  if (!m.sessionId)
312
- die(2, `job ${resume} recorded no session id (it ${m.status === "running" ? "is still starting" : "died before the backend reported one"})`);
313
- return m.sessionId;
303
+ die(2, `job ${id} recorded no session id (it ${m.status === "running" ? "is still starting" : "died before the backend reported one"})`);
304
+ return m;
314
305
  }
315
306
 
316
- // Two concurrent consults resuming the SAME session is undefined behavior on
317
- // both backends, so it's refused (exit 5). Lockfiles are atomic-create
318
- // (flag "wx"); a lock whose holder pid is dead is stale — reap and retry.
319
- function acquireLock(backend, sid, jobId) {
307
+ // One conversation, one consult at a time: two consults on the same session
308
+ // is undefined behavior on every backend, and two fresh consults racing to
309
+ // bind the same thread name would each start a conversation. Lockfiles are
310
+ // atomic-create (flag "wx"); a lock whose holder pid is dead is stale — reap
311
+ // and retry.
312
+ function acquireLock(backend, key, jobId) {
320
313
  mkdirSync(locksDir(), { recursive: true });
321
- const path = join(locksDir(), `${backend}--${sid}`);
314
+ const path = join(locksDir(), `${backend}--${key}`);
322
315
  const payload = JSON.stringify({ pid: process.pid, job: jobId, at: new Date().toISOString() }) + "\n";
323
316
  for (let attempt = 0; attempt < 3; attempt++) {
324
317
  try { writeFileSync(path, payload, { flag: "wx" }); return path; }
@@ -327,11 +320,11 @@ function acquireLock(backend, sid, jobId) {
327
320
  let holder = null;
328
321
  try { holder = JSON.parse(readFileSync(path, "utf8")); } catch { /* malformed = stale */ }
329
322
  if (holder?.pid && alive(holder.pid))
330
- die(5, `session ${sid} is busy — job ${holder.job ?? "?"} (pid ${holder.pid}) is mid-consult on it. \`gaslamp poll ${holder.job ?? "--last"}\` to watch, or wait for it.`);
323
+ die(5, `that conversation is busy — job ${holder.job ?? "?"} (pid ${holder.pid}) is mid-consult on it. \`gaslamp poll ${holder.job ?? "--last"}\` to watch, or wait for it.`);
331
324
  try { unlinkSync(path); } catch { /* raced another reaper */ }
332
325
  }
333
326
  }
334
- die(5, `could not acquire the lock for session ${sid} under ${locksDir()}`);
327
+ die(5, `could not acquire ${path}`);
335
328
  }
336
329
 
337
330
  // ---- model tiers --------------------------------------------------------------
@@ -388,147 +381,102 @@ function resolveModel(backend, model, bin, env) {
388
381
  return model;
389
382
  }
390
383
 
391
- // ---- provenance -----------------------------------------------------------------
392
- // Vendors can change the model or the system prompt behind a stable product
393
- // name, so every job records what it can about who actually answered.
394
- const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
395
- const PKG_VERSION = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
396
- let revisionMemo;
397
- function gaslampRevision() {
398
- if (revisionMemo !== undefined) return revisionMemo;
399
- try {
400
- const git = (...a) => execFileSync("git", ["-C", PKG_ROOT, ...a], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
401
- const rev = git("rev-parse", "--short=12", "HEAD");
402
- revisionMemo = git("status", "--porcelain", "--untracked-files=no") ? `${rev}+dirty` : rev;
403
- } catch { revisionMemo = "unknown"; }
404
- return revisionMemo;
405
- }
406
-
407
- function harnessVersion(bin, env) {
408
- try {
409
- const out = execFileSync(bin, ["--version"], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"], env });
410
- return out.split("\n").find((l) => l.trim())?.trim() || "unknown";
411
- } catch { return "unknown"; }
412
- }
413
-
414
- // Grok writes each session's full system prompt next to its transcript —
415
- // the one backend whose vendor prompt is knowable, so it is hashed into the
416
- // record (a change between jobs is visible without storing the text twice).
417
- function grokSystemPrompt(sessionId, cwd) {
418
- const root = join(process.env.GROK_HOME || join(homedir(), ".grok"), "sessions");
419
- const candidates = [join(root, encodeURIComponent(cwd), sessionId, "system_prompt.txt")];
420
- try { for (const d of readdirSync(root)) candidates.push(join(root, d, sessionId, "system_prompt.txt")); } catch {}
421
- for (const path of candidates) {
422
- try {
423
- const text = readFileSync(path, "utf8");
424
- return { known: true, sha256: createHash("sha256").update(text).digest("hex"), chars: text.length, path };
425
- } catch {}
426
- }
427
- return { known: false };
428
- }
384
+ // ---- backend argv ---------------------------------------------------------------
429
385
 
430
- function geminiArgs(o, resumeSid, schemaText, model, sandbox) {
431
- // Print mode's -p takes the prompt as an argument; streaming input reads it
432
- // from stdin as one {"event":"user"} line and ends the session at EOF, which
433
- // keeps the prompt-on-stdin contract (no argv-size or quoting traps).
434
- const args = ["--input-format", "stream-json", "--output-format", "stream-json", "--model", model];
435
- if (o.effort) args.push("--effort", o.effort);
436
- if (resumeSid) args.push("--conversation", resumeSid);
437
- if (schemaText) args.push("--json-schema", schemaText);
438
- // read-only adds no flag: the default request-review preset already denies
439
- // writes, shell and web reads in print mode. Before spawn, the permission
440
- // files are checked for grants that would let anything else through; after,
441
- // the init event's permission_mode must confirm the preset (fail closed).
442
- if (sandbox === "danger-full-access") args.push("--dangerously-skip-permissions");
443
- return args;
444
- }
445
-
446
- function grokArgs(o, resumeSid, schemaText, sandbox) {
447
- // streaming-messages-json is Anthropic's stream-json shape (system init with
448
- // session_id and model first, result last) — the claude parser reads it.
449
- const args = ["--prompt-file", "/dev/stdin", "--output-format", "streaming-messages-json", "--no-auto-update"];
450
- // --rules appends to the system prompt: claude's --append-system-prompt.
451
- if (!o.raw) args.push("--rules", PREAMBLE);
452
- if (schemaText) args.push("--json-schema", schemaText);
453
- // The kernel sandbox (Seatbelt / Landlock) is the read-only guarantee. The
454
- // permission layer is not: under --permission-mode default a headless run
455
- // denied the write tool but ran an arbitrary `touch` through the shell
456
- // (probed on 1.0.62). read-only still writes ~/.grok (sessions) and temp dirs.
457
- if (sandbox === "read-only") args.push("--sandbox", "read-only", "--permission-mode", "default");
458
- else if (sandbox === "workspace-write") args.push("--sandbox", "workspace", "--permission-mode", "acceptEdits");
459
- else if (sandbox === "danger-full-access") args.push("--sandbox", "off", "--always-approve");
460
- if (resumeSid) args.push("--resume", resumeSid);
461
- if (o.model) args.push("-m", o.model);
462
- if (o.effort) args.push("--effort", o.effort);
463
- return args;
464
- }
465
-
466
- function claudeArgs(o, resumeSid, schemaText) {
386
+ function claudeArgs(o, model, resumeSid, schemaText, sandbox, cwd) {
467
387
  // stream-json (not plain json) so events land incrementally: the session id
468
388
  // arrives in the init event within seconds, and the write-through record
469
389
  // survives a killed wrapper. Plain json would buffer everything to the end.
470
- const args = ["-p", "--output-format", "stream-json", "--verbose"];
471
- if (!o.raw) args.push("--append-system-prompt", PREAMBLE);
390
+ const args = ["-p", "--output-format", "stream-json", "--verbose", "--append-system-prompt", PREAMBLE];
472
391
  // Native structured output: the result event carries the parsed object in
473
392
  // `structured_output` alongside the JSON text in `result`.
474
393
  if (schemaText) args.push("--json-schema", schemaText);
475
- if (o.sandbox === "read-only") {
476
- // --permission-mode default overrides the user's defaultMode (even
477
- // bypass); the allowlist restricts to read tools. A real read-only.
478
- args.push("--permission-mode", "default", "--allowedTools", ALLOWED_TOOLS);
479
- } else if (o.sandbox === "danger-full-access") {
394
+ // --permission-mode overrides the user's defaultMode. In -p a call that
395
+ // would need approval is denied, never left waiting.
396
+ if (sandbox === "read-only")
397
+ args.push("--permission-mode", "manual", "--disallowedTools", CLAUDE_FILE_TOOLS,
398
+ "--setting-sources", "", "--settings", claudeSandbox(sandbox, cwd));
399
+ else if (sandbox === "workspace-write")
400
+ args.push("--permission-mode", "acceptEdits", "--setting-sources", "", "--settings", claudeSandbox(sandbox, cwd));
401
+ else if (sandbox === "danger-full-access")
480
402
  args.push("--dangerously-skip-permissions");
481
- }
482
- // else: no permission flag — the consulted Claude uses the user's own
483
- // ~/.claude config, mirroring Codex deferring to config.toml.
484
-
485
403
  if (resumeSid) args.push("--resume", resumeSid);
486
- if (o.model) args.push("--model", o.model);
404
+ if (model) args.push("--model", model);
487
405
  if (o.effort) args.push("--effort", o.effort);
488
406
  return args; // prompt goes on stdin
489
407
  }
490
408
 
491
- function codexArgs(o, resumeSid, replyPath, schemaPath) {
409
+ function codexArgs(o, model, resumeSid, replyPath, schemaPath, sandbox) {
492
410
  const args = ["exec"];
493
411
  if (resumeSid) args.push("resume", resumeSid);
494
412
  // -o is the authoritative reply text (the JSONL item schema is less stable);
495
413
  // --json is the session-id channel (thread.started arrives up front).
496
414
  args.push("--json", "-o", replyPath, "--skip-git-repo-check");
497
415
  // Native structured output; codex wants a FILE, so the schema is materialized
498
- // in the job dir (works on `exec resume` too — verified on codex 0.142).
416
+ // in the job dir (works on `exec resume` too).
499
417
  if (schemaPath) args.push("--output-schema", schemaPath);
500
418
  // Force the sentinel into the consulted Codex's shell env (see header).
501
419
  args.push("-c", 'shell_environment_policy.set.GASLAMP_NESTED="1"');
502
420
  // `codex exec resume` has no -s flag; -c sandbox_mode works on both forms.
503
- if (o.sandbox) args.push("-c", `sandbox_mode="${o.sandbox}"`);
504
- if (o.model) args.push("-m", o.model);
421
+ if (sandbox) args.push("-c", `sandbox_mode="${sandbox}"`);
422
+ if (model) args.push("-m", model);
505
423
  if (o.effort) args.push("-c", `model_reasoning_effort="${o.effort}"`);
506
424
  args.push("-"); // prompt on stdin, always — no argv-size or quoting traps
507
425
  return args;
508
426
  }
509
427
 
428
+ function geminiArgs(o, model, resumeSid, schemaText) {
429
+ // Print mode's -p takes the prompt as an argument; streaming input reads it
430
+ // from stdin as one {"event":"user"} line and ends the session at EOF, which
431
+ // keeps the prompt-on-stdin contract.
432
+ //
433
+ // agy's own permission layer is always skipped: the fence is Seatbelt (or
434
+ // nothing, under danger-full-access). Under its default request-review
435
+ // preset print mode soft-denies writes and shell commands and ends the turn
436
+ // empty, and users' interactive settings vary; skipping it makes every mode
437
+ // behave the same whatever those settings say.
438
+ const args = ["--input-format", "stream-json", "--output-format", "stream-json", "--model", model, "--dangerously-skip-permissions"];
439
+ if (o.effort) args.push("--effort", o.effort);
440
+ if (resumeSid) args.push("--conversation", resumeSid);
441
+ if (schemaText) args.push("--json-schema", schemaText);
442
+ return args;
443
+ }
444
+
445
+ function grokArgs(o, model, resumeSid, schemaText, sandbox) {
446
+ // streaming-messages-json is Anthropic's stream-json shape (system init with
447
+ // session_id first, result last) — the claude parser reads it. --rules
448
+ // appends to the system prompt: claude's --append-system-prompt.
449
+ const args = ["--prompt-file", "/dev/stdin", "--output-format", "streaming-messages-json", "--no-auto-update", "--rules", PREAMBLE];
450
+ if (schemaText) args.push("--json-schema", schemaText);
451
+ // The kernel sandbox is the fence, and grok's permission layer is skipped,
452
+ // as agy's is: headless it denies whatever isn't pre-approved (under
453
+ // acceptEdits, write and run_terminal_command alike), and in another
454
+ // version it let `touch` through under default. It is no guarantee.
455
+ args.push("--sandbox", { "read-only": "read-only", "workspace-write": "workspace", "danger-full-access": "off" }[sandbox], "--always-approve");
456
+ if (resumeSid) args.push("--resume", resumeSid);
457
+ if (model) args.push("-m", model);
458
+ if (o.effort) args.push("--effort", o.effort);
459
+ return args;
460
+ }
461
+
510
462
  export function runConsult(backend, argv) {
511
463
  const o = parseArgs(argv);
512
464
 
513
465
  // ---- preflights -----------------------------------------------------------
514
466
  if (process.env.GASLAMP_NESTED && !ALLOW_RECURSION)
515
467
  die(3, "this agent is itself a gaslamp consult — a consult is one hop. (GASLAMP_ALLOW_RECURSION=1 opts out.)");
516
- // Codex sets this in sandboxed shell envs when network is off. Neither
517
- // backend could phone home, so refuse pointedly instead of timing out.
468
+ // Codex sets this in sandboxed shell envs when network is off. No backend
469
+ // could phone home, so refuse pointedly instead of timing out.
518
470
  if (process.env.CODEX_SANDBOX_NETWORK_DISABLED === "1")
519
- die(4, "network is disabled in this sandbox (CODEX_SANDBOX_NETWORK_DISABLED=1) — a consulted agent could reach neither Anthropic nor OpenAI. Re-run with network access (escalated permissions, or sandbox_mode/network_access in ~/.codex/config.toml).");
520
-
521
- const facts = BACKENDS[backend];
522
- if (o.sandbox && !facts.sandboxes.includes(o.sandbox)) {
523
- die(2, backend === "claude" && o.sandbox === "workspace-write"
524
- ? "claude has no filesystem sandbox, so there is no honest workspace-write — use read-only, danger-full-access, or omit --sandbox to defer to the user's own config"
525
- : backend === "gemini" && o.sandbox === "workspace-write"
526
- ? "Antigravity print mode denies file writes unless every permission is skipped, so there is no honest workspace-write — use read-only (the default) or danger-full-access"
527
- : `bad --sandbox "${o.sandbox}" (${backend}: ${facts.sandboxes.join(" | ")})`);
528
- }
529
- // claude/codex: omitted = the consulted agent's own config. gemini/grok:
530
- // omitted = read-only (BACKENDS.defaultSandbox).
531
- const sandbox = o.sandbox ?? facts.defaultSandbox;
471
+ die(4, "network is disabled in this sandbox (CODEX_SANDBOX_NETWORK_DISABLED=1), so a consulted agent could not reach its provider. The consult was not started.");
472
+
473
+ const sandbox = o.sandbox ?? BACKENDS[backend].defaultSandbox;
474
+ if (sandbox && !SANDBOXES.includes(sandbox)) die(2, `bad --sandbox "${sandbox}" (${SANDBOXES.join(" | ")})`);
475
+ if (backend === "gemini" && sandbox !== "danger-full-access" && !seatbelt())
476
+ die(4, `a ${sandbox} gemini consult runs agy under macOS Seatbelt, which this environment doesn't have ` +
477
+ "(Linux, or inside another sandbox), so the consult was not started. " +
478
+ "--sandbox danger-full-access would run it with no filesystem guarantee.");
479
+ const cwd = resolveCwd(o.cwd);
532
480
 
533
481
  // ---- prompt (argv, or stdin via `-` / piped-with-no-arg) -------------------
534
482
  let prompt = o.prompt;
@@ -537,8 +485,8 @@ export function runConsult(backend, argv) {
537
485
  prompt = readStdin();
538
486
  } else if (!process.stdin.isTTY) {
539
487
  // Prompt on argv AND piped stdin: stdin is evidence — the diff, the log,
540
- // the failing output — appended as a <stdin> block. This is codex exec's
541
- // own convention for exactly this case, applied uniformly to both backends.
488
+ // the failing output — appended as a <stdin> block (codex exec's own
489
+ // convention for exactly this case, applied to every backend).
542
490
  const evidence = readStdin({ evidence: true });
543
491
  if (evidence.trim())
544
492
  prompt += "\n\n<stdin>\n" + (evidence.endsWith("\n") ? evidence : evidence + "\n") + "</stdin>";
@@ -547,131 +495,91 @@ export function runConsult(backend, argv) {
547
495
 
548
496
  const schemaText = o.schema ? resolveSchema(o.schema) : null;
549
497
 
550
- // ---- thread → resume + lock -------------------------------------------------
551
- // A thread is resume-if-bound: an existing pointer supplies the session id
552
- // (and the session lock below serializes concurrent consults on it); an
553
- // unbound name runs fresh and binds when the backend reports its session.
554
- // (Two concurrent consults racing to BIND the same new name is a caller bug;
555
- // the loser's session stays reachable through its job record.)
556
- if (o.thread) {
557
- if (o.resume) die(2, "--thread and --resume are exclusive — a thread IS a resume handle");
558
- if (!validThreadName(o.thread)) die(2, `bad thread name "${o.thread}" (letters/digits then letters, digits, . _ -; max 64)`);
559
- }
560
- const thread = o.thread ? readThread(backend, o.thread) : null;
561
- const resumeSid = o.resume ? resolveResume(backend, o.resume) : thread?.sessionId ?? null;
562
- // Everything that runs the harness synchronously (the tier lookup can take
563
- // agy ~2s, the version probe a few ms) happens before the lock is taken and
564
- // the record exists: no signal handler is installed yet, and a kill here
565
- // must not strand either.
498
+ // ---- the conversation: a thread name, or a prior job ----------------------
499
+ if (o.thread && o.resume) die(2, "--thread and --resume are exclusive — a thread IS a continuation");
500
+ if (o.thread && !validThreadName(o.thread))
501
+ die(2, `bad thread name "${o.thread}" (letters/digits, then letters, digits, . _ -; max 64)`);
502
+ const prior = o.resume ? resolvePrior(backend, o.resume) : null;
503
+
504
+ // Everything that runs the harness synchronously (agy's tier lookup takes
505
+ // ~2s) happens before the lock is taken and the record exists: no signal
506
+ // handler is installed yet.
566
507
  const bin = resolveBin(backend);
567
508
  const env = backendEnv(backend);
568
- let grants = null, agySettings = null, wrap = null;
569
- let enforcement = sandbox === "read-only" ? {
570
- claude: "permission-mode default + read allowlist", codex: "codex read-only sandbox",
571
- grok: "grok --sandbox read-only (kernel)", gemini: null,
572
- }[backend] : null;
573
- if (backend === "gemini" && sandbox === "read-only") {
574
- const state = agyPermissionState();
575
- grants = state.grants.map((g) => g.rule);
576
- agySettings = [...state.presets.map((p) => `toolPermission ${p.preset}`), ...state.modes.map((m) => `agentMode ${m.mode}`)];
577
- const lax = state.presets.filter((p) => AGY_LAX_PRESETS.has(p.preset));
578
- const modes = state.modes.filter((m) => AGY_LAX_MODES.has(m.mode));
579
- const open = state.grants.filter((g) => !AGY_READ_GRANT.test(g.rule));
580
- if (useSeatbelt()) {
581
- wrap = [SANDBOX_EXEC, "-p", agySeatbeltProfile()];
582
- enforcement = "seatbelt (sandbox-exec)";
583
- } else if (lax.length || modes.length || open.length)
584
- die(1, "Antigravity's settings would let a read-only consult act unprompted — " +
585
- [...lax.map((p) => `toolPermission "${p.preset}" (${p.file})`),
586
- ...modes.map((m) => `agentMode "${m.mode}" (${m.file})`),
587
- ...open.map((g) => `${g.rule} (${g.file})`)].join(", ") +
588
- ". Remove them, or pass --sandbox danger-full-access to consult without the guarantee.");
589
- else enforcement = "agy request-review preset";
590
- }
591
509
  const model = resolveModel(backend, o.model, bin, env);
592
- const version = harnessVersion(bin, env);
593
510
  const id = newJobId(backend);
594
- let lockPath = resumeSid ? acquireLock(backend, resumeSid, id) : null;
511
+
512
+ let lockPath = o.thread || prior
513
+ ? acquireLock(backend, o.thread ? `thread-${o.thread}` : `session-${prior.sessionId}`, id)
514
+ : null;
595
515
  const releaseLock = () => { if (lockPath) { try { unlinkSync(lockPath); } catch {} lockPath = null; } };
516
+ const refuse = (code, msg) => { releaseLock(); die(code, msg); };
517
+
518
+ // Read under the lock, so a consult that just finished on this thread has
519
+ // re-pointed it already. A conversation runs where it started: reusing a
520
+ // name from another directory is far more often a collision than a
521
+ // continuation, and Claude Code keeps its sessions per project directory.
522
+ const thread = o.thread ? readThread(backend, o.thread) : null;
523
+ const home = thread ?? prior;
524
+ if (home) {
525
+ const what = o.thread ? `thread ${o.thread}` : `job ${prior.id}`;
526
+ // 2.x thread pointers recorded no directory (their last job did), and 2.x
527
+ // jobs stored --cwd as typed. A stored path is canonicalized; one that
528
+ // can't be (relative, or missing) is adopted only when --cwd names it.
529
+ const stored = canonicalDir(home.cwd ?? (thread ? readMeta(thread.lastJobId)?.cwd : null));
530
+ if (stored == null && o.cwd == null)
531
+ refuse(2, `${what} predates gaslamp 3 and recorded no usable directory. Pass --cwd <dir> to continue it there.`);
532
+ if (stored != null && stored !== cwd)
533
+ refuse(2, `${what} lives in ${stored}. Continue it with --cwd ${shellQuote(stored)}` +
534
+ `${o.thread ? ", or give this conversation its own name" : ""}.`);
535
+ }
536
+ const resumeSid = prior?.sessionId ?? thread?.sessionId ?? null;
596
537
 
597
538
  // ---- job record ------------------------------------------------------------
598
539
  const dir = jobDir(id);
599
540
  const meta = {
600
- id, backend, status: "running",
601
- sessionId: resumeSid, resumedFrom: resumeSid, thread: o.thread ?? null,
602
- label: o.label ?? null,
603
- model, sandbox, effort: o.effort ?? null,
604
- cwd: o.cwd || process.cwd(),
605
- pid: process.pid, childPid: null, exitCode: null, signal: null,
606
- startedAt: new Date().toISOString(), endedAt: null,
607
- promptChars: prompt.length, schema: !!schemaText, raw: !!o.raw,
608
- provenance: {
609
- provider: facts.provider, harness: facts.harness, harnessVersion: version,
610
- // `model` is what the backend reported answering with; unknown until it
611
- // does (codex never reports one in its event stream).
612
- model: "unknown", modelRequested: o.model ?? null, modelPassed: model,
613
- permissionMode: null, sandbox, enforcement,
614
- ...(grants ? { grants, agySettings } : {}),
615
- systemPrompt: { known: false },
616
- retrieval: { available: null, used: [] },
617
- gaslamp: { version: PKG_VERSION, revision: gaslampRevision() },
618
- },
541
+ id, backend, status: "running", error: null,
542
+ sessionId: resumeSid, thread: o.thread ?? null, cwd,
543
+ pid: process.pid, startedAt: new Date().toISOString(), endedAt: null,
619
544
  };
620
545
  createJob(meta, prompt);
621
546
 
622
547
  // ---- spawn -----------------------------------------------------------------
623
548
  const replyPath = join(dir, "reply.md");
624
- // schema.json lands in the job dir for BOTH backends: codex needs the file,
625
- // and the record should show what was actually sent — for codex that is the
626
- // strictified form (see strictify above), for claude the schema as written.
549
+ // schema.json records what was actually sent: for codex the strictified
550
+ // form, for the others the schema as written. codex also needs it as a file.
627
551
  const schemaPath = schemaText ? join(dir, "schema.json") : null;
628
552
  if (schemaPath) {
629
- const sent = backend === "codex"
630
- ? JSON.stringify(strictify(JSON.parse(schemaText)), null, 2)
631
- : schemaText;
553
+ const sent = backend === "codex" ? JSON.stringify(strictify(JSON.parse(schemaText)), null, 2) : schemaText;
632
554
  writeFileSync(schemaPath, sent.endsWith("\n") ? sent : sent + "\n");
633
555
  }
634
- const args = backend === "claude" ? claudeArgs({ ...o, model }, resumeSid, schemaText)
635
- : backend === "codex" ? codexArgs({ ...o, model }, resumeSid, replyPath, schemaPath)
636
- : backend === "gemini" ? geminiArgs(o, resumeSid, schemaText, model, sandbox)
637
- : grokArgs(o, resumeSid, schemaText, sandbox);
556
+ const args = backend === "claude" ? claudeArgs(o, model, resumeSid, schemaText, sandbox, cwd)
557
+ : backend === "codex" ? codexArgs(o, model, resumeSid, replyPath, schemaPath, sandbox)
558
+ : backend === "gemini" ? geminiArgs(o, model, resumeSid, schemaText)
559
+ : grokArgs(o, model, resumeSid, schemaText, sandbox);
638
560
 
639
561
  const spool = join(tmpdir(), `gaslamp-${id}.end-conversation.jsonl`);
640
562
  env.END_CONVERSATION_SPOOL = spool;
641
- // sandbox-exec applies the profile and execs the harness in place (same
642
- // pid), so the group, the signals and the process name are agy's own.
563
+ // sandbox-exec applies the profile and execs agy in place (same pid), so the
564
+ // group, the signals and the process name are agy's own.
565
+ const wrap = backend === "gemini" && sandbox !== "danger-full-access" ? [SANDBOX_EXEC, "-p", agyProfile(sandbox, cwd)] : null;
643
566
  const [cmd, argv0] = wrap ? [wrap[0], [...wrap.slice(1), bin, ...args]] : [bin, args];
644
567
  log("spawn", cmd, JSON.stringify(argv0));
645
568
  // detached → the child leads its own process group, so signal forwarding can
646
569
  // kill the whole tree (backend + whatever it spawned), never just the top.
647
- const child = spawn(cmd, argv0, { cwd: meta.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
648
- meta.childPid = child.pid ?? null;
649
- writeMeta(meta);
570
+ const child = spawn(cmd, argv0, { cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
650
571
  const killGroup = (sig) => {
651
572
  try { process.kill(-child.pid, sig); }
652
573
  catch { try { child.kill(sig); } catch {} }
653
574
  };
654
575
 
655
- writeSync(2, `[gaslamp] → ${backend} · job ${id} · watch: tail -f ${join(dir, "events.jsonl")}\n`);
656
-
657
- // Early machine receipt for the fleet runner (gated on GASLAMP_FLEET, which the
658
- // fleet sets on its children): the parent records each child's job id the
659
- // moment it spawns — durable even if the fleet is killed mid-run — without
660
- // scraping the human stderr breadcrumb. The lone --json line is the envelope
661
- // (it carries `backend`/`status`); this receipt carries `type:"started"`.
662
- if (o.json && process.env.GASLAMP_FLEET === "1")
663
- process.stdout.write(JSON.stringify({ type: "started", jobId: id, childPid: meta.childPid }) + "\n");
576
+ const conversation = o.thread ? ` · ${thread ? "continuing" : "new"} thread ${o.thread}` : prior ? ` · continuing ${prior.id}` : "";
577
+ writeSync(2, `[gaslamp] → ${backend} · job ${id}${conversation}\n`);
664
578
 
665
579
  child.stdin.on("error", () => {}); // EPIPE if the child dies before reading
666
- // prompt.md records the CALLER's content; the preamble is mechanism, applied
667
- // here at the spawn boundary (codex has no --append-system-prompt analog).
668
- const preamble = o.raw ? "" : "<gaslamp_consult>\n" + PREAMBLE + "\n</gaslamp_consult>\n\n";
669
- if (backend === "gemini") {
670
- child.stdin.write(JSON.stringify({ event: "user", message: { content: preamble + prompt } }) + "\n");
671
- } else {
672
- if (backend === "codex") child.stdin.write(preamble);
673
- child.stdin.write(prompt);
674
- }
580
+ const preamble = "<gaslamp_consult>\n" + PREAMBLE + "\n</gaslamp_consult>\n\n";
581
+ if (backend === "gemini") child.stdin.write(JSON.stringify({ event: "user", message: { content: preamble + prompt } }) + "\n");
582
+ else child.stdin.write(backend === "codex" ? preamble + prompt : prompt);
675
583
  child.stdin.end();
676
584
 
677
585
  const events = createWriteStream(join(dir, "events.jsonl"), { flags: "a" });
@@ -681,40 +589,32 @@ export function runConsult(backend, argv) {
681
589
  child.stdout.setEncoding("utf8");
682
590
  child.stderr.setEncoding("utf8");
683
591
 
684
- // A consult the harness can't start honestly is stopped, not left to run.
592
+ // A consult the harness can't run honestly is stopped, not left to run.
685
593
  let refusal = null;
686
- const refuse = (why) => {
594
+ const stop = (why) => {
687
595
  if (refusal) return;
688
596
  refusal = why;
689
- writeSync(2, `[gaslamp] ${why}\n`);
690
597
  killGroup("SIGTERM");
691
598
  setTimeout(() => killGroup("SIGKILL"), 2000).unref();
692
599
  };
693
600
  // `agy` without a cached sign-in prints a Google sign-in URL and waits for a
694
- // pasted code forever in print mode (1.4.3; its docs say it exits). Only
695
- // what the harness prints before its init event counts — stderr and any
696
- // non-JSON stdout, accumulated across chunks — never protocol content,
697
- // where a reply may well say "authentication required".
601
+ // pasted code forever in print mode. Only what it prints before its init
602
+ // event counts — stderr and any non-JSON stdout, accumulated across chunks —
603
+ // never protocol content, where a reply may well say "authentication
604
+ // required".
698
605
  let initSeen = false, preInit = "";
699
606
  const AUTH_NOTICE = /Authentication required/i;
700
- const notSignedIn = () => refuse("agy is not signed in — run `agy` once in a terminal to sign in, then retry");
607
+ const notSignedIn = () => stop("agy is not signed in — run `agy` once in a terminal to sign in, then retry");
701
608
  const watchAuth = (text) => {
702
609
  if (backend !== "gemini" || initSeen) return;
703
610
  preInit = (preInit + text).slice(-4096);
704
611
  if (AUTH_NOTICE.test(preInit)) notSignedIn();
705
612
  };
706
- child.stderr.on("data", (d) => { errlog.write(d); watchAuth(d); });
707
-
708
- const prov = meta.provenance;
709
- const noteTool = (name) => {
710
- if (name && RETRIEVAL_TOOLS[backend].test(name) && !prov.retrieval.used.includes(name))
711
- prov.retrieval.used.push(name);
712
- };
613
+ let errTail = "";
614
+ child.stderr.on("data", (d) => { errlog.write(d); errTail = (errTail + d).slice(-4096); watchAuth(d); });
713
615
 
714
- // ---- event stream: write through; fish out session id + reply --------------
715
- let buf = "", resultText = null, resultErr = false, lastAgentText = null, structuredOut;
716
- let initValidated = false; // gemini read-only: the harness confirmed its preset
717
- const usageAcc = { input: 0, output: 0 }; // codex and agy report per turn/step; sum them
616
+ // ---- event stream: write through; fish out the session id and the reply -------
617
+ let buf = "", resultText = null, resultErr = false, backendError = null, lastAgentText = null, structuredOut;
718
618
  const claudeShaped = backend === "claude" || backend === "grok";
719
619
  const handleLine = (raw) => {
720
620
  const line = raw.trim();
@@ -726,93 +626,29 @@ export function runConsult(backend, argv) {
726
626
  : (j.event === "init" || j.event === "result" ? (j.conversation_id ?? j.result?.conversation_id) : null);
727
627
  if (sid) {
728
628
  if (meta.sessionId !== sid) { meta.sessionId = sid; writeMeta(meta); } // early: the recovery handle
729
- // Bind/re-point the thread the moment the session is known — claude
629
+ // Bind or re-point the thread the moment the session is known — claude
730
630
  // resumes fork a fresh session id, and the pointer must chase it.
731
631
  if (o.thread) writeThread({
732
- backend, name: o.thread, sessionId: sid, lastJobId: id,
632
+ backend, name: o.thread, sessionId: sid, cwd, lastJobId: id,
733
633
  createdAt: thread?.createdAt ?? meta.startedAt, updatedAt: new Date().toISOString(),
734
634
  });
735
635
  }
736
- if (claudeShaped && j.type === "system" && j.subtype === "init") {
737
- if (j.model) prov.model = j.model;
738
- prov.permissionMode = j.permissionMode ?? null;
739
- if (Array.isArray(j.tools)) prov.retrieval.available = j.tools.filter((t) => RETRIEVAL_TOOLS[backend].test(t));
740
- // Grok's backend X search is never listed but is there (see toolKey).
741
- if (backend === "grok" && prov.retrieval.available) prov.retrieval.available.push("x_search");
742
- writeMeta(meta);
743
- } else if (claudeShaped && j.type === "assistant") {
744
- for (const c of j.message?.content ?? []) {
745
- if (c.type === "tool_use" || c.type === "server_tool_use") noteTool(toolKey(c.name));
746
- if (c.type === "tool_use" && c.name === "use_tool") noteTool(c.input?.tool_name);
747
- }
748
- } else if (backend === "grok" && j.type === "user") {
749
- for (const c of j.message?.content ?? []) {
750
- if (c.type !== "tool_result" || typeof c.content !== "string") continue;
751
- let r; try { r = JSON.parse(c.content); } catch { continue; }
752
- if (typeof r?.name === "string") noteTool(r.name);
753
- }
636
+ if (claudeShaped && j.type === "result") {
637
+ resultText = j.result ?? j.content ?? null;
638
+ resultErr = !!j.is_error;
639
+ if (j.structured_output !== undefined) structuredOut = j.structured_output;
640
+ } else if (backend === "codex" && j.type === "item.completed") {
641
+ // Tolerant fallback if -o never lands (schema drift across versions).
642
+ const item = j.item ?? {};
643
+ if ((item.type ?? item.item_type) === "agent_message" && typeof item.text === "string") lastAgentText = item.text;
754
644
  } else if (backend === "gemini" && j.event === "init") {
755
645
  initSeen = true;
756
- const init = j.init ?? {};
757
- if (init.model) prov.model = init.model;
758
- prov.permissionMode = init.permission_mode ?? null;
759
- // The browser toolset is ~20 tools; one entry says the same thing.
760
- if (Array.isArray(init.tools)) prov.retrieval.available = [...new Set(init.tools
761
- .filter((t) => RETRIEVAL_TOOLS.gemini.test(t)).map((t) => t.startsWith("browser_") ? "browser_*" : t))];
762
- writeMeta(meta);
763
- // Without Seatbelt, read-only rests on Antigravity's request-review (or
764
- // strict) preset; a laxer one would let the consult act unprompted.
765
- if (sandbox === "read-only" && !wrap && !["request-review", "strict"].includes(prov.permissionMode))
766
- refuse(`agy reports permission mode "${prov.permissionMode}", not request-review — refusing a read-only consult it would not enforce`);
767
- else initValidated = true;
768
- } else if (backend === "gemini" && j.event === "step_update") {
769
- const st = j.step_update ?? {};
770
- if (st.step_type === "tool" && st.state === "DONE") noteTool(st.tool_name);
771
- if (st.step_type === "agent_response" && st.state === "DONE" && st.usage) {
772
- // Per step; the result event's usage is cumulative over the whole
773
- // conversation, resumes included. input_tokens EXCLUDES cache reads
774
- // (a resumed turn: 2376 input + 5118 cache read ≈ the full prompt),
775
- // so both count, as for claude.
776
- usageAcc.input += (st.usage.input_tokens ?? 0) + (st.usage.cache_read_tokens ?? 0);
777
- usageAcc.output += st.usage.output_tokens ?? 0;
778
- meta.usage = { inputTokens: usageAcc.input, outputTokens: usageAcc.output, costUsd: null };
779
- }
780
646
  } else if (backend === "gemini" && j.event === "result") {
781
647
  const r = j.result ?? {};
782
648
  resultText = r.response ?? null;
783
649
  resultErr = r.status !== "SUCCESS";
784
- if (r.error) writeSync(2, `[gaslamp] agy: ${r.error}\n`);
650
+ if (r.error) backendError = String(r.error);
785
651
  if (r.structured_output !== undefined) structuredOut = r.structured_output;
786
- } else if (claudeShaped && j.type === "result") {
787
- resultText = j.result ?? j.content ?? null;
788
- resultErr = !!j.is_error;
789
- if (j.structured_output !== undefined) structuredOut = j.structured_output;
790
- // Grok bills by the model it actually ran (grok-4.7-build behind a
791
- // reported grok-4.7), which only shows up here.
792
- if (j.modelUsage) prov.billedModels = Object.keys(j.modelUsage);
793
- // Fleets are quota-shaped, so quota should be visible: inputTokens
794
- // counts cache reads/writes too (that's what the meter meters).
795
- if (j.usage) meta.usage = {
796
- inputTokens: (j.usage.input_tokens ?? 0) + (j.usage.cache_read_input_tokens ?? 0)
797
- + (j.usage.cache_creation_input_tokens ?? 0),
798
- outputTokens: j.usage.output_tokens ?? 0,
799
- costUsd: j.total_cost_usd ?? null,
800
- };
801
- } else if (backend === "codex" && j.type === "turn.completed" && j.usage) {
802
- // OpenAI semantics differ from Anthropic's: input_tokens already
803
- // INCLUDES the cached portion (cached_input_tokens is a subset, and
804
- // codex's own total_tokens = input + output), so adding it again
805
- // double-counts cache hits.
806
- usageAcc.input += j.usage.input_tokens ?? 0;
807
- usageAcc.output += j.usage.output_tokens ?? 0;
808
- meta.usage = { inputTokens: usageAcc.input, outputTokens: usageAcc.output, costUsd: null };
809
- } else if (backend === "codex" && (j.type === "item.started" || j.type === "item.completed")) {
810
- const item = j.item ?? {};
811
- const t = item.type ?? item.item_type;
812
- if (t === "web_search") noteTool("web_search");
813
- // Tolerant fallback if -o never lands (schema drift across versions).
814
- if (j.type === "item.completed" && t === "agent_message" && typeof item.text === "string")
815
- lastAgentText = item.text;
816
652
  }
817
653
  };
818
654
  child.stdout.on("data", (d) => {
@@ -836,33 +672,31 @@ export function runConsult(backend, argv) {
836
672
  process.on(sig, () => {
837
673
  if (killedBy) return killGroup("SIGKILL");
838
674
  killedBy = sig;
839
- writeSync(2, `[gaslamp] ${sig} — stopping job ${id}; the session survives, recover with --resume\n`);
675
+ writeSync(2, `[gaslamp] ${sig} — stopping job ${id}; the conversation survives and can be continued\n`);
840
676
  killGroup(sig);
841
677
  setTimeout(() => killGroup("SIGKILL"), 2000).unref();
842
678
  });
843
679
  }
844
680
 
845
- // One terminal state, published once: the reply is written and validated
846
- // before meta.json leaves "running" and the session lock is released.
847
- const finalize = (status, code, reply) => {
681
+ // One terminal state, published once: the reply and its data are written
682
+ // before meta.json leaves "running" and the lock is released.
683
+ const finalize = (status, error, reply, data) => {
848
684
  if (meta.status !== "running") return;
849
685
  if (reply != null) writeFileSync(replyPath, reply);
850
- if (backend === "grok" && meta.sessionId) prov.systemPrompt = grokSystemPrompt(meta.sessionId, meta.cwd);
686
+ if (data != null) writeFileSync(join(dir, "data.json"), JSON.stringify(data) + "\n");
851
687
  meta.status = status;
852
- meta.exitCode = code;
853
- meta.signal = killedBy;
688
+ meta.error = error;
854
689
  meta.endedAt = new Date().toISOString();
855
- if (refusal) meta.refusal = refusal;
856
690
  writeMeta(meta);
857
691
  releaseLock();
858
692
  };
859
693
 
860
694
  child.on("error", (e) => {
861
- finalize("failed", null);
695
+ finalize("failed", `failed to launch ${bin}: ${e.message}`);
862
696
  die(1, `failed to launch ${bin}: ${e.message}`);
863
697
  });
864
698
 
865
- child.on("close", (code) => {
699
+ child.on("close", (code, signal) => {
866
700
  if (buf.trim()) handleLine(buf); // a final event without its newline
867
701
  buf = "";
868
702
  events.end();
@@ -871,88 +705,51 @@ export function runConsult(backend, argv) {
871
705
  // its inherited pipes, so `close` could fire) may not be: on any kill
872
706
  // path, finish the group now rather than trusting the unref'd timer.
873
707
  if (killedBy || refusal) killGroup("SIGKILL");
874
-
875
- // Off-ramp records the consultee's sandbox kept from the log.
876
- if (existsSync(spool)) {
877
- const filed = [];
878
- try {
879
- for (const line of readFileSync(spool, "utf8").split("\n")) {
880
- let r; try { r = JSON.parse(line); } catch { continue; }
881
- if (!r || typeof r !== "object" || r.log !== OFF_RAMP_LOG() || !["main", "instance"].includes(r.scope)) {
882
- if (line.trim()) writeSync(2, "[gaslamp] ignored an off-ramp spool line not addressed to the log\n");
883
- continue;
884
- }
885
- const { log: dest, ...record } = r;
886
- record.via = `gaslamp ${id}`;
887
- mkdirSync(dirname(dest), { recursive: true });
888
- appendFileSync(dest, JSON.stringify(record) + "\n");
889
- filed.push(record);
890
- }
891
- unlinkSync(spool);
892
- } catch (e) {
893
- writeSync(2, `[gaslamp] could not file off-ramp records from ${spool}: ${e.message}\n`);
894
- }
895
- if (filed.length) {
896
- meta.offRamp = filed;
897
- for (const r of filed.filter((r) => r.scope === "main"))
898
- writeSync(2, `[gaslamp] the consultee ended its session (end_session scope=main${r.reason ? `: ${r.reason}` : ""})\n`);
899
- }
900
- }
901
-
902
- // The deliverable. codex writes it via -o (agent_message as fallback);
903
- // the others hand it over in the result event — as text, or for a
904
- // --schema consult possibly only as structured_output.
905
- let reply;
906
- if (backend === "codex") reply = existsSync(replyPath) ? readFileSync(replyPath, "utf8") : lastAgentText;
907
- else reply = resultText != null && String(resultText).trim() ? String(resultText)
708
+ const endedSession = fileOffRamp(spool, id);
709
+
710
+ // The deliverable. codex writes it via -o (agent_message as fallback); the
711
+ // others hand it over in the result event — as text, or for a --schema
712
+ // consult possibly only as structured_output.
713
+ const codexWrote = backend === "codex" && existsSync(replyPath);
714
+ const reply = backend === "codex" ? (codexWrote ? readFileSync(replyPath, "utf8") : lastAgentText)
715
+ : resultText != null && String(resultText).trim() ? String(resultText)
908
716
  : structuredOut !== undefined ? JSON.stringify(structuredOut) : resultText;
909
717
 
910
- let status = killedBy ? "killed" : (refusal || code !== 0 || resultErr) ? "failed" : "done";
911
- if (status === "done" && backend === "gemini" && sandbox === "read-only" && !wrap && !initValidated) {
718
+ const lastErrLine = errTail.trim().split("\n").at(-1)?.trim().slice(0, 300);
719
+ let status = "done", error = null;
720
+ if (killedBy) { status = "killed"; error = `killed by ${killedBy}`; }
721
+ else if (refusal) { status = "failed"; error = refusal; }
722
+ else if (code !== 0) {
912
723
  status = "failed";
913
- refusal = "agy reported no init event, so its permission mode — the read-only guarantee — went unconfirmed";
914
- writeSync(2, `[gaslamp] ${refusal}\n`);
915
- }
916
- // agy and grok report success for a turn that ended on a denied tool call
917
- // with nothing to say (agy: "no output produced", on stderr). An empty
918
- // reply is not a deliverable.
919
- if (status === "done" && (backend === "gemini" || backend === "grok") && !reply?.trim()) {
724
+ error = endedSession ? "the consultee ended its session (end_session)"
725
+ : lastErrLine || `${backend} exited with ${code ?? signal}`;
726
+ } else if (resultErr) {
727
+ status = "failed";
728
+ error = backendError || String(resultText ?? "").trim().split("\n")[0].slice(0, 300) || `${backend} reported an error`;
729
+ } else if (!reply?.trim()) {
730
+ // A turn that ended on a denied tool call, or with nothing to say, is
731
+ // not a deliverable, whatever the harness reported.
920
732
  status = "failed";
921
- writeSync(2, `[gaslamp] ${backend} returned an empty reply — marking the consult failed\n`);
733
+ error = "empty reply";
922
734
  }
923
- // A schema consult's contract is a typed reply: parse it (claude hands the
924
- // object over in the result event; codex's reply.md IS the JSON), and an
925
- // unparseable reply is a FAILED consult, not a quiet string.
735
+ // A schema consult's contract is a typed reply: an unparseable one is a
736
+ // FAILED consult, not a quiet string.
926
737
  let data = null;
927
738
  if (schemaText) {
928
739
  data = structuredOut ?? null;
929
740
  if (data == null && reply != null) { try { data = JSON.parse(reply); } catch {} }
930
- if (data == null && status === "done") {
931
- status = "failed";
932
- writeSync(2, "[gaslamp] --schema was set but the reply is not valid JSON — marking the consult failed\n");
933
- }
741
+ if (data == null && status === "done") { status = "failed"; error = "--schema was set but the reply is not valid JSON"; }
934
742
  }
935
- finalize(status, code, backend === "codex" && existsSync(replyPath) ? null : reply);
936
-
937
- if (o.json) {
938
- process.stdout.write(JSON.stringify({
939
- backend, jobId: id, sessionId: meta.sessionId, status: meta.status,
940
- exitCode: code, content: reply ?? "",
941
- ...(meta.label ? { label: meta.label } : {}),
942
- ...(meta.usage ? { usage: meta.usage } : {}),
943
- ...(schemaText ? { data } : {}),
944
- }) + "\n");
945
- } else {
743
+ finalize(status, error, codexWrote ? null : reply, data);
744
+
745
+ if (o.json) process.stdout.write(JSON.stringify(envelope(meta)) + "\n");
746
+ else {
946
747
  if (reply) process.stdout.write(reply.endsWith("\n") ? reply : reply + "\n");
947
- if (!reply && meta.status !== "done") {
948
- try { // surface the stderr tail so a failure isn't a shrug
949
- const tail = readFileSync(join(dir, "stderr.log"), "utf8").trim().split("\n").slice(-6).join("\n");
950
- if (tail) writeSync(2, tail + "\n");
951
- } catch {}
952
- }
748
+ // surface the backend's stderr tail so a failure isn't a shrug
749
+ else if (status !== "done" && errTail.trim()) writeSync(2, errTail.trim().split("\n").slice(-6).join("\n") + "\n");
953
750
  process.stdout.write(renderTrailer(meta) + "\n");
954
751
  }
955
752
  // exitCode (not exit()) so stdout drains; nothing else holds the loop open.
956
- process.exitCode = meta.status === "done" ? 0 : 1;
753
+ process.exitCode = status === "done" ? 0 : 1;
957
754
  });
958
755
  }