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 +12 -0
- package/package.json +1 -1
- package/src/consult.mjs +42 -3
- package/src/fleet.mjs +1 -1
- package/src/setup.mjs +2 -0
- package/src/stdin.mjs +38 -10
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
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
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
|
|
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") {
|
|
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
|
}
|