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 +25 -9
- package/bin/gaslamp.mjs +2 -0
- package/package.json +1 -1
- package/src/consult.mjs +109 -17
- package/src/fleet.mjs +3 -2
- package/src/setup.mjs +3 -1
- package/src/stdin.mjs +56 -0
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`.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
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|
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
+
}
|