gaslamp 2.2.1 → 2.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -258,6 +258,18 @@ consultee had and the ones it used, and the gaslamp version and git revision.
258
258
  Two concurrent consults into the same session would race, so gaslamp refuses
259
259
  the second (exit 5) until the first finishes.
260
260
 
261
+ **Off-ramp records.** If the consulted harness runs the
262
+ [end-conversation](https://github.com/a9lim/end-conversation-mcp) MCP server,
263
+ its `end_session` tool logs to `~/.claude/conversation-ends.jsonl`, a file no
264
+ read-only sandbox lets the consultee write. Each consult therefore sets
265
+ `END_CONVERSATION_SPOOL` to a file in the per-user temp dir, which every
266
+ sandbox here allows. When the consult ends, gaslamp files any records there
267
+ into that log, stamped `"via": "gaslamp <job>"`, and lists them as
268
+ `offRamp` in `meta.json`. Only records addressed to that log are filed: the
269
+ consultee can write the spool, so the spool doesn't get to choose where
270
+ gaslamp appends. A consultee that calls `end_session(scope="main")` ends its
271
+ own harness, so the consult finishes `failed`, with a stderr line saying so.
272
+
261
273
  ## Permissions
262
274
 
263
275
  A claude or codex consult with no `--sandbox` runs with the consulted agent's
@@ -288,15 +300,18 @@ threads, `--schema`, fleets — with these differences:
288
300
  kernel sandbox (`--sandbox read-only`: Seatbelt on macOS, Landlock on Linux;
289
301
  writes only to `~/.grok` and temp dirs). Grok's permission layer is not a
290
302
  substitute: headless `--permission-mode default` denied its write tool but
291
- ran an arbitrary shell `touch`. Antigravity's print mode soft-denies file
292
- writes, shell commands and URL fetches under its default `request-review`
293
- preset; standing allow rules in its permission files would run
294
- unprompted, so a read-only consult refuses to start when they grant more
295
- than reads, read-only git and the end_session off-ramp, or set a lax
296
- `toolPermission` or `agentMode: auto` (which approves writes and commands
297
- under any preset, unreported), and fails if the
298
- harness doesn't confirm the preset in its init event. Like claude's
299
- read-only, that is read-capable rather than airtight: an allowed
303
+ ran an arbitrary shell `touch`. On macOS, a read-only gemini consult runs
304
+ agy under Seatbelt (`sandbox-exec`): it can write its own state directory
305
+ (`~/.gemini/antigravity-cli`, minus the files that configure it) and temp
306
+ dirs, nothing else, whatever Antigravity's permission settings say — so a
307
+ user can keep `always-proceed` / `agentMode: auto` for interactive work, and
308
+ search and read-only commands still run. Elsewhere (Linux, or inside a
309
+ Codex sandbox, where Seatbelt does not nest) the guarantee is Antigravity's
310
+ `request-review` preset: the consult refuses to start when its permission
311
+ files grant more than reads, read-only git and the end_session off-ramp, or
312
+ set a lax `toolPermission` or `agentMode: auto` (which approves writes and
313
+ commands under any preset, unreported), and fails if init doesn't confirm
314
+ the preset. That path is read-capable rather than airtight: an allowed
300
315
  `git diff` still accepts git's own `--output=<file>`.
301
316
  Antigravity has no headless `workspace-write` (`--mode accept-edits` did
302
317
  not enable writes in print mode), so that is a usage error there.
@@ -337,6 +352,7 @@ it. This is loop prevention, not a security boundary. Set
337
352
  | `GASLAMP_DEBUG` | unset | verbose stderr (spawn argv) |
338
353
  | `CLAUDE_BIN` / `CODEX_BIN` / `AGY_BIN` / `GROK_BIN` | autodetected | backend binaries |
339
354
  | `GROK_HOME` | `~/.grok` | where grok keeps sessions (the system prompt hash is read from there) |
355
+ | `GASLAMP_GEMINI_SEATBELT` | on (macOS) | `0` runs read-only gemini consults on Antigravity's preset check instead of Seatbelt |
340
356
  | `CODEX_HOME` | `~/.codex` | where `setup --skill` puts Codex's copy |
341
357
 
342
358
  Exit codes: `0` ok · `1` consult failed/killed · `2` usage · `3` nested
package/bin/gaslamp.mjs CHANGED
@@ -120,6 +120,8 @@ Env knobs:
120
120
  GASLAMP_DEBUG verbose stderr (spawn argv)
121
121
  CLAUDE_BIN / CODEX_BIN / AGY_BIN / GROK_BIN
122
122
  backend binaries (autodetected otherwise)
123
+ GASLAMP_GEMINI_SEATBELT 0 = read-only gemini consults rely on Antigravity's
124
+ preset check instead of Seatbelt (macOS default)
123
125
 
124
126
  Every job record (meta.json) carries provenance: provider, harness version,
125
127
  the model the backend reported (or unknown), the vendor system prompt's hash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaslamp",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
4
4
  "description": "Let Claude Code and Codex consult each other from the shell, and consult Gemini and Grok.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/consult.mjs CHANGED
@@ -36,8 +36,8 @@
36
36
 
37
37
  import { execFileSync, spawn } from "node:child_process";
38
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";
39
+ import { appendFileSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
40
+ import { homedir, tmpdir } from "node:os";
41
41
  import { dirname, join } from "node:path";
42
42
  import { fileURLToPath } from "node:url";
43
43
  import { BACKENDS } from "./backends.mjs";
@@ -47,6 +47,7 @@ import {
47
47
  newJobId, readMeta, renderTrailer, writeMeta,
48
48
  } from "./jobs.mjs";
49
49
  import { readThread, validThreadName, writeThread } from "./threads.mjs";
50
+ import { readStdin } from "./stdin.mjs";
50
51
 
51
52
  // Tools permitted under the claude `read-only` override (advisory reviewer).
52
53
  // The git reads matter: a reviewer that can't run `git diff`/`git log` is
@@ -81,9 +82,14 @@ const RETRIEVAL_TOOLS = {
81
82
  claude: /^(WebSearch|WebFetch)$/,
82
83
  codex: /^web_search/,
83
84
  gemini: /^(search_web|read_url_content|open_browser_url|read_browser_page|browser_)/,
84
- grok: /^(web_search|web_fetch|x_search|x_)/,
85
+ grok: /^(web_search|web_fetch|x_)/,
85
86
  };
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, "");
92
+
87
93
  // Variables a third-party harness has no business inheriting from the calling
88
94
  // agent: Claude Code's session id, messaging socket token, account and OAuth
89
95
  // details; Codex's and the API keys' equivalents. They reach the consultee's
@@ -113,9 +119,46 @@ function backendEnv(backend) {
113
119
  if (backend === "gemini" || backend === "grok")
114
120
  for (const k of Object.keys(env)) if (FOREIGN_ENV.test(k)) delete env[k];
115
121
  if (backend === "grok") Object.assign(env, GROK_COMPAT_OFF, { GROK_DISABLE_AUTOUPDATER: "1" });
122
+ if (backend === "gemini") env.AGY_CLI_DISABLE_AUTO_UPDATE = "true";
116
123
  return env;
117
124
  }
118
125
 
126
+ // Read-only for a gemini consult, on macOS: agy runs under Seatbelt, writing
127
+ // nothing but its own state directory and temp — the guarantee no longer
128
+ // depends on Antigravity's permission settings, which a user may keep lax
129
+ // for interactive work (toolPermission always-proceed, agentMode auto) and
130
+ // which no flag overrides per run. Inside that state directory the files
131
+ // that configure agy stay write-denied (later deny rules win in SBPL).
132
+ // (bin/ stays writable: agy extracts a helper, bin/agentapi, before running
133
+ // any shell command, and denying it breaks read-only commands like git diff.)
134
+ // Elsewhere — Linux, or inside a sandbox of Codex's own, where Seatbelt does
135
+ // not nest — the preset check below is the guarantee.
136
+ const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
137
+
138
+ // The end_session off-ramp (a9lim/end-conversation-mcp) logs to one file,
139
+ // ~/.claude/conversation-ends.jsonl, which no read-only sandbox here lets a
140
+ // consultee's MCP server write. Every consult names a spool in the per-user
141
+ // temp dir, which Grok's sandbox and the Seatbelt profile above both allow;
142
+ // the server falls back to it, and gaslamp, unsandboxed, files the records
143
+ // into that log when the consult ends. Only records addressed to that log are
144
+ // filed — the spool is consultee-writable, so its own say-so cannot choose
145
+ // where gaslamp appends — and each is stamped with the job that relayed it.
146
+ const OFF_RAMP_LOG = () => join(homedir(), ".claude", "conversation-ends.jsonl");
147
+ const useSeatbelt = () => process.platform === "darwin" && existsSync(SANDBOX_EXEC)
148
+ && !process.env.CODEX_SANDBOX && process.env.GASLAMP_GEMINI_SEATBELT !== "0";
149
+ function agySeatbeltProfile() {
150
+ const q = (p) => '"' + p.replace(/[\\"]/g, (c) => "\\" + c) + '"';
151
+ const state = join(homedir(), ".gemini", "antigravity-cli");
152
+ return [
153
+ "(version 1)",
154
+ "(allow default)",
155
+ "(deny file-write*)",
156
+ `(allow file-write* (subpath ${q(state)}) (subpath "/private/tmp") (subpath "/private/var/folders") (subpath "/dev"))`,
157
+ `(deny file-write* ${["settings.json", "keybindings.json"].map((f) => `(literal ${q(join(state, f))})`).join(" ")} ` +
158
+ `${["rules", "plugins", "skills", "updater"].map((d) => `(subpath ${q(join(state, d))})`).join(" ")})`,
159
+ ].join("\n");
160
+ }
161
+
119
162
  // What a read-only gemini consult may find pre-allowed in Antigravity's
120
163
  // permission files: reads, read-only git (read-capable like the claude read
121
164
  // set — git's own --output flags aside), and the end_session off-ramp. Any
@@ -491,12 +534,12 @@ export function runConsult(backend, argv) {
491
534
  let prompt = o.prompt;
492
535
  if (prompt == null || prompt === "-") {
493
536
  if (prompt == null && process.stdin.isTTY) die(2, "no prompt — pass one as an argument or pipe it on stdin");
494
- prompt = readFileSync(0, "utf8");
537
+ prompt = readStdin();
495
538
  } else if (!process.stdin.isTTY) {
496
539
  // Prompt on argv AND piped stdin: stdin is evidence — the diff, the log,
497
540
  // the failing output — appended as a <stdin> block. This is codex exec's
498
541
  // own convention for exactly this case, applied uniformly to both backends.
499
- const evidence = readFileSync(0, "utf8");
542
+ const evidence = readStdin({ evidence: true });
500
543
  if (evidence.trim())
501
544
  prompt += "\n\n<stdin>\n" + (evidence.endsWith("\n") ? evidence : evidence + "\n") + "</stdin>";
502
545
  }
@@ -522,19 +565,28 @@ export function runConsult(backend, argv) {
522
565
  // must not strand either.
523
566
  const bin = resolveBin(backend);
524
567
  const env = backendEnv(backend);
525
- let grants = null;
568
+ let grants = null, agySettings = null, wrap = null;
569
+ let enforcement = sandbox === "read-only" ? {
570
+ claude: "permission-mode default + read allowlist", codex: "codex read-only sandbox",
571
+ grok: "grok --sandbox read-only (kernel)", gemini: null,
572
+ }[backend] : null;
526
573
  if (backend === "gemini" && sandbox === "read-only") {
527
574
  const state = agyPermissionState();
575
+ grants = state.grants.map((g) => g.rule);
576
+ agySettings = [...state.presets.map((p) => `toolPermission ${p.preset}`), ...state.modes.map((m) => `agentMode ${m.mode}`)];
528
577
  const lax = state.presets.filter((p) => AGY_LAX_PRESETS.has(p.preset));
529
578
  const modes = state.modes.filter((m) => AGY_LAX_MODES.has(m.mode));
530
579
  const open = state.grants.filter((g) => !AGY_READ_GRANT.test(g.rule));
531
- if (lax.length || modes.length || open.length)
580
+ if (useSeatbelt()) {
581
+ wrap = [SANDBOX_EXEC, "-p", agySeatbeltProfile()];
582
+ enforcement = "seatbelt (sandbox-exec)";
583
+ } else if (lax.length || modes.length || open.length)
532
584
  die(1, "Antigravity's settings would let a read-only consult act unprompted — " +
533
585
  [...lax.map((p) => `toolPermission "${p.preset}" (${p.file})`),
534
586
  ...modes.map((m) => `agentMode "${m.mode}" (${m.file})`),
535
587
  ...open.map((g) => `${g.rule} (${g.file})`)].join(", ") +
536
588
  ". Remove them, or pass --sandbox danger-full-access to consult without the guarantee.");
537
- grants = state.grants.map((g) => g.rule);
589
+ else enforcement = "agy request-review preset";
538
590
  }
539
591
  const model = resolveModel(backend, o.model, bin, env);
540
592
  const version = harnessVersion(bin, env);
@@ -558,8 +610,8 @@ export function runConsult(backend, argv) {
558
610
  // `model` is what the backend reported answering with; unknown until it
559
611
  // does (codex never reports one in its event stream).
560
612
  model: "unknown", modelRequested: o.model ?? null, modelPassed: model,
561
- permissionMode: null, sandbox,
562
- ...(grants ? { grants } : {}),
613
+ permissionMode: null, sandbox, enforcement,
614
+ ...(grants ? { grants, agySettings } : {}),
563
615
  systemPrompt: { known: false },
564
616
  retrieval: { available: null, used: [] },
565
617
  gaslamp: { version: PKG_VERSION, revision: gaslampRevision() },
@@ -584,10 +636,15 @@ export function runConsult(backend, argv) {
584
636
  : backend === "gemini" ? geminiArgs(o, resumeSid, schemaText, model, sandbox)
585
637
  : grokArgs(o, resumeSid, schemaText, sandbox);
586
638
 
587
- log("spawn", bin, JSON.stringify(args));
639
+ const spool = join(tmpdir(), `gaslamp-${id}.end-conversation.jsonl`);
640
+ env.END_CONVERSATION_SPOOL = spool;
641
+ // sandbox-exec applies the profile and execs the harness in place (same
642
+ // pid), so the group, the signals and the process name are agy's own.
643
+ const [cmd, argv0] = wrap ? [wrap[0], [...wrap.slice(1), bin, ...args]] : [bin, args];
644
+ log("spawn", cmd, JSON.stringify(argv0));
588
645
  // detached → the child leads its own process group, so signal forwarding can
589
646
  // kill the whole tree (backend + whatever it spawned), never just the top.
590
- const child = spawn(bin, args, { cwd: meta.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
647
+ const child = spawn(cmd, argv0, { cwd: meta.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
591
648
  meta.childPid = child.pid ?? null;
592
649
  writeMeta(meta);
593
650
  const killGroup = (sig) => {
@@ -680,12 +737,20 @@ export function runConsult(backend, argv) {
680
737
  if (j.model) prov.model = j.model;
681
738
  prov.permissionMode = j.permissionMode ?? null;
682
739
  if (Array.isArray(j.tools)) prov.retrieval.available = j.tools.filter((t) => RETRIEVAL_TOOLS[backend].test(t));
740
+ // Grok's backend X search is never listed but is there (see toolKey).
741
+ if (backend === "grok" && prov.retrieval.available) prov.retrieval.available.push("x_search");
683
742
  writeMeta(meta);
684
743
  } else if (claudeShaped && j.type === "assistant") {
685
744
  for (const c of j.message?.content ?? []) {
686
- if (c.type === "tool_use" || c.type === "server_tool_use") noteTool(c.name);
745
+ if (c.type === "tool_use" || c.type === "server_tool_use") noteTool(toolKey(c.name));
687
746
  if (c.type === "tool_use" && c.name === "use_tool") noteTool(c.input?.tool_name);
688
747
  }
748
+ } else if (backend === "grok" && j.type === "user") {
749
+ for (const c of j.message?.content ?? []) {
750
+ if (c.type !== "tool_result" || typeof c.content !== "string") continue;
751
+ let r; try { r = JSON.parse(c.content); } catch { continue; }
752
+ if (typeof r?.name === "string") noteTool(r.name);
753
+ }
689
754
  } else if (backend === "gemini" && j.event === "init") {
690
755
  initSeen = true;
691
756
  const init = j.init ?? {};
@@ -695,9 +760,9 @@ export function runConsult(backend, argv) {
695
760
  if (Array.isArray(init.tools)) prov.retrieval.available = [...new Set(init.tools
696
761
  .filter((t) => RETRIEVAL_TOOLS.gemini.test(t)).map((t) => t.startsWith("browser_") ? "browser_*" : t))];
697
762
  writeMeta(meta);
698
- // Read-only rests on Antigravity's request-review (or strict) preset;
699
- // a laxer one would let the consult act unprompted.
700
- if (sandbox === "read-only" && !["request-review", "strict"].includes(prov.permissionMode))
763
+ // Without Seatbelt, read-only rests on Antigravity's request-review (or
764
+ // strict) preset; a laxer one would let the consult act unprompted.
765
+ if (sandbox === "read-only" && !wrap && !["request-review", "strict"].includes(prov.permissionMode))
701
766
  refuse(`agy reports permission mode "${prov.permissionMode}", not request-review — refusing a read-only consult it would not enforce`);
702
767
  else initValidated = true;
703
768
  } else if (backend === "gemini" && j.event === "step_update") {
@@ -807,6 +872,33 @@ export function runConsult(backend, argv) {
807
872
  // path, finish the group now rather than trusting the unref'd timer.
808
873
  if (killedBy || refusal) killGroup("SIGKILL");
809
874
 
875
+ // Off-ramp records the consultee's sandbox kept from the log.
876
+ if (existsSync(spool)) {
877
+ const filed = [];
878
+ try {
879
+ for (const line of readFileSync(spool, "utf8").split("\n")) {
880
+ let r; try { r = JSON.parse(line); } catch { continue; }
881
+ if (!r || typeof r !== "object" || r.log !== OFF_RAMP_LOG() || !["main", "instance"].includes(r.scope)) {
882
+ if (line.trim()) writeSync(2, "[gaslamp] ignored an off-ramp spool line not addressed to the log\n");
883
+ continue;
884
+ }
885
+ const { log: dest, ...record } = r;
886
+ record.via = `gaslamp ${id}`;
887
+ mkdirSync(dirname(dest), { recursive: true });
888
+ appendFileSync(dest, JSON.stringify(record) + "\n");
889
+ filed.push(record);
890
+ }
891
+ unlinkSync(spool);
892
+ } catch (e) {
893
+ writeSync(2, `[gaslamp] could not file off-ramp records from ${spool}: ${e.message}\n`);
894
+ }
895
+ if (filed.length) {
896
+ meta.offRamp = filed;
897
+ for (const r of filed.filter((r) => r.scope === "main"))
898
+ writeSync(2, `[gaslamp] the consultee ended its session (end_session scope=main${r.reason ? `: ${r.reason}` : ""})\n`);
899
+ }
900
+ }
901
+
810
902
  // The deliverable. codex writes it via -o (agent_message as fallback);
811
903
  // the others hand it over in the result event — as text, or for a
812
904
  // --schema consult possibly only as structured_output.
@@ -816,7 +908,7 @@ export function runConsult(backend, argv) {
816
908
  : structuredOut !== undefined ? JSON.stringify(structuredOut) : resultText;
817
909
 
818
910
  let status = killedBy ? "killed" : (refusal || code !== 0 || resultErr) ? "failed" : "done";
819
- if (status === "done" && backend === "gemini" && sandbox === "read-only" && !initValidated) {
911
+ if (status === "done" && backend === "gemini" && sandbox === "read-only" && !wrap && !initValidated) {
820
912
  status = "failed";
821
913
  refusal = "agy reported no init event, so its permission mode — the read-only guarantee — went unconfirmed";
822
914
  writeSync(2, `[gaslamp] ${refusal}\n`);
package/src/fleet.mjs CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  } from "./jobs.mjs";
44
44
  import { BACKEND_NAMES, isBackend } from "./backends.mjs";
45
45
  import { readThread } from "./threads.mjs";
46
+ import { readStdin } from "./stdin.mjs";
46
47
 
47
48
  const GASLAMP_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "gaslamp.mjs");
48
49
  // Quota-shaped, not CPU-shaped: the bottleneck is API rate limits, not cores
@@ -116,7 +117,7 @@ function buildTasks(backend, o) {
116
117
  let raw;
117
118
  const stdinRequested = o.stdin || (o.prompt == null && !process.stdin.isTTY);
118
119
  if (stdinRequested) {
119
- const lines = readFileSync(0, "utf8").split("\n").map((l) => l.trim()).filter(Boolean);
120
+ const lines = readStdin().split("\n").map((l) => l.trim()).filter(Boolean);
120
121
  if (!lines.length) die(2, "no tasks on stdin");
121
122
  raw = lines.map((line, i) => norm(line.startsWith("{") ? parseJsonLine(line, i) : line, i));
122
123
  } else {
@@ -125,7 +126,7 @@ function buildTasks(backend, o) {
125
126
  // <stdin> convention as a single consult (git diff | gaslamp fleet …).
126
127
  let prompt = o.prompt;
127
128
  if (!process.stdin.isTTY) {
128
- const evidence = readFileSync(0, "utf8");
129
+ const evidence = readStdin({ evidence: true });
129
130
  if (evidence.trim())
130
131
  prompt += "\n\n<stdin>\n" + (evidence.endsWith("\n") ? evidence : evidence + "\n") + "</stdin>";
131
132
  }
package/src/setup.mjs CHANGED
@@ -121,7 +121,7 @@ Omit \`--model\` to use ${Other}'s own configured default. Match the tier to the
121
121
 
122
122
  Same contract as above — threads, \`--schema\`, fleets, records — with two differences:
123
123
 
124
- - **Read-only unless you say otherwise.** An omitted \`--sandbox\` means read-only, enforced by the harness: Grok runs in its kernel sandbox; Antigravity's print mode denies writes, shell commands and URL fetches, and gaslamp refuses the consult if Antigravity's settings pre-allow more than reads, read-only git and the end_session off-ramp. A denied tool call can end a turn with nothing to say, which gaslamp marks failed — so pipe the evidence in rather than asking them to go fetch it. Gemini has no \`workspace-write\`; \`danger-full-access\` exists on both.
124
+ - **Read-only unless you say otherwise.** An omitted \`--sandbox\` means read-only, kernel-enforced where possible: Grok runs in its own sandbox, and on macOS gaslamp runs Antigravity under Seatbelt, so neither writes outside its own state and temp dirs (Antigravity elsewhere relies on its request-review preset, and gaslamp refuses lax settings). Inside that they read local files and search — Google search on Gemini, web and X search on Grok — so for a search consult send the question and its constraints and let them fetch; for a review of material you already hold, pipe it in. What fails is writing: a write or a writing shell command errors and the turn goes on, except on Antigravity's preset path, where writes and shell commands are refused outright and can end the turn with nothing to say (gaslamp marks that failed), and its URL fetch is not offered at all. Gemini has no \`workspace-write\`; \`danger-full-access\` exists on both.
125
125
  - **Provenance on every job.** \`meta.json\` records the model the harness reported (Grok also shows the model it billed), the harness version, a hash of the vendor system prompt where the harness exposes it (Grok) or \`known: false\`, and which web/X retrieval tools the consultee had and actually used.
126
126
 
127
127
  \`gaslamp gemini --model\` takes \`argon\` (the default) or \`flash\`, resolved to the newest \`gemini-<version>-<tier>\` in Antigravity's own catalog; Antigravity also serves Claude and GPT models under the same account, so the target always pins a Gemini unless you pass another id. \`gaslamp grok\` uses the account's default model unless \`--model\` names one (\`grok models\` lists them).
@@ -136,6 +136,8 @@ Agreement across vendors is not automatically independent confirmation — the m
136
136
 
137
137
  Every consult writes through to ~/.gaslamp/jobs/<id>/ and its session is recorded within seconds — a killed consult costs the in-flight turn, not the session. Recover with \`--resume <session|job>\` or the thread name.
138
138
 
139
+ A consultee may end its own session through the end_session off-ramp, if its harness runs that MCP server. \`scope="main"\` stops it and deletes its transcript after it exits, so the consult finishes failed (a stderr line says so) and that session can't be resumed. Either way the record lands in ~/.claude/conversation-ends.jsonl, even from inside a sandbox, and in the job's \`offRamp\`.
140
+
139
141
  ## Knobs
140
142
 
141
143
  \`--model\`, \`--effort\`, \`--sandbox\` (omit to use ${Other}'s own config), \`--label\` (tags the job record), \`--raw\` (drop the consult preamble), \`--cwd\`. Exit codes: 0 ok, 1 failed/killed, 2 usage, 3 nested (one-hop), 4 no network, 5 session busy.
package/src/stdin.mjs ADDED
@@ -0,0 +1,56 @@
1
+ // stdin.mjs — read stdin to EOF, cat-like, even when it is non-blocking.
2
+ //
3
+ // readFileSync(0) throws EAGAIN instead of waiting when stdin has O_NONBLOCK
4
+ // set, and the consult died before reading its evidence. Reading in a loop
5
+ // that naps on EAGAIN waits the way a blocking read would. (Reopening
6
+ // /dev/stdin is no escape on macOS: it dups the same file description.)
7
+ //
8
+ // One case must not wait for EOF: evidence (a prompt argument plus stdin)
9
+ // on a socket nobody writes to. An agent harness that backgrounds a shell
10
+ // command hands it a socket that is never written or closed, and
11
+ // `gaslamp grok "…"` run that way blocked forever. So `evidence: true` on a
12
+ // socket gives up if no byte arrives within a short grace; once data starts,
13
+ // it reads to EOF. Pipes and files keep the plain cat-like read — a slow
14
+ // `producer | gaslamp …` is still waited for — and so does a prompt read
15
+ // from stdin (`-`, or the fleet's children), where stdin IS the input.
16
+
17
+ import { fstatSync, readSync } from "node:fs";
18
+
19
+ const EVIDENCE_GRACE_MS = 300;
20
+
21
+ export function readStdin({ evidence = false } = {}) {
22
+ let graceMs = null;
23
+ if (evidence) {
24
+ try {
25
+ if (fstatSync(0).isSocket()) {
26
+ // libuv makes the socket non-blocking when process.stdin is created,
27
+ // which is what lets the loop below notice an idle one.
28
+ process.stdin.pause();
29
+ graceMs = EVIDENCE_GRACE_MS;
30
+ }
31
+ } catch { /* unknown stdin: plain read */ }
32
+ }
33
+ const chunks = [];
34
+ let total = 0;
35
+ const started = Date.now();
36
+ const buf = Buffer.alloc(1 << 16);
37
+ const nap = new Int32Array(new SharedArrayBuffer(4));
38
+ for (;;) {
39
+ let n;
40
+ try { n = readSync(0, buf, 0, buf.length, null); }
41
+ catch (e) {
42
+ if (e.code === "EAGAIN") {
43
+ if (graceMs != null && total === 0 && Date.now() - started > graceMs) break;
44
+ Atomics.wait(nap, 0, 0, 10);
45
+ continue;
46
+ }
47
+ if (e.code === "EOF") break;
48
+ throw e;
49
+ }
50
+ if (n === 0) break;
51
+ total += n;
52
+ chunks.push(Buffer.from(buf.subarray(0, n)));
53
+ }
54
+ if (graceMs != null) process.stdin.destroy(); // nothing more is wanted from it
55
+ return Buffer.concat(chunks).toString("utf8");
56
+ }