gaslamp 2.2.2 → 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 { createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
40
- import { homedir } from "node:os";
41
- import { dirname, join } from "node:path";
42
- import { fileURLToPath } from "node:url";
43
- import { BACKENDS } from "./backends.mjs";
32
+ import { appendFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
33
+ import { homedir, tmpdir } from "node:os";
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,84 +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.
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
+ }
132
+
136
133
  const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
137
- const useSeatbelt = () => process.platform === "darwin" && existsSync(SANDBOX_EXEC)
138
- && !process.env.CODEX_SANDBOX && process.env.GASLAMP_GEMINI_SEATBELT !== "0";
139
- function agySeatbeltProfile() {
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) {
140
142
  const q = (p) => '"' + p.replace(/[\\"]/g, (c) => "\\" + c) + '"';
141
- 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] : [])];
142
145
  return [
143
146
  "(version 1)",
144
147
  "(allow default)",
145
148
  "(deny file-write*)",
146
- `(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(" ")})`,
147
150
  `(deny file-write* ${["settings.json", "keybindings.json"].map((f) => `(literal ${q(join(state, f))})`).join(" ")} ` +
148
151
  `${["rules", "plugins", "skills", "updater"].map((d) => `(subpath ${q(join(state, d))})`).join(" ")})`,
149
152
  ].join("\n");
150
153
  }
151
154
 
152
- // What a read-only gemini consult may find pre-allowed in Antigravity's
153
- // permission files: reads, read-only git (read-capable like the claude read
154
- // set — git's own --output flags aside), and the end_session off-ramp. Any
155
- // other grant (writes, other commands, unsandboxed, browser actuation, other
156
- // MCP tools) would run unprompted under the request-review preset, so its
157
- // presence refuses the consult rather than letting "read-only" mean less.
158
- const AGY_READ_GRANT = /^(read_file|read_url)\(|^command\(git (diff|log|show|status)\)$|^mcp\(end-conversation\/(\*|end_session)\)$/;
159
- const AGY_LAX_PRESETS = new Set(["always-proceed", "proceed-in-sandbox"]);
160
- // agentMode "auto" approves file edits and commands itself: under it a print
161
- // run wrote a file and ran `touch` while init still reported request-review
162
- // (1.4.3), so the init check cannot see it. accept-edits is refused likewise.
163
- const AGY_LAX_MODES = new Set(["auto", "accept-edits"]);
164
-
165
- // Allow rules and the tool-permission preset from every file the CLI merges:
166
- // its settings.json, the settings shared with the Antigravity app, and the
167
- // per-project files (all of them — which one applies is not knowable here).
168
- function agyPermissionState() {
169
- const root = join(homedir(), ".gemini");
170
- 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;
171
169
  try {
172
- for (const f of readdirSync(join(root, "config", "projects")))
173
- if (f.endsWith(".json")) files.push(join(root, "config", "projects", f));
174
- } catch {}
175
- const grants = [], presets = [], modes = [];
176
- const walk = (node, file) => {
177
- if (Array.isArray(node)) return node.forEach((n) => walk(n, file));
178
- if (!node || typeof node !== "object") return;
179
- for (const [k, v] of Object.entries(node)) {
180
- if (k === "allow" && Array.isArray(v)) for (const r of v) if (typeof r === "string") grants.push({ rule: r, file });
181
- if (k === "toolPermission" && typeof v === "string") presets.push({ preset: v, file });
182
- if (k === "agentMode" && typeof v === "string") modes.push({ mode: v, file });
183
- 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
+ }
184
184
  }
185
- };
186
- for (const f of files) {
187
- 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`);
188
188
  }
189
- return { grants, presets, modes };
189
+ return ended;
190
190
  }
191
191
 
192
- // The consult preamble: the one piece of framing a consulted agent needs.
193
- // Workflow subagents are told their final text IS the return value; a consult
194
- // deserves the same, or it replies conversationally and promises work instead
195
- // of delivering it. Fixed and tiny by design — not a context-injection hook.
196
- // claude carries it as --append-system-prompt; codex (no such flag) gets it
197
- // prepended to the prompt as a <gaslamp_consult> block. --raw drops it.
198
- const PREAMBLE =
199
- "You are being consulted by another coding agent via gaslamp; this is one hop, and you cannot consult back. " +
200
- "Your final message is returned verbatim to the calling agent as the consult's result — make it the deliverable " +
201
- "itself (findings, verdict, diff, answer), not a conversational reply or a promise of future work. " +
202
- "Distinguish what you verified from what you infer.";
203
-
204
192
  function resolveBin(backend) {
205
193
  if (backend === "gemini") {
206
194
  for (const c of [process.env.AGY_BIN, join(homedir(), ".local/bin/agy")].filter(Boolean))
@@ -241,12 +229,11 @@ function parseArgs(argv) {
241
229
  else if (a === "--model" || a === "-m") o.model = val();
242
230
  else if (a === "--sandbox" || a === "-s") o.sandbox = val();
243
231
  else if (a === "--effort" || a === "-e") o.effort = val();
244
- else if (a === "--label" || a === "-l") o.label = val();
245
232
  else if (a === "--cwd" || a === "-C") o.cwd = val();
246
233
  else if (a === "--schema") o.schema = val();
247
- else if (a === "--raw") o.raw = true;
248
234
  else if (a === "--json") o.json = true;
249
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");
250
237
  else if (a.startsWith("-") && a.length > 1) die(2, `unknown flag ${a} (see gaslamp --help)`);
251
238
  else if (o.prompt == null) o.prompt = a;
252
239
  else die(2, "more than one prompt argument — quote the prompt, or pipe it on stdin");
@@ -254,6 +241,18 @@ function parseArgs(argv) {
254
241
  return o;
255
242
  }
256
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
+
257
256
  // --schema takes inline JSON (anything starting with "{") or a file path.
258
257
  // Returns the schema text, checked to parse — a bad schema should die here as
259
258
  // a usage error, not forty seconds into a consult as a backend error.
@@ -270,9 +269,9 @@ function resolveSchema(raw) {
270
269
  // OpenAI's structured-output endpoint only accepts STRICT schemas: every
271
270
  // object node must carry additionalProperties:false and a `required` listing
272
271
  // EVERY key in properties (both verified live — it 400s otherwise). Normalize
273
- // on the way to codex; claude takes the schema as written. The one semantic
274
- // this costs is optional-by-omission — spell optional fields nullable
275
- // ({"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.
276
275
  export function strictify(node) {
277
276
  if (Array.isArray(node)) return node.map(strictify);
278
277
  if (node === null || typeof node !== "object") return node;
@@ -290,25 +289,29 @@ export function strictify(node) {
290
289
  return out;
291
290
  }
292
291
 
293
- // Map --resume to a concrete session id. Accepts a prior JOB id too (the
294
- // handle the caller usually has at hand) and resolves it via the job record.
295
- function resolveResume(backend, resume) {
296
- if (!looksLikeJobId(resume)) return resume;
297
- const m = readMeta(resume);
298
- 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()}`);
299
297
  if (m.backend !== backend)
300
- 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}`);
301
302
  if (!m.sessionId)
302
- die(2, `job ${resume} recorded no session id (it ${m.status === "running" ? "is still starting" : "died before the backend reported one"})`);
303
- 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;
304
305
  }
305
306
 
306
- // Two concurrent consults resuming the SAME session is undefined behavior on
307
- // both backends, so it's refused (exit 5). Lockfiles are atomic-create
308
- // (flag "wx"); a lock whose holder pid is dead is stale — reap and retry.
309
- 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) {
310
313
  mkdirSync(locksDir(), { recursive: true });
311
- const path = join(locksDir(), `${backend}--${sid}`);
314
+ const path = join(locksDir(), `${backend}--${key}`);
312
315
  const payload = JSON.stringify({ pid: process.pid, job: jobId, at: new Date().toISOString() }) + "\n";
313
316
  for (let attempt = 0; attempt < 3; attempt++) {
314
317
  try { writeFileSync(path, payload, { flag: "wx" }); return path; }
@@ -317,11 +320,11 @@ function acquireLock(backend, sid, jobId) {
317
320
  let holder = null;
318
321
  try { holder = JSON.parse(readFileSync(path, "utf8")); } catch { /* malformed = stale */ }
319
322
  if (holder?.pid && alive(holder.pid))
320
- 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.`);
321
324
  try { unlinkSync(path); } catch { /* raced another reaper */ }
322
325
  }
323
326
  }
324
- die(5, `could not acquire the lock for session ${sid} under ${locksDir()}`);
327
+ die(5, `could not acquire ${path}`);
325
328
  }
326
329
 
327
330
  // ---- model tiers --------------------------------------------------------------
@@ -378,147 +381,102 @@ function resolveModel(backend, model, bin, env) {
378
381
  return model;
379
382
  }
380
383
 
381
- // ---- provenance -----------------------------------------------------------------
382
- // Vendors can change the model or the system prompt behind a stable product
383
- // name, so every job records what it can about who actually answered.
384
- const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
385
- const PKG_VERSION = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
386
- let revisionMemo;
387
- function gaslampRevision() {
388
- if (revisionMemo !== undefined) return revisionMemo;
389
- try {
390
- const git = (...a) => execFileSync("git", ["-C", PKG_ROOT, ...a], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
391
- const rev = git("rev-parse", "--short=12", "HEAD");
392
- revisionMemo = git("status", "--porcelain", "--untracked-files=no") ? `${rev}+dirty` : rev;
393
- } catch { revisionMemo = "unknown"; }
394
- return revisionMemo;
395
- }
384
+ // ---- backend argv ---------------------------------------------------------------
396
385
 
397
- function harnessVersion(bin, env) {
398
- try {
399
- const out = execFileSync(bin, ["--version"], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"], env });
400
- return out.split("\n").find((l) => l.trim())?.trim() || "unknown";
401
- } catch { return "unknown"; }
402
- }
403
-
404
- // Grok writes each session's full system prompt next to its transcript —
405
- // the one backend whose vendor prompt is knowable, so it is hashed into the
406
- // record (a change between jobs is visible without storing the text twice).
407
- function grokSystemPrompt(sessionId, cwd) {
408
- const root = join(process.env.GROK_HOME || join(homedir(), ".grok"), "sessions");
409
- const candidates = [join(root, encodeURIComponent(cwd), sessionId, "system_prompt.txt")];
410
- try { for (const d of readdirSync(root)) candidates.push(join(root, d, sessionId, "system_prompt.txt")); } catch {}
411
- for (const path of candidates) {
412
- try {
413
- const text = readFileSync(path, "utf8");
414
- return { known: true, sha256: createHash("sha256").update(text).digest("hex"), chars: text.length, path };
415
- } catch {}
416
- }
417
- return { known: false };
418
- }
419
-
420
- function geminiArgs(o, resumeSid, schemaText, model, sandbox) {
421
- // Print mode's -p takes the prompt as an argument; streaming input reads it
422
- // from stdin as one {"event":"user"} line and ends the session at EOF, which
423
- // keeps the prompt-on-stdin contract (no argv-size or quoting traps).
424
- const args = ["--input-format", "stream-json", "--output-format", "stream-json", "--model", model];
425
- if (o.effort) args.push("--effort", o.effort);
426
- if (resumeSid) args.push("--conversation", resumeSid);
427
- if (schemaText) args.push("--json-schema", schemaText);
428
- // read-only adds no flag: the default request-review preset already denies
429
- // writes, shell and web reads in print mode. Before spawn, the permission
430
- // files are checked for grants that would let anything else through; after,
431
- // the init event's permission_mode must confirm the preset (fail closed).
432
- if (sandbox === "danger-full-access") args.push("--dangerously-skip-permissions");
433
- return args;
434
- }
435
-
436
- function grokArgs(o, resumeSid, schemaText, sandbox) {
437
- // streaming-messages-json is Anthropic's stream-json shape (system init with
438
- // session_id and model first, result last) — the claude parser reads it.
439
- const args = ["--prompt-file", "/dev/stdin", "--output-format", "streaming-messages-json", "--no-auto-update"];
440
- // --rules appends to the system prompt: claude's --append-system-prompt.
441
- if (!o.raw) args.push("--rules", PREAMBLE);
442
- if (schemaText) args.push("--json-schema", schemaText);
443
- // The kernel sandbox (Seatbelt / Landlock) is the read-only guarantee. The
444
- // permission layer is not: under --permission-mode default a headless run
445
- // denied the write tool but ran an arbitrary `touch` through the shell
446
- // (probed on 1.0.62). read-only still writes ~/.grok (sessions) and temp dirs.
447
- if (sandbox === "read-only") args.push("--sandbox", "read-only", "--permission-mode", "default");
448
- else if (sandbox === "workspace-write") args.push("--sandbox", "workspace", "--permission-mode", "acceptEdits");
449
- else if (sandbox === "danger-full-access") args.push("--sandbox", "off", "--always-approve");
450
- if (resumeSid) args.push("--resume", resumeSid);
451
- if (o.model) args.push("-m", o.model);
452
- if (o.effort) args.push("--effort", o.effort);
453
- return args;
454
- }
455
-
456
- function claudeArgs(o, resumeSid, schemaText) {
386
+ function claudeArgs(o, model, resumeSid, schemaText, sandbox, cwd) {
457
387
  // stream-json (not plain json) so events land incrementally: the session id
458
388
  // arrives in the init event within seconds, and the write-through record
459
389
  // survives a killed wrapper. Plain json would buffer everything to the end.
460
- const args = ["-p", "--output-format", "stream-json", "--verbose"];
461
- if (!o.raw) args.push("--append-system-prompt", PREAMBLE);
390
+ const args = ["-p", "--output-format", "stream-json", "--verbose", "--append-system-prompt", PREAMBLE];
462
391
  // Native structured output: the result event carries the parsed object in
463
392
  // `structured_output` alongside the JSON text in `result`.
464
393
  if (schemaText) args.push("--json-schema", schemaText);
465
- if (o.sandbox === "read-only") {
466
- // --permission-mode default overrides the user's defaultMode (even
467
- // bypass); the allowlist restricts to read tools. A real read-only.
468
- args.push("--permission-mode", "default", "--allowedTools", ALLOWED_TOOLS);
469
- } 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")
470
402
  args.push("--dangerously-skip-permissions");
471
- }
472
- // else: no permission flag — the consulted Claude uses the user's own
473
- // ~/.claude config, mirroring Codex deferring to config.toml.
474
-
475
403
  if (resumeSid) args.push("--resume", resumeSid);
476
- if (o.model) args.push("--model", o.model);
404
+ if (model) args.push("--model", model);
477
405
  if (o.effort) args.push("--effort", o.effort);
478
406
  return args; // prompt goes on stdin
479
407
  }
480
408
 
481
- function codexArgs(o, resumeSid, replyPath, schemaPath) {
409
+ function codexArgs(o, model, resumeSid, replyPath, schemaPath, sandbox) {
482
410
  const args = ["exec"];
483
411
  if (resumeSid) args.push("resume", resumeSid);
484
412
  // -o is the authoritative reply text (the JSONL item schema is less stable);
485
413
  // --json is the session-id channel (thread.started arrives up front).
486
414
  args.push("--json", "-o", replyPath, "--skip-git-repo-check");
487
415
  // Native structured output; codex wants a FILE, so the schema is materialized
488
- // in the job dir (works on `exec resume` too — verified on codex 0.142).
416
+ // in the job dir (works on `exec resume` too).
489
417
  if (schemaPath) args.push("--output-schema", schemaPath);
490
418
  // Force the sentinel into the consulted Codex's shell env (see header).
491
419
  args.push("-c", 'shell_environment_policy.set.GASLAMP_NESTED="1"');
492
420
  // `codex exec resume` has no -s flag; -c sandbox_mode works on both forms.
493
- if (o.sandbox) args.push("-c", `sandbox_mode="${o.sandbox}"`);
494
- if (o.model) args.push("-m", o.model);
421
+ if (sandbox) args.push("-c", `sandbox_mode="${sandbox}"`);
422
+ if (model) args.push("-m", model);
495
423
  if (o.effort) args.push("-c", `model_reasoning_effort="${o.effort}"`);
496
424
  args.push("-"); // prompt on stdin, always — no argv-size or quoting traps
497
425
  return args;
498
426
  }
499
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
+
500
462
  export function runConsult(backend, argv) {
501
463
  const o = parseArgs(argv);
502
464
 
503
465
  // ---- preflights -----------------------------------------------------------
504
466
  if (process.env.GASLAMP_NESTED && !ALLOW_RECURSION)
505
467
  die(3, "this agent is itself a gaslamp consult — a consult is one hop. (GASLAMP_ALLOW_RECURSION=1 opts out.)");
506
- // Codex sets this in sandboxed shell envs when network is off. Neither
507
- // 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.
508
470
  if (process.env.CODEX_SANDBOX_NETWORK_DISABLED === "1")
509
- 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).");
510
-
511
- const facts = BACKENDS[backend];
512
- if (o.sandbox && !facts.sandboxes.includes(o.sandbox)) {
513
- die(2, backend === "claude" && o.sandbox === "workspace-write"
514
- ? "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"
515
- : backend === "gemini" && o.sandbox === "workspace-write"
516
- ? "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"
517
- : `bad --sandbox "${o.sandbox}" (${backend}: ${facts.sandboxes.join(" | ")})`);
518
- }
519
- // claude/codex: omitted = the consulted agent's own config. gemini/grok:
520
- // omitted = read-only (BACKENDS.defaultSandbox).
521
- 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);
522
480
 
523
481
  // ---- prompt (argv, or stdin via `-` / piped-with-no-arg) -------------------
524
482
  let prompt = o.prompt;
@@ -527,9 +485,9 @@ export function runConsult(backend, argv) {
527
485
  prompt = readStdin();
528
486
  } else if (!process.stdin.isTTY) {
529
487
  // Prompt on argv AND piped stdin: stdin is evidence — the diff, the log,
530
- // the failing output — appended as a <stdin> block. This is codex exec's
531
- // own convention for exactly this case, applied uniformly to both backends.
532
- const evidence = readStdin();
488
+ // the failing output — appended as a <stdin> block (codex exec's own
489
+ // convention for exactly this case, applied to every backend).
490
+ const evidence = readStdin({ evidence: true });
533
491
  if (evidence.trim())
534
492
  prompt += "\n\n<stdin>\n" + (evidence.endsWith("\n") ? evidence : evidence + "\n") + "</stdin>";
535
493
  }
@@ -537,129 +495,91 @@ export function runConsult(backend, argv) {
537
495
 
538
496
  const schemaText = o.schema ? resolveSchema(o.schema) : null;
539
497
 
540
- // ---- thread → resume + lock -------------------------------------------------
541
- // A thread is resume-if-bound: an existing pointer supplies the session id
542
- // (and the session lock below serializes concurrent consults on it); an
543
- // unbound name runs fresh and binds when the backend reports its session.
544
- // (Two concurrent consults racing to BIND the same new name is a caller bug;
545
- // the loser's session stays reachable through its job record.)
546
- if (o.thread) {
547
- if (o.resume) die(2, "--thread and --resume are exclusive — a thread IS a resume handle");
548
- if (!validThreadName(o.thread)) die(2, `bad thread name "${o.thread}" (letters/digits then letters, digits, . _ -; max 64)`);
549
- }
550
- const thread = o.thread ? readThread(backend, o.thread) : null;
551
- const resumeSid = o.resume ? resolveResume(backend, o.resume) : thread?.sessionId ?? null;
552
- // Everything that runs the harness synchronously (the tier lookup can take
553
- // agy ~2s, the version probe a few ms) happens before the lock is taken and
554
- // the record exists: no signal handler is installed yet, and a kill here
555
- // 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.
556
507
  const bin = resolveBin(backend);
557
508
  const env = backendEnv(backend);
558
- let grants = null, agySettings = null, wrap = null;
559
- let enforcement = sandbox === "read-only" ? {
560
- claude: "permission-mode default + read allowlist", codex: "codex read-only sandbox",
561
- grok: "grok --sandbox read-only (kernel)", gemini: null,
562
- }[backend] : null;
563
- if (backend === "gemini" && sandbox === "read-only") {
564
- const state = agyPermissionState();
565
- grants = state.grants.map((g) => g.rule);
566
- agySettings = [...state.presets.map((p) => `toolPermission ${p.preset}`), ...state.modes.map((m) => `agentMode ${m.mode}`)];
567
- const lax = state.presets.filter((p) => AGY_LAX_PRESETS.has(p.preset));
568
- const modes = state.modes.filter((m) => AGY_LAX_MODES.has(m.mode));
569
- const open = state.grants.filter((g) => !AGY_READ_GRANT.test(g.rule));
570
- if (useSeatbelt()) {
571
- wrap = [SANDBOX_EXEC, "-p", agySeatbeltProfile()];
572
- enforcement = "seatbelt (sandbox-exec)";
573
- } else if (lax.length || modes.length || open.length)
574
- die(1, "Antigravity's settings would let a read-only consult act unprompted — " +
575
- [...lax.map((p) => `toolPermission "${p.preset}" (${p.file})`),
576
- ...modes.map((m) => `agentMode "${m.mode}" (${m.file})`),
577
- ...open.map((g) => `${g.rule} (${g.file})`)].join(", ") +
578
- ". Remove them, or pass --sandbox danger-full-access to consult without the guarantee.");
579
- else enforcement = "agy request-review preset";
580
- }
581
509
  const model = resolveModel(backend, o.model, bin, env);
582
- const version = harnessVersion(bin, env);
583
510
  const id = newJobId(backend);
584
- 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;
585
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;
586
537
 
587
538
  // ---- job record ------------------------------------------------------------
588
539
  const dir = jobDir(id);
589
540
  const meta = {
590
- id, backend, status: "running",
591
- sessionId: resumeSid, resumedFrom: resumeSid, thread: o.thread ?? null,
592
- label: o.label ?? null,
593
- model, sandbox, effort: o.effort ?? null,
594
- cwd: o.cwd || process.cwd(),
595
- pid: process.pid, childPid: null, exitCode: null, signal: null,
596
- startedAt: new Date().toISOString(), endedAt: null,
597
- promptChars: prompt.length, schema: !!schemaText, raw: !!o.raw,
598
- provenance: {
599
- provider: facts.provider, harness: facts.harness, harnessVersion: version,
600
- // `model` is what the backend reported answering with; unknown until it
601
- // does (codex never reports one in its event stream).
602
- model: "unknown", modelRequested: o.model ?? null, modelPassed: model,
603
- permissionMode: null, sandbox, enforcement,
604
- ...(grants ? { grants, agySettings } : {}),
605
- systemPrompt: { known: false },
606
- retrieval: { available: null, used: [] },
607
- gaslamp: { version: PKG_VERSION, revision: gaslampRevision() },
608
- },
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,
609
544
  };
610
545
  createJob(meta, prompt);
611
546
 
612
547
  // ---- spawn -----------------------------------------------------------------
613
548
  const replyPath = join(dir, "reply.md");
614
- // schema.json lands in the job dir for BOTH backends: codex needs the file,
615
- // and the record should show what was actually sent — for codex that is the
616
- // 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.
617
551
  const schemaPath = schemaText ? join(dir, "schema.json") : null;
618
552
  if (schemaPath) {
619
- const sent = backend === "codex"
620
- ? JSON.stringify(strictify(JSON.parse(schemaText)), null, 2)
621
- : schemaText;
553
+ const sent = backend === "codex" ? JSON.stringify(strictify(JSON.parse(schemaText)), null, 2) : schemaText;
622
554
  writeFileSync(schemaPath, sent.endsWith("\n") ? sent : sent + "\n");
623
555
  }
624
- const args = backend === "claude" ? claudeArgs({ ...o, model }, resumeSid, schemaText)
625
- : backend === "codex" ? codexArgs({ ...o, model }, resumeSid, replyPath, schemaPath)
626
- : backend === "gemini" ? geminiArgs(o, resumeSid, schemaText, model, sandbox)
627
- : grokArgs(o, resumeSid, schemaText, sandbox);
628
-
629
- // sandbox-exec applies the profile and execs the harness in place (same
630
- // pid), so the group, the signals and the process name are agy's own.
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);
560
+
561
+ const spool = join(tmpdir(), `gaslamp-${id}.end-conversation.jsonl`);
562
+ env.END_CONVERSATION_SPOOL = spool;
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;
631
566
  const [cmd, argv0] = wrap ? [wrap[0], [...wrap.slice(1), bin, ...args]] : [bin, args];
632
567
  log("spawn", cmd, JSON.stringify(argv0));
633
568
  // detached → the child leads its own process group, so signal forwarding can
634
569
  // kill the whole tree (backend + whatever it spawned), never just the top.
635
- const child = spawn(cmd, argv0, { cwd: meta.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
636
- meta.childPid = child.pid ?? null;
637
- writeMeta(meta);
570
+ const child = spawn(cmd, argv0, { cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
638
571
  const killGroup = (sig) => {
639
572
  try { process.kill(-child.pid, sig); }
640
573
  catch { try { child.kill(sig); } catch {} }
641
574
  };
642
575
 
643
- writeSync(2, `[gaslamp] → ${backend} · job ${id} · watch: tail -f ${join(dir, "events.jsonl")}\n`);
644
-
645
- // Early machine receipt for the fleet runner (gated on GASLAMP_FLEET, which the
646
- // fleet sets on its children): the parent records each child's job id the
647
- // moment it spawns — durable even if the fleet is killed mid-run — without
648
- // scraping the human stderr breadcrumb. The lone --json line is the envelope
649
- // (it carries `backend`/`status`); this receipt carries `type:"started"`.
650
- if (o.json && process.env.GASLAMP_FLEET === "1")
651
- 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`);
652
578
 
653
579
  child.stdin.on("error", () => {}); // EPIPE if the child dies before reading
654
- // prompt.md records the CALLER's content; the preamble is mechanism, applied
655
- // here at the spawn boundary (codex has no --append-system-prompt analog).
656
- const preamble = o.raw ? "" : "<gaslamp_consult>\n" + PREAMBLE + "\n</gaslamp_consult>\n\n";
657
- if (backend === "gemini") {
658
- child.stdin.write(JSON.stringify({ event: "user", message: { content: preamble + prompt } }) + "\n");
659
- } else {
660
- if (backend === "codex") child.stdin.write(preamble);
661
- child.stdin.write(prompt);
662
- }
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);
663
583
  child.stdin.end();
664
584
 
665
585
  const events = createWriteStream(join(dir, "events.jsonl"), { flags: "a" });
@@ -669,40 +589,32 @@ export function runConsult(backend, argv) {
669
589
  child.stdout.setEncoding("utf8");
670
590
  child.stderr.setEncoding("utf8");
671
591
 
672
- // 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.
673
593
  let refusal = null;
674
- const refuse = (why) => {
594
+ const stop = (why) => {
675
595
  if (refusal) return;
676
596
  refusal = why;
677
- writeSync(2, `[gaslamp] ${why}\n`);
678
597
  killGroup("SIGTERM");
679
598
  setTimeout(() => killGroup("SIGKILL"), 2000).unref();
680
599
  };
681
600
  // `agy` without a cached sign-in prints a Google sign-in URL and waits for a
682
- // pasted code forever in print mode (1.4.3; its docs say it exits). Only
683
- // what the harness prints before its init event counts — stderr and any
684
- // non-JSON stdout, accumulated across chunks — never protocol content,
685
- // 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".
686
605
  let initSeen = false, preInit = "";
687
606
  const AUTH_NOTICE = /Authentication required/i;
688
- 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");
689
608
  const watchAuth = (text) => {
690
609
  if (backend !== "gemini" || initSeen) return;
691
610
  preInit = (preInit + text).slice(-4096);
692
611
  if (AUTH_NOTICE.test(preInit)) notSignedIn();
693
612
  };
694
- child.stderr.on("data", (d) => { errlog.write(d); watchAuth(d); });
613
+ let errTail = "";
614
+ child.stderr.on("data", (d) => { errlog.write(d); errTail = (errTail + d).slice(-4096); watchAuth(d); });
695
615
 
696
- const prov = meta.provenance;
697
- const noteTool = (name) => {
698
- if (name && RETRIEVAL_TOOLS[backend].test(name) && !prov.retrieval.used.includes(name))
699
- prov.retrieval.used.push(name);
700
- };
701
-
702
- // ---- event stream: write through; fish out session id + reply --------------
703
- let buf = "", resultText = null, resultErr = false, lastAgentText = null, structuredOut;
704
- let initValidated = false; // gemini read-only: the harness confirmed its preset
705
- 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;
706
618
  const claudeShaped = backend === "claude" || backend === "grok";
707
619
  const handleLine = (raw) => {
708
620
  const line = raw.trim();
@@ -714,93 +626,29 @@ export function runConsult(backend, argv) {
714
626
  : (j.event === "init" || j.event === "result" ? (j.conversation_id ?? j.result?.conversation_id) : null);
715
627
  if (sid) {
716
628
  if (meta.sessionId !== sid) { meta.sessionId = sid; writeMeta(meta); } // early: the recovery handle
717
- // 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
718
630
  // resumes fork a fresh session id, and the pointer must chase it.
719
631
  if (o.thread) writeThread({
720
- backend, name: o.thread, sessionId: sid, lastJobId: id,
632
+ backend, name: o.thread, sessionId: sid, cwd, lastJobId: id,
721
633
  createdAt: thread?.createdAt ?? meta.startedAt, updatedAt: new Date().toISOString(),
722
634
  });
723
635
  }
724
- if (claudeShaped && j.type === "system" && j.subtype === "init") {
725
- if (j.model) prov.model = j.model;
726
- prov.permissionMode = j.permissionMode ?? null;
727
- if (Array.isArray(j.tools)) prov.retrieval.available = j.tools.filter((t) => RETRIEVAL_TOOLS[backend].test(t));
728
- // Grok's backend X search is never listed but is there (see toolKey).
729
- if (backend === "grok" && prov.retrieval.available) prov.retrieval.available.push("x_search");
730
- writeMeta(meta);
731
- } else if (claudeShaped && j.type === "assistant") {
732
- for (const c of j.message?.content ?? []) {
733
- if (c.type === "tool_use" || c.type === "server_tool_use") noteTool(toolKey(c.name));
734
- if (c.type === "tool_use" && c.name === "use_tool") noteTool(c.input?.tool_name);
735
- }
736
- } else if (backend === "grok" && j.type === "user") {
737
- for (const c of j.message?.content ?? []) {
738
- if (c.type !== "tool_result" || typeof c.content !== "string") continue;
739
- let r; try { r = JSON.parse(c.content); } catch { continue; }
740
- if (typeof r?.name === "string") noteTool(r.name);
741
- }
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;
742
644
  } else if (backend === "gemini" && j.event === "init") {
743
645
  initSeen = true;
744
- const init = j.init ?? {};
745
- if (init.model) prov.model = init.model;
746
- prov.permissionMode = init.permission_mode ?? null;
747
- // The browser toolset is ~20 tools; one entry says the same thing.
748
- if (Array.isArray(init.tools)) prov.retrieval.available = [...new Set(init.tools
749
- .filter((t) => RETRIEVAL_TOOLS.gemini.test(t)).map((t) => t.startsWith("browser_") ? "browser_*" : t))];
750
- writeMeta(meta);
751
- // Without Seatbelt, read-only rests on Antigravity's request-review (or
752
- // strict) preset; a laxer one would let the consult act unprompted.
753
- if (sandbox === "read-only" && !wrap && !["request-review", "strict"].includes(prov.permissionMode))
754
- refuse(`agy reports permission mode "${prov.permissionMode}", not request-review — refusing a read-only consult it would not enforce`);
755
- else initValidated = true;
756
- } else if (backend === "gemini" && j.event === "step_update") {
757
- const st = j.step_update ?? {};
758
- if (st.step_type === "tool" && st.state === "DONE") noteTool(st.tool_name);
759
- if (st.step_type === "agent_response" && st.state === "DONE" && st.usage) {
760
- // Per step; the result event's usage is cumulative over the whole
761
- // conversation, resumes included. input_tokens EXCLUDES cache reads
762
- // (a resumed turn: 2376 input + 5118 cache read ≈ the full prompt),
763
- // so both count, as for claude.
764
- usageAcc.input += (st.usage.input_tokens ?? 0) + (st.usage.cache_read_tokens ?? 0);
765
- usageAcc.output += st.usage.output_tokens ?? 0;
766
- meta.usage = { inputTokens: usageAcc.input, outputTokens: usageAcc.output, costUsd: null };
767
- }
768
646
  } else if (backend === "gemini" && j.event === "result") {
769
647
  const r = j.result ?? {};
770
648
  resultText = r.response ?? null;
771
649
  resultErr = r.status !== "SUCCESS";
772
- if (r.error) writeSync(2, `[gaslamp] agy: ${r.error}\n`);
650
+ if (r.error) backendError = String(r.error);
773
651
  if (r.structured_output !== undefined) structuredOut = r.structured_output;
774
- } else if (claudeShaped && j.type === "result") {
775
- resultText = j.result ?? j.content ?? null;
776
- resultErr = !!j.is_error;
777
- if (j.structured_output !== undefined) structuredOut = j.structured_output;
778
- // Grok bills by the model it actually ran (grok-4.7-build behind a
779
- // reported grok-4.7), which only shows up here.
780
- if (j.modelUsage) prov.billedModels = Object.keys(j.modelUsage);
781
- // Fleets are quota-shaped, so quota should be visible: inputTokens
782
- // counts cache reads/writes too (that's what the meter meters).
783
- if (j.usage) meta.usage = {
784
- inputTokens: (j.usage.input_tokens ?? 0) + (j.usage.cache_read_input_tokens ?? 0)
785
- + (j.usage.cache_creation_input_tokens ?? 0),
786
- outputTokens: j.usage.output_tokens ?? 0,
787
- costUsd: j.total_cost_usd ?? null,
788
- };
789
- } else if (backend === "codex" && j.type === "turn.completed" && j.usage) {
790
- // OpenAI semantics differ from Anthropic's: input_tokens already
791
- // INCLUDES the cached portion (cached_input_tokens is a subset, and
792
- // codex's own total_tokens = input + output), so adding it again
793
- // double-counts cache hits.
794
- usageAcc.input += j.usage.input_tokens ?? 0;
795
- usageAcc.output += j.usage.output_tokens ?? 0;
796
- meta.usage = { inputTokens: usageAcc.input, outputTokens: usageAcc.output, costUsd: null };
797
- } else if (backend === "codex" && (j.type === "item.started" || j.type === "item.completed")) {
798
- const item = j.item ?? {};
799
- const t = item.type ?? item.item_type;
800
- if (t === "web_search") noteTool("web_search");
801
- // Tolerant fallback if -o never lands (schema drift across versions).
802
- if (j.type === "item.completed" && t === "agent_message" && typeof item.text === "string")
803
- lastAgentText = item.text;
804
652
  }
805
653
  };
806
654
  child.stdout.on("data", (d) => {
@@ -824,33 +672,31 @@ export function runConsult(backend, argv) {
824
672
  process.on(sig, () => {
825
673
  if (killedBy) return killGroup("SIGKILL");
826
674
  killedBy = sig;
827
- 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`);
828
676
  killGroup(sig);
829
677
  setTimeout(() => killGroup("SIGKILL"), 2000).unref();
830
678
  });
831
679
  }
832
680
 
833
- // One terminal state, published once: the reply is written and validated
834
- // before meta.json leaves "running" and the session lock is released.
835
- 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) => {
836
684
  if (meta.status !== "running") return;
837
685
  if (reply != null) writeFileSync(replyPath, reply);
838
- 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");
839
687
  meta.status = status;
840
- meta.exitCode = code;
841
- meta.signal = killedBy;
688
+ meta.error = error;
842
689
  meta.endedAt = new Date().toISOString();
843
- if (refusal) meta.refusal = refusal;
844
690
  writeMeta(meta);
845
691
  releaseLock();
846
692
  };
847
693
 
848
694
  child.on("error", (e) => {
849
- finalize("failed", null);
695
+ finalize("failed", `failed to launch ${bin}: ${e.message}`);
850
696
  die(1, `failed to launch ${bin}: ${e.message}`);
851
697
  });
852
698
 
853
- child.on("close", (code) => {
699
+ child.on("close", (code, signal) => {
854
700
  if (buf.trim()) handleLine(buf); // a final event without its newline
855
701
  buf = "";
856
702
  events.end();
@@ -859,61 +705,51 @@ export function runConsult(backend, argv) {
859
705
  // its inherited pipes, so `close` could fire) may not be: on any kill
860
706
  // path, finish the group now rather than trusting the unref'd timer.
861
707
  if (killedBy || refusal) killGroup("SIGKILL");
862
-
863
- // The deliverable. codex writes it via -o (agent_message as fallback);
864
- // the others hand it over in the result event — as text, or for a
865
- // --schema consult possibly only as structured_output.
866
- let reply;
867
- if (backend === "codex") reply = existsSync(replyPath) ? readFileSync(replyPath, "utf8") : lastAgentText;
868
- 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)
869
716
  : structuredOut !== undefined ? JSON.stringify(structuredOut) : resultText;
870
717
 
871
- let status = killedBy ? "killed" : (refusal || code !== 0 || resultErr) ? "failed" : "done";
872
- 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) {
873
723
  status = "failed";
874
- refusal = "agy reported no init event, so its permission mode — the read-only guarantee — went unconfirmed";
875
- writeSync(2, `[gaslamp] ${refusal}\n`);
876
- }
877
- // agy and grok report success for a turn that ended on a denied tool call
878
- // with nothing to say (agy: "no output produced", on stderr). An empty
879
- // reply is not a deliverable.
880
- 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.
881
732
  status = "failed";
882
- writeSync(2, `[gaslamp] ${backend} returned an empty reply — marking the consult failed\n`);
733
+ error = "empty reply";
883
734
  }
884
- // A schema consult's contract is a typed reply: parse it (claude hands the
885
- // object over in the result event; codex's reply.md IS the JSON), and an
886
- // 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.
887
737
  let data = null;
888
738
  if (schemaText) {
889
739
  data = structuredOut ?? null;
890
740
  if (data == null && reply != null) { try { data = JSON.parse(reply); } catch {} }
891
- if (data == null && status === "done") {
892
- status = "failed";
893
- writeSync(2, "[gaslamp] --schema was set but the reply is not valid JSON — marking the consult failed\n");
894
- }
741
+ if (data == null && status === "done") { status = "failed"; error = "--schema was set but the reply is not valid JSON"; }
895
742
  }
896
- finalize(status, code, backend === "codex" && existsSync(replyPath) ? null : reply);
897
-
898
- if (o.json) {
899
- process.stdout.write(JSON.stringify({
900
- backend, jobId: id, sessionId: meta.sessionId, status: meta.status,
901
- exitCode: code, content: reply ?? "",
902
- ...(meta.label ? { label: meta.label } : {}),
903
- ...(meta.usage ? { usage: meta.usage } : {}),
904
- ...(schemaText ? { data } : {}),
905
- }) + "\n");
906
- } else {
743
+ finalize(status, error, codexWrote ? null : reply, data);
744
+
745
+ if (o.json) process.stdout.write(JSON.stringify(envelope(meta)) + "\n");
746
+ else {
907
747
  if (reply) process.stdout.write(reply.endsWith("\n") ? reply : reply + "\n");
908
- if (!reply && meta.status !== "done") {
909
- try { // surface the stderr tail so a failure isn't a shrug
910
- const tail = readFileSync(join(dir, "stderr.log"), "utf8").trim().split("\n").slice(-6).join("\n");
911
- if (tail) writeSync(2, tail + "\n");
912
- } catch {}
913
- }
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");
914
750
  process.stdout.write(renderTrailer(meta) + "\n");
915
751
  }
916
752
  // exitCode (not exit()) so stdout drains; nothing else holds the loop open.
917
- process.exitCode = meta.status === "done" ? 0 : 1;
753
+ process.exitCode = status === "done" ? 0 : 1;
918
754
  });
919
755
  }