gaslamp 2.2.2 → 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaslamp",
3
- "version": "2.2.2",
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";
@@ -134,6 +134,16 @@ function backendEnv(backend) {
134
134
  // Elsewhere — Linux, or inside a sandbox of Codex's own, where Seatbelt does
135
135
  // not nest — the preset check below is the guarantee.
136
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");
137
147
  const useSeatbelt = () => process.platform === "darwin" && existsSync(SANDBOX_EXEC)
138
148
  && !process.env.CODEX_SANDBOX && process.env.GASLAMP_GEMINI_SEATBELT !== "0";
139
149
  function agySeatbeltProfile() {
@@ -529,7 +539,7 @@ export function runConsult(backend, argv) {
529
539
  // Prompt on argv AND piped stdin: stdin is evidence — the diff, the log,
530
540
  // the failing output — appended as a <stdin> block. This is codex exec's
531
541
  // own convention for exactly this case, applied uniformly to both backends.
532
- const evidence = readStdin();
542
+ const evidence = readStdin({ evidence: true });
533
543
  if (evidence.trim())
534
544
  prompt += "\n\n<stdin>\n" + (evidence.endsWith("\n") ? evidence : evidence + "\n") + "</stdin>";
535
545
  }
@@ -626,6 +636,8 @@ export function runConsult(backend, argv) {
626
636
  : backend === "gemini" ? geminiArgs(o, resumeSid, schemaText, model, sandbox)
627
637
  : grokArgs(o, resumeSid, schemaText, sandbox);
628
638
 
639
+ const spool = join(tmpdir(), `gaslamp-${id}.end-conversation.jsonl`);
640
+ env.END_CONVERSATION_SPOOL = spool;
629
641
  // sandbox-exec applies the profile and execs the harness in place (same
630
642
  // pid), so the group, the signals and the process name are agy's own.
631
643
  const [cmd, argv0] = wrap ? [wrap[0], [...wrap.slice(1), bin, ...args]] : [bin, args];
@@ -860,6 +872,33 @@ export function runConsult(backend, argv) {
860
872
  // path, finish the group now rather than trusting the unref'd timer.
861
873
  if (killedBy || refusal) killGroup("SIGKILL");
862
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
+
863
902
  // The deliverable. codex writes it via -o (agent_message as fallback);
864
903
  // the others hand it over in the result event — as text, or for a
865
904
  // --schema consult possibly only as structured_output.
package/src/fleet.mjs CHANGED
@@ -126,7 +126,7 @@ function buildTasks(backend, o) {
126
126
  // <stdin> convention as a single consult (git diff | gaslamp fleet …).
127
127
  let prompt = o.prompt;
128
128
  if (!process.stdin.isTTY) {
129
- const evidence = readStdin();
129
+ const evidence = readStdin({ evidence: true });
130
130
  if (evidence.trim())
131
131
  prompt += "\n\n<stdin>\n" + (evidence.endsWith("\n") ? evidence : evidence + "\n") + "</stdin>";
132
132
  }
package/src/setup.mjs CHANGED
@@ -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 CHANGED
@@ -1,28 +1,56 @@
1
- // stdin.mjs — read all of stdin, cat-like, even when it is non-blocking.
1
+ // stdin.mjs — read stdin to EOF, cat-like, even when it is non-blocking.
2
2
  //
3
- // readFileSync(0) throws EAGAIN instead of waiting when the caller hands
4
- // gaslamp a stdin with O_NONBLOCK set (seen from an agent harness's shell),
5
- // and the consult died before reading its evidence. Reading in a loop that
6
- // naps on EAGAIN waits for the data and the EOF the way a blocking read would;
7
- // on macOS reopening /dev/stdin is no escape, since it dups the same
8
- // non-blocking file description.
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";
9
18
 
10
- import { readSync } from "node:fs";
19
+ const EVIDENCE_GRACE_MS = 300;
11
20
 
12
- export function readStdin() {
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
+ }
13
33
  const chunks = [];
34
+ let total = 0;
35
+ const started = Date.now();
14
36
  const buf = Buffer.alloc(1 << 16);
15
37
  const nap = new Int32Array(new SharedArrayBuffer(4));
16
38
  for (;;) {
17
39
  let n;
18
40
  try { n = readSync(0, buf, 0, buf.length, null); }
19
41
  catch (e) {
20
- if (e.code === "EAGAIN") { Atomics.wait(nap, 0, 0, 10); continue; }
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
+ }
21
47
  if (e.code === "EOF") break;
22
48
  throw e;
23
49
  }
24
50
  if (n === 0) break;
51
+ total += n;
25
52
  chunks.push(Buffer.from(buf.subarray(0, n)));
26
53
  }
54
+ if (graceMs != null) process.stdin.destroy(); // nothing more is wanted from it
27
55
  return Buffer.concat(chunks).toString("utf8");
28
56
  }