gaslamp 2.1.2 → 2.2.1
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 +75 -9
- package/bin/gaslamp.mjs +29 -7
- package/package.json +5 -2
- package/src/backends.mjs +39 -0
- package/src/consult.mjs +463 -76
- package/src/fleet.mjs +3 -2
- package/src/jobs.mjs +13 -5
- package/src/setup.mjs +24 -11
- package/src/tail.mjs +14 -3
package/README.md
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/gaslamp)
|
|
7
7
|
[](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>
|
|
109
|
-
|
|
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
|
|
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
|
|
243
|
-
exactly as that agent would behave if you drove it yourself.
|
|
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,49 @@ 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, or set a lax
|
|
296
|
+
`toolPermission` or `agentMode: auto` (which approves writes and commands
|
|
297
|
+
under any preset, unreported), and fails if the
|
|
298
|
+
harness doesn't confirm the preset in its init event. Like claude's
|
|
299
|
+
read-only, that is read-capable rather than airtight: an allowed
|
|
300
|
+
`git diff` still accepts git's own `--output=<file>`.
|
|
301
|
+
Antigravity has no headless `workspace-write` (`--mode accept-edits` did
|
|
302
|
+
not enable writes in print mode), so that is a usage error there.
|
|
303
|
+
- **A denied tool call can end the turn empty.** Both harnesses report success
|
|
304
|
+
for it; gaslamp marks an empty reply failed and surfaces the harness's
|
|
305
|
+
stderr. Pipe evidence in rather than asking the consultee to fetch it.
|
|
306
|
+
- **Gemini is pinned.** Antigravity also serves Claude and GPT-OSS models under
|
|
307
|
+
the same account; without `--model`, a gemini consult runs the newest
|
|
308
|
+
`argon` tier, never the account default.
|
|
309
|
+
- **The caller's harness stays home.** Their spawns drop `CLAUDE*`,
|
|
310
|
+
`ANTHROPIC_*`, `CODEX_*` and `OPENAI_*` variables (session ids, a messaging
|
|
311
|
+
token, account details), and Grok's vendor-compat imports are switched off
|
|
312
|
+
for the consult: by default Grok loads `~/.claude/CLAUDE.md` as its own
|
|
313
|
+
instructions and runs Claude's skills, MCP servers and hooks. (It also reads
|
|
314
|
+
`~/.claude/settings.json` permissions with no switch; gaslamp's explicit
|
|
315
|
+
`--permission-mode` overrides that file's `defaultMode`.)
|
|
316
|
+
- **Unsigned `agy` hangs.** Without a cached sign-in, print mode prints a
|
|
317
|
+
sign-in URL and waits forever; gaslamp stops it and says to run `agy` once.
|
|
318
|
+
- **Sessions.** `agy --conversation <id>` resumes in place (same id); so does
|
|
319
|
+
`grok --resume <id>`. Usage comes from Antigravity's per-step events, since
|
|
320
|
+
its result event counts the whole conversation.
|
|
321
|
+
|
|
257
322
|
## Recursion
|
|
258
323
|
|
|
259
324
|
A consult is **one hop**: agents gaslamp spawns carry `GASLAMP_NESTED=1`
|
|
@@ -270,7 +335,8 @@ it. This is loop prevention, not a security boundary. Set
|
|
|
270
335
|
| `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
336
|
| `GASLAMP_ALLOW_RECURSION` | unset | allow consulted agents to consult back |
|
|
272
337
|
| `GASLAMP_DEBUG` | unset | verbose stderr (spawn argv) |
|
|
273
|
-
| `CLAUDE_BIN` / `CODEX_BIN` | autodetected | backend binaries |
|
|
338
|
+
| `CLAUDE_BIN` / `CODEX_BIN` / `AGY_BIN` / `GROK_BIN` | autodetected | backend binaries |
|
|
339
|
+
| `GROK_HOME` | `~/.grok` | where grok keeps sessions (the system prompt hash is read from there) |
|
|
274
340
|
| `CODEX_HOME` | `~/.codex` | where `setup --skill` puts Codex's copy |
|
|
275
341
|
|
|
276
342
|
Exit codes: `0` ok · `1` consult failed/killed · `2` usage · `3` nested
|
|
@@ -280,7 +346,7 @@ still running.
|
|
|
280
346
|
## Development
|
|
281
347
|
|
|
282
348
|
```sh
|
|
283
|
-
npm test # node --test against stub claude and
|
|
349
|
+
npm test # node --test against stub claude, codex, agy and grok binaries
|
|
284
350
|
npm run check # node -c syntax check on every source file
|
|
285
351
|
```
|
|
286
352
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
4
|
-
"description": "Let Claude Code and Codex consult each other from the shell.",
|
|
3
|
+
"version": "2.2.1",
|
|
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",
|
package/src/backends.mjs
ADDED
|
@@ -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 {
|
|
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,93 @@ 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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
+
// agentMode "auto" approves file edits and commands itself: under it a print
|
|
128
|
+
// run wrote a file and ran `touch` while init still reported request-review
|
|
129
|
+
// (1.4.3), so the init check cannot see it. accept-edits is refused likewise.
|
|
130
|
+
const AGY_LAX_MODES = new Set(["auto", "accept-edits"]);
|
|
131
|
+
|
|
132
|
+
// Allow rules and the tool-permission preset from every file the CLI merges:
|
|
133
|
+
// its settings.json, the settings shared with the Antigravity app, and the
|
|
134
|
+
// per-project files (all of them — which one applies is not knowable here).
|
|
135
|
+
function agyPermissionState() {
|
|
136
|
+
const root = join(homedir(), ".gemini");
|
|
137
|
+
const files = [join(root, "antigravity-cli", "settings.json"), join(root, "config", "config.json")];
|
|
138
|
+
try {
|
|
139
|
+
for (const f of readdirSync(join(root, "config", "projects")))
|
|
140
|
+
if (f.endsWith(".json")) files.push(join(root, "config", "projects", f));
|
|
141
|
+
} catch {}
|
|
142
|
+
const grants = [], presets = [], modes = [];
|
|
143
|
+
const walk = (node, file) => {
|
|
144
|
+
if (Array.isArray(node)) return node.forEach((n) => walk(n, file));
|
|
145
|
+
if (!node || typeof node !== "object") return;
|
|
146
|
+
for (const [k, v] of Object.entries(node)) {
|
|
147
|
+
if (k === "allow" && Array.isArray(v)) for (const r of v) if (typeof r === "string") grants.push({ rule: r, file });
|
|
148
|
+
if (k === "toolPermission" && typeof v === "string") presets.push({ preset: v, file });
|
|
149
|
+
if (k === "agentMode" && typeof v === "string") modes.push({ mode: v, file });
|
|
150
|
+
walk(v, file);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
for (const f of files) {
|
|
154
|
+
try { walk(JSON.parse(readFileSync(f, "utf8")), f); } catch {}
|
|
155
|
+
}
|
|
156
|
+
return { grants, presets, modes };
|
|
157
|
+
}
|
|
158
|
+
|
|
72
159
|
// The consult preamble: the one piece of framing a consulted agent needs.
|
|
73
160
|
// Workflow subagents are told their final text IS the return value; a consult
|
|
74
161
|
// deserves the same, or it replies conversationally and promises work instead
|
|
@@ -82,6 +169,18 @@ const PREAMBLE =
|
|
|
82
169
|
"Distinguish what you verified from what you infer.";
|
|
83
170
|
|
|
84
171
|
function resolveBin(backend) {
|
|
172
|
+
if (backend === "gemini") {
|
|
173
|
+
for (const c of [process.env.AGY_BIN, join(homedir(), ".local/bin/agy")].filter(Boolean))
|
|
174
|
+
if (existsSync(c)) return c;
|
|
175
|
+
return which("agy") || "agy";
|
|
176
|
+
}
|
|
177
|
+
if (backend === "grok") {
|
|
178
|
+
// Never realpath this one: the end-conversation server finds the harness it
|
|
179
|
+
// runs under by process name, and a resolved spawn is named after the
|
|
180
|
+
// versioned download (grok-macos-aarch64), not `grok`.
|
|
181
|
+
if (process.env.GROK_BIN && existsSync(process.env.GROK_BIN)) return process.env.GROK_BIN;
|
|
182
|
+
return which("grok") || [join(homedir(), ".grok/bin/grok")].find(existsSync) || "grok";
|
|
183
|
+
}
|
|
85
184
|
if (backend === "claude") {
|
|
86
185
|
for (const c of [process.env.CLAUDE_BIN, join(homedir(), ".local/bin/claude")].filter(Boolean))
|
|
87
186
|
if (existsSync(c)) return c;
|
|
@@ -192,6 +291,135 @@ function acquireLock(backend, sid, jobId) {
|
|
|
192
291
|
die(5, `could not acquire the lock for session ${sid} under ${locksDir()}`);
|
|
193
292
|
}
|
|
194
293
|
|
|
294
|
+
// ---- model tiers --------------------------------------------------------------
|
|
295
|
+
// --model takes a tier word as well as an exact id. claude's CLI resolves its
|
|
296
|
+
// own aliases (fable, opus, sonnet, haiku); codex and agy only take versioned
|
|
297
|
+
// ids, so a bare tier is resolved here against the harness's OWN live catalog
|
|
298
|
+
// — the newest listed gpt-<version>-<tier> or gemini-<version>-<tier> — and a
|
|
299
|
+
// new generation is picked up without a gaslamp release. A word the catalog
|
|
300
|
+
// does not know passes through untouched, and the harness reports it.
|
|
301
|
+
const versionKey = (v) => v.split(".").map(Number);
|
|
302
|
+
const compareVersions = (a, b) => {
|
|
303
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
304
|
+
const d = (a[i] ?? 0) - (b[i] ?? 0);
|
|
305
|
+
if (d) return d;
|
|
306
|
+
}
|
|
307
|
+
return 0;
|
|
308
|
+
};
|
|
309
|
+
export function newestOfTier(slugs, family, tier) {
|
|
310
|
+
const re = new RegExp(`^${family}-(\\d+(?:\\.\\d+)*)-${tier}$`);
|
|
311
|
+
let best = null, bestV = null;
|
|
312
|
+
for (const slug of slugs) {
|
|
313
|
+
const m = re.exec(slug);
|
|
314
|
+
if (!m) continue;
|
|
315
|
+
const v = versionKey(m[1]);
|
|
316
|
+
if (!bestV || compareVersions(v, bestV) > 0) { best = slug; bestV = v; }
|
|
317
|
+
}
|
|
318
|
+
return best;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function codexCatalog() {
|
|
322
|
+
try {
|
|
323
|
+
const home = process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
324
|
+
const d = JSON.parse(readFileSync(join(home, "models_cache.json"), "utf8"));
|
|
325
|
+
return (d.models ?? []).filter((m) => m.visibility !== "hide").map((m) => m.slug).filter(Boolean);
|
|
326
|
+
} catch { return []; }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// `agy models` lists one slug per effort level (gemini-4-argon-high, …); the
|
|
330
|
+
// base slug takes the model's default effort, and --effort picks another.
|
|
331
|
+
export function agyCatalog(bin, env = process.env) {
|
|
332
|
+
try {
|
|
333
|
+
const out = execFileSync(bin, ["models"], { encoding: "utf8", timeout: 30000, stdio: ["ignore", "pipe", "ignore"], env });
|
|
334
|
+
return [...new Set(out.split("\n").map((l) => l.split("\t")[0].trim())
|
|
335
|
+
.filter((s) => /^[a-z0-9.-]+$/.test(s))
|
|
336
|
+
.map((s) => s.replace(/-(low|medium|high|xhigh|max)$/, "")))];
|
|
337
|
+
} catch { return []; }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function resolveModel(backend, model, bin, env) {
|
|
341
|
+
if (backend === "gemini") model ??= GEMINI_DEFAULT_TIER;
|
|
342
|
+
if (!model || !/^[a-z]+$/.test(model)) return model ?? null;
|
|
343
|
+
if (backend === "codex") return newestOfTier(codexCatalog(), "gpt", model) ?? model;
|
|
344
|
+
if (backend === "gemini") return newestOfTier(agyCatalog(bin, env), "gemini", model) ?? model;
|
|
345
|
+
return model;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---- provenance -----------------------------------------------------------------
|
|
349
|
+
// Vendors can change the model or the system prompt behind a stable product
|
|
350
|
+
// name, so every job records what it can about who actually answered.
|
|
351
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
352
|
+
const PKG_VERSION = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
|
|
353
|
+
let revisionMemo;
|
|
354
|
+
function gaslampRevision() {
|
|
355
|
+
if (revisionMemo !== undefined) return revisionMemo;
|
|
356
|
+
try {
|
|
357
|
+
const git = (...a) => execFileSync("git", ["-C", PKG_ROOT, ...a], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
|
|
358
|
+
const rev = git("rev-parse", "--short=12", "HEAD");
|
|
359
|
+
revisionMemo = git("status", "--porcelain", "--untracked-files=no") ? `${rev}+dirty` : rev;
|
|
360
|
+
} catch { revisionMemo = "unknown"; }
|
|
361
|
+
return revisionMemo;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function harnessVersion(bin, env) {
|
|
365
|
+
try {
|
|
366
|
+
const out = execFileSync(bin, ["--version"], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"], env });
|
|
367
|
+
return out.split("\n").find((l) => l.trim())?.trim() || "unknown";
|
|
368
|
+
} catch { return "unknown"; }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Grok writes each session's full system prompt next to its transcript —
|
|
372
|
+
// the one backend whose vendor prompt is knowable, so it is hashed into the
|
|
373
|
+
// record (a change between jobs is visible without storing the text twice).
|
|
374
|
+
function grokSystemPrompt(sessionId, cwd) {
|
|
375
|
+
const root = join(process.env.GROK_HOME || join(homedir(), ".grok"), "sessions");
|
|
376
|
+
const candidates = [join(root, encodeURIComponent(cwd), sessionId, "system_prompt.txt")];
|
|
377
|
+
try { for (const d of readdirSync(root)) candidates.push(join(root, d, sessionId, "system_prompt.txt")); } catch {}
|
|
378
|
+
for (const path of candidates) {
|
|
379
|
+
try {
|
|
380
|
+
const text = readFileSync(path, "utf8");
|
|
381
|
+
return { known: true, sha256: createHash("sha256").update(text).digest("hex"), chars: text.length, path };
|
|
382
|
+
} catch {}
|
|
383
|
+
}
|
|
384
|
+
return { known: false };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function geminiArgs(o, resumeSid, schemaText, model, sandbox) {
|
|
388
|
+
// Print mode's -p takes the prompt as an argument; streaming input reads it
|
|
389
|
+
// from stdin as one {"event":"user"} line and ends the session at EOF, which
|
|
390
|
+
// keeps the prompt-on-stdin contract (no argv-size or quoting traps).
|
|
391
|
+
const args = ["--input-format", "stream-json", "--output-format", "stream-json", "--model", model];
|
|
392
|
+
if (o.effort) args.push("--effort", o.effort);
|
|
393
|
+
if (resumeSid) args.push("--conversation", resumeSid);
|
|
394
|
+
if (schemaText) args.push("--json-schema", schemaText);
|
|
395
|
+
// read-only adds no flag: the default request-review preset already denies
|
|
396
|
+
// writes, shell and web reads in print mode. Before spawn, the permission
|
|
397
|
+
// files are checked for grants that would let anything else through; after,
|
|
398
|
+
// the init event's permission_mode must confirm the preset (fail closed).
|
|
399
|
+
if (sandbox === "danger-full-access") args.push("--dangerously-skip-permissions");
|
|
400
|
+
return args;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function grokArgs(o, resumeSid, schemaText, sandbox) {
|
|
404
|
+
// streaming-messages-json is Anthropic's stream-json shape (system init with
|
|
405
|
+
// session_id and model first, result last) — the claude parser reads it.
|
|
406
|
+
const args = ["--prompt-file", "/dev/stdin", "--output-format", "streaming-messages-json", "--no-auto-update"];
|
|
407
|
+
// --rules appends to the system prompt: claude's --append-system-prompt.
|
|
408
|
+
if (!o.raw) args.push("--rules", PREAMBLE);
|
|
409
|
+
if (schemaText) args.push("--json-schema", schemaText);
|
|
410
|
+
// The kernel sandbox (Seatbelt / Landlock) is the read-only guarantee. The
|
|
411
|
+
// permission layer is not: under --permission-mode default a headless run
|
|
412
|
+
// denied the write tool but ran an arbitrary `touch` through the shell
|
|
413
|
+
// (probed on 1.0.62). read-only still writes ~/.grok (sessions) and temp dirs.
|
|
414
|
+
if (sandbox === "read-only") args.push("--sandbox", "read-only", "--permission-mode", "default");
|
|
415
|
+
else if (sandbox === "workspace-write") args.push("--sandbox", "workspace", "--permission-mode", "acceptEdits");
|
|
416
|
+
else if (sandbox === "danger-full-access") args.push("--sandbox", "off", "--always-approve");
|
|
417
|
+
if (resumeSid) args.push("--resume", resumeSid);
|
|
418
|
+
if (o.model) args.push("-m", o.model);
|
|
419
|
+
if (o.effort) args.push("--effort", o.effort);
|
|
420
|
+
return args;
|
|
421
|
+
}
|
|
422
|
+
|
|
195
423
|
function claudeArgs(o, resumeSid, schemaText) {
|
|
196
424
|
// stream-json (not plain json) so events land incrementally: the session id
|
|
197
425
|
// arrives in the init event within seconds, and the write-through record
|
|
@@ -247,11 +475,17 @@ export function runConsult(backend, argv) {
|
|
|
247
475
|
if (process.env.CODEX_SANDBOX_NETWORK_DISABLED === "1")
|
|
248
476
|
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
477
|
|
|
250
|
-
|
|
478
|
+
const facts = BACKENDS[backend];
|
|
479
|
+
if (o.sandbox && !facts.sandboxes.includes(o.sandbox)) {
|
|
251
480
|
die(2, backend === "claude" && o.sandbox === "workspace-write"
|
|
252
481
|
? "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
|
-
:
|
|
482
|
+
: backend === "gemini" && o.sandbox === "workspace-write"
|
|
483
|
+
? "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"
|
|
484
|
+
: `bad --sandbox "${o.sandbox}" (${backend}: ${facts.sandboxes.join(" | ")})`);
|
|
254
485
|
}
|
|
486
|
+
// claude/codex: omitted = the consulted agent's own config. gemini/grok:
|
|
487
|
+
// omitted = read-only (BACKENDS.defaultSandbox).
|
|
488
|
+
const sandbox = o.sandbox ?? facts.defaultSandbox;
|
|
255
489
|
|
|
256
490
|
// ---- prompt (argv, or stdin via `-` / piped-with-no-arg) -------------------
|
|
257
491
|
let prompt = o.prompt;
|
|
@@ -282,6 +516,28 @@ export function runConsult(backend, argv) {
|
|
|
282
516
|
}
|
|
283
517
|
const thread = o.thread ? readThread(backend, o.thread) : null;
|
|
284
518
|
const resumeSid = o.resume ? resolveResume(backend, o.resume) : thread?.sessionId ?? null;
|
|
519
|
+
// Everything that runs the harness synchronously (the tier lookup can take
|
|
520
|
+
// agy ~2s, the version probe a few ms) happens before the lock is taken and
|
|
521
|
+
// the record exists: no signal handler is installed yet, and a kill here
|
|
522
|
+
// must not strand either.
|
|
523
|
+
const bin = resolveBin(backend);
|
|
524
|
+
const env = backendEnv(backend);
|
|
525
|
+
let grants = null;
|
|
526
|
+
if (backend === "gemini" && sandbox === "read-only") {
|
|
527
|
+
const state = agyPermissionState();
|
|
528
|
+
const lax = state.presets.filter((p) => AGY_LAX_PRESETS.has(p.preset));
|
|
529
|
+
const modes = state.modes.filter((m) => AGY_LAX_MODES.has(m.mode));
|
|
530
|
+
const open = state.grants.filter((g) => !AGY_READ_GRANT.test(g.rule));
|
|
531
|
+
if (lax.length || modes.length || open.length)
|
|
532
|
+
die(1, "Antigravity's settings would let a read-only consult act unprompted — " +
|
|
533
|
+
[...lax.map((p) => `toolPermission "${p.preset}" (${p.file})`),
|
|
534
|
+
...modes.map((m) => `agentMode "${m.mode}" (${m.file})`),
|
|
535
|
+
...open.map((g) => `${g.rule} (${g.file})`)].join(", ") +
|
|
536
|
+
". Remove them, or pass --sandbox danger-full-access to consult without the guarantee.");
|
|
537
|
+
grants = state.grants.map((g) => g.rule);
|
|
538
|
+
}
|
|
539
|
+
const model = resolveModel(backend, o.model, bin, env);
|
|
540
|
+
const version = harnessVersion(bin, env);
|
|
285
541
|
const id = newJobId(backend);
|
|
286
542
|
let lockPath = resumeSid ? acquireLock(backend, resumeSid, id) : null;
|
|
287
543
|
const releaseLock = () => { if (lockPath) { try { unlinkSync(lockPath); } catch {} lockPath = null; } };
|
|
@@ -292,16 +548,26 @@ export function runConsult(backend, argv) {
|
|
|
292
548
|
id, backend, status: "running",
|
|
293
549
|
sessionId: resumeSid, resumedFrom: resumeSid, thread: o.thread ?? null,
|
|
294
550
|
label: o.label ?? null,
|
|
295
|
-
model
|
|
551
|
+
model, sandbox, effort: o.effort ?? null,
|
|
296
552
|
cwd: o.cwd || process.cwd(),
|
|
297
553
|
pid: process.pid, childPid: null, exitCode: null, signal: null,
|
|
298
554
|
startedAt: new Date().toISOString(), endedAt: null,
|
|
299
555
|
promptChars: prompt.length, schema: !!schemaText, raw: !!o.raw,
|
|
556
|
+
provenance: {
|
|
557
|
+
provider: facts.provider, harness: facts.harness, harnessVersion: version,
|
|
558
|
+
// `model` is what the backend reported answering with; unknown until it
|
|
559
|
+
// does (codex never reports one in its event stream).
|
|
560
|
+
model: "unknown", modelRequested: o.model ?? null, modelPassed: model,
|
|
561
|
+
permissionMode: null, sandbox,
|
|
562
|
+
...(grants ? { grants } : {}),
|
|
563
|
+
systemPrompt: { known: false },
|
|
564
|
+
retrieval: { available: null, used: [] },
|
|
565
|
+
gaslamp: { version: PKG_VERSION, revision: gaslampRevision() },
|
|
566
|
+
},
|
|
300
567
|
};
|
|
301
568
|
createJob(meta, prompt);
|
|
302
569
|
|
|
303
570
|
// ---- spawn -----------------------------------------------------------------
|
|
304
|
-
const bin = resolveBin(backend);
|
|
305
571
|
const replyPath = join(dir, "reply.md");
|
|
306
572
|
// schema.json lands in the job dir for BOTH backends: codex needs the file,
|
|
307
573
|
// and the record should show what was actually sent — for codex that is the
|
|
@@ -313,12 +579,10 @@ export function runConsult(backend, argv) {
|
|
|
313
579
|
: schemaText;
|
|
314
580
|
writeFileSync(schemaPath, sent.endsWith("\n") ? sent : sent + "\n");
|
|
315
581
|
}
|
|
316
|
-
const args = backend === "claude" ? claudeArgs(o,
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
// 401s every call (1.0's hardest auth lesson). No Codex analog needed.
|
|
321
|
-
if (backend === "claude") delete env.ANTHROPIC_API_KEY;
|
|
582
|
+
const args = backend === "claude" ? claudeArgs({ ...o, model }, resumeSid, schemaText)
|
|
583
|
+
: backend === "codex" ? codexArgs({ ...o, model }, resumeSid, replyPath, schemaPath)
|
|
584
|
+
: backend === "gemini" ? geminiArgs(o, resumeSid, schemaText, model, sandbox)
|
|
585
|
+
: grokArgs(o, resumeSid, schemaText, sandbox);
|
|
322
586
|
|
|
323
587
|
log("spawn", bin, JSON.stringify(args));
|
|
324
588
|
// detached → the child leads its own process group, so signal forwarding can
|
|
@@ -326,6 +590,10 @@ export function runConsult(backend, argv) {
|
|
|
326
590
|
const child = spawn(bin, args, { cwd: meta.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
327
591
|
meta.childPid = child.pid ?? null;
|
|
328
592
|
writeMeta(meta);
|
|
593
|
+
const killGroup = (sig) => {
|
|
594
|
+
try { process.kill(-child.pid, sig); }
|
|
595
|
+
catch { try { child.kill(sig); } catch {} }
|
|
596
|
+
};
|
|
329
597
|
|
|
330
598
|
writeSync(2, `[gaslamp] → ${backend} · job ${id} · watch: tail -f ${join(dir, "events.jsonl")}\n`);
|
|
331
599
|
|
|
@@ -340,74 +608,165 @@ export function runConsult(backend, argv) {
|
|
|
340
608
|
child.stdin.on("error", () => {}); // EPIPE if the child dies before reading
|
|
341
609
|
// prompt.md records the CALLER's content; the preamble is mechanism, applied
|
|
342
610
|
// here at the spawn boundary (codex has no --append-system-prompt analog).
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
611
|
+
const preamble = o.raw ? "" : "<gaslamp_consult>\n" + PREAMBLE + "\n</gaslamp_consult>\n\n";
|
|
612
|
+
if (backend === "gemini") {
|
|
613
|
+
child.stdin.write(JSON.stringify({ event: "user", message: { content: preamble + prompt } }) + "\n");
|
|
614
|
+
} else {
|
|
615
|
+
if (backend === "codex") child.stdin.write(preamble);
|
|
616
|
+
child.stdin.write(prompt);
|
|
617
|
+
}
|
|
346
618
|
child.stdin.end();
|
|
347
619
|
|
|
348
620
|
const events = createWriteStream(join(dir, "events.jsonl"), { flags: "a" });
|
|
349
621
|
const errlog = createWriteStream(join(dir, "stderr.log"), { flags: "a" });
|
|
350
|
-
|
|
622
|
+
// Decode as UTF-8 streams: a multibyte character split across two chunks
|
|
623
|
+
// must not turn into replacement characters in the parsed events.
|
|
624
|
+
child.stdout.setEncoding("utf8");
|
|
625
|
+
child.stderr.setEncoding("utf8");
|
|
626
|
+
|
|
627
|
+
// A consult the harness can't start honestly is stopped, not left to run.
|
|
628
|
+
let refusal = null;
|
|
629
|
+
const refuse = (why) => {
|
|
630
|
+
if (refusal) return;
|
|
631
|
+
refusal = why;
|
|
632
|
+
writeSync(2, `[gaslamp] ${why}\n`);
|
|
633
|
+
killGroup("SIGTERM");
|
|
634
|
+
setTimeout(() => killGroup("SIGKILL"), 2000).unref();
|
|
635
|
+
};
|
|
636
|
+
// `agy` without a cached sign-in prints a Google sign-in URL and waits for a
|
|
637
|
+
// pasted code forever in print mode (1.4.3; its docs say it exits). Only
|
|
638
|
+
// what the harness prints before its init event counts — stderr and any
|
|
639
|
+
// non-JSON stdout, accumulated across chunks — never protocol content,
|
|
640
|
+
// where a reply may well say "authentication required".
|
|
641
|
+
let initSeen = false, preInit = "";
|
|
642
|
+
const AUTH_NOTICE = /Authentication required/i;
|
|
643
|
+
const notSignedIn = () => refuse("agy is not signed in — run `agy` once in a terminal to sign in, then retry");
|
|
644
|
+
const watchAuth = (text) => {
|
|
645
|
+
if (backend !== "gemini" || initSeen) return;
|
|
646
|
+
preInit = (preInit + text).slice(-4096);
|
|
647
|
+
if (AUTH_NOTICE.test(preInit)) notSignedIn();
|
|
648
|
+
};
|
|
649
|
+
child.stderr.on("data", (d) => { errlog.write(d); watchAuth(d); });
|
|
650
|
+
|
|
651
|
+
const prov = meta.provenance;
|
|
652
|
+
const noteTool = (name) => {
|
|
653
|
+
if (name && RETRIEVAL_TOOLS[backend].test(name) && !prov.retrieval.used.includes(name))
|
|
654
|
+
prov.retrieval.used.push(name);
|
|
655
|
+
};
|
|
351
656
|
|
|
352
657
|
// ---- event stream: write through; fish out session id + reply --------------
|
|
353
658
|
let buf = "", resultText = null, resultErr = false, lastAgentText = null, structuredOut;
|
|
354
|
-
|
|
659
|
+
let initValidated = false; // gemini read-only: the harness confirmed its preset
|
|
660
|
+
const usageAcc = { input: 0, output: 0 }; // codex and agy report per turn/step; sum them
|
|
661
|
+
const claudeShaped = backend === "claude" || backend === "grok";
|
|
662
|
+
const handleLine = (raw) => {
|
|
663
|
+
const line = raw.trim();
|
|
664
|
+
if (!line) return;
|
|
665
|
+
let j; try { j = JSON.parse(line); } catch { watchAuth(line + "\n"); return; }
|
|
666
|
+
const sid = claudeShaped
|
|
667
|
+
? ((j.type === "system" && j.subtype === "init") || j.type === "result" ? j.session_id : null)
|
|
668
|
+
: backend === "codex" ? (j.type === "thread.started" ? (j.thread_id ?? j.threadId) : null)
|
|
669
|
+
: (j.event === "init" || j.event === "result" ? (j.conversation_id ?? j.result?.conversation_id) : null);
|
|
670
|
+
if (sid) {
|
|
671
|
+
if (meta.sessionId !== sid) { meta.sessionId = sid; writeMeta(meta); } // early: the recovery handle
|
|
672
|
+
// Bind/re-point the thread the moment the session is known — claude
|
|
673
|
+
// resumes fork a fresh session id, and the pointer must chase it.
|
|
674
|
+
if (o.thread) writeThread({
|
|
675
|
+
backend, name: o.thread, sessionId: sid, lastJobId: id,
|
|
676
|
+
createdAt: thread?.createdAt ?? meta.startedAt, updatedAt: new Date().toISOString(),
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
if (claudeShaped && j.type === "system" && j.subtype === "init") {
|
|
680
|
+
if (j.model) prov.model = j.model;
|
|
681
|
+
prov.permissionMode = j.permissionMode ?? null;
|
|
682
|
+
if (Array.isArray(j.tools)) prov.retrieval.available = j.tools.filter((t) => RETRIEVAL_TOOLS[backend].test(t));
|
|
683
|
+
writeMeta(meta);
|
|
684
|
+
} else if (claudeShaped && j.type === "assistant") {
|
|
685
|
+
for (const c of j.message?.content ?? []) {
|
|
686
|
+
if (c.type === "tool_use" || c.type === "server_tool_use") noteTool(c.name);
|
|
687
|
+
if (c.type === "tool_use" && c.name === "use_tool") noteTool(c.input?.tool_name);
|
|
688
|
+
}
|
|
689
|
+
} else if (backend === "gemini" && j.event === "init") {
|
|
690
|
+
initSeen = true;
|
|
691
|
+
const init = j.init ?? {};
|
|
692
|
+
if (init.model) prov.model = init.model;
|
|
693
|
+
prov.permissionMode = init.permission_mode ?? null;
|
|
694
|
+
// The browser toolset is ~20 tools; one entry says the same thing.
|
|
695
|
+
if (Array.isArray(init.tools)) prov.retrieval.available = [...new Set(init.tools
|
|
696
|
+
.filter((t) => RETRIEVAL_TOOLS.gemini.test(t)).map((t) => t.startsWith("browser_") ? "browser_*" : t))];
|
|
697
|
+
writeMeta(meta);
|
|
698
|
+
// Read-only rests on Antigravity's request-review (or strict) preset;
|
|
699
|
+
// a laxer one would let the consult act unprompted.
|
|
700
|
+
if (sandbox === "read-only" && !["request-review", "strict"].includes(prov.permissionMode))
|
|
701
|
+
refuse(`agy reports permission mode "${prov.permissionMode}", not request-review — refusing a read-only consult it would not enforce`);
|
|
702
|
+
else initValidated = true;
|
|
703
|
+
} else if (backend === "gemini" && j.event === "step_update") {
|
|
704
|
+
const st = j.step_update ?? {};
|
|
705
|
+
if (st.step_type === "tool" && st.state === "DONE") noteTool(st.tool_name);
|
|
706
|
+
if (st.step_type === "agent_response" && st.state === "DONE" && st.usage) {
|
|
707
|
+
// Per step; the result event's usage is cumulative over the whole
|
|
708
|
+
// conversation, resumes included. input_tokens EXCLUDES cache reads
|
|
709
|
+
// (a resumed turn: 2376 input + 5118 cache read ≈ the full prompt),
|
|
710
|
+
// so both count, as for claude.
|
|
711
|
+
usageAcc.input += (st.usage.input_tokens ?? 0) + (st.usage.cache_read_tokens ?? 0);
|
|
712
|
+
usageAcc.output += st.usage.output_tokens ?? 0;
|
|
713
|
+
meta.usage = { inputTokens: usageAcc.input, outputTokens: usageAcc.output, costUsd: null };
|
|
714
|
+
}
|
|
715
|
+
} else if (backend === "gemini" && j.event === "result") {
|
|
716
|
+
const r = j.result ?? {};
|
|
717
|
+
resultText = r.response ?? null;
|
|
718
|
+
resultErr = r.status !== "SUCCESS";
|
|
719
|
+
if (r.error) writeSync(2, `[gaslamp] agy: ${r.error}\n`);
|
|
720
|
+
if (r.structured_output !== undefined) structuredOut = r.structured_output;
|
|
721
|
+
} else if (claudeShaped && j.type === "result") {
|
|
722
|
+
resultText = j.result ?? j.content ?? null;
|
|
723
|
+
resultErr = !!j.is_error;
|
|
724
|
+
if (j.structured_output !== undefined) structuredOut = j.structured_output;
|
|
725
|
+
// Grok bills by the model it actually ran (grok-4.7-build behind a
|
|
726
|
+
// reported grok-4.7), which only shows up here.
|
|
727
|
+
if (j.modelUsage) prov.billedModels = Object.keys(j.modelUsage);
|
|
728
|
+
// Fleets are quota-shaped, so quota should be visible: inputTokens
|
|
729
|
+
// counts cache reads/writes too (that's what the meter meters).
|
|
730
|
+
if (j.usage) meta.usage = {
|
|
731
|
+
inputTokens: (j.usage.input_tokens ?? 0) + (j.usage.cache_read_input_tokens ?? 0)
|
|
732
|
+
+ (j.usage.cache_creation_input_tokens ?? 0),
|
|
733
|
+
outputTokens: j.usage.output_tokens ?? 0,
|
|
734
|
+
costUsd: j.total_cost_usd ?? null,
|
|
735
|
+
};
|
|
736
|
+
} else if (backend === "codex" && j.type === "turn.completed" && j.usage) {
|
|
737
|
+
// OpenAI semantics differ from Anthropic's: input_tokens already
|
|
738
|
+
// INCLUDES the cached portion (cached_input_tokens is a subset, and
|
|
739
|
+
// codex's own total_tokens = input + output), so adding it again
|
|
740
|
+
// double-counts cache hits.
|
|
741
|
+
usageAcc.input += j.usage.input_tokens ?? 0;
|
|
742
|
+
usageAcc.output += j.usage.output_tokens ?? 0;
|
|
743
|
+
meta.usage = { inputTokens: usageAcc.input, outputTokens: usageAcc.output, costUsd: null };
|
|
744
|
+
} else if (backend === "codex" && (j.type === "item.started" || j.type === "item.completed")) {
|
|
745
|
+
const item = j.item ?? {};
|
|
746
|
+
const t = item.type ?? item.item_type;
|
|
747
|
+
if (t === "web_search") noteTool("web_search");
|
|
748
|
+
// Tolerant fallback if -o never lands (schema drift across versions).
|
|
749
|
+
if (j.type === "item.completed" && t === "agent_message" && typeof item.text === "string")
|
|
750
|
+
lastAgentText = item.text;
|
|
751
|
+
}
|
|
752
|
+
};
|
|
355
753
|
child.stdout.on("data", (d) => {
|
|
356
754
|
events.write(d);
|
|
357
|
-
buf += d
|
|
755
|
+
buf += d;
|
|
358
756
|
let nl;
|
|
359
757
|
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
360
|
-
const line = buf.slice(0, nl)
|
|
758
|
+
const line = buf.slice(0, nl);
|
|
361
759
|
buf = buf.slice(nl + 1);
|
|
362
|
-
|
|
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
|
-
}
|
|
760
|
+
handleLine(line);
|
|
402
761
|
}
|
|
762
|
+
// A prompt waiting for input may never end its line: look at the
|
|
763
|
+
// unterminated non-JSON tail too, without consuming it.
|
|
764
|
+
if (backend === "gemini" && !initSeen && buf && !buf.trimStart().startsWith("{")
|
|
765
|
+
&& AUTH_NOTICE.test(preInit + buf)) notSignedIn();
|
|
403
766
|
});
|
|
404
767
|
|
|
405
768
|
// ---- signals: kill the group, finalize, no orphans --------------------------
|
|
406
769
|
let killedBy = null;
|
|
407
|
-
const killGroup = (sig) => {
|
|
408
|
-
try { process.kill(-child.pid, sig); }
|
|
409
|
-
catch { try { child.kill(sig); } catch {} }
|
|
410
|
-
};
|
|
411
770
|
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
412
771
|
process.on(sig, () => {
|
|
413
772
|
if (killedBy) return killGroup("SIGKILL");
|
|
@@ -418,14 +777,17 @@ export function runConsult(backend, argv) {
|
|
|
418
777
|
});
|
|
419
778
|
}
|
|
420
779
|
|
|
421
|
-
|
|
780
|
+
// One terminal state, published once: the reply is written and validated
|
|
781
|
+
// before meta.json leaves "running" and the session lock is released.
|
|
782
|
+
const finalize = (status, code, reply) => {
|
|
422
783
|
if (meta.status !== "running") return;
|
|
784
|
+
if (reply != null) writeFileSync(replyPath, reply);
|
|
785
|
+
if (backend === "grok" && meta.sessionId) prov.systemPrompt = grokSystemPrompt(meta.sessionId, meta.cwd);
|
|
423
786
|
meta.status = status;
|
|
424
787
|
meta.exitCode = code;
|
|
425
788
|
meta.signal = killedBy;
|
|
426
789
|
meta.endedAt = new Date().toISOString();
|
|
427
|
-
if (
|
|
428
|
-
if (backend === "codex" && !existsSync(replyPath) && lastAgentText != null) writeFileSync(replyPath, lastAgentText);
|
|
790
|
+
if (refusal) meta.refusal = refusal;
|
|
429
791
|
writeMeta(meta);
|
|
430
792
|
releaseLock();
|
|
431
793
|
};
|
|
@@ -436,11 +798,36 @@ export function runConsult(backend, argv) {
|
|
|
436
798
|
});
|
|
437
799
|
|
|
438
800
|
child.on("close", (code) => {
|
|
801
|
+
if (buf.trim()) handleLine(buf); // a final event without its newline
|
|
802
|
+
buf = "";
|
|
439
803
|
events.end();
|
|
440
804
|
errlog.end();
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
805
|
+
// The leader is gone, but a descendant that ignored SIGTERM (and closed
|
|
806
|
+
// its inherited pipes, so `close` could fire) may not be: on any kill
|
|
807
|
+
// path, finish the group now rather than trusting the unref'd timer.
|
|
808
|
+
if (killedBy || refusal) killGroup("SIGKILL");
|
|
809
|
+
|
|
810
|
+
// The deliverable. codex writes it via -o (agent_message as fallback);
|
|
811
|
+
// the others hand it over in the result event — as text, or for a
|
|
812
|
+
// --schema consult possibly only as structured_output.
|
|
813
|
+
let reply;
|
|
814
|
+
if (backend === "codex") reply = existsSync(replyPath) ? readFileSync(replyPath, "utf8") : lastAgentText;
|
|
815
|
+
else reply = resultText != null && String(resultText).trim() ? String(resultText)
|
|
816
|
+
: structuredOut !== undefined ? JSON.stringify(structuredOut) : resultText;
|
|
817
|
+
|
|
818
|
+
let status = killedBy ? "killed" : (refusal || code !== 0 || resultErr) ? "failed" : "done";
|
|
819
|
+
if (status === "done" && backend === "gemini" && sandbox === "read-only" && !initValidated) {
|
|
820
|
+
status = "failed";
|
|
821
|
+
refusal = "agy reported no init event, so its permission mode — the read-only guarantee — went unconfirmed";
|
|
822
|
+
writeSync(2, `[gaslamp] ${refusal}\n`);
|
|
823
|
+
}
|
|
824
|
+
// agy and grok report success for a turn that ended on a denied tool call
|
|
825
|
+
// with nothing to say (agy: "no output produced", on stderr). An empty
|
|
826
|
+
// reply is not a deliverable.
|
|
827
|
+
if (status === "done" && (backend === "gemini" || backend === "grok") && !reply?.trim()) {
|
|
828
|
+
status = "failed";
|
|
829
|
+
writeSync(2, `[gaslamp] ${backend} returned an empty reply — marking the consult failed\n`);
|
|
830
|
+
}
|
|
444
831
|
// A schema consult's contract is a typed reply: parse it (claude hands the
|
|
445
832
|
// object over in the result event; codex's reply.md IS the JSON), and an
|
|
446
833
|
// unparseable reply is a FAILED consult, not a quiet string.
|
|
@@ -448,12 +835,12 @@ export function runConsult(backend, argv) {
|
|
|
448
835
|
if (schemaText) {
|
|
449
836
|
data = structuredOut ?? null;
|
|
450
837
|
if (data == null && reply != null) { try { data = JSON.parse(reply); } catch {} }
|
|
451
|
-
if (data == null &&
|
|
452
|
-
|
|
453
|
-
writeMeta(meta);
|
|
838
|
+
if (data == null && status === "done") {
|
|
839
|
+
status = "failed";
|
|
454
840
|
writeSync(2, "[gaslamp] --schema was set but the reply is not valid JSON — marking the consult failed\n");
|
|
455
841
|
}
|
|
456
842
|
}
|
|
843
|
+
finalize(status, code, backend === "codex" && existsSync(replyPath) ? null : reply);
|
|
457
844
|
|
|
458
845
|
if (o.json) {
|
|
459
846
|
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
|
|
296
|
-
die(2, `fleet needs a backend: gaslamp fleet
|
|
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),
|
|
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
|
|
45
|
+
export const newJobId = (backend) => `${BACKENDS[backend].prefix}-${stampAndRand()}`;
|
|
43
46
|
export const newFleetId = () => `fl-${stampAndRand()}`;
|
|
44
47
|
|
|
45
|
-
export const looksLikeJobId = (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
|
|
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 >
|
|
94
|
+
Both ladders, strongest first: fable ≈ astra > opus ≈ sol > sonnet ≈ luna > haiku.
|
|
95
95
|
|
|
96
96
|
${other === "codex"
|
|
97
|
-
? `Codex tiers —
|
|
98
|
-
|
|
99
|
-
|
|
|
100
|
-
|
|
101
|
-
|
|
|
102
|
-
|
|
|
103
|
-
|
|
|
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
|
|
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
|
|
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 === "
|
|
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
|
}
|