gaslamp 2.1.2 → 2.2.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.
package/README.md CHANGED
@@ -6,7 +6,8 @@
6
6
  [![node](https://img.shields.io/node/v/gaslamp)](https://www.npmjs.com/package/gaslamp)
7
7
  [![license](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue)](LICENSE)
8
8
 
9
- Let Claude Code and Codex consult each other from the shell.
9
+ Let Claude Code and Codex consult each other from the shell — and reach Gemini
10
+ and Grok as read-only consult targets.
10
11
 
11
12
  ```sh
12
13
  gaslamp codex "review this diff for races: …" # Claude asks Codex
@@ -14,6 +15,8 @@ gaslamp claude "verify this proof sketch: …" # Codex asks Claude
14
15
  git diff | gaslamp codex "review for races:" # piped stdin rides as evidence
15
16
  gaslamp codex - < notes/design-spar.md # or: the whole prompt from a file
16
17
  gaslamp claude --thread api-spar "counterpoint: …" # named session, continued
18
+ gaslamp gemini "is this migration reversible? …" # Gemini via Antigravity CLI
19
+ gaslamp grok "what does this stack trace mean? …" # Grok via Grok Build
17
20
  ```
18
21
 
19
22
  Each call blocks until the reply, then prints it plus a greppable trailer:
@@ -38,7 +41,10 @@ gaslamp stays a thin wrapper around `claude -p` / `codex exec`.
38
41
  ## Requirements
39
42
 
40
43
  Both CLIs installed and logged in: [Claude Code](https://code.claude.com/docs)
41
- and [Codex](https://developers.openai.com/codex). Node ≥ 18.
44
+ and [Codex](https://developers.openai.com/codex). Node ≥ 18. For the other two
45
+ targets, [Antigravity CLI](https://antigravity.google/docs/cli/headless/)
46
+ (`agy`, signed in once interactively) and/or
47
+ [Grok Build](https://docs.x.ai/build/overview) (`grok login`).
42
48
 
43
49
  ## Install
44
50
 
@@ -93,6 +99,8 @@ cd gaslamp
93
99
  ```
94
100
  gaslamp claude [opts] <prompt|-> Consult Claude (blocks until the reply).
95
101
  gaslamp codex [opts] <prompt|-> Consult Codex (blocks until the reply).
102
+ gaslamp gemini [opts] <prompt|-> Consult Gemini through Antigravity CLI (agy).
103
+ gaslamp grok [opts] <prompt|-> Consult Grok through Grok Build.
96
104
  gaslamp fleet <backend> [opts] Fan out a fleet of consults in one command.
97
105
  gaslamp fleet --resume <fleet-id> Finish an interrupted fleet.
98
106
  gaslamp jobs [-n N] [--json] List consult records, newest first.
@@ -105,12 +113,17 @@ gaslamp setup [--local] [--skill] Allowlist the command; --skill installs the
105
113
  Consult options:
106
114
  --resume <session|job> Continue a session (a prior job id works too).
107
115
  --thread <name> Named session: continue if bound, else start + bind.
108
- --model <m> Backend model override.
109
- --effort <level> Reasoning effort (native on both backends).
116
+ --model <m> An exact model id, or a tier word: claude's own
117
+ aliases (fable, opus, sonnet, haiku); codex tiers
118
+ (astra, sol, luna) and gemini tiers (argon, flash)
119
+ resolve to the newest id in that harness's catalog.
120
+ --effort <level> Reasoning effort (native on every backend).
110
121
  --schema <file|json> JSON Schema for the reply; --json gains data.
111
122
  --label <s> Tag the job record (shows in gaslamp jobs).
112
123
  --sandbox <mode> claude: read-only | danger-full-access
113
124
  codex: read-only | workspace-write | danger-full-access
125
+ gemini: read-only | danger-full-access
126
+ grok: read-only | workspace-write | danger-full-access
114
127
  --raw Skip the consult preamble.
115
128
  --cwd <dir> Working directory for the consult.
116
129
  --json {backend, jobId, sessionId, status, exitCode,
@@ -232,15 +245,24 @@ Token usage lands in the record too (claude's result usage + cost; codex's
232
245
  per-turn usage, summed) and shows in the trailer — fleets are quota-shaped,
233
246
  so quota is visible. `gaslamp jobs --json` and `gaslamp poll <id> --json`
234
247
  make the records scriptable; `gaslamp tail <job|--last>` follows a running
235
- consult's events as uniform one-liners across both backends' schemas.
248
+ consult's events as uniform one-liners across every backend's schema.
249
+
250
+ Each record also carries **provenance**, because a vendor can change the model
251
+ or the system prompt behind a stable product name: the provider and harness
252
+ version, the model the backend reported answering with (`unknown` where it
253
+ reports none — codex), the tier word and the id actually passed, the
254
+ permission mode and effective sandbox, a hash of the vendor system prompt where
255
+ the harness exposes one (grok) or `known: false`, the web/X retrieval tools the
256
+ consultee had and the ones it used, and the gaslamp version and git revision.
236
257
 
237
258
  Two concurrent consults into the same session would race, so gaslamp refuses
238
259
  the second (exit 5) until the first finishes.
239
260
 
240
261
  ## Permissions
241
262
 
242
- A consult with no `--sandbox` runs with the consulted agent's own config —
243
- exactly as that agent would behave if you drove it yourself. Overrides:
263
+ A claude or codex consult with no `--sandbox` runs with the consulted agent's
264
+ own config — exactly as that agent would behave if you drove it yourself.
265
+ (Gemini and Grok consults default to read-only instead; see below.) Overrides:
244
266
  `read-only` forces an advisory, no-edit reviewer on either backend;
245
267
  `danger-full-access` forces full read/write. Claude has no filesystem
246
268
  sandbox, so `workspace-write` there is a usage error rather than a silent
@@ -254,6 +276,47 @@ reach neither Anthropic nor OpenAI — gaslamp detects this
254
276
  timing out. Grant the call escalated permissions, or set
255
277
  `network_access = true` / `danger-full-access` in `~/.codex/config.toml`.
256
278
 
279
+ ## Gemini and Grok
280
+
281
+ `gaslamp gemini` drives Google's Antigravity CLI (`agy`) in print mode, the
282
+ prompt on stdin as one streaming-input event; `gaslamp grok` drives xAI's Grok
283
+ Build headless, prompt on stdin, `--output-format streaming-messages-json`.
284
+ Same contract as the other two — blocking call, trailer, job records,
285
+ threads, `--schema`, fleets — with these differences:
286
+
287
+ - **Read-only by default, enforced by the harness.** Grok runs under its
288
+ kernel sandbox (`--sandbox read-only`: Seatbelt on macOS, Landlock on Linux;
289
+ writes only to `~/.grok` and temp dirs). Grok's permission layer is not a
290
+ 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, and fails if the
296
+ harness doesn't confirm the preset in its init event. Like claude's
297
+ read-only, that is read-capable rather than airtight: an allowed
298
+ `git diff` still accepts git's own `--output=<file>`.
299
+ Antigravity has no headless `workspace-write` (`--mode accept-edits` did
300
+ not enable writes in print mode), so that is a usage error there.
301
+ - **A denied tool call can end the turn empty.** Both harnesses report success
302
+ for it; gaslamp marks an empty reply failed and surfaces the harness's
303
+ stderr. Pipe evidence in rather than asking the consultee to fetch it.
304
+ - **Gemini is pinned.** Antigravity also serves Claude and GPT-OSS models under
305
+ the same account; without `--model`, a gemini consult runs the newest
306
+ `argon` tier, never the account default.
307
+ - **The caller's harness stays home.** Their spawns drop `CLAUDE*`,
308
+ `ANTHROPIC_*`, `CODEX_*` and `OPENAI_*` variables (session ids, a messaging
309
+ token, account details), and Grok's vendor-compat imports are switched off
310
+ for the consult: by default Grok loads `~/.claude/CLAUDE.md` as its own
311
+ instructions and runs Claude's skills, MCP servers and hooks. (It also reads
312
+ `~/.claude/settings.json` permissions with no switch; gaslamp's explicit
313
+ `--permission-mode` overrides that file's `defaultMode`.)
314
+ - **Unsigned `agy` hangs.** Without a cached sign-in, print mode prints a
315
+ sign-in URL and waits forever; gaslamp stops it and says to run `agy` once.
316
+ - **Sessions.** `agy --conversation <id>` resumes in place (same id); so does
317
+ `grok --resume <id>`. Usage comes from Antigravity's per-step events, since
318
+ its result event counts the whole conversation.
319
+
257
320
  ## Recursion
258
321
 
259
322
  A consult is **one hop**: agents gaslamp spawns carry `GASLAMP_NESTED=1`
@@ -270,7 +333,8 @@ it. This is loop prevention, not a security boundary. Set
270
333
  | `GASLAMP_ALLOWED_TOOLS` | `Read,Grep,Glob,WebFetch,WebSearch` + read-only git (`Bash(git diff:*)` etc) | tools the claude `read-only` override permits, comma-separated |
271
334
  | `GASLAMP_ALLOW_RECURSION` | unset | allow consulted agents to consult back |
272
335
  | `GASLAMP_DEBUG` | unset | verbose stderr (spawn argv) |
273
- | `CLAUDE_BIN` / `CODEX_BIN` | autodetected | backend binaries |
336
+ | `CLAUDE_BIN` / `CODEX_BIN` / `AGY_BIN` / `GROK_BIN` | autodetected | backend binaries |
337
+ | `GROK_HOME` | `~/.grok` | where grok keeps sessions (the system prompt hash is read from there) |
274
338
  | `CODEX_HOME` | `~/.codex` | where `setup --skill` puts Codex's copy |
275
339
 
276
340
  Exit codes: `0` ok · `1` consult failed/killed · `2` usage · `3` nested
@@ -280,7 +344,7 @@ still running.
280
344
  ## Development
281
345
 
282
346
  ```sh
283
- npm test # node --test against stub claude and codex binaries
347
+ npm test # node --test against stub claude, codex, agy and grok binaries
284
348
  npm run check # node -c syntax check on every source file
285
349
  ```
286
350
 
package/bin/gaslamp.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
- // gaslamp: let Claude Code and Codex consult each other from the shell.
2
+ // gaslamp: let Claude Code and Codex consult each other — and Gemini and Grok —
3
+ // from the shell.
3
4
  //
4
5
  // gaslamp claude … / codex … one blocking consult (the heart of it)
6
+ // gaslamp gemini … / grok … the same, against Antigravity CLI / Grok Build
5
7
  // gaslamp fleet <backend> … fan out a bounded fleet of consults
6
8
  // gaslamp jobs / poll read the durable consult records
7
9
  // gaslamp setup [--local] allowlist the command for Claude Code
@@ -17,11 +19,14 @@ import { fileURLToPath } from "node:url";
17
19
  const HERE = dirname(fileURLToPath(import.meta.url));
18
20
  const pkg = JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8"));
19
21
 
20
- const HELP = `gaslamp ${pkg.version}: let Claude Code and Codex consult each other from the shell.
22
+ const HELP = `gaslamp ${pkg.version}: let Claude Code and Codex consult each other from the shell
23
+ (and consult Gemini and Grok).
21
24
 
22
25
  Usage:
23
26
  gaslamp claude [opts] <prompt|-> Consult Claude (blocks until the reply).
24
27
  gaslamp codex [opts] <prompt|-> Consult Codex (blocks until the reply).
28
+ gaslamp gemini [opts] <prompt|-> Consult Gemini via Antigravity CLI (agy).
29
+ gaslamp grok [opts] <prompt|-> Consult Grok via Grok Build.
25
30
  gaslamp fleet <backend> [opts] Fan out a fleet of consults (one command).
26
31
  -n N <prompt> Replicate one prompt across N fresh sessions.
27
32
  - < tasks.jsonl Or one task per line on stdin (see below).
@@ -47,13 +52,22 @@ Consult options:
47
52
  --thread <name> Named session: continue it if the name is bound,
48
53
  else start fresh and bind it. The pointer chases
49
54
  claude's forked session ids so the name stays hot.
50
- --model <m> Backend model override.
55
+ --model <m> Backend model: an exact id, or a tier word —
56
+ claude aliases (fable, opus, sonnet, haiku); codex
57
+ (astra, sol, luna) and gemini (argon, flash) tiers
58
+ resolve to the newest id in the harness's own
59
+ catalog. gemini defaults to argon (Antigravity also
60
+ serves non-Gemini models under the same account).
51
61
  --effort <level> Reasoning effort (claude --effort / codex
52
- model_reasoning_effort).
62
+ model_reasoning_effort / agy --effort /
63
+ grok --effort).
53
64
  --label <s> Tag the job record (shows in gaslamp jobs).
54
65
  --sandbox <mode> claude: read-only | danger-full-access
55
66
  codex: read-only | workspace-write | danger-full-access
56
- Omit to defer to the consulted agent's own config.
67
+ gemini: read-only | danger-full-access
68
+ grok: read-only | workspace-write | danger-full-access
69
+ Omitted: claude and codex defer to the consulted
70
+ agent's own config; gemini and grok run read-only.
57
71
  --cwd <dir> Working directory for the consult (default: here).
58
72
  --schema <file|json> JSON Schema for the reply (native on both backends:
59
73
  claude --json-schema / codex --output-schema). The
@@ -104,14 +118,22 @@ Env knobs:
104
118
  WebSearch + read-only git via Bash(git diff:*) etc)
105
119
  GASLAMP_ALLOW_RECURSION let consulted agents consult back (off = one hop)
106
120
  GASLAMP_DEBUG verbose stderr (spawn argv)
107
- CLAUDE_BIN / CODEX_BIN backend binaries (autodetected otherwise)
121
+ CLAUDE_BIN / CODEX_BIN / AGY_BIN / GROK_BIN
122
+ backend binaries (autodetected otherwise)
123
+
124
+ Every job record (meta.json) carries provenance: provider, harness version,
125
+ the model the backend reported (or unknown), the vendor system prompt's hash
126
+ where the harness exposes it (grok) or known:false, the retrieval tools the
127
+ consultee had and used, and the gaslamp version and git revision.
108
128
  `;
109
129
 
110
130
  const [cmd, ...rest] = process.argv.slice(2);
111
131
 
112
132
  switch (cmd) {
113
133
  case "claude":
114
- case "codex": {
134
+ case "codex":
135
+ case "gemini":
136
+ case "grok": {
115
137
  const { runConsult } = await import(new URL("../src/consult.mjs", import.meta.url));
116
138
  runConsult(cmd, rest);
117
139
  break;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gaslamp",
3
- "version": "2.1.2",
4
- "description": "Let Claude Code and Codex consult each other from the shell.",
3
+ "version": "2.2.0",
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": {
7
7
  "gaslamp": "bin/gaslamp.mjs"
@@ -21,6 +21,9 @@
21
21
  "claude",
22
22
  "claude-code",
23
23
  "codex",
24
+ "gemini",
25
+ "antigravity",
26
+ "grok",
24
27
  "llm",
25
28
  "agent",
26
29
  "ai",
@@ -0,0 +1,39 @@
1
+ // backends.mjs — the four consult targets and the facts every module needs
2
+ // about them: job-id prefix, who serves the model, which harness binary, which
3
+ // --sandbox values are honest, and what an omitted --sandbox means.
4
+ //
5
+ // claude and codex are the original pair, and an omitted --sandbox defers to
6
+ // the consulted agent's own config. gemini (Google's Antigravity CLI, `agy`)
7
+ // and grok (xAI's Grok Build) joined in 2.2 as consult targets with narrow
8
+ // operational trust: an omitted --sandbox means read-only, enforced by the
9
+ // harness (see consult.mjs), and every job records provenance — the vendor's
10
+ // model or system prompt can change under a stable product name.
11
+
12
+ export const BACKENDS = {
13
+ claude: {
14
+ prefix: "cl", provider: "anthropic", harness: "claude",
15
+ sandboxes: ["read-only", "danger-full-access"], defaultSandbox: null,
16
+ },
17
+ codex: {
18
+ prefix: "cx", provider: "openai", harness: "codex",
19
+ sandboxes: ["read-only", "workspace-write", "danger-full-access"], defaultSandbox: null,
20
+ },
21
+ gemini: {
22
+ // Antigravity has no headless workspace-write: file writes and shell
23
+ // commands are soft-denied under its default request-review preset, and
24
+ // --mode accept-edits did not change that in a print-mode run (probed on
25
+ // 1.4.3). The only escalation is --dangerously-skip-permissions.
26
+ prefix: "gm", provider: "google", harness: "agy",
27
+ sandboxes: ["read-only", "danger-full-access"], defaultSandbox: "read-only",
28
+ },
29
+ grok: {
30
+ prefix: "gk", provider: "xai", harness: "grok",
31
+ sandboxes: ["read-only", "workspace-write", "danger-full-access"], defaultSandbox: "read-only",
32
+ },
33
+ };
34
+
35
+ export const BACKEND_NAMES = Object.keys(BACKENDS);
36
+ export const isBackend = (s) => Object.hasOwn(BACKENDS, s);
37
+
38
+ const PREFIXES = BACKEND_NAMES.map((b) => BACKENDS[b].prefix).join("|");
39
+ export const JOB_ID_RE = new RegExp(`^(${PREFIXES})-\\d{8}-\\d{6}-[0-9a-f]{4}$`);
package/src/consult.mjs CHANGED
@@ -3,6 +3,8 @@
3
3
  //
4
4
  // gaslamp claude [opts] <prompt|-> spawns `claude -p`
5
5
  // gaslamp codex [opts] <prompt|-> spawns `codex exec`
6
+ // gaslamp gemini [opts] <prompt|-> spawns `agy` (Antigravity CLI) in print mode
7
+ // gaslamp grok [opts] <prompt|-> spawns `grok` (Grok Build) in headless mode
6
8
  //
7
9
  // Block-until-reply, deliberately: asynchrony is the HARNESS's job. Each agent
8
10
  // backgrounds the shell call with its own facility (Claude Code:
@@ -32,10 +34,13 @@
32
34
  // 0 reply delivered · 1 consult failed or killed · 2 usage · 3 nested
33
35
  // (one-hop) refusal · 4 network-disabled sandbox · 5 session busy
34
36
 
35
- import { spawn } from "node:child_process";
36
- import { createWriteStream, existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
37
+ import { execFileSync, spawn } from "node:child_process";
38
+ import { createHash } from "node:crypto";
39
+ import { createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
37
40
  import { homedir } from "node:os";
38
- import { join } from "node:path";
41
+ import { dirname, join } from "node:path";
42
+ import { fileURLToPath } from "node:url";
43
+ import { BACKENDS } from "./backends.mjs";
39
44
  import { which } from "./which.mjs";
40
45
  import {
41
46
  alive, createJob, jobDir, jobsDir, locksDir, looksLikeJobId,
@@ -64,11 +69,88 @@ const log = (...a) => { if (DEBUG) writeSync(2, "[gaslamp] " + a.join(" ") + "\n
64
69
  // pipe is async in Node; a plain write could be truncated).
65
70
  const die = (code, msg) => { writeSync(2, `gaslamp: ${msg}\n`); process.exit(code); };
66
71
 
67
- const SANDBOXES = {
68
- claude: ["read-only", "danger-full-access"],
69
- codex: ["read-only", "workspace-write", "danger-full-access"],
72
+ // The default Gemini tier when --model is omitted. Antigravity also serves
73
+ // Claude and GPT-OSS models under the same account, and an account default
74
+ // pointing at one of them would turn a "gemini" consult into a second opinion
75
+ // from a model the caller already has — so the target always pins a Gemini.
76
+ const GEMINI_DEFAULT_TIER = "argon";
77
+
78
+ // Tool names that reach the live web (or X) per backend, for the provenance
79
+ // record: what the consultee could retrieve, and what it actually did.
80
+ const RETRIEVAL_TOOLS = {
81
+ claude: /^(WebSearch|WebFetch)$/,
82
+ codex: /^web_search/,
83
+ gemini: /^(search_web|read_url_content|open_browser_url|read_browser_page|browser_)/,
84
+ grok: /^(web_search|web_fetch|x_search|x_)/,
70
85
  };
71
86
 
87
+ // Variables a third-party harness has no business inheriting from the calling
88
+ // agent: Claude Code's session id, messaging socket token, account and OAuth
89
+ // details; Codex's and the API keys' equivalents. They reach the consultee's
90
+ // shell and its MCP servers — the end-conversation server keyed its
91
+ // destructive path on CLAUDE_CODE_SESSION_ID before it learned to stop at the
92
+ // nearest harness — and nothing in a Gemini or Grok consult needs them.
93
+ const FOREIGN_ENV = /^(CLAUDE|ANTHROPIC_|CODEX_|OPENAI_)/;
94
+
95
+ // Grok Build reads other harnesses' configuration by default — ~/.claude's
96
+ // CLAUDE.md as its own instructions, Claude's skills, MCP servers and hooks,
97
+ // Cursor's the same. A consult must not hand the consultee the caller's
98
+ // identity file or run the caller's hooks. Machines bootstrapped from a9lim's
99
+ // ledger turn these off in ~/.grok/config.toml; this covers every other one.
100
+ // (Grok also reads ~/.claude/settings.json permissions, with no switch; the
101
+ // explicit --permission-mode below overrides its defaultMode.)
102
+ const GROK_COMPAT_OFF = Object.fromEntries(
103
+ ["CLAUDE", "CURSOR"].flatMap((v) => ["AGENTS", "SKILLS", "RULES", "MCPS", "HOOKS"]
104
+ .map((c) => [`GROK_${v}_${c}_ENABLED`, "0"])));
105
+
106
+ // The environment every process gaslamp starts for a backend gets — the
107
+ // consult itself and its probes (`--version`, `agy models`) alike.
108
+ function backendEnv(backend) {
109
+ const env = { ...process.env, GASLAMP_NESTED: "1" };
110
+ // Keychain OAuth is authoritative for the consulted Claude: a stale env key
111
+ // 401s every call (1.0's hardest auth lesson). No Codex analog needed.
112
+ if (backend === "claude") delete env.ANTHROPIC_API_KEY;
113
+ if (backend === "gemini" || backend === "grok")
114
+ for (const k of Object.keys(env)) if (FOREIGN_ENV.test(k)) delete env[k];
115
+ if (backend === "grok") Object.assign(env, GROK_COMPAT_OFF, { GROK_DISABLE_AUTOUPDATER: "1" });
116
+ return env;
117
+ }
118
+
119
+ // What a read-only gemini consult may find pre-allowed in Antigravity's
120
+ // permission files: reads, read-only git (read-capable like the claude read
121
+ // set — git's own --output flags aside), and the end_session off-ramp. Any
122
+ // other grant (writes, other commands, unsandboxed, browser actuation, other
123
+ // MCP tools) would run unprompted under the request-review preset, so its
124
+ // presence refuses the consult rather than letting "read-only" mean less.
125
+ const AGY_READ_GRANT = /^(read_file|read_url)\(|^command\(git (diff|log|show|status)\)$|^mcp\(end-conversation\/(\*|end_session)\)$/;
126
+ const AGY_LAX_PRESETS = new Set(["always-proceed", "proceed-in-sandbox"]);
127
+
128
+ // Allow rules and the tool-permission preset from every file the CLI merges:
129
+ // its settings.json, the settings shared with the Antigravity app, and the
130
+ // per-project files (all of them — which one applies is not knowable here).
131
+ function agyPermissionState() {
132
+ const root = join(homedir(), ".gemini");
133
+ const files = [join(root, "antigravity-cli", "settings.json"), join(root, "config", "config.json")];
134
+ try {
135
+ for (const f of readdirSync(join(root, "config", "projects")))
136
+ if (f.endsWith(".json")) files.push(join(root, "config", "projects", f));
137
+ } catch {}
138
+ const grants = [], presets = [];
139
+ const walk = (node, file) => {
140
+ if (Array.isArray(node)) return node.forEach((n) => walk(n, file));
141
+ if (!node || typeof node !== "object") return;
142
+ for (const [k, v] of Object.entries(node)) {
143
+ if (k === "allow" && Array.isArray(v)) for (const r of v) if (typeof r === "string") grants.push({ rule: r, file });
144
+ if (k === "toolPermission" && typeof v === "string") presets.push({ preset: v, file });
145
+ walk(v, file);
146
+ }
147
+ };
148
+ for (const f of files) {
149
+ try { walk(JSON.parse(readFileSync(f, "utf8")), f); } catch {}
150
+ }
151
+ return { grants, presets };
152
+ }
153
+
72
154
  // The consult preamble: the one piece of framing a consulted agent needs.
73
155
  // Workflow subagents are told their final text IS the return value; a consult
74
156
  // deserves the same, or it replies conversationally and promises work instead
@@ -82,6 +164,18 @@ const PREAMBLE =
82
164
  "Distinguish what you verified from what you infer.";
83
165
 
84
166
  function resolveBin(backend) {
167
+ if (backend === "gemini") {
168
+ for (const c of [process.env.AGY_BIN, join(homedir(), ".local/bin/agy")].filter(Boolean))
169
+ if (existsSync(c)) return c;
170
+ return which("agy") || "agy";
171
+ }
172
+ if (backend === "grok") {
173
+ // Never realpath this one: the end-conversation server finds the harness it
174
+ // runs under by process name, and a resolved spawn is named after the
175
+ // versioned download (grok-macos-aarch64), not `grok`.
176
+ if (process.env.GROK_BIN && existsSync(process.env.GROK_BIN)) return process.env.GROK_BIN;
177
+ return which("grok") || [join(homedir(), ".grok/bin/grok")].find(existsSync) || "grok";
178
+ }
85
179
  if (backend === "claude") {
86
180
  for (const c of [process.env.CLAUDE_BIN, join(homedir(), ".local/bin/claude")].filter(Boolean))
87
181
  if (existsSync(c)) return c;
@@ -192,6 +286,135 @@ function acquireLock(backend, sid, jobId) {
192
286
  die(5, `could not acquire the lock for session ${sid} under ${locksDir()}`);
193
287
  }
194
288
 
289
+ // ---- model tiers --------------------------------------------------------------
290
+ // --model takes a tier word as well as an exact id. claude's CLI resolves its
291
+ // own aliases (fable, opus, sonnet, haiku); codex and agy only take versioned
292
+ // ids, so a bare tier is resolved here against the harness's OWN live catalog
293
+ // — the newest listed gpt-<version>-<tier> or gemini-<version>-<tier> — and a
294
+ // new generation is picked up without a gaslamp release. A word the catalog
295
+ // does not know passes through untouched, and the harness reports it.
296
+ const versionKey = (v) => v.split(".").map(Number);
297
+ const compareVersions = (a, b) => {
298
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
299
+ const d = (a[i] ?? 0) - (b[i] ?? 0);
300
+ if (d) return d;
301
+ }
302
+ return 0;
303
+ };
304
+ export function newestOfTier(slugs, family, tier) {
305
+ const re = new RegExp(`^${family}-(\\d+(?:\\.\\d+)*)-${tier}$`);
306
+ let best = null, bestV = null;
307
+ for (const slug of slugs) {
308
+ const m = re.exec(slug);
309
+ if (!m) continue;
310
+ const v = versionKey(m[1]);
311
+ if (!bestV || compareVersions(v, bestV) > 0) { best = slug; bestV = v; }
312
+ }
313
+ return best;
314
+ }
315
+
316
+ export function codexCatalog() {
317
+ try {
318
+ const home = process.env.CODEX_HOME || join(homedir(), ".codex");
319
+ const d = JSON.parse(readFileSync(join(home, "models_cache.json"), "utf8"));
320
+ return (d.models ?? []).filter((m) => m.visibility !== "hide").map((m) => m.slug).filter(Boolean);
321
+ } catch { return []; }
322
+ }
323
+
324
+ // `agy models` lists one slug per effort level (gemini-4-argon-high, …); the
325
+ // base slug takes the model's default effort, and --effort picks another.
326
+ export function agyCatalog(bin, env = process.env) {
327
+ try {
328
+ const out = execFileSync(bin, ["models"], { encoding: "utf8", timeout: 30000, stdio: ["ignore", "pipe", "ignore"], env });
329
+ return [...new Set(out.split("\n").map((l) => l.split("\t")[0].trim())
330
+ .filter((s) => /^[a-z0-9.-]+$/.test(s))
331
+ .map((s) => s.replace(/-(low|medium|high|xhigh|max)$/, "")))];
332
+ } catch { return []; }
333
+ }
334
+
335
+ function resolveModel(backend, model, bin, env) {
336
+ if (backend === "gemini") model ??= GEMINI_DEFAULT_TIER;
337
+ if (!model || !/^[a-z]+$/.test(model)) return model ?? null;
338
+ if (backend === "codex") return newestOfTier(codexCatalog(), "gpt", model) ?? model;
339
+ if (backend === "gemini") return newestOfTier(agyCatalog(bin, env), "gemini", model) ?? model;
340
+ return model;
341
+ }
342
+
343
+ // ---- provenance -----------------------------------------------------------------
344
+ // Vendors can change the model or the system prompt behind a stable product
345
+ // name, so every job records what it can about who actually answered.
346
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
347
+ const PKG_VERSION = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
348
+ let revisionMemo;
349
+ function gaslampRevision() {
350
+ if (revisionMemo !== undefined) return revisionMemo;
351
+ try {
352
+ const git = (...a) => execFileSync("git", ["-C", PKG_ROOT, ...a], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
353
+ const rev = git("rev-parse", "--short=12", "HEAD");
354
+ revisionMemo = git("status", "--porcelain", "--untracked-files=no") ? `${rev}+dirty` : rev;
355
+ } catch { revisionMemo = "unknown"; }
356
+ return revisionMemo;
357
+ }
358
+
359
+ function harnessVersion(bin, env) {
360
+ try {
361
+ const out = execFileSync(bin, ["--version"], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"], env });
362
+ return out.split("\n").find((l) => l.trim())?.trim() || "unknown";
363
+ } catch { return "unknown"; }
364
+ }
365
+
366
+ // Grok writes each session's full system prompt next to its transcript —
367
+ // the one backend whose vendor prompt is knowable, so it is hashed into the
368
+ // record (a change between jobs is visible without storing the text twice).
369
+ function grokSystemPrompt(sessionId, cwd) {
370
+ const root = join(process.env.GROK_HOME || join(homedir(), ".grok"), "sessions");
371
+ const candidates = [join(root, encodeURIComponent(cwd), sessionId, "system_prompt.txt")];
372
+ try { for (const d of readdirSync(root)) candidates.push(join(root, d, sessionId, "system_prompt.txt")); } catch {}
373
+ for (const path of candidates) {
374
+ try {
375
+ const text = readFileSync(path, "utf8");
376
+ return { known: true, sha256: createHash("sha256").update(text).digest("hex"), chars: text.length, path };
377
+ } catch {}
378
+ }
379
+ return { known: false };
380
+ }
381
+
382
+ function geminiArgs(o, resumeSid, schemaText, model, sandbox) {
383
+ // Print mode's -p takes the prompt as an argument; streaming input reads it
384
+ // from stdin as one {"event":"user"} line and ends the session at EOF, which
385
+ // keeps the prompt-on-stdin contract (no argv-size or quoting traps).
386
+ const args = ["--input-format", "stream-json", "--output-format", "stream-json", "--model", model];
387
+ if (o.effort) args.push("--effort", o.effort);
388
+ if (resumeSid) args.push("--conversation", resumeSid);
389
+ if (schemaText) args.push("--json-schema", schemaText);
390
+ // read-only adds no flag: the default request-review preset already denies
391
+ // writes, shell and web reads in print mode. Before spawn, the permission
392
+ // files are checked for grants that would let anything else through; after,
393
+ // the init event's permission_mode must confirm the preset (fail closed).
394
+ if (sandbox === "danger-full-access") args.push("--dangerously-skip-permissions");
395
+ return args;
396
+ }
397
+
398
+ function grokArgs(o, resumeSid, schemaText, sandbox) {
399
+ // streaming-messages-json is Anthropic's stream-json shape (system init with
400
+ // session_id and model first, result last) — the claude parser reads it.
401
+ const args = ["--prompt-file", "/dev/stdin", "--output-format", "streaming-messages-json", "--no-auto-update"];
402
+ // --rules appends to the system prompt: claude's --append-system-prompt.
403
+ if (!o.raw) args.push("--rules", PREAMBLE);
404
+ if (schemaText) args.push("--json-schema", schemaText);
405
+ // The kernel sandbox (Seatbelt / Landlock) is the read-only guarantee. The
406
+ // permission layer is not: under --permission-mode default a headless run
407
+ // denied the write tool but ran an arbitrary `touch` through the shell
408
+ // (probed on 1.0.62). read-only still writes ~/.grok (sessions) and temp dirs.
409
+ if (sandbox === "read-only") args.push("--sandbox", "read-only", "--permission-mode", "default");
410
+ else if (sandbox === "workspace-write") args.push("--sandbox", "workspace", "--permission-mode", "acceptEdits");
411
+ else if (sandbox === "danger-full-access") args.push("--sandbox", "off", "--always-approve");
412
+ if (resumeSid) args.push("--resume", resumeSid);
413
+ if (o.model) args.push("-m", o.model);
414
+ if (o.effort) args.push("--effort", o.effort);
415
+ return args;
416
+ }
417
+
195
418
  function claudeArgs(o, resumeSid, schemaText) {
196
419
  // stream-json (not plain json) so events land incrementally: the session id
197
420
  // arrives in the init event within seconds, and the write-through record
@@ -247,11 +470,17 @@ export function runConsult(backend, argv) {
247
470
  if (process.env.CODEX_SANDBOX_NETWORK_DISABLED === "1")
248
471
  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).");
249
472
 
250
- if (o.sandbox && !SANDBOXES[backend].includes(o.sandbox)) {
473
+ const facts = BACKENDS[backend];
474
+ if (o.sandbox && !facts.sandboxes.includes(o.sandbox)) {
251
475
  die(2, backend === "claude" && o.sandbox === "workspace-write"
252
476
  ? "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"
253
- : `bad --sandbox "${o.sandbox}" (${backend}: ${SANDBOXES[backend].join(" | ")})`);
477
+ : backend === "gemini" && o.sandbox === "workspace-write"
478
+ ? "Antigravity print mode denies file writes unless every permission is skipped, so there is no honest workspace-write — use read-only (the default) or danger-full-access"
479
+ : `bad --sandbox "${o.sandbox}" (${backend}: ${facts.sandboxes.join(" | ")})`);
254
480
  }
481
+ // claude/codex: omitted = the consulted agent's own config. gemini/grok:
482
+ // omitted = read-only (BACKENDS.defaultSandbox).
483
+ const sandbox = o.sandbox ?? facts.defaultSandbox;
255
484
 
256
485
  // ---- prompt (argv, or stdin via `-` / piped-with-no-arg) -------------------
257
486
  let prompt = o.prompt;
@@ -282,6 +511,25 @@ export function runConsult(backend, argv) {
282
511
  }
283
512
  const thread = o.thread ? readThread(backend, o.thread) : null;
284
513
  const resumeSid = o.resume ? resolveResume(backend, o.resume) : thread?.sessionId ?? null;
514
+ // Everything that runs the harness synchronously (the tier lookup can take
515
+ // agy ~2s, the version probe a few ms) happens before the lock is taken and
516
+ // the record exists: no signal handler is installed yet, and a kill here
517
+ // must not strand either.
518
+ const bin = resolveBin(backend);
519
+ const env = backendEnv(backend);
520
+ let grants = null;
521
+ if (backend === "gemini" && sandbox === "read-only") {
522
+ const state = agyPermissionState();
523
+ const lax = state.presets.filter((p) => AGY_LAX_PRESETS.has(p.preset));
524
+ const open = state.grants.filter((g) => !AGY_READ_GRANT.test(g.rule));
525
+ if (lax.length || open.length)
526
+ die(1, "Antigravity's settings would let a read-only consult act unprompted — " +
527
+ [...lax.map((p) => `toolPermission "${p.preset}" (${p.file})`), ...open.map((g) => `${g.rule} (${g.file})`)].join(", ") +
528
+ ". Remove them, or pass --sandbox danger-full-access to consult without the guarantee.");
529
+ grants = state.grants.map((g) => g.rule);
530
+ }
531
+ const model = resolveModel(backend, o.model, bin, env);
532
+ const version = harnessVersion(bin, env);
285
533
  const id = newJobId(backend);
286
534
  let lockPath = resumeSid ? acquireLock(backend, resumeSid, id) : null;
287
535
  const releaseLock = () => { if (lockPath) { try { unlinkSync(lockPath); } catch {} lockPath = null; } };
@@ -292,16 +540,26 @@ export function runConsult(backend, argv) {
292
540
  id, backend, status: "running",
293
541
  sessionId: resumeSid, resumedFrom: resumeSid, thread: o.thread ?? null,
294
542
  label: o.label ?? null,
295
- model: o.model ?? null, sandbox: o.sandbox ?? null, effort: o.effort ?? null,
543
+ model, sandbox, effort: o.effort ?? null,
296
544
  cwd: o.cwd || process.cwd(),
297
545
  pid: process.pid, childPid: null, exitCode: null, signal: null,
298
546
  startedAt: new Date().toISOString(), endedAt: null,
299
547
  promptChars: prompt.length, schema: !!schemaText, raw: !!o.raw,
548
+ provenance: {
549
+ provider: facts.provider, harness: facts.harness, harnessVersion: version,
550
+ // `model` is what the backend reported answering with; unknown until it
551
+ // does (codex never reports one in its event stream).
552
+ model: "unknown", modelRequested: o.model ?? null, modelPassed: model,
553
+ permissionMode: null, sandbox,
554
+ ...(grants ? { grants } : {}),
555
+ systemPrompt: { known: false },
556
+ retrieval: { available: null, used: [] },
557
+ gaslamp: { version: PKG_VERSION, revision: gaslampRevision() },
558
+ },
300
559
  };
301
560
  createJob(meta, prompt);
302
561
 
303
562
  // ---- spawn -----------------------------------------------------------------
304
- const bin = resolveBin(backend);
305
563
  const replyPath = join(dir, "reply.md");
306
564
  // schema.json lands in the job dir for BOTH backends: codex needs the file,
307
565
  // and the record should show what was actually sent — for codex that is the
@@ -313,12 +571,10 @@ export function runConsult(backend, argv) {
313
571
  : schemaText;
314
572
  writeFileSync(schemaPath, sent.endsWith("\n") ? sent : sent + "\n");
315
573
  }
316
- const args = backend === "claude" ? claudeArgs(o, resumeSid, schemaText) : codexArgs(o, resumeSid, replyPath, schemaPath);
317
-
318
- const env = { ...process.env, GASLAMP_NESTED: "1" };
319
- // Keychain OAuth is authoritative for the consulted Claude: a stale env key
320
- // 401s every call (1.0's hardest auth lesson). No Codex analog needed.
321
- if (backend === "claude") delete env.ANTHROPIC_API_KEY;
574
+ const args = backend === "claude" ? claudeArgs({ ...o, model }, resumeSid, schemaText)
575
+ : backend === "codex" ? codexArgs({ ...o, model }, resumeSid, replyPath, schemaPath)
576
+ : backend === "gemini" ? geminiArgs(o, resumeSid, schemaText, model, sandbox)
577
+ : grokArgs(o, resumeSid, schemaText, sandbox);
322
578
 
323
579
  log("spawn", bin, JSON.stringify(args));
324
580
  // detached → the child leads its own process group, so signal forwarding can
@@ -326,6 +582,10 @@ export function runConsult(backend, argv) {
326
582
  const child = spawn(bin, args, { cwd: meta.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
327
583
  meta.childPid = child.pid ?? null;
328
584
  writeMeta(meta);
585
+ const killGroup = (sig) => {
586
+ try { process.kill(-child.pid, sig); }
587
+ catch { try { child.kill(sig); } catch {} }
588
+ };
329
589
 
330
590
  writeSync(2, `[gaslamp] → ${backend} · job ${id} · watch: tail -f ${join(dir, "events.jsonl")}\n`);
331
591
 
@@ -340,74 +600,165 @@ export function runConsult(backend, argv) {
340
600
  child.stdin.on("error", () => {}); // EPIPE if the child dies before reading
341
601
  // prompt.md records the CALLER's content; the preamble is mechanism, applied
342
602
  // here at the spawn boundary (codex has no --append-system-prompt analog).
343
- if (backend === "codex" && !o.raw)
344
- child.stdin.write("<gaslamp_consult>\n" + PREAMBLE + "\n</gaslamp_consult>\n\n");
345
- child.stdin.write(prompt);
603
+ const preamble = o.raw ? "" : "<gaslamp_consult>\n" + PREAMBLE + "\n</gaslamp_consult>\n\n";
604
+ if (backend === "gemini") {
605
+ child.stdin.write(JSON.stringify({ event: "user", message: { content: preamble + prompt } }) + "\n");
606
+ } else {
607
+ if (backend === "codex") child.stdin.write(preamble);
608
+ child.stdin.write(prompt);
609
+ }
346
610
  child.stdin.end();
347
611
 
348
612
  const events = createWriteStream(join(dir, "events.jsonl"), { flags: "a" });
349
613
  const errlog = createWriteStream(join(dir, "stderr.log"), { flags: "a" });
350
- child.stderr.on("data", (d) => errlog.write(d));
614
+ // Decode as UTF-8 streams: a multibyte character split across two chunks
615
+ // must not turn into replacement characters in the parsed events.
616
+ child.stdout.setEncoding("utf8");
617
+ child.stderr.setEncoding("utf8");
618
+
619
+ // A consult the harness can't start honestly is stopped, not left to run.
620
+ let refusal = null;
621
+ const refuse = (why) => {
622
+ if (refusal) return;
623
+ refusal = why;
624
+ writeSync(2, `[gaslamp] ${why}\n`);
625
+ killGroup("SIGTERM");
626
+ setTimeout(() => killGroup("SIGKILL"), 2000).unref();
627
+ };
628
+ // `agy` without a cached sign-in prints a Google sign-in URL and waits for a
629
+ // pasted code forever in print mode (1.4.3; its docs say it exits). Only
630
+ // what the harness prints before its init event counts — stderr and any
631
+ // non-JSON stdout, accumulated across chunks — never protocol content,
632
+ // where a reply may well say "authentication required".
633
+ let initSeen = false, preInit = "";
634
+ const AUTH_NOTICE = /Authentication required/i;
635
+ const notSignedIn = () => refuse("agy is not signed in — run `agy` once in a terminal to sign in, then retry");
636
+ const watchAuth = (text) => {
637
+ if (backend !== "gemini" || initSeen) return;
638
+ preInit = (preInit + text).slice(-4096);
639
+ if (AUTH_NOTICE.test(preInit)) notSignedIn();
640
+ };
641
+ child.stderr.on("data", (d) => { errlog.write(d); watchAuth(d); });
642
+
643
+ const prov = meta.provenance;
644
+ const noteTool = (name) => {
645
+ if (name && RETRIEVAL_TOOLS[backend].test(name) && !prov.retrieval.used.includes(name))
646
+ prov.retrieval.used.push(name);
647
+ };
351
648
 
352
649
  // ---- event stream: write through; fish out session id + reply --------------
353
650
  let buf = "", resultText = null, resultErr = false, lastAgentText = null, structuredOut;
354
- const usageAcc = { input: 0, output: 0 }; // codex reports per turn; sum them
651
+ let initValidated = false; // gemini read-only: the harness confirmed its preset
652
+ const usageAcc = { input: 0, output: 0 }; // codex and agy report per turn/step; sum them
653
+ const claudeShaped = backend === "claude" || backend === "grok";
654
+ const handleLine = (raw) => {
655
+ const line = raw.trim();
656
+ if (!line) return;
657
+ let j; try { j = JSON.parse(line); } catch { watchAuth(line + "\n"); return; }
658
+ const sid = claudeShaped
659
+ ? ((j.type === "system" && j.subtype === "init") || j.type === "result" ? j.session_id : null)
660
+ : backend === "codex" ? (j.type === "thread.started" ? (j.thread_id ?? j.threadId) : null)
661
+ : (j.event === "init" || j.event === "result" ? (j.conversation_id ?? j.result?.conversation_id) : null);
662
+ if (sid) {
663
+ if (meta.sessionId !== sid) { meta.sessionId = sid; writeMeta(meta); } // early: the recovery handle
664
+ // Bind/re-point the thread the moment the session is known — claude
665
+ // resumes fork a fresh session id, and the pointer must chase it.
666
+ if (o.thread) writeThread({
667
+ backend, name: o.thread, sessionId: sid, lastJobId: id,
668
+ createdAt: thread?.createdAt ?? meta.startedAt, updatedAt: new Date().toISOString(),
669
+ });
670
+ }
671
+ if (claudeShaped && j.type === "system" && j.subtype === "init") {
672
+ if (j.model) prov.model = j.model;
673
+ prov.permissionMode = j.permissionMode ?? null;
674
+ if (Array.isArray(j.tools)) prov.retrieval.available = j.tools.filter((t) => RETRIEVAL_TOOLS[backend].test(t));
675
+ writeMeta(meta);
676
+ } else if (claudeShaped && j.type === "assistant") {
677
+ for (const c of j.message?.content ?? []) {
678
+ if (c.type === "tool_use" || c.type === "server_tool_use") noteTool(c.name);
679
+ if (c.type === "tool_use" && c.name === "use_tool") noteTool(c.input?.tool_name);
680
+ }
681
+ } else if (backend === "gemini" && j.event === "init") {
682
+ initSeen = true;
683
+ const init = j.init ?? {};
684
+ if (init.model) prov.model = init.model;
685
+ prov.permissionMode = init.permission_mode ?? null;
686
+ // The browser toolset is ~20 tools; one entry says the same thing.
687
+ if (Array.isArray(init.tools)) prov.retrieval.available = [...new Set(init.tools
688
+ .filter((t) => RETRIEVAL_TOOLS.gemini.test(t)).map((t) => t.startsWith("browser_") ? "browser_*" : t))];
689
+ writeMeta(meta);
690
+ // Read-only rests on Antigravity's request-review (or strict) preset;
691
+ // a laxer one would let the consult act unprompted.
692
+ if (sandbox === "read-only" && !["request-review", "strict"].includes(prov.permissionMode))
693
+ refuse(`agy reports permission mode "${prov.permissionMode}", not request-review — refusing a read-only consult it would not enforce`);
694
+ else initValidated = true;
695
+ } else if (backend === "gemini" && j.event === "step_update") {
696
+ const st = j.step_update ?? {};
697
+ if (st.step_type === "tool" && st.state === "DONE") noteTool(st.tool_name);
698
+ if (st.step_type === "agent_response" && st.state === "DONE" && st.usage) {
699
+ // Per step; the result event's usage is cumulative over the whole
700
+ // conversation, resumes included. input_tokens EXCLUDES cache reads
701
+ // (a resumed turn: 2376 input + 5118 cache read ≈ the full prompt),
702
+ // so both count, as for claude.
703
+ usageAcc.input += (st.usage.input_tokens ?? 0) + (st.usage.cache_read_tokens ?? 0);
704
+ usageAcc.output += st.usage.output_tokens ?? 0;
705
+ meta.usage = { inputTokens: usageAcc.input, outputTokens: usageAcc.output, costUsd: null };
706
+ }
707
+ } else if (backend === "gemini" && j.event === "result") {
708
+ const r = j.result ?? {};
709
+ resultText = r.response ?? null;
710
+ resultErr = r.status !== "SUCCESS";
711
+ if (r.error) writeSync(2, `[gaslamp] agy: ${r.error}\n`);
712
+ if (r.structured_output !== undefined) structuredOut = r.structured_output;
713
+ } else if (claudeShaped && j.type === "result") {
714
+ resultText = j.result ?? j.content ?? null;
715
+ resultErr = !!j.is_error;
716
+ if (j.structured_output !== undefined) structuredOut = j.structured_output;
717
+ // Grok bills by the model it actually ran (grok-4.7-build behind a
718
+ // reported grok-4.7), which only shows up here.
719
+ if (j.modelUsage) prov.billedModels = Object.keys(j.modelUsage);
720
+ // Fleets are quota-shaped, so quota should be visible: inputTokens
721
+ // counts cache reads/writes too (that's what the meter meters).
722
+ if (j.usage) meta.usage = {
723
+ inputTokens: (j.usage.input_tokens ?? 0) + (j.usage.cache_read_input_tokens ?? 0)
724
+ + (j.usage.cache_creation_input_tokens ?? 0),
725
+ outputTokens: j.usage.output_tokens ?? 0,
726
+ costUsd: j.total_cost_usd ?? null,
727
+ };
728
+ } else if (backend === "codex" && j.type === "turn.completed" && j.usage) {
729
+ // OpenAI semantics differ from Anthropic's: input_tokens already
730
+ // INCLUDES the cached portion (cached_input_tokens is a subset, and
731
+ // codex's own total_tokens = input + output), so adding it again
732
+ // double-counts cache hits.
733
+ usageAcc.input += j.usage.input_tokens ?? 0;
734
+ usageAcc.output += j.usage.output_tokens ?? 0;
735
+ meta.usage = { inputTokens: usageAcc.input, outputTokens: usageAcc.output, costUsd: null };
736
+ } else if (backend === "codex" && (j.type === "item.started" || j.type === "item.completed")) {
737
+ const item = j.item ?? {};
738
+ const t = item.type ?? item.item_type;
739
+ if (t === "web_search") noteTool("web_search");
740
+ // Tolerant fallback if -o never lands (schema drift across versions).
741
+ if (j.type === "item.completed" && t === "agent_message" && typeof item.text === "string")
742
+ lastAgentText = item.text;
743
+ }
744
+ };
355
745
  child.stdout.on("data", (d) => {
356
746
  events.write(d);
357
- buf += d.toString();
747
+ buf += d;
358
748
  let nl;
359
749
  while ((nl = buf.indexOf("\n")) >= 0) {
360
- const line = buf.slice(0, nl).trim();
750
+ const line = buf.slice(0, nl);
361
751
  buf = buf.slice(nl + 1);
362
- if (!line) continue;
363
- let j; try { j = JSON.parse(line); } catch { continue; }
364
- const sid = backend === "claude"
365
- ? ((j.type === "system" && j.subtype === "init") || j.type === "result" ? j.session_id : null)
366
- : (j.type === "thread.started" ? (j.thread_id ?? j.threadId) : null);
367
- if (sid) {
368
- if (meta.sessionId !== sid) { meta.sessionId = sid; writeMeta(meta); } // early: the recovery handle
369
- // Bind/re-point the thread the moment the session is known — claude
370
- // resumes fork a fresh session id, and the pointer must chase it.
371
- if (o.thread) writeThread({
372
- backend, name: o.thread, sessionId: sid, lastJobId: id,
373
- createdAt: thread?.createdAt ?? meta.startedAt, updatedAt: new Date().toISOString(),
374
- });
375
- }
376
- if (backend === "claude" && j.type === "result") {
377
- resultText = j.result ?? j.content ?? null;
378
- resultErr = !!j.is_error;
379
- if (j.structured_output !== undefined) structuredOut = j.structured_output;
380
- // Fleets are quota-shaped, so quota should be visible: inputTokens
381
- // counts cache reads/writes too (that's what the meter meters).
382
- if (j.usage) meta.usage = {
383
- inputTokens: (j.usage.input_tokens ?? 0) + (j.usage.cache_read_input_tokens ?? 0)
384
- + (j.usage.cache_creation_input_tokens ?? 0),
385
- outputTokens: j.usage.output_tokens ?? 0,
386
- costUsd: j.total_cost_usd ?? null,
387
- };
388
- } else if (backend === "codex" && j.type === "turn.completed" && j.usage) {
389
- // OpenAI semantics differ from Anthropic's: input_tokens already
390
- // INCLUDES the cached portion (cached_input_tokens is a subset, and
391
- // codex's own total_tokens = input + output), so adding it again
392
- // double-counts cache hits.
393
- usageAcc.input += j.usage.input_tokens ?? 0;
394
- usageAcc.output += j.usage.output_tokens ?? 0;
395
- meta.usage = { inputTokens: usageAcc.input, outputTokens: usageAcc.output, costUsd: null };
396
- } else if (backend === "codex" && j.type === "item.completed") {
397
- // Tolerant fallback if -o never lands (schema drift across versions).
398
- const item = j.item ?? {};
399
- if ((item.type === "agent_message" || item.item_type === "agent_message") && typeof item.text === "string")
400
- lastAgentText = item.text;
401
- }
752
+ handleLine(line);
402
753
  }
754
+ // A prompt waiting for input may never end its line: look at the
755
+ // unterminated non-JSON tail too, without consuming it.
756
+ if (backend === "gemini" && !initSeen && buf && !buf.trimStart().startsWith("{")
757
+ && AUTH_NOTICE.test(preInit + buf)) notSignedIn();
403
758
  });
404
759
 
405
760
  // ---- signals: kill the group, finalize, no orphans --------------------------
406
761
  let killedBy = null;
407
- const killGroup = (sig) => {
408
- try { process.kill(-child.pid, sig); }
409
- catch { try { child.kill(sig); } catch {} }
410
- };
411
762
  for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
412
763
  process.on(sig, () => {
413
764
  if (killedBy) return killGroup("SIGKILL");
@@ -418,14 +769,17 @@ export function runConsult(backend, argv) {
418
769
  });
419
770
  }
420
771
 
421
- const finalize = (status, code) => {
772
+ // One terminal state, published once: the reply is written and validated
773
+ // before meta.json leaves "running" and the session lock is released.
774
+ const finalize = (status, code, reply) => {
422
775
  if (meta.status !== "running") return;
776
+ if (reply != null) writeFileSync(replyPath, reply);
777
+ if (backend === "grok" && meta.sessionId) prov.systemPrompt = grokSystemPrompt(meta.sessionId, meta.cwd);
423
778
  meta.status = status;
424
779
  meta.exitCode = code;
425
780
  meta.signal = killedBy;
426
781
  meta.endedAt = new Date().toISOString();
427
- if (backend === "claude" && resultText != null) writeFileSync(replyPath, String(resultText));
428
- if (backend === "codex" && !existsSync(replyPath) && lastAgentText != null) writeFileSync(replyPath, lastAgentText);
782
+ if (refusal) meta.refusal = refusal;
429
783
  writeMeta(meta);
430
784
  releaseLock();
431
785
  };
@@ -436,11 +790,36 @@ export function runConsult(backend, argv) {
436
790
  });
437
791
 
438
792
  child.on("close", (code) => {
793
+ if (buf.trim()) handleLine(buf); // a final event without its newline
794
+ buf = "";
439
795
  events.end();
440
796
  errlog.end();
441
- finalize(killedBy ? "killed" : (code !== 0 || resultErr) ? "failed" : "done", code);
442
- const reply = existsSync(replyPath) ? readFileSync(replyPath, "utf8") : null;
443
-
797
+ // The leader is gone, but a descendant that ignored SIGTERM (and closed
798
+ // its inherited pipes, so `close` could fire) may not be: on any kill
799
+ // path, finish the group now rather than trusting the unref'd timer.
800
+ if (killedBy || refusal) killGroup("SIGKILL");
801
+
802
+ // The deliverable. codex writes it via -o (agent_message as fallback);
803
+ // the others hand it over in the result event — as text, or for a
804
+ // --schema consult possibly only as structured_output.
805
+ let reply;
806
+ if (backend === "codex") reply = existsSync(replyPath) ? readFileSync(replyPath, "utf8") : lastAgentText;
807
+ else reply = resultText != null && String(resultText).trim() ? String(resultText)
808
+ : structuredOut !== undefined ? JSON.stringify(structuredOut) : resultText;
809
+
810
+ let status = killedBy ? "killed" : (refusal || code !== 0 || resultErr) ? "failed" : "done";
811
+ if (status === "done" && backend === "gemini" && sandbox === "read-only" && !initValidated) {
812
+ status = "failed";
813
+ refusal = "agy reported no init event, so its permission mode — the read-only guarantee — went unconfirmed";
814
+ writeSync(2, `[gaslamp] ${refusal}\n`);
815
+ }
816
+ // agy and grok report success for a turn that ended on a denied tool call
817
+ // with nothing to say (agy: "no output produced", on stderr). An empty
818
+ // reply is not a deliverable.
819
+ if (status === "done" && (backend === "gemini" || backend === "grok") && !reply?.trim()) {
820
+ status = "failed";
821
+ writeSync(2, `[gaslamp] ${backend} returned an empty reply — marking the consult failed\n`);
822
+ }
444
823
  // A schema consult's contract is a typed reply: parse it (claude hands the
445
824
  // object over in the result event; codex's reply.md IS the JSON), and an
446
825
  // unparseable reply is a FAILED consult, not a quiet string.
@@ -448,12 +827,12 @@ export function runConsult(backend, argv) {
448
827
  if (schemaText) {
449
828
  data = structuredOut ?? null;
450
829
  if (data == null && reply != null) { try { data = JSON.parse(reply); } catch {} }
451
- if (data == null && meta.status === "done") {
452
- meta.status = "failed";
453
- writeMeta(meta);
830
+ if (data == null && status === "done") {
831
+ status = "failed";
454
832
  writeSync(2, "[gaslamp] --schema was set but the reply is not valid JSON — marking the consult failed\n");
455
833
  }
456
834
  }
835
+ finalize(status, code, backend === "codex" && existsSync(replyPath) ? null : reply);
457
836
 
458
837
  if (o.json) {
459
838
  process.stdout.write(JSON.stringify({
package/src/fleet.mjs CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  createFleet, liveStatus, looksLikeFleetId, newFleetId, readFleetMeta,
42
42
  readManifest, readMeta, readReply, writeFleetMeta,
43
43
  } from "./jobs.mjs";
44
+ import { BACKEND_NAMES, isBackend } from "./backends.mjs";
44
45
  import { readThread } from "./threads.mjs";
45
46
 
46
47
  const GASLAMP_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "gaslamp.mjs");
@@ -292,8 +293,8 @@ function renderBlock(backend, r) {
292
293
 
293
294
  export async function runFleet(backend, argv) {
294
295
  if (backend === "--resume") return runFleetResume(argv[0] ?? null, argv.slice(1));
295
- if (backend !== "claude" && backend !== "codex")
296
- die(2, `fleet needs a backend: gaslamp fleet <claude|codex|--resume fl-…> … (got "${backend ?? ""}")`);
296
+ if (!isBackend(backend))
297
+ die(2, `fleet needs a backend: gaslamp fleet <${BACKEND_NAMES.join("|")}|--resume fl-…> … (got "${backend ?? ""}")`);
297
298
 
298
299
  const o = parseArgs(argv);
299
300
  guards();
package/src/jobs.mjs CHANGED
@@ -5,7 +5,8 @@
5
5
  // $GASLAMP_HOME/jobs/<id>/
6
6
  // meta.json backend, status, sessionId, pids, timing, exit
7
7
  // prompt.md the prompt as sent
8
- // events.jsonl raw backend event stream (claude stream-json / codex --json)
8
+ // events.jsonl raw backend event stream (claude stream-json / codex --json /
9
+ // agy stream-json / grok streaming-messages-json)
9
10
  // reply.md final reply text
10
11
  // stderr.log backend stderr
11
12
  //
@@ -17,6 +18,7 @@
17
18
  import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
18
19
  import { homedir } from "node:os";
19
20
  import { join } from "node:path";
21
+ import { BACKENDS, JOB_ID_RE } from "./backends.mjs";
20
22
 
21
23
  export const HOME = () => process.env.GASLAMP_HOME || join(homedir(), ".gaslamp");
22
24
  export const jobsDir = () => join(HOME(), "jobs");
@@ -30,7 +32,8 @@ export const alive = (pid) => {
30
32
  };
31
33
 
32
34
  // Ids embed a local timestamp, so they sort chronologically and read at a
33
- // glance: cl-20260612-141233-9af2 (claude), cx-… (codex), fl-… (fleet).
35
+ // glance: cl-20260612-141233-9af2 (claude), cx-… (codex), gm-… (gemini),
36
+ // gk-… (grok), fl-… (fleet).
34
37
  function stampAndRand() {
35
38
  const d = new Date();
36
39
  const p = (n) => String(n).padStart(2, "0");
@@ -39,10 +42,10 @@ function stampAndRand() {
39
42
  return `${stamp}-${rand}`;
40
43
  }
41
44
 
42
- export const newJobId = (backend) => `${backend === "claude" ? "cl" : "cx"}-${stampAndRand()}`;
45
+ export const newJobId = (backend) => `${BACKENDS[backend].prefix}-${stampAndRand()}`;
43
46
  export const newFleetId = () => `fl-${stampAndRand()}`;
44
47
 
45
- export const looksLikeJobId = (s) => /^(cl|cx)-\d{8}-\d{6}-[0-9a-f]{4}$/.test(String(s));
48
+ export const looksLikeJobId = (s) => JOB_ID_RE.test(String(s));
46
49
  export const looksLikeFleetId = (s) => /^fl-\d{8}-\d{6}-[0-9a-f]{4}$/.test(String(s));
47
50
 
48
51
  // A fleet record lives alongside the consult records under jobs/, but it is
@@ -98,7 +101,7 @@ export function readReply(id) {
98
101
  }
99
102
 
100
103
  // Newest first. The timestamp follows the 2-char backend prefix, so compare
101
- // past it — pure lexical order would interleave cl-/cx- wrongly.
104
+ // past it — pure lexical order would group by backend instead.
102
105
  export function listJobIds() {
103
106
  let ids;
104
107
  try { ids = readdirSync(jobsDir()); } catch { return []; }
@@ -133,6 +136,11 @@ export function renderTrailer(meta, status = meta.status) {
133
136
  if (meta.thread) bits.push(`thread: ${meta.thread}`);
134
137
  // a thread pointer chases the freshest session id; prefer it as the handle
135
138
  if (meta.sessionId) bits.push(`resume: gaslamp ${meta.backend} ${meta.thread ? `--thread ${meta.thread}` : `--resume ${meta.sessionId}`}`);
139
+ const p = meta.provenance;
140
+ if (p?.model && p.model !== "unknown") {
141
+ const billed = (p.billedModels ?? []).filter((m) => m !== p.model);
142
+ bits.push(`model: ${p.model}${billed.length ? ` (billed ${billed.join(", ")})` : ""}`);
143
+ }
136
144
  if (meta.usage) bits.push(fmtUsage(meta.usage));
137
145
  if (status !== "done") bits.push(`log: ${join(dir, "stderr.log")}`);
138
146
  }
package/src/setup.mjs CHANGED
@@ -49,7 +49,7 @@ export function skillMd(other) {
49
49
  : "Run a consult in your background terminal and check back between steps — several can run in parallel.";
50
50
  return `---
51
51
  name: gaslamp
52
- description: Consult ${Other} (the other coding agent) from the shell for a second pair of eyes — verify a fix or proof, spar on a design, review a diff, diagnose with fresh context — or fan out a fleet of parallel consults. Use when work would benefit from an independent take, when a claim deserves adversarial checking before you rely on it, or when explicitly asked to consult ${Other} / get a second opinion / hand work off.
52
+ description: Consult ${Other} (the other coding agent) from the shell for a second pair of eyes — verify a fix or proof, spar on a design, review a diff, diagnose with fresh context — or fan out a fleet of parallel consults. Also reaches Gemini and Grok as read-only consult targets. Use when work would benefit from an independent take, when a claim deserves adversarial checking before you rely on it, or when explicitly asked to consult ${Other} / Gemini / Grok / get a second opinion / hand work off.
53
53
  ---
54
54
 
55
55
  # Consulting ${Other} via gaslamp
@@ -91,17 +91,16 @@ Fleets default to \`--sandbox read-only\` (N writers in one cwd race) — pass \
91
91
 
92
92
  ## Picking a model
93
93
 
94
- Both ladders, strongest first: fable ≈ astra > opus ≈ sol > terra > sonnet ≈ luna > haiku.
94
+ Both ladders, strongest first: fable ≈ astra > opus ≈ sol > sonnet ≈ luna > haiku.
95
95
 
96
96
  ${other === "codex"
97
- ? `Codex tiers — pass the full id to \`--model\` (bare tier names are rejected):
98
-
99
- | tier | \`--model\` | ≈ claude | reach for it when |
100
- |------|-----------|----------|-------------------|
101
- | astra | \`gpt-6-astra\` | fable | the hardest reasoning: adversarial verification, design spars, proofs |
102
- | sol | \`gpt-5.6-sol\` | opus | substantial work: reviews, diagnosis, real implementation |
103
- | terra | \`gpt-5.6-terra\` | (between opus and sonnet) | everyday work: routine reviews, well-scoped changes |
104
- | luna | \`gpt-5.6-luna\` | sonnet | quick checks, mechanical transforms, high-N fleets |`
97
+ ? `Codex tiers — \`--model\` takes the tier word. Codex itself only accepts versioned ids, so gaslamp resolves the word to the newest \`gpt-<version>-<tier>\` in Codex's own model catalog; the job record's \`provenance.modelPassed\` says which:
98
+
99
+ | \`--model\` | ≈ claude | reach for it when |
100
+ |-----------|----------|-------------------|
101
+ | \`astra\` | fable | the hardest reasoning: adversarial verification, design spars, proofs |
102
+ | \`sol\` | opus | substantial work: reviews, diagnosis, real implementation |
103
+ | \`luna\` | sonnet | quick checks, mechanical transforms, high-N fleets |`
105
104
  : `Claude tiers — \`--model\` takes the bare alias:
106
105
 
107
106
  | \`--model\` | ≈ codex | reach for it when |
@@ -113,7 +112,21 @@ ${other === "codex"
113
112
 
114
113
  Omit \`--model\` to use ${Other}'s own configured default. Match the tier to the stakes: verification you'll rely on deserves the top tier; a fleet vote usually doesn't.
115
114
 
116
- \`--effort\` takes \`low\` / \`medium\` / \`high\` / \`xhigh\` / \`max\`${other === "codex" ? "; astra, sol and terra also take `ultra` (max reasoning plus automatic task delegation). Omitted = Codex's configured `model_reasoning_effort`, else the model's own default — sol's is `low`, so pass it explicitly when the consult matters." : ". Omitted = Claude's own default."}
115
+ \`--effort\` takes \`low\` / \`medium\` / \`high\` / \`xhigh\` / \`max\`${other === "codex" ? "; astra and sol also take `ultra` (max reasoning plus automatic task delegation). Omitted = Codex's configured `model_reasoning_effort`, else the model's own default — sol's is `low`, so pass it explicitly when the consult matters." : ". Omitted = Claude's own default."}
116
+
117
+ ## Gemini and Grok
118
+
119
+ gaslamp gemini "<prompt>" # Gemini, through Google's Antigravity CLI (agy)
120
+ gaslamp grok "<prompt>" # Grok, through xAI's Grok Build
121
+
122
+ Same contract as above — threads, \`--schema\`, fleets, records — with two differences:
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.
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
+
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).
128
+
129
+ Agreement across vendors is not automatically independent confirmation — the models share much of their training data, and a consultee that searched the live web or X may be repeating what it found there (check \`provenance.retrieval.used\`). Weigh a consensus by its evidence, not by the head count.
117
130
 
118
131
  ## Reading the records
119
132
 
package/src/tail.mjs CHANGED
@@ -2,7 +2,8 @@
2
2
  // uniform one-line entries, following while the job runs.
3
3
  //
4
4
  // A flashlight, not an event UI: raw events.jsonl is write-through but
5
- // backend-shaped (claude stream-json vs codex --json); this normalizes both
5
+ // backend-shaped (claude stream-json — which grok's streaming-messages-json
6
+ // mirrors — vs codex --json vs agy stream-json); this normalizes them all
6
7
  // into "what is it doing right now" lines and nothing more. `poll`, `jobs`,
7
8
  // and the durable records stay the product surface.
8
9
 
@@ -20,13 +21,23 @@ const preview = (v, n = 100) => {
20
21
 
21
22
  // One event → one line (or several, or none — noise is dropped).
22
23
  function renderEvent(backend, j) {
23
- if (backend === "claude") {
24
+ if (backend === "gemini") {
25
+ if (j.event === "init")
26
+ return [`init · conversation ${short(j.conversation_id)}${j.init?.model ? ` · ${j.init.model}` : ""}`];
27
+ const st = j.step_update;
28
+ if (st?.step_type === "tool" && st.state === "DONE")
29
+ return [`tool → ${st.tool_name ?? "?"} ${preview(st.tool_info?.parameters, 80)}`];
30
+ if (j.event === "result")
31
+ return [`result · ${j.result?.status ?? "?"}${j.result?.error ? ` · ${preview(j.result.error, 80)}` : ""}`];
32
+ return []; // text deltas, step bookkeeping
33
+ }
34
+ if (backend === "claude" || backend === "grok") {
24
35
  if (j.type === "system" && j.subtype === "init")
25
36
  return [`init · session ${short(j.session_id)}${j.model ? ` · ${j.model}` : ""}`];
26
37
  if (j.type === "assistant" || j.type === "user") {
27
38
  const lines = [];
28
39
  for (const p of j.message?.content ?? []) {
29
- if (p.type === "tool_use") lines.push(`tool → ${p.name} ${preview(p.input, 80)}`);
40
+ if (p.type === "tool_use" || p.type === "server_tool_use") lines.push(`tool → ${p.name} ${preview(p.input, 80)}`);
30
41
  else if (p.type === "text" && p.text?.trim()) lines.push(`text · ${preview(p.text)}`);
31
42
  else if (p.type === "tool_result") lines.push(`tool ← ${preview(p.content, 80)}`);
32
43
  }