gaslamp 1.0.1 → 2.1.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.
@@ -0,0 +1,467 @@
1
+ // consult.mjs — the heart of gaslamp 2.0: one blocking consult, spawned as a
2
+ // shell child of whichever agent called it.
3
+ //
4
+ // gaslamp claude [opts] <prompt|-> spawns `claude -p`
5
+ // gaslamp codex [opts] <prompt|-> spawns `codex exec`
6
+ //
7
+ // Block-until-reply, deliberately: asynchrony is the HARNESS's job. Each agent
8
+ // backgrounds the shell call with its own facility (Claude Code:
9
+ // run_in_background; Codex: its background terminal) and is told when it
10
+ // finishes — so consults run in parallel and replies trickle in, with no
11
+ // daemon, no job spool, and no second async layer inside gaslamp.
12
+ //
13
+ // What survives of a spool is passive write-through durability (see jobs.mjs):
14
+ // every consult records prompt/events/reply/stderr as it runs, and the session
15
+ // id is captured the moment the backend reports it. If the harness kills the
16
+ // wrapper we kill the whole child process group — deliberate orphans would
17
+ // invisibly double-burn API quota — and recovery is `--resume <session>`, not
18
+ // a surviving child.
19
+ //
20
+ // The recursion guard is ONE mechanism now (1.0 needed two): children are
21
+ // spawned with GASLAMP_NESTED=1 and the consult verbs refuse under it. A
22
+ // consult is one hop; GASLAMP_ALLOW_RECURSION=1 opts out. For Codex the
23
+ // sentinel is *also* forced into its shell env via
24
+ // `-c shell_environment_policy.set.GASLAMP_NESTED="1"`, because a
25
+ // `shell_environment_policy` with `inherit = "core"` would otherwise strip it
26
+ // before any nested `gaslamp` call could see it. (The dotted leaf MERGES with
27
+ // the user's [shell_environment_policy.set] table — verified on codex 0.139 —
28
+ // it does not replace it.) It is loop prevention, not a security boundary: a
29
+ // shell-capable agent could unset the env var.
30
+ //
31
+ // Exit codes (also in --help):
32
+ // 0 reply delivered · 1 consult failed or killed · 2 usage · 3 nested
33
+ // (one-hop) refusal · 4 network-disabled sandbox · 5 session busy
34
+
35
+ import { spawn } from "node:child_process";
36
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
37
+ import { homedir } from "node:os";
38
+ import { join } from "node:path";
39
+ import { which } from "./which.mjs";
40
+ import {
41
+ alive, createJob, jobDir, jobsDir, locksDir, looksLikeJobId,
42
+ newJobId, readMeta, renderTrailer, writeMeta,
43
+ } from "./jobs.mjs";
44
+ import { readThread, validThreadName, writeThread } from "./threads.mjs";
45
+
46
+ // Tools permitted under the claude `read-only` override (advisory reviewer).
47
+ // The git reads matter: a reviewer that can't run `git diff`/`git log` is
48
+ // blind to exactly the evidence it's usually asked about (codex's read-only
49
+ // sandbox can run them; this keeps the two read-onlys comparable). Note the
50
+ // override is "read-capable", not purely tool-read-only.
51
+ const DEFAULT_ALLOWED_TOOLS = "Read,Grep,Glob,WebFetch,WebSearch," +
52
+ "Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git status:*)";
53
+ // Comma-split when a comma is present (entries like `Bash(git diff:*)` carry
54
+ // spaces — a naive /[\s,]+/ shreds them); legacy whitespace lists still work.
55
+ const ALLOWED_TOOLS_RAW = process.env.GASLAMP_ALLOWED_TOOLS || DEFAULT_ALLOWED_TOOLS;
56
+ const ALLOWED_TOOLS = ALLOWED_TOOLS_RAW
57
+ .split(ALLOWED_TOOLS_RAW.includes(",") ? "," : /\s+/)
58
+ .map((s) => s.trim()).filter(Boolean).join(",");
59
+ const ALLOW_RECURSION = !!process.env.GASLAMP_ALLOW_RECURSION;
60
+ const DEBUG = !!process.env.GASLAMP_DEBUG;
61
+ const log = (...a) => { if (DEBUG) writeSync(2, "[gaslamp] " + a.join(" ") + "\n"); };
62
+
63
+ // writeSync(2, …) so the message lands even through process.exit (stderr to a
64
+ // pipe is async in Node; a plain write could be truncated).
65
+ const die = (code, msg) => { writeSync(2, `gaslamp: ${msg}\n`); process.exit(code); };
66
+
67
+ const SANDBOXES = {
68
+ claude: ["read-only", "danger-full-access"],
69
+ codex: ["read-only", "workspace-write", "danger-full-access"],
70
+ };
71
+
72
+ // The consult preamble: the one piece of framing a consulted agent needs.
73
+ // Workflow subagents are told their final text IS the return value; a consult
74
+ // deserves the same, or it replies conversationally and promises work instead
75
+ // of delivering it. Fixed and tiny by design — not a context-injection hook.
76
+ // claude carries it as --append-system-prompt; codex (no such flag) gets it
77
+ // prepended to the prompt as a <gaslamp_consult> block. --raw drops it.
78
+ const PREAMBLE =
79
+ "You are being consulted by another coding agent via gaslamp; this is one hop, and you cannot consult back. " +
80
+ "Your final message is returned verbatim to the calling agent as the consult's result — make it the deliverable " +
81
+ "itself (findings, verdict, diff, answer), not a conversational reply or a promise of future work. " +
82
+ "Distinguish what you verified from what you infer.";
83
+
84
+ function resolveBin(backend) {
85
+ if (backend === "claude") {
86
+ for (const c of [process.env.CLAUDE_BIN, join(homedir(), ".local/bin/claude")].filter(Boolean))
87
+ if (existsSync(c)) return c;
88
+ return which("claude") || "claude";
89
+ }
90
+ if (process.env.CODEX_BIN && existsSync(process.env.CODEX_BIN)) return process.env.CODEX_BIN;
91
+ return which("codex") || "codex";
92
+ }
93
+
94
+ function parseArgs(argv) {
95
+ const o = { json: false, prompt: null };
96
+ for (let i = 0; i < argv.length; i++) {
97
+ const a = argv[i];
98
+ const val = () => { if (i + 1 >= argv.length) die(2, `${a} needs a value`); return argv[++i]; };
99
+ if (a === "--resume" || a === "-r") o.resume = val();
100
+ else if (a === "--thread" || a === "-t") o.thread = val();
101
+ else if (a === "--model" || a === "-m") o.model = val();
102
+ else if (a === "--sandbox" || a === "-s") o.sandbox = val();
103
+ else if (a === "--effort" || a === "-e") o.effort = val();
104
+ else if (a === "--label" || a === "-l") o.label = val();
105
+ else if (a === "--cwd" || a === "-C") o.cwd = val();
106
+ else if (a === "--schema") o.schema = val();
107
+ else if (a === "--raw") o.raw = true;
108
+ else if (a === "--json") o.json = true;
109
+ else if (a === "-") o.prompt = "-";
110
+ else if (a.startsWith("-") && a.length > 1) die(2, `unknown flag ${a} (see gaslamp --help)`);
111
+ else if (o.prompt == null) o.prompt = a;
112
+ else die(2, "more than one prompt argument — quote the prompt, or pipe it on stdin");
113
+ }
114
+ return o;
115
+ }
116
+
117
+ // --schema takes inline JSON (anything starting with "{") or a file path.
118
+ // Returns the schema text, checked to parse — a bad schema should die here as
119
+ // a usage error, not forty seconds into a consult as a backend error.
120
+ function resolveSchema(raw) {
121
+ let text = raw.trim();
122
+ if (!text.startsWith("{")) {
123
+ try { text = readFileSync(raw, "utf8"); }
124
+ catch (e) { die(2, `--schema: cannot read ${raw}: ${e.message}`); }
125
+ }
126
+ try { JSON.parse(text); } catch (e) { die(2, `--schema is not valid JSON: ${e.message}`); }
127
+ return text;
128
+ }
129
+
130
+ // OpenAI's structured-output endpoint only accepts STRICT schemas: every
131
+ // object node must carry additionalProperties:false and a `required` listing
132
+ // EVERY key in properties (both verified live — it 400s otherwise). Normalize
133
+ // on the way to codex; claude takes the schema as written. The one semantic
134
+ // this costs is optional-by-omission — spell optional fields nullable
135
+ // ({"type":["string","null"]}) if a codex consult may leave them out.
136
+ export function strictify(node) {
137
+ if (Array.isArray(node)) return node.map(strictify);
138
+ if (node === null || typeof node !== "object") return node;
139
+ const out = {};
140
+ for (const [k, v] of Object.entries(node)) out[k] = k === "properties" && v && typeof v === "object"
141
+ ? Object.fromEntries(Object.entries(v).map(([p, s]) => [p, strictify(s)]))
142
+ : strictify(v);
143
+ const isObject = out.type === "object"
144
+ || (Array.isArray(out.type) && out.type.includes("object"))
145
+ || (out.type === undefined && out.properties != null);
146
+ if (isObject) {
147
+ out.additionalProperties ??= false;
148
+ if (out.properties) out.required = Object.keys(out.properties);
149
+ }
150
+ return out;
151
+ }
152
+
153
+ // Map --resume to a concrete session id. Accepts a prior JOB id too (the
154
+ // handle the caller usually has at hand) and resolves it via the job record.
155
+ function resolveResume(backend, resume) {
156
+ if (!looksLikeJobId(resume)) return resume;
157
+ const m = readMeta(resume);
158
+ if (!m) die(2, `no job record ${resume} under ${jobsDir()}`);
159
+ if (m.backend !== backend)
160
+ die(2, `job ${resume} was a ${m.backend} consult — use \`gaslamp ${m.backend} --resume ${resume}\``);
161
+ if (!m.sessionId)
162
+ die(2, `job ${resume} recorded no session id (it ${m.status === "running" ? "is still starting" : "died before the backend reported one"})`);
163
+ return m.sessionId;
164
+ }
165
+
166
+ // Two concurrent consults resuming the SAME session is undefined behavior on
167
+ // both backends, so it's refused (exit 5). Lockfiles are atomic-create
168
+ // (flag "wx"); a lock whose holder pid is dead is stale — reap and retry.
169
+ function acquireLock(backend, sid, jobId) {
170
+ mkdirSync(locksDir(), { recursive: true });
171
+ const path = join(locksDir(), `${backend}--${sid}`);
172
+ const payload = JSON.stringify({ pid: process.pid, job: jobId, at: new Date().toISOString() }) + "\n";
173
+ for (let attempt = 0; attempt < 3; attempt++) {
174
+ try { writeFileSync(path, payload, { flag: "wx" }); return path; }
175
+ catch (e) {
176
+ if (e.code !== "EEXIST") throw e;
177
+ let holder = null;
178
+ try { holder = JSON.parse(readFileSync(path, "utf8")); } catch { /* malformed = stale */ }
179
+ if (holder?.pid && alive(holder.pid))
180
+ 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.`);
181
+ try { unlinkSync(path); } catch { /* raced another reaper */ }
182
+ }
183
+ }
184
+ die(5, `could not acquire the lock for session ${sid} under ${locksDir()}`);
185
+ }
186
+
187
+ function claudeArgs(o, resumeSid, schemaText) {
188
+ // stream-json (not plain json) so events land incrementally: the session id
189
+ // arrives in the init event within seconds, and the write-through record
190
+ // survives a killed wrapper. Plain json would buffer everything to the end.
191
+ const args = ["-p", "--output-format", "stream-json", "--verbose"];
192
+ if (!o.raw) args.push("--append-system-prompt", PREAMBLE);
193
+ // Native structured output: the result event carries the parsed object in
194
+ // `structured_output` alongside the JSON text in `result`.
195
+ if (schemaText) args.push("--json-schema", schemaText);
196
+ if (o.sandbox === "read-only") {
197
+ // --permission-mode default overrides the user's defaultMode (even
198
+ // bypass); the allowlist restricts to read tools. A real read-only.
199
+ args.push("--permission-mode", "default", "--allowedTools", ALLOWED_TOOLS);
200
+ } else if (o.sandbox === "danger-full-access") {
201
+ args.push("--dangerously-skip-permissions");
202
+ }
203
+ // else: no permission flag — the consulted Claude uses the user's own
204
+ // ~/.claude config, mirroring Codex deferring to config.toml.
205
+
206
+ if (resumeSid) args.push("--resume", resumeSid);
207
+ if (o.model) args.push("--model", o.model);
208
+ if (o.effort) args.push("--effort", o.effort);
209
+ return args; // prompt goes on stdin
210
+ }
211
+
212
+ function codexArgs(o, resumeSid, replyPath, schemaPath) {
213
+ const args = ["exec"];
214
+ if (resumeSid) args.push("resume", resumeSid);
215
+ // -o is the authoritative reply text (the JSONL item schema is less stable);
216
+ // --json is the session-id channel (thread.started arrives up front).
217
+ args.push("--json", "-o", replyPath, "--skip-git-repo-check");
218
+ // Native structured output; codex wants a FILE, so the schema is materialized
219
+ // in the job dir (works on `exec resume` too — verified on codex 0.142).
220
+ if (schemaPath) args.push("--output-schema", schemaPath);
221
+ // Force the sentinel into the consulted Codex's shell env (see header).
222
+ args.push("-c", 'shell_environment_policy.set.GASLAMP_NESTED="1"');
223
+ // `codex exec resume` has no -s flag; -c sandbox_mode works on both forms.
224
+ if (o.sandbox) args.push("-c", `sandbox_mode="${o.sandbox}"`);
225
+ if (o.model) args.push("-m", o.model);
226
+ if (o.effort) args.push("-c", `model_reasoning_effort="${o.effort}"`);
227
+ args.push("-"); // prompt on stdin, always — no argv-size or quoting traps
228
+ return args;
229
+ }
230
+
231
+ export function runConsult(backend, argv) {
232
+ const o = parseArgs(argv);
233
+
234
+ // ---- preflights -----------------------------------------------------------
235
+ if (process.env.GASLAMP_NESTED && !ALLOW_RECURSION)
236
+ die(3, "this agent is itself a gaslamp consult — a consult is one hop. (GASLAMP_ALLOW_RECURSION=1 opts out.)");
237
+ // Codex sets this in sandboxed shell envs when network is off. Neither
238
+ // backend could phone home, so refuse pointedly instead of timing out.
239
+ if (process.env.CODEX_SANDBOX_NETWORK_DISABLED === "1")
240
+ 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).");
241
+
242
+ if (o.sandbox && !SANDBOXES[backend].includes(o.sandbox)) {
243
+ die(2, backend === "claude" && o.sandbox === "workspace-write"
244
+ ? "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"
245
+ : `bad --sandbox "${o.sandbox}" (${backend}: ${SANDBOXES[backend].join(" | ")})`);
246
+ }
247
+
248
+ // ---- prompt (argv, or stdin via `-` / piped-with-no-arg) -------------------
249
+ let prompt = o.prompt;
250
+ if (prompt == null || prompt === "-") {
251
+ if (prompt == null && process.stdin.isTTY) die(2, "no prompt — pass one as an argument or pipe it on stdin");
252
+ prompt = readFileSync(0, "utf8");
253
+ } else if (!process.stdin.isTTY) {
254
+ // Prompt on argv AND piped stdin: stdin is evidence — the diff, the log,
255
+ // the failing output — appended as a <stdin> block. This is codex exec's
256
+ // own convention for exactly this case, applied uniformly to both backends.
257
+ const evidence = readFileSync(0, "utf8");
258
+ if (evidence.trim())
259
+ prompt += "\n\n<stdin>\n" + (evidence.endsWith("\n") ? evidence : evidence + "\n") + "</stdin>";
260
+ }
261
+ if (!prompt.trim()) die(2, "empty prompt");
262
+
263
+ const schemaText = o.schema ? resolveSchema(o.schema) : null;
264
+
265
+ // ---- thread → resume + lock -------------------------------------------------
266
+ // A thread is resume-if-bound: an existing pointer supplies the session id
267
+ // (and the session lock below serializes concurrent consults on it); an
268
+ // unbound name runs fresh and binds when the backend reports its session.
269
+ // (Two concurrent consults racing to BIND the same new name is a caller bug;
270
+ // the loser's session stays reachable through its job record.)
271
+ if (o.thread) {
272
+ if (o.resume) die(2, "--thread and --resume are exclusive — a thread IS a resume handle");
273
+ if (!validThreadName(o.thread)) die(2, `bad thread name "${o.thread}" (letters/digits then letters, digits, . _ -; max 64)`);
274
+ }
275
+ const thread = o.thread ? readThread(backend, o.thread) : null;
276
+ const resumeSid = o.resume ? resolveResume(backend, o.resume) : thread?.sessionId ?? null;
277
+ const id = newJobId(backend);
278
+ let lockPath = resumeSid ? acquireLock(backend, resumeSid, id) : null;
279
+ const releaseLock = () => { if (lockPath) { try { unlinkSync(lockPath); } catch {} lockPath = null; } };
280
+
281
+ // ---- job record ------------------------------------------------------------
282
+ const dir = jobDir(id);
283
+ const meta = {
284
+ id, backend, status: "running",
285
+ sessionId: resumeSid, resumedFrom: resumeSid, thread: o.thread ?? null,
286
+ label: o.label ?? null,
287
+ model: o.model ?? null, sandbox: o.sandbox ?? null, effort: o.effort ?? null,
288
+ cwd: o.cwd || process.cwd(),
289
+ pid: process.pid, childPid: null, exitCode: null, signal: null,
290
+ startedAt: new Date().toISOString(), endedAt: null,
291
+ promptChars: prompt.length, schema: !!schemaText, raw: !!o.raw,
292
+ };
293
+ createJob(meta, prompt);
294
+
295
+ // ---- spawn -----------------------------------------------------------------
296
+ const bin = resolveBin(backend);
297
+ const replyPath = join(dir, "reply.md");
298
+ // schema.json lands in the job dir for BOTH backends: codex needs the file,
299
+ // and the record should show what was actually sent — for codex that is the
300
+ // strictified form (see strictify above), for claude the schema as written.
301
+ const schemaPath = schemaText ? join(dir, "schema.json") : null;
302
+ if (schemaPath) {
303
+ const sent = backend === "codex"
304
+ ? JSON.stringify(strictify(JSON.parse(schemaText)), null, 2)
305
+ : schemaText;
306
+ writeFileSync(schemaPath, sent.endsWith("\n") ? sent : sent + "\n");
307
+ }
308
+ const args = backend === "claude" ? claudeArgs(o, resumeSid, schemaText) : codexArgs(o, resumeSid, replyPath, schemaPath);
309
+
310
+ const env = { ...process.env, GASLAMP_NESTED: "1" };
311
+ // Keychain OAuth is authoritative for the consulted Claude: a stale env key
312
+ // 401s every call (1.0's hardest auth lesson). No Codex analog needed.
313
+ if (backend === "claude") delete env.ANTHROPIC_API_KEY;
314
+
315
+ log("spawn", bin, JSON.stringify(args));
316
+ // detached → the child leads its own process group, so signal forwarding can
317
+ // kill the whole tree (backend + whatever it spawned), never just the top.
318
+ const child = spawn(bin, args, { cwd: meta.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
319
+ meta.childPid = child.pid ?? null;
320
+ writeMeta(meta);
321
+
322
+ writeSync(2, `[gaslamp] → ${backend} · job ${id} · watch: tail -f ${join(dir, "events.jsonl")}\n`);
323
+
324
+ // Early machine receipt for the fleet runner (gated on GASLAMP_FLEET, which the
325
+ // fleet sets on its children): the parent records each child's job id the
326
+ // moment it spawns — durable even if the fleet is killed mid-run — without
327
+ // scraping the human stderr breadcrumb. The lone --json line is the envelope
328
+ // (it carries `backend`/`status`); this receipt carries `type:"started"`.
329
+ if (o.json && process.env.GASLAMP_FLEET === "1")
330
+ process.stdout.write(JSON.stringify({ type: "started", jobId: id, childPid: meta.childPid }) + "\n");
331
+
332
+ child.stdin.on("error", () => {}); // EPIPE if the child dies before reading
333
+ // prompt.md records the CALLER's content; the preamble is mechanism, applied
334
+ // here at the spawn boundary (codex has no --append-system-prompt analog).
335
+ if (backend === "codex" && !o.raw)
336
+ child.stdin.write("<gaslamp_consult>\n" + PREAMBLE + "\n</gaslamp_consult>\n\n");
337
+ child.stdin.write(prompt);
338
+ child.stdin.end();
339
+
340
+ const events = createWriteStream(join(dir, "events.jsonl"), { flags: "a" });
341
+ const errlog = createWriteStream(join(dir, "stderr.log"), { flags: "a" });
342
+ child.stderr.on("data", (d) => errlog.write(d));
343
+
344
+ // ---- event stream: write through; fish out session id + reply --------------
345
+ let buf = "", resultText = null, resultErr = false, lastAgentText = null, structuredOut;
346
+ const usageAcc = { input: 0, output: 0 }; // codex reports per turn; sum them
347
+ child.stdout.on("data", (d) => {
348
+ events.write(d);
349
+ buf += d.toString();
350
+ let nl;
351
+ while ((nl = buf.indexOf("\n")) >= 0) {
352
+ const line = buf.slice(0, nl).trim();
353
+ buf = buf.slice(nl + 1);
354
+ if (!line) continue;
355
+ let j; try { j = JSON.parse(line); } catch { continue; }
356
+ const sid = backend === "claude"
357
+ ? ((j.type === "system" && j.subtype === "init") || j.type === "result" ? j.session_id : null)
358
+ : (j.type === "thread.started" ? (j.thread_id ?? j.threadId) : null);
359
+ if (sid) {
360
+ if (meta.sessionId !== sid) { meta.sessionId = sid; writeMeta(meta); } // early: the recovery handle
361
+ // Bind/re-point the thread the moment the session is known — claude
362
+ // resumes fork a fresh session id, and the pointer must chase it.
363
+ if (o.thread) writeThread({
364
+ backend, name: o.thread, sessionId: sid, lastJobId: id,
365
+ createdAt: thread?.createdAt ?? meta.startedAt, updatedAt: new Date().toISOString(),
366
+ });
367
+ }
368
+ if (backend === "claude" && j.type === "result") {
369
+ resultText = j.result ?? j.content ?? null;
370
+ resultErr = !!j.is_error;
371
+ if (j.structured_output !== undefined) structuredOut = j.structured_output;
372
+ // Fleets are quota-shaped, so quota should be visible: inputTokens
373
+ // counts cache reads/writes too (that's what the meter meters).
374
+ if (j.usage) meta.usage = {
375
+ inputTokens: (j.usage.input_tokens ?? 0) + (j.usage.cache_read_input_tokens ?? 0)
376
+ + (j.usage.cache_creation_input_tokens ?? 0),
377
+ outputTokens: j.usage.output_tokens ?? 0,
378
+ costUsd: j.total_cost_usd ?? null,
379
+ };
380
+ } else if (backend === "codex" && j.type === "turn.completed" && j.usage) {
381
+ usageAcc.input += (j.usage.input_tokens ?? 0) + (j.usage.cached_input_tokens ?? 0);
382
+ usageAcc.output += j.usage.output_tokens ?? 0;
383
+ meta.usage = { inputTokens: usageAcc.input, outputTokens: usageAcc.output, costUsd: null };
384
+ } else if (backend === "codex" && j.type === "item.completed") {
385
+ // Tolerant fallback if -o never lands (schema drift across versions).
386
+ const item = j.item ?? {};
387
+ if ((item.type === "agent_message" || item.item_type === "agent_message") && typeof item.text === "string")
388
+ lastAgentText = item.text;
389
+ }
390
+ }
391
+ });
392
+
393
+ // ---- signals: kill the group, finalize, no orphans --------------------------
394
+ let killedBy = null;
395
+ const killGroup = (sig) => {
396
+ try { process.kill(-child.pid, sig); }
397
+ catch { try { child.kill(sig); } catch {} }
398
+ };
399
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
400
+ process.on(sig, () => {
401
+ if (killedBy) return killGroup("SIGKILL");
402
+ killedBy = sig;
403
+ writeSync(2, `[gaslamp] ${sig} — stopping job ${id}; the session survives, recover with --resume\n`);
404
+ killGroup(sig);
405
+ setTimeout(() => killGroup("SIGKILL"), 2000).unref();
406
+ });
407
+ }
408
+
409
+ const finalize = (status, code) => {
410
+ if (meta.status !== "running") return;
411
+ meta.status = status;
412
+ meta.exitCode = code;
413
+ meta.signal = killedBy;
414
+ meta.endedAt = new Date().toISOString();
415
+ if (backend === "claude" && resultText != null) writeFileSync(replyPath, String(resultText));
416
+ if (backend === "codex" && !existsSync(replyPath) && lastAgentText != null) writeFileSync(replyPath, lastAgentText);
417
+ writeMeta(meta);
418
+ releaseLock();
419
+ };
420
+
421
+ child.on("error", (e) => {
422
+ finalize("failed", null);
423
+ die(1, `failed to launch ${bin}: ${e.message}`);
424
+ });
425
+
426
+ child.on("close", (code) => {
427
+ events.end();
428
+ errlog.end();
429
+ finalize(killedBy ? "killed" : (code !== 0 || resultErr) ? "failed" : "done", code);
430
+ const reply = existsSync(replyPath) ? readFileSync(replyPath, "utf8") : null;
431
+
432
+ // A schema consult's contract is a typed reply: parse it (claude hands the
433
+ // object over in the result event; codex's reply.md IS the JSON), and an
434
+ // unparseable reply is a FAILED consult, not a quiet string.
435
+ let data = null;
436
+ if (schemaText) {
437
+ data = structuredOut ?? null;
438
+ if (data == null && reply != null) { try { data = JSON.parse(reply); } catch {} }
439
+ if (data == null && meta.status === "done") {
440
+ meta.status = "failed";
441
+ writeMeta(meta);
442
+ writeSync(2, "[gaslamp] --schema was set but the reply is not valid JSON — marking the consult failed\n");
443
+ }
444
+ }
445
+
446
+ if (o.json) {
447
+ process.stdout.write(JSON.stringify({
448
+ backend, jobId: id, sessionId: meta.sessionId, status: meta.status,
449
+ exitCode: code, content: reply ?? "",
450
+ ...(meta.label ? { label: meta.label } : {}),
451
+ ...(meta.usage ? { usage: meta.usage } : {}),
452
+ ...(schemaText ? { data } : {}),
453
+ }) + "\n");
454
+ } else {
455
+ if (reply) process.stdout.write(reply.endsWith("\n") ? reply : reply + "\n");
456
+ if (!reply && meta.status !== "done") {
457
+ try { // surface the stderr tail so a failure isn't a shrug
458
+ const tail = readFileSync(join(dir, "stderr.log"), "utf8").trim().split("\n").slice(-6).join("\n");
459
+ if (tail) writeSync(2, tail + "\n");
460
+ } catch {}
461
+ }
462
+ process.stdout.write(renderTrailer(meta) + "\n");
463
+ }
464
+ // exitCode (not exit()) so stdout drains; nothing else holds the loop open.
465
+ process.exitCode = meta.status === "done" ? 0 : 1;
466
+ });
467
+ }