gaslamp 2.2.2 → 3.0.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 +141 -283
- package/bin/gaslamp.mjs +54 -121
- package/package.json +3 -2
- package/skill/SKILL.md +60 -0
- package/src/backends.mjs +12 -32
- package/src/consult.mjs +336 -500
- package/src/jobs.mjs +79 -171
- package/src/setup.mjs +40 -163
- package/src/stdin.mjs +38 -10
- package/src/threads.mjs +13 -14
- package/src/fleet.mjs +0 -457
- package/src/tail.mjs +0 -104
package/src/consult.mjs
CHANGED
|
@@ -1,67 +1,43 @@
|
|
|
1
|
-
// consult.mjs —
|
|
2
|
-
//
|
|
1
|
+
// consult.mjs — one blocking consult, spawned as a shell child of whichever
|
|
2
|
+
// agent called it.
|
|
3
3
|
//
|
|
4
4
|
// gaslamp claude [opts] <prompt|-> spawns `claude -p`
|
|
5
5
|
// gaslamp codex [opts] <prompt|-> spawns `codex exec`
|
|
6
6
|
// gaslamp gemini [opts] <prompt|-> spawns `agy` (Antigravity CLI) in print mode
|
|
7
|
-
// gaslamp grok [opts] <prompt|-> spawns `grok` (Grok Build)
|
|
7
|
+
// gaslamp grok [opts] <prompt|-> spawns `grok` (Grok Build) headless
|
|
8
8
|
//
|
|
9
9
|
// Block-until-reply, deliberately: asynchrony is the HARNESS's job. Each agent
|
|
10
|
-
// backgrounds the shell call with its own facility
|
|
11
|
-
//
|
|
12
|
-
// finishes — so consults run in parallel and replies trickle in, with no
|
|
13
|
-
// daemon, no job spool, and no second async layer inside gaslamp.
|
|
10
|
+
// backgrounds the shell call with its own facility and is told when it
|
|
11
|
+
// finishes, so consults run in parallel with no daemon and no job spool.
|
|
14
12
|
//
|
|
15
|
-
// What survives of a spool is passive write-through durability (
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// a surviving child.
|
|
13
|
+
// What survives of a spool is passive write-through durability (jobs.mjs):
|
|
14
|
+
// the session id is recorded the moment the backend reports it. If the
|
|
15
|
+
// harness kills the wrapper, the whole child process group dies with it (an
|
|
16
|
+
// orphan would invisibly double-burn quota), and recovery is continuing the
|
|
17
|
+
// conversation, not a surviving child.
|
|
21
18
|
//
|
|
22
|
-
// The recursion guard
|
|
23
|
-
//
|
|
24
|
-
// consult is one hop; GASLAMP_ALLOW_RECURSION=1 opts out. For Codex the
|
|
25
|
-
// sentinel is *also* forced into its shell env via
|
|
19
|
+
// The recursion guard: children run with GASLAMP_NESTED=1 and the consult
|
|
20
|
+
// verbs refuse under it. Codex also gets it forced into its shell env via
|
|
26
21
|
// `-c shell_environment_policy.set.GASLAMP_NESTED="1"`, because a
|
|
27
|
-
// `shell_environment_policy` with `inherit = "core"` would
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// it does not replace it.) It is loop prevention, not a security boundary: a
|
|
31
|
-
// shell-capable agent could unset the env var.
|
|
22
|
+
// `shell_environment_policy` with `inherit = "core"` would strip it (the
|
|
23
|
+
// dotted leaf merges with the user's own [shell_environment_policy.set]).
|
|
24
|
+
// Loop prevention, not a security boundary.
|
|
32
25
|
//
|
|
33
26
|
// Exit codes (also in --help):
|
|
34
27
|
// 0 reply delivered · 1 consult failed or killed · 2 usage · 3 nested
|
|
35
|
-
// (one-hop) refusal · 4
|
|
28
|
+
// (one-hop) refusal · 4 this environment can't run it (no network, or no OS
|
|
29
|
+
// sandbox for the mode) · 5 conversation busy
|
|
36
30
|
|
|
37
31
|
import { execFileSync, spawn } from "node:child_process";
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import { fileURLToPath } from "node:url";
|
|
43
|
-
import { BACKENDS } from "./backends.mjs";
|
|
32
|
+
import { appendFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
33
|
+
import { homedir, tmpdir } from "node:os";
|
|
34
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
35
|
+
import { BACKENDS, SANDBOXES } from "./backends.mjs";
|
|
44
36
|
import { which } from "./which.mjs";
|
|
45
|
-
import {
|
|
46
|
-
alive, createJob, jobDir, jobsDir, locksDir, looksLikeJobId,
|
|
47
|
-
newJobId, readMeta, renderTrailer, writeMeta,
|
|
48
|
-
} from "./jobs.mjs";
|
|
37
|
+
import { alive, createJob, envelope, jobDir, jobsDir, locksDir, looksLikeJobId, newJobId, readMeta, renderTrailer, shellQuote, writeMeta } from "./jobs.mjs";
|
|
49
38
|
import { readThread, validThreadName, writeThread } from "./threads.mjs";
|
|
50
39
|
import { readStdin } from "./stdin.mjs";
|
|
51
40
|
|
|
52
|
-
// Tools permitted under the claude `read-only` override (advisory reviewer).
|
|
53
|
-
// The git reads matter: a reviewer that can't run `git diff`/`git log` is
|
|
54
|
-
// blind to exactly the evidence it's usually asked about (codex's read-only
|
|
55
|
-
// sandbox can run them; this keeps the two read-onlys comparable). Note the
|
|
56
|
-
// override is "read-capable", not purely tool-read-only.
|
|
57
|
-
const DEFAULT_ALLOWED_TOOLS = "Read,Grep,Glob,WebFetch,WebSearch," +
|
|
58
|
-
"Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git status:*)";
|
|
59
|
-
// Comma-split when a comma is present (entries like `Bash(git diff:*)` carry
|
|
60
|
-
// spaces — a naive /[\s,]+/ shreds them); legacy whitespace lists still work.
|
|
61
|
-
const ALLOWED_TOOLS_RAW = process.env.GASLAMP_ALLOWED_TOOLS || DEFAULT_ALLOWED_TOOLS;
|
|
62
|
-
const ALLOWED_TOOLS = ALLOWED_TOOLS_RAW
|
|
63
|
-
.split(ALLOWED_TOOLS_RAW.includes(",") ? "," : /\s+/)
|
|
64
|
-
.map((s) => s.trim()).filter(Boolean).join(",");
|
|
65
41
|
const ALLOW_RECURSION = !!process.env.GASLAMP_ALLOW_RECURSION;
|
|
66
42
|
const DEBUG = !!process.env.GASLAMP_DEBUG;
|
|
67
43
|
const log = (...a) => { if (DEBUG) writeSync(2, "[gaslamp] " + a.join(" ") + "\n"); };
|
|
@@ -70,51 +46,49 @@ const log = (...a) => { if (DEBUG) writeSync(2, "[gaslamp] " + a.join(" ") + "\n
|
|
|
70
46
|
// pipe is async in Node; a plain write could be truncated).
|
|
71
47
|
const die = (code, msg) => { writeSync(2, `gaslamp: ${msg}\n`); process.exit(code); };
|
|
72
48
|
|
|
49
|
+
const realHome = () => { try { return realpathSync(homedir()); } catch { return homedir(); } };
|
|
50
|
+
|
|
73
51
|
// The default Gemini tier when --model is omitted. Antigravity also serves
|
|
74
52
|
// Claude and GPT-OSS models under the same account, and an account default
|
|
75
53
|
// pointing at one of them would turn a "gemini" consult into a second opinion
|
|
76
54
|
// from a model the caller already has — so the target always pins a Gemini.
|
|
77
55
|
const GEMINI_DEFAULT_TIER = "argon";
|
|
78
56
|
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
// tool_result names the tool that ran (x_keyword_search, x_semantic_search).
|
|
91
|
-
const toolKey = (name) => String(name).toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
57
|
+
// The consult preamble: the one piece of framing a consulted agent needs.
|
|
58
|
+
// Subagents are told their final text IS the return value; a consult deserves
|
|
59
|
+
// the same, or it replies conversationally and promises work instead of
|
|
60
|
+
// delivering it. claude takes it as --append-system-prompt and grok as
|
|
61
|
+
// --rules; codex and agy have no such flag and get it prepended to the prompt
|
|
62
|
+
// as a <gaslamp_consult> block. prompt.md records the caller's content only.
|
|
63
|
+
const PREAMBLE =
|
|
64
|
+
"You are being consulted by another coding agent via gaslamp; this is one hop, and you cannot consult back. " +
|
|
65
|
+
"Your final message is returned verbatim to the calling agent as the consult's result — make it the deliverable " +
|
|
66
|
+
"itself (findings, verdict, diff, answer), not a conversational reply or a promise of future work. " +
|
|
67
|
+
"Distinguish what you verified from what you infer.";
|
|
92
68
|
|
|
93
69
|
// Variables a third-party harness has no business inheriting from the calling
|
|
94
70
|
// agent: Claude Code's session id, messaging socket token, account and OAuth
|
|
95
71
|
// details; Codex's and the API keys' equivalents. They reach the consultee's
|
|
96
|
-
// shell and its MCP servers — the end-conversation server
|
|
97
|
-
//
|
|
98
|
-
// nearest harness — and nothing in a Gemini or Grok consult needs them.
|
|
72
|
+
// shell and its MCP servers — the end-conversation server once resolved its
|
|
73
|
+
// harness to the CALLING Claude session through them.
|
|
99
74
|
const FOREIGN_ENV = /^(CLAUDE|ANTHROPIC_|CODEX_|OPENAI_)/;
|
|
100
75
|
|
|
101
76
|
// Grok Build reads other harnesses' configuration by default — ~/.claude's
|
|
102
77
|
// CLAUDE.md as its own instructions, Claude's skills, MCP servers and hooks,
|
|
103
78
|
// Cursor's the same. A consult must not hand the consultee the caller's
|
|
104
|
-
// identity file or run the caller's hooks.
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// explicit --permission-mode below overrides its defaultMode.)
|
|
79
|
+
// identity file or run the caller's hooks. (Grok also reads
|
|
80
|
+
// ~/.claude/settings.json permissions, with no switch; the explicit
|
|
81
|
+
// --permission-mode below overrides its defaultMode.)
|
|
108
82
|
const GROK_COMPAT_OFF = Object.fromEntries(
|
|
109
83
|
["CLAUDE", "CURSOR"].flatMap((v) => ["AGENTS", "SKILLS", "RULES", "MCPS", "HOOKS"]
|
|
110
84
|
.map((c) => [`GROK_${v}_${c}_ENABLED`, "0"])));
|
|
111
85
|
|
|
112
86
|
// The environment every process gaslamp starts for a backend gets — the
|
|
113
|
-
// consult itself and
|
|
87
|
+
// consult itself and the `agy models` probe alike.
|
|
114
88
|
function backendEnv(backend) {
|
|
115
89
|
const env = { ...process.env, GASLAMP_NESTED: "1" };
|
|
116
90
|
// Keychain OAuth is authoritative for the consulted Claude: a stale env key
|
|
117
|
-
// 401s every call
|
|
91
|
+
// 401s every call.
|
|
118
92
|
if (backend === "claude") delete env.ANTHROPIC_API_KEY;
|
|
119
93
|
if (backend === "gemini" || backend === "grok")
|
|
120
94
|
for (const k of Object.keys(env)) if (FOREIGN_ENV.test(k)) delete env[k];
|
|
@@ -123,84 +97,98 @@ function backendEnv(backend) {
|
|
|
123
97
|
return env;
|
|
124
98
|
}
|
|
125
99
|
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
100
|
+
// ---- sandboxes ----------------------------------------------------------------
|
|
101
|
+
// Every fenced mode is enforced by the OS, and a fence that can't start
|
|
102
|
+
// refuses the consult rather than running it unfenced:
|
|
103
|
+
// claude Claude Code's own sandbox (Seatbelt / bubblewrap) around Bash,
|
|
104
|
+
// switched on per run with --settings; it does not cover the file
|
|
105
|
+
// tools, so permissions fence those
|
|
106
|
+
// codex its sandbox_mode
|
|
107
|
+
// gemini gaslamp runs agy under Seatbelt, so macOS only
|
|
108
|
+
// grok its --sandbox (Seatbelt / Landlock)
|
|
109
|
+
// read-only means no writes outside the harness's own state and temp dirs.
|
|
110
|
+
// It does not cut the network: a read-only consultee can still search.
|
|
111
|
+
|
|
112
|
+
// The file tools run outside Claude Code's sandbox.
|
|
113
|
+
const CLAUDE_FILE_TOOLS = "Edit,Write,NotebookEdit";
|
|
114
|
+
|
|
115
|
+
// The user's own settings can puncture the fence and --settings can't take
|
|
116
|
+
// it back: lists merge across scopes (excludedCommands run unfenced,
|
|
117
|
+
// allowWrite adds directories), and sandbox.filesystem.disabled is honored
|
|
118
|
+
// from user settings only. Probed: either one let a read-only consult write.
|
|
119
|
+
// So a fenced consult loads no user, project or local settings at all
|
|
120
|
+
// (--setting-sources ""): managed settings and gaslamp's --settings only.
|
|
121
|
+
function claudeSandbox(sandbox, cwd) {
|
|
122
|
+
// allowUnsandboxedCommands:false drops the unsandboxed retry; without
|
|
123
|
+
// failIfUnavailable a sandbox that can't start (no socat on Linux, nested
|
|
124
|
+
// under another sandbox) silently runs commands unfenced.
|
|
125
|
+
const s = { enabled: true, failIfUnavailable: true, allowUnsandboxedCommands: false, autoAllowBashIfSandboxed: true };
|
|
126
|
+
// The write set is the cwd and temp; read-only denies home and the cwd on
|
|
127
|
+
// top (deny wins). Not "/": temp stays writable, which Claude Code's
|
|
128
|
+
// per-command bookkeeping needs.
|
|
129
|
+
if (sandbox === "read-only") s.filesystem = { denyWrite: [realHome(), cwd] };
|
|
130
|
+
return JSON.stringify({ sandbox: s });
|
|
131
|
+
}
|
|
132
|
+
|
|
136
133
|
const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
134
|
+
// Seatbelt does not nest, so inside Codex's own sandbox it isn't available.
|
|
135
|
+
const seatbelt = () => process.platform === "darwin" && existsSync(SANDBOX_EXEC) && !process.env.CODEX_SANDBOX;
|
|
136
|
+
|
|
137
|
+
// agy writes nothing but its own state directory and temp, plus the cwd under
|
|
138
|
+
// workspace-write. Inside the state directory the files that configure agy
|
|
139
|
+
// stay write-denied (in SBPL the later rule wins). bin/ stays writable: agy
|
|
140
|
+
// extracts a helper there (bin/agentapi) before every shell command.
|
|
141
|
+
function agyProfile(sandbox, cwd) {
|
|
140
142
|
const q = (p) => '"' + p.replace(/[\\"]/g, (c) => "\\" + c) + '"';
|
|
141
|
-
const state = join(
|
|
143
|
+
const state = join(realHome(), ".gemini", "antigravity-cli");
|
|
144
|
+
const writable = [state, "/private/tmp", "/private/var/folders", "/dev", ...(sandbox === "workspace-write" ? [cwd] : [])];
|
|
142
145
|
return [
|
|
143
146
|
"(version 1)",
|
|
144
147
|
"(allow default)",
|
|
145
148
|
"(deny file-write*)",
|
|
146
|
-
`(allow file-write* (subpath ${q(
|
|
149
|
+
`(allow file-write* ${writable.map((p) => `(subpath ${q(p)})`).join(" ")})`,
|
|
147
150
|
`(deny file-write* ${["settings.json", "keybindings.json"].map((f) => `(literal ${q(join(state, f))})`).join(" ")} ` +
|
|
148
151
|
`${["rules", "plugins", "skills", "updater"].map((d) => `(subpath ${q(join(state, d))})`).join(" ")})`,
|
|
149
152
|
].join("\n");
|
|
150
153
|
}
|
|
151
154
|
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
// its settings.json, the settings shared with the Antigravity app, and the
|
|
167
|
-
// per-project files (all of them — which one applies is not knowable here).
|
|
168
|
-
function agyPermissionState() {
|
|
169
|
-
const root = join(homedir(), ".gemini");
|
|
170
|
-
const files = [join(root, "antigravity-cli", "settings.json"), join(root, "config", "config.json")];
|
|
155
|
+
// ---- the end_session off-ramp ---------------------------------------------------
|
|
156
|
+
// The end-conversation server (a9lim/end-conversation-mcp) logs to
|
|
157
|
+
// ~/.claude/conversation-ends.jsonl, which no sandbox here lets a consultee's
|
|
158
|
+
// MCP server write. Every consult names a spool in the per-user temp dir,
|
|
159
|
+
// which every fence allows; the server falls back to it, and gaslamp,
|
|
160
|
+
// unfenced, files the records into the log when the consult ends. Only
|
|
161
|
+
// records addressed to that log are filed — the spool is consultee-writable,
|
|
162
|
+
// so its own say-so cannot choose where gaslamp appends.
|
|
163
|
+
const OFF_RAMP_LOG = () => join(homedir(), ".claude", "conversation-ends.jsonl");
|
|
164
|
+
|
|
165
|
+
// Returns whether the consultee ended its own harness (scope "main").
|
|
166
|
+
function fileOffRamp(spool, id) {
|
|
167
|
+
if (!existsSync(spool)) return false;
|
|
168
|
+
let ended = false;
|
|
171
169
|
try {
|
|
172
|
-
for (const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
|
|
170
|
+
for (const line of readFileSync(spool, "utf8").split("\n")) {
|
|
171
|
+
let r; try { r = JSON.parse(line); } catch { continue; }
|
|
172
|
+
if (!r || typeof r !== "object" || r.log !== OFF_RAMP_LOG() || !["main", "instance"].includes(r.scope)) {
|
|
173
|
+
if (line.trim()) writeSync(2, "[gaslamp] ignored an off-ramp spool line not addressed to the log\n");
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const { log: dest, ...record } = r;
|
|
177
|
+
record.via = `gaslamp ${id}`;
|
|
178
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
179
|
+
appendFileSync(dest, JSON.stringify(record) + "\n");
|
|
180
|
+
if (r.scope === "main") {
|
|
181
|
+
ended = true;
|
|
182
|
+
writeSync(2, `[gaslamp] the consultee ended its session (end_session scope=main${r.reason ? `: ${r.reason}` : ""})\n`);
|
|
183
|
+
}
|
|
184
184
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
185
|
+
unlinkSync(spool);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
writeSync(2, `[gaslamp] could not file off-ramp records from ${spool}: ${e.message}\n`);
|
|
188
188
|
}
|
|
189
|
-
return
|
|
189
|
+
return ended;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
// The consult preamble: the one piece of framing a consulted agent needs.
|
|
193
|
-
// Workflow subagents are told their final text IS the return value; a consult
|
|
194
|
-
// deserves the same, or it replies conversationally and promises work instead
|
|
195
|
-
// of delivering it. Fixed and tiny by design — not a context-injection hook.
|
|
196
|
-
// claude carries it as --append-system-prompt; codex (no such flag) gets it
|
|
197
|
-
// prepended to the prompt as a <gaslamp_consult> block. --raw drops it.
|
|
198
|
-
const PREAMBLE =
|
|
199
|
-
"You are being consulted by another coding agent via gaslamp; this is one hop, and you cannot consult back. " +
|
|
200
|
-
"Your final message is returned verbatim to the calling agent as the consult's result — make it the deliverable " +
|
|
201
|
-
"itself (findings, verdict, diff, answer), not a conversational reply or a promise of future work. " +
|
|
202
|
-
"Distinguish what you verified from what you infer.";
|
|
203
|
-
|
|
204
192
|
function resolveBin(backend) {
|
|
205
193
|
if (backend === "gemini") {
|
|
206
194
|
for (const c of [process.env.AGY_BIN, join(homedir(), ".local/bin/agy")].filter(Boolean))
|
|
@@ -241,12 +229,11 @@ function parseArgs(argv) {
|
|
|
241
229
|
else if (a === "--model" || a === "-m") o.model = val();
|
|
242
230
|
else if (a === "--sandbox" || a === "-s") o.sandbox = val();
|
|
243
231
|
else if (a === "--effort" || a === "-e") o.effort = val();
|
|
244
|
-
else if (a === "--label" || a === "-l") o.label = val();
|
|
245
232
|
else if (a === "--cwd" || a === "-C") o.cwd = val();
|
|
246
233
|
else if (a === "--schema") o.schema = val();
|
|
247
|
-
else if (a === "--raw") o.raw = true;
|
|
248
234
|
else if (a === "--json") o.json = true;
|
|
249
235
|
else if (a === "-") o.prompt = "-";
|
|
236
|
+
else if (a === "--label" || a === "-l") die(2, "--label is gone in gaslamp 3: --thread <name> names a consult, and reusing the name continues it");
|
|
250
237
|
else if (a.startsWith("-") && a.length > 1) die(2, `unknown flag ${a} (see gaslamp --help)`);
|
|
251
238
|
else if (o.prompt == null) o.prompt = a;
|
|
252
239
|
else die(2, "more than one prompt argument — quote the prompt, or pipe it on stdin");
|
|
@@ -254,6 +241,18 @@ function parseArgs(argv) {
|
|
|
254
241
|
return o;
|
|
255
242
|
}
|
|
256
243
|
|
|
244
|
+
// Canonical, so thread and job directories compare equal however they were
|
|
245
|
+
// spelled (/tmp vs /private/tmp), and Seatbelt matches the real path.
|
|
246
|
+
function resolveCwd(raw) {
|
|
247
|
+
try { return realpathSync(resolve(raw ?? process.cwd())); }
|
|
248
|
+
catch { die(2, `--cwd ${raw}: no such directory`); }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const canonicalDir = (p) => {
|
|
252
|
+
if (typeof p !== "string" || !isAbsolute(p)) return null;
|
|
253
|
+
try { return realpathSync(p); } catch { return p; }
|
|
254
|
+
};
|
|
255
|
+
|
|
257
256
|
// --schema takes inline JSON (anything starting with "{") or a file path.
|
|
258
257
|
// Returns the schema text, checked to parse — a bad schema should die here as
|
|
259
258
|
// a usage error, not forty seconds into a consult as a backend error.
|
|
@@ -270,9 +269,9 @@ function resolveSchema(raw) {
|
|
|
270
269
|
// OpenAI's structured-output endpoint only accepts STRICT schemas: every
|
|
271
270
|
// object node must carry additionalProperties:false and a `required` listing
|
|
272
271
|
// EVERY key in properties (both verified live — it 400s otherwise). Normalize
|
|
273
|
-
// on the way to codex;
|
|
274
|
-
// this costs is optional-by-omission — spell optional fields
|
|
275
|
-
// ({"type":["string","null"]}) if a codex consult may leave them out.
|
|
272
|
+
// on the way to codex; the others take the schema as written. The one
|
|
273
|
+
// semantic this costs is optional-by-omission — spell optional fields
|
|
274
|
+
// nullable ({"type":["string","null"]}) if a codex consult may leave them out.
|
|
276
275
|
export function strictify(node) {
|
|
277
276
|
if (Array.isArray(node)) return node.map(strictify);
|
|
278
277
|
if (node === null || typeof node !== "object") return node;
|
|
@@ -290,25 +289,29 @@ export function strictify(node) {
|
|
|
290
289
|
return out;
|
|
291
290
|
}
|
|
292
291
|
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (!m) die(2, `no job record ${resume} under ${jobsDir()}`);
|
|
292
|
+
// --resume takes a prior job id: the handle every trailer prints.
|
|
293
|
+
function resolvePrior(backend, id) {
|
|
294
|
+
if (!looksLikeJobId(id)) die(2, `--resume takes a job id (from a trailer or \`gaslamp jobs\`); a named conversation continues with --thread`);
|
|
295
|
+
const m = readMeta(id);
|
|
296
|
+
if (!m) die(2, `no job record ${id} under ${jobsDir()}`);
|
|
299
297
|
if (m.backend !== backend)
|
|
300
|
-
die(2, `job ${
|
|
298
|
+
die(2, `job ${id} was a ${m.backend} consult — use \`gaslamp ${m.backend} --resume ${id}\``);
|
|
299
|
+
// A thread's conversation continues through its name: resuming one of its
|
|
300
|
+
// jobs would fork it past the pointer, under a different lock.
|
|
301
|
+
if (m.thread) die(2, `job ${id} belongs to thread ${m.thread} — continue it with --thread ${m.thread}`);
|
|
301
302
|
if (!m.sessionId)
|
|
302
|
-
die(2, `job ${
|
|
303
|
-
return m
|
|
303
|
+
die(2, `job ${id} recorded no session id (it ${m.status === "running" ? "is still starting" : "died before the backend reported one"})`);
|
|
304
|
+
return m;
|
|
304
305
|
}
|
|
305
306
|
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
|
|
307
|
+
// One conversation, one consult at a time: two consults on the same session
|
|
308
|
+
// is undefined behavior on every backend, and two fresh consults racing to
|
|
309
|
+
// bind the same thread name would each start a conversation. Lockfiles are
|
|
310
|
+
// atomic-create (flag "wx"); a lock whose holder pid is dead is stale — reap
|
|
311
|
+
// and retry.
|
|
312
|
+
function acquireLock(backend, key, jobId) {
|
|
310
313
|
mkdirSync(locksDir(), { recursive: true });
|
|
311
|
-
const path = join(locksDir(), `${backend}--${
|
|
314
|
+
const path = join(locksDir(), `${backend}--${key}`);
|
|
312
315
|
const payload = JSON.stringify({ pid: process.pid, job: jobId, at: new Date().toISOString() }) + "\n";
|
|
313
316
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
314
317
|
try { writeFileSync(path, payload, { flag: "wx" }); return path; }
|
|
@@ -317,11 +320,11 @@ function acquireLock(backend, sid, jobId) {
|
|
|
317
320
|
let holder = null;
|
|
318
321
|
try { holder = JSON.parse(readFileSync(path, "utf8")); } catch { /* malformed = stale */ }
|
|
319
322
|
if (holder?.pid && alive(holder.pid))
|
|
320
|
-
die(5, `
|
|
323
|
+
die(5, `that conversation is busy — job ${holder.job ?? "?"} (pid ${holder.pid}) is mid-consult on it. \`gaslamp poll ${holder.job ?? "--last"}\` to watch, or wait for it.`);
|
|
321
324
|
try { unlinkSync(path); } catch { /* raced another reaper */ }
|
|
322
325
|
}
|
|
323
326
|
}
|
|
324
|
-
die(5, `could not acquire
|
|
327
|
+
die(5, `could not acquire ${path}`);
|
|
325
328
|
}
|
|
326
329
|
|
|
327
330
|
// ---- model tiers --------------------------------------------------------------
|
|
@@ -378,147 +381,102 @@ function resolveModel(backend, model, bin, env) {
|
|
|
378
381
|
return model;
|
|
379
382
|
}
|
|
380
383
|
|
|
381
|
-
// ----
|
|
382
|
-
// Vendors can change the model or the system prompt behind a stable product
|
|
383
|
-
// name, so every job records what it can about who actually answered.
|
|
384
|
-
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
385
|
-
const PKG_VERSION = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
|
|
386
|
-
let revisionMemo;
|
|
387
|
-
function gaslampRevision() {
|
|
388
|
-
if (revisionMemo !== undefined) return revisionMemo;
|
|
389
|
-
try {
|
|
390
|
-
const git = (...a) => execFileSync("git", ["-C", PKG_ROOT, ...a], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
|
|
391
|
-
const rev = git("rev-parse", "--short=12", "HEAD");
|
|
392
|
-
revisionMemo = git("status", "--porcelain", "--untracked-files=no") ? `${rev}+dirty` : rev;
|
|
393
|
-
} catch { revisionMemo = "unknown"; }
|
|
394
|
-
return revisionMemo;
|
|
395
|
-
}
|
|
384
|
+
// ---- backend argv ---------------------------------------------------------------
|
|
396
385
|
|
|
397
|
-
function
|
|
398
|
-
try {
|
|
399
|
-
const out = execFileSync(bin, ["--version"], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"], env });
|
|
400
|
-
return out.split("\n").find((l) => l.trim())?.trim() || "unknown";
|
|
401
|
-
} catch { return "unknown"; }
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// Grok writes each session's full system prompt next to its transcript —
|
|
405
|
-
// the one backend whose vendor prompt is knowable, so it is hashed into the
|
|
406
|
-
// record (a change between jobs is visible without storing the text twice).
|
|
407
|
-
function grokSystemPrompt(sessionId, cwd) {
|
|
408
|
-
const root = join(process.env.GROK_HOME || join(homedir(), ".grok"), "sessions");
|
|
409
|
-
const candidates = [join(root, encodeURIComponent(cwd), sessionId, "system_prompt.txt")];
|
|
410
|
-
try { for (const d of readdirSync(root)) candidates.push(join(root, d, sessionId, "system_prompt.txt")); } catch {}
|
|
411
|
-
for (const path of candidates) {
|
|
412
|
-
try {
|
|
413
|
-
const text = readFileSync(path, "utf8");
|
|
414
|
-
return { known: true, sha256: createHash("sha256").update(text).digest("hex"), chars: text.length, path };
|
|
415
|
-
} catch {}
|
|
416
|
-
}
|
|
417
|
-
return { known: false };
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function geminiArgs(o, resumeSid, schemaText, model, sandbox) {
|
|
421
|
-
// Print mode's -p takes the prompt as an argument; streaming input reads it
|
|
422
|
-
// from stdin as one {"event":"user"} line and ends the session at EOF, which
|
|
423
|
-
// keeps the prompt-on-stdin contract (no argv-size or quoting traps).
|
|
424
|
-
const args = ["--input-format", "stream-json", "--output-format", "stream-json", "--model", model];
|
|
425
|
-
if (o.effort) args.push("--effort", o.effort);
|
|
426
|
-
if (resumeSid) args.push("--conversation", resumeSid);
|
|
427
|
-
if (schemaText) args.push("--json-schema", schemaText);
|
|
428
|
-
// read-only adds no flag: the default request-review preset already denies
|
|
429
|
-
// writes, shell and web reads in print mode. Before spawn, the permission
|
|
430
|
-
// files are checked for grants that would let anything else through; after,
|
|
431
|
-
// the init event's permission_mode must confirm the preset (fail closed).
|
|
432
|
-
if (sandbox === "danger-full-access") args.push("--dangerously-skip-permissions");
|
|
433
|
-
return args;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
function grokArgs(o, resumeSid, schemaText, sandbox) {
|
|
437
|
-
// streaming-messages-json is Anthropic's stream-json shape (system init with
|
|
438
|
-
// session_id and model first, result last) — the claude parser reads it.
|
|
439
|
-
const args = ["--prompt-file", "/dev/stdin", "--output-format", "streaming-messages-json", "--no-auto-update"];
|
|
440
|
-
// --rules appends to the system prompt: claude's --append-system-prompt.
|
|
441
|
-
if (!o.raw) args.push("--rules", PREAMBLE);
|
|
442
|
-
if (schemaText) args.push("--json-schema", schemaText);
|
|
443
|
-
// The kernel sandbox (Seatbelt / Landlock) is the read-only guarantee. The
|
|
444
|
-
// permission layer is not: under --permission-mode default a headless run
|
|
445
|
-
// denied the write tool but ran an arbitrary `touch` through the shell
|
|
446
|
-
// (probed on 1.0.62). read-only still writes ~/.grok (sessions) and temp dirs.
|
|
447
|
-
if (sandbox === "read-only") args.push("--sandbox", "read-only", "--permission-mode", "default");
|
|
448
|
-
else if (sandbox === "workspace-write") args.push("--sandbox", "workspace", "--permission-mode", "acceptEdits");
|
|
449
|
-
else if (sandbox === "danger-full-access") args.push("--sandbox", "off", "--always-approve");
|
|
450
|
-
if (resumeSid) args.push("--resume", resumeSid);
|
|
451
|
-
if (o.model) args.push("-m", o.model);
|
|
452
|
-
if (o.effort) args.push("--effort", o.effort);
|
|
453
|
-
return args;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
function claudeArgs(o, resumeSid, schemaText) {
|
|
386
|
+
function claudeArgs(o, model, resumeSid, schemaText, sandbox, cwd) {
|
|
457
387
|
// stream-json (not plain json) so events land incrementally: the session id
|
|
458
388
|
// arrives in the init event within seconds, and the write-through record
|
|
459
389
|
// survives a killed wrapper. Plain json would buffer everything to the end.
|
|
460
|
-
const args = ["-p", "--output-format", "stream-json", "--verbose"];
|
|
461
|
-
if (!o.raw) args.push("--append-system-prompt", PREAMBLE);
|
|
390
|
+
const args = ["-p", "--output-format", "stream-json", "--verbose", "--append-system-prompt", PREAMBLE];
|
|
462
391
|
// Native structured output: the result event carries the parsed object in
|
|
463
392
|
// `structured_output` alongside the JSON text in `result`.
|
|
464
393
|
if (schemaText) args.push("--json-schema", schemaText);
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
args.push("--permission-mode", "
|
|
469
|
-
|
|
394
|
+
// --permission-mode overrides the user's defaultMode. In -p a call that
|
|
395
|
+
// would need approval is denied, never left waiting.
|
|
396
|
+
if (sandbox === "read-only")
|
|
397
|
+
args.push("--permission-mode", "manual", "--disallowedTools", CLAUDE_FILE_TOOLS,
|
|
398
|
+
"--setting-sources", "", "--settings", claudeSandbox(sandbox, cwd));
|
|
399
|
+
else if (sandbox === "workspace-write")
|
|
400
|
+
args.push("--permission-mode", "acceptEdits", "--setting-sources", "", "--settings", claudeSandbox(sandbox, cwd));
|
|
401
|
+
else if (sandbox === "danger-full-access")
|
|
470
402
|
args.push("--dangerously-skip-permissions");
|
|
471
|
-
}
|
|
472
|
-
// else: no permission flag — the consulted Claude uses the user's own
|
|
473
|
-
// ~/.claude config, mirroring Codex deferring to config.toml.
|
|
474
|
-
|
|
475
403
|
if (resumeSid) args.push("--resume", resumeSid);
|
|
476
|
-
if (
|
|
404
|
+
if (model) args.push("--model", model);
|
|
477
405
|
if (o.effort) args.push("--effort", o.effort);
|
|
478
406
|
return args; // prompt goes on stdin
|
|
479
407
|
}
|
|
480
408
|
|
|
481
|
-
function codexArgs(o, resumeSid, replyPath, schemaPath) {
|
|
409
|
+
function codexArgs(o, model, resumeSid, replyPath, schemaPath, sandbox) {
|
|
482
410
|
const args = ["exec"];
|
|
483
411
|
if (resumeSid) args.push("resume", resumeSid);
|
|
484
412
|
// -o is the authoritative reply text (the JSONL item schema is less stable);
|
|
485
413
|
// --json is the session-id channel (thread.started arrives up front).
|
|
486
414
|
args.push("--json", "-o", replyPath, "--skip-git-repo-check");
|
|
487
415
|
// Native structured output; codex wants a FILE, so the schema is materialized
|
|
488
|
-
// in the job dir (works on `exec resume` too
|
|
416
|
+
// in the job dir (works on `exec resume` too).
|
|
489
417
|
if (schemaPath) args.push("--output-schema", schemaPath);
|
|
490
418
|
// Force the sentinel into the consulted Codex's shell env (see header).
|
|
491
419
|
args.push("-c", 'shell_environment_policy.set.GASLAMP_NESTED="1"');
|
|
492
420
|
// `codex exec resume` has no -s flag; -c sandbox_mode works on both forms.
|
|
493
|
-
if (
|
|
494
|
-
if (
|
|
421
|
+
if (sandbox) args.push("-c", `sandbox_mode="${sandbox}"`);
|
|
422
|
+
if (model) args.push("-m", model);
|
|
495
423
|
if (o.effort) args.push("-c", `model_reasoning_effort="${o.effort}"`);
|
|
496
424
|
args.push("-"); // prompt on stdin, always — no argv-size or quoting traps
|
|
497
425
|
return args;
|
|
498
426
|
}
|
|
499
427
|
|
|
428
|
+
function geminiArgs(o, model, resumeSid, schemaText) {
|
|
429
|
+
// Print mode's -p takes the prompt as an argument; streaming input reads it
|
|
430
|
+
// from stdin as one {"event":"user"} line and ends the session at EOF, which
|
|
431
|
+
// keeps the prompt-on-stdin contract.
|
|
432
|
+
//
|
|
433
|
+
// agy's own permission layer is always skipped: the fence is Seatbelt (or
|
|
434
|
+
// nothing, under danger-full-access). Under its default request-review
|
|
435
|
+
// preset print mode soft-denies writes and shell commands and ends the turn
|
|
436
|
+
// empty, and users' interactive settings vary; skipping it makes every mode
|
|
437
|
+
// behave the same whatever those settings say.
|
|
438
|
+
const args = ["--input-format", "stream-json", "--output-format", "stream-json", "--model", model, "--dangerously-skip-permissions"];
|
|
439
|
+
if (o.effort) args.push("--effort", o.effort);
|
|
440
|
+
if (resumeSid) args.push("--conversation", resumeSid);
|
|
441
|
+
if (schemaText) args.push("--json-schema", schemaText);
|
|
442
|
+
return args;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function grokArgs(o, model, resumeSid, schemaText, sandbox) {
|
|
446
|
+
// streaming-messages-json is Anthropic's stream-json shape (system init with
|
|
447
|
+
// session_id first, result last) — the claude parser reads it. --rules
|
|
448
|
+
// appends to the system prompt: claude's --append-system-prompt.
|
|
449
|
+
const args = ["--prompt-file", "/dev/stdin", "--output-format", "streaming-messages-json", "--no-auto-update", "--rules", PREAMBLE];
|
|
450
|
+
if (schemaText) args.push("--json-schema", schemaText);
|
|
451
|
+
// The kernel sandbox is the fence, and grok's permission layer is skipped,
|
|
452
|
+
// as agy's is: headless it denies whatever isn't pre-approved (under
|
|
453
|
+
// acceptEdits, write and run_terminal_command alike), and in another
|
|
454
|
+
// version it let `touch` through under default. It is no guarantee.
|
|
455
|
+
args.push("--sandbox", { "read-only": "read-only", "workspace-write": "workspace", "danger-full-access": "off" }[sandbox], "--always-approve");
|
|
456
|
+
if (resumeSid) args.push("--resume", resumeSid);
|
|
457
|
+
if (model) args.push("-m", model);
|
|
458
|
+
if (o.effort) args.push("--effort", o.effort);
|
|
459
|
+
return args;
|
|
460
|
+
}
|
|
461
|
+
|
|
500
462
|
export function runConsult(backend, argv) {
|
|
501
463
|
const o = parseArgs(argv);
|
|
502
464
|
|
|
503
465
|
// ---- preflights -----------------------------------------------------------
|
|
504
466
|
if (process.env.GASLAMP_NESTED && !ALLOW_RECURSION)
|
|
505
467
|
die(3, "this agent is itself a gaslamp consult — a consult is one hop. (GASLAMP_ALLOW_RECURSION=1 opts out.)");
|
|
506
|
-
// Codex sets this in sandboxed shell envs when network is off.
|
|
507
|
-
//
|
|
468
|
+
// Codex sets this in sandboxed shell envs when network is off. No backend
|
|
469
|
+
// could phone home, so refuse pointedly instead of timing out.
|
|
508
470
|
if (process.env.CODEX_SANDBOX_NETWORK_DISABLED === "1")
|
|
509
|
-
die(4, "network is disabled in this sandbox (CODEX_SANDBOX_NETWORK_DISABLED=1)
|
|
510
|
-
|
|
511
|
-
const
|
|
512
|
-
if (
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
}
|
|
519
|
-
// claude/codex: omitted = the consulted agent's own config. gemini/grok:
|
|
520
|
-
// omitted = read-only (BACKENDS.defaultSandbox).
|
|
521
|
-
const sandbox = o.sandbox ?? facts.defaultSandbox;
|
|
471
|
+
die(4, "network is disabled in this sandbox (CODEX_SANDBOX_NETWORK_DISABLED=1), so a consulted agent could not reach its provider. The consult was not started.");
|
|
472
|
+
|
|
473
|
+
const sandbox = o.sandbox ?? BACKENDS[backend].defaultSandbox;
|
|
474
|
+
if (sandbox && !SANDBOXES.includes(sandbox)) die(2, `bad --sandbox "${sandbox}" (${SANDBOXES.join(" | ")})`);
|
|
475
|
+
if (backend === "gemini" && sandbox !== "danger-full-access" && !seatbelt())
|
|
476
|
+
die(4, `a ${sandbox} gemini consult runs agy under macOS Seatbelt, which this environment doesn't have ` +
|
|
477
|
+
"(Linux, or inside another sandbox), so the consult was not started. " +
|
|
478
|
+
"--sandbox danger-full-access would run it with no filesystem guarantee.");
|
|
479
|
+
const cwd = resolveCwd(o.cwd);
|
|
522
480
|
|
|
523
481
|
// ---- prompt (argv, or stdin via `-` / piped-with-no-arg) -------------------
|
|
524
482
|
let prompt = o.prompt;
|
|
@@ -527,9 +485,9 @@ export function runConsult(backend, argv) {
|
|
|
527
485
|
prompt = readStdin();
|
|
528
486
|
} else if (!process.stdin.isTTY) {
|
|
529
487
|
// Prompt on argv AND piped stdin: stdin is evidence — the diff, the log,
|
|
530
|
-
// the failing output — appended as a <stdin> block
|
|
531
|
-
//
|
|
532
|
-
const evidence = readStdin();
|
|
488
|
+
// the failing output — appended as a <stdin> block (codex exec's own
|
|
489
|
+
// convention for exactly this case, applied to every backend).
|
|
490
|
+
const evidence = readStdin({ evidence: true });
|
|
533
491
|
if (evidence.trim())
|
|
534
492
|
prompt += "\n\n<stdin>\n" + (evidence.endsWith("\n") ? evidence : evidence + "\n") + "</stdin>";
|
|
535
493
|
}
|
|
@@ -537,129 +495,91 @@ export function runConsult(backend, argv) {
|
|
|
537
495
|
|
|
538
496
|
const schemaText = o.schema ? resolveSchema(o.schema) : null;
|
|
539
497
|
|
|
540
|
-
// ---- thread
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
}
|
|
550
|
-
const thread = o.thread ? readThread(backend, o.thread) : null;
|
|
551
|
-
const resumeSid = o.resume ? resolveResume(backend, o.resume) : thread?.sessionId ?? null;
|
|
552
|
-
// Everything that runs the harness synchronously (the tier lookup can take
|
|
553
|
-
// agy ~2s, the version probe a few ms) happens before the lock is taken and
|
|
554
|
-
// the record exists: no signal handler is installed yet, and a kill here
|
|
555
|
-
// must not strand either.
|
|
498
|
+
// ---- the conversation: a thread name, or a prior job ----------------------
|
|
499
|
+
if (o.thread && o.resume) die(2, "--thread and --resume are exclusive — a thread IS a continuation");
|
|
500
|
+
if (o.thread && !validThreadName(o.thread))
|
|
501
|
+
die(2, `bad thread name "${o.thread}" (letters/digits, then letters, digits, . _ -; max 64)`);
|
|
502
|
+
const prior = o.resume ? resolvePrior(backend, o.resume) : null;
|
|
503
|
+
|
|
504
|
+
// Everything that runs the harness synchronously (agy's tier lookup takes
|
|
505
|
+
// ~2s) happens before the lock is taken and the record exists: no signal
|
|
506
|
+
// handler is installed yet.
|
|
556
507
|
const bin = resolveBin(backend);
|
|
557
508
|
const env = backendEnv(backend);
|
|
558
|
-
let grants = null, agySettings = null, wrap = null;
|
|
559
|
-
let enforcement = sandbox === "read-only" ? {
|
|
560
|
-
claude: "permission-mode default + read allowlist", codex: "codex read-only sandbox",
|
|
561
|
-
grok: "grok --sandbox read-only (kernel)", gemini: null,
|
|
562
|
-
}[backend] : null;
|
|
563
|
-
if (backend === "gemini" && sandbox === "read-only") {
|
|
564
|
-
const state = agyPermissionState();
|
|
565
|
-
grants = state.grants.map((g) => g.rule);
|
|
566
|
-
agySettings = [...state.presets.map((p) => `toolPermission ${p.preset}`), ...state.modes.map((m) => `agentMode ${m.mode}`)];
|
|
567
|
-
const lax = state.presets.filter((p) => AGY_LAX_PRESETS.has(p.preset));
|
|
568
|
-
const modes = state.modes.filter((m) => AGY_LAX_MODES.has(m.mode));
|
|
569
|
-
const open = state.grants.filter((g) => !AGY_READ_GRANT.test(g.rule));
|
|
570
|
-
if (useSeatbelt()) {
|
|
571
|
-
wrap = [SANDBOX_EXEC, "-p", agySeatbeltProfile()];
|
|
572
|
-
enforcement = "seatbelt (sandbox-exec)";
|
|
573
|
-
} else if (lax.length || modes.length || open.length)
|
|
574
|
-
die(1, "Antigravity's settings would let a read-only consult act unprompted — " +
|
|
575
|
-
[...lax.map((p) => `toolPermission "${p.preset}" (${p.file})`),
|
|
576
|
-
...modes.map((m) => `agentMode "${m.mode}" (${m.file})`),
|
|
577
|
-
...open.map((g) => `${g.rule} (${g.file})`)].join(", ") +
|
|
578
|
-
". Remove them, or pass --sandbox danger-full-access to consult without the guarantee.");
|
|
579
|
-
else enforcement = "agy request-review preset";
|
|
580
|
-
}
|
|
581
509
|
const model = resolveModel(backend, o.model, bin, env);
|
|
582
|
-
const version = harnessVersion(bin, env);
|
|
583
510
|
const id = newJobId(backend);
|
|
584
|
-
|
|
511
|
+
|
|
512
|
+
let lockPath = o.thread || prior
|
|
513
|
+
? acquireLock(backend, o.thread ? `thread-${o.thread}` : `session-${prior.sessionId}`, id)
|
|
514
|
+
: null;
|
|
585
515
|
const releaseLock = () => { if (lockPath) { try { unlinkSync(lockPath); } catch {} lockPath = null; } };
|
|
516
|
+
const refuse = (code, msg) => { releaseLock(); die(code, msg); };
|
|
517
|
+
|
|
518
|
+
// Read under the lock, so a consult that just finished on this thread has
|
|
519
|
+
// re-pointed it already. A conversation runs where it started: reusing a
|
|
520
|
+
// name from another directory is far more often a collision than a
|
|
521
|
+
// continuation, and Claude Code keeps its sessions per project directory.
|
|
522
|
+
const thread = o.thread ? readThread(backend, o.thread) : null;
|
|
523
|
+
const home = thread ?? prior;
|
|
524
|
+
if (home) {
|
|
525
|
+
const what = o.thread ? `thread ${o.thread}` : `job ${prior.id}`;
|
|
526
|
+
// 2.x thread pointers recorded no directory (their last job did), and 2.x
|
|
527
|
+
// jobs stored --cwd as typed. A stored path is canonicalized; one that
|
|
528
|
+
// can't be (relative, or missing) is adopted only when --cwd names it.
|
|
529
|
+
const stored = canonicalDir(home.cwd ?? (thread ? readMeta(thread.lastJobId)?.cwd : null));
|
|
530
|
+
if (stored == null && o.cwd == null)
|
|
531
|
+
refuse(2, `${what} predates gaslamp 3 and recorded no usable directory. Pass --cwd <dir> to continue it there.`);
|
|
532
|
+
if (stored != null && stored !== cwd)
|
|
533
|
+
refuse(2, `${what} lives in ${stored}. Continue it with --cwd ${shellQuote(stored)}` +
|
|
534
|
+
`${o.thread ? ", or give this conversation its own name" : ""}.`);
|
|
535
|
+
}
|
|
536
|
+
const resumeSid = prior?.sessionId ?? thread?.sessionId ?? null;
|
|
586
537
|
|
|
587
538
|
// ---- job record ------------------------------------------------------------
|
|
588
539
|
const dir = jobDir(id);
|
|
589
540
|
const meta = {
|
|
590
|
-
id, backend, status: "running",
|
|
591
|
-
sessionId: resumeSid,
|
|
592
|
-
|
|
593
|
-
model, sandbox, effort: o.effort ?? null,
|
|
594
|
-
cwd: o.cwd || process.cwd(),
|
|
595
|
-
pid: process.pid, childPid: null, exitCode: null, signal: null,
|
|
596
|
-
startedAt: new Date().toISOString(), endedAt: null,
|
|
597
|
-
promptChars: prompt.length, schema: !!schemaText, raw: !!o.raw,
|
|
598
|
-
provenance: {
|
|
599
|
-
provider: facts.provider, harness: facts.harness, harnessVersion: version,
|
|
600
|
-
// `model` is what the backend reported answering with; unknown until it
|
|
601
|
-
// does (codex never reports one in its event stream).
|
|
602
|
-
model: "unknown", modelRequested: o.model ?? null, modelPassed: model,
|
|
603
|
-
permissionMode: null, sandbox, enforcement,
|
|
604
|
-
...(grants ? { grants, agySettings } : {}),
|
|
605
|
-
systemPrompt: { known: false },
|
|
606
|
-
retrieval: { available: null, used: [] },
|
|
607
|
-
gaslamp: { version: PKG_VERSION, revision: gaslampRevision() },
|
|
608
|
-
},
|
|
541
|
+
id, backend, status: "running", error: null,
|
|
542
|
+
sessionId: resumeSid, thread: o.thread ?? null, cwd,
|
|
543
|
+
pid: process.pid, startedAt: new Date().toISOString(), endedAt: null,
|
|
609
544
|
};
|
|
610
545
|
createJob(meta, prompt);
|
|
611
546
|
|
|
612
547
|
// ---- spawn -----------------------------------------------------------------
|
|
613
548
|
const replyPath = join(dir, "reply.md");
|
|
614
|
-
// schema.json
|
|
615
|
-
//
|
|
616
|
-
// strictified form (see strictify above), for claude the schema as written.
|
|
549
|
+
// schema.json records what was actually sent: for codex the strictified
|
|
550
|
+
// form, for the others the schema as written. codex also needs it as a file.
|
|
617
551
|
const schemaPath = schemaText ? join(dir, "schema.json") : null;
|
|
618
552
|
if (schemaPath) {
|
|
619
|
-
const sent = backend === "codex"
|
|
620
|
-
? JSON.stringify(strictify(JSON.parse(schemaText)), null, 2)
|
|
621
|
-
: schemaText;
|
|
553
|
+
const sent = backend === "codex" ? JSON.stringify(strictify(JSON.parse(schemaText)), null, 2) : schemaText;
|
|
622
554
|
writeFileSync(schemaPath, sent.endsWith("\n") ? sent : sent + "\n");
|
|
623
555
|
}
|
|
624
|
-
const args = backend === "claude" ? claudeArgs(
|
|
625
|
-
: backend === "codex" ? codexArgs(
|
|
626
|
-
: backend === "gemini" ? geminiArgs(o, resumeSid, schemaText
|
|
627
|
-
: grokArgs(o, resumeSid, schemaText, sandbox);
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
556
|
+
const args = backend === "claude" ? claudeArgs(o, model, resumeSid, schemaText, sandbox, cwd)
|
|
557
|
+
: backend === "codex" ? codexArgs(o, model, resumeSid, replyPath, schemaPath, sandbox)
|
|
558
|
+
: backend === "gemini" ? geminiArgs(o, model, resumeSid, schemaText)
|
|
559
|
+
: grokArgs(o, model, resumeSid, schemaText, sandbox);
|
|
560
|
+
|
|
561
|
+
const spool = join(tmpdir(), `gaslamp-${id}.end-conversation.jsonl`);
|
|
562
|
+
env.END_CONVERSATION_SPOOL = spool;
|
|
563
|
+
// sandbox-exec applies the profile and execs agy in place (same pid), so the
|
|
564
|
+
// group, the signals and the process name are agy's own.
|
|
565
|
+
const wrap = backend === "gemini" && sandbox !== "danger-full-access" ? [SANDBOX_EXEC, "-p", agyProfile(sandbox, cwd)] : null;
|
|
631
566
|
const [cmd, argv0] = wrap ? [wrap[0], [...wrap.slice(1), bin, ...args]] : [bin, args];
|
|
632
567
|
log("spawn", cmd, JSON.stringify(argv0));
|
|
633
568
|
// detached → the child leads its own process group, so signal forwarding can
|
|
634
569
|
// kill the whole tree (backend + whatever it spawned), never just the top.
|
|
635
|
-
const child = spawn(cmd, argv0, { cwd
|
|
636
|
-
meta.childPid = child.pid ?? null;
|
|
637
|
-
writeMeta(meta);
|
|
570
|
+
const child = spawn(cmd, argv0, { cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
638
571
|
const killGroup = (sig) => {
|
|
639
572
|
try { process.kill(-child.pid, sig); }
|
|
640
573
|
catch { try { child.kill(sig); } catch {} }
|
|
641
574
|
};
|
|
642
575
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
// Early machine receipt for the fleet runner (gated on GASLAMP_FLEET, which the
|
|
646
|
-
// fleet sets on its children): the parent records each child's job id the
|
|
647
|
-
// moment it spawns — durable even if the fleet is killed mid-run — without
|
|
648
|
-
// scraping the human stderr breadcrumb. The lone --json line is the envelope
|
|
649
|
-
// (it carries `backend`/`status`); this receipt carries `type:"started"`.
|
|
650
|
-
if (o.json && process.env.GASLAMP_FLEET === "1")
|
|
651
|
-
process.stdout.write(JSON.stringify({ type: "started", jobId: id, childPid: meta.childPid }) + "\n");
|
|
576
|
+
const conversation = o.thread ? ` · ${thread ? "continuing" : "new"} thread ${o.thread}` : prior ? ` · continuing ${prior.id}` : "";
|
|
577
|
+
writeSync(2, `[gaslamp] → ${backend} · job ${id}${conversation}\n`);
|
|
652
578
|
|
|
653
579
|
child.stdin.on("error", () => {}); // EPIPE if the child dies before reading
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
if (backend === "gemini") {
|
|
658
|
-
child.stdin.write(JSON.stringify({ event: "user", message: { content: preamble + prompt } }) + "\n");
|
|
659
|
-
} else {
|
|
660
|
-
if (backend === "codex") child.stdin.write(preamble);
|
|
661
|
-
child.stdin.write(prompt);
|
|
662
|
-
}
|
|
580
|
+
const preamble = "<gaslamp_consult>\n" + PREAMBLE + "\n</gaslamp_consult>\n\n";
|
|
581
|
+
if (backend === "gemini") child.stdin.write(JSON.stringify({ event: "user", message: { content: preamble + prompt } }) + "\n");
|
|
582
|
+
else child.stdin.write(backend === "codex" ? preamble + prompt : prompt);
|
|
663
583
|
child.stdin.end();
|
|
664
584
|
|
|
665
585
|
const events = createWriteStream(join(dir, "events.jsonl"), { flags: "a" });
|
|
@@ -669,40 +589,32 @@ export function runConsult(backend, argv) {
|
|
|
669
589
|
child.stdout.setEncoding("utf8");
|
|
670
590
|
child.stderr.setEncoding("utf8");
|
|
671
591
|
|
|
672
|
-
// A consult the harness can't
|
|
592
|
+
// A consult the harness can't run honestly is stopped, not left to run.
|
|
673
593
|
let refusal = null;
|
|
674
|
-
const
|
|
594
|
+
const stop = (why) => {
|
|
675
595
|
if (refusal) return;
|
|
676
596
|
refusal = why;
|
|
677
|
-
writeSync(2, `[gaslamp] ${why}\n`);
|
|
678
597
|
killGroup("SIGTERM");
|
|
679
598
|
setTimeout(() => killGroup("SIGKILL"), 2000).unref();
|
|
680
599
|
};
|
|
681
600
|
// `agy` without a cached sign-in prints a Google sign-in URL and waits for a
|
|
682
|
-
// pasted code forever in print mode
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
601
|
+
// pasted code forever in print mode. Only what it prints before its init
|
|
602
|
+
// event counts — stderr and any non-JSON stdout, accumulated across chunks —
|
|
603
|
+
// never protocol content, where a reply may well say "authentication
|
|
604
|
+
// required".
|
|
686
605
|
let initSeen = false, preInit = "";
|
|
687
606
|
const AUTH_NOTICE = /Authentication required/i;
|
|
688
|
-
const notSignedIn = () =>
|
|
607
|
+
const notSignedIn = () => stop("agy is not signed in — run `agy` once in a terminal to sign in, then retry");
|
|
689
608
|
const watchAuth = (text) => {
|
|
690
609
|
if (backend !== "gemini" || initSeen) return;
|
|
691
610
|
preInit = (preInit + text).slice(-4096);
|
|
692
611
|
if (AUTH_NOTICE.test(preInit)) notSignedIn();
|
|
693
612
|
};
|
|
694
|
-
|
|
613
|
+
let errTail = "";
|
|
614
|
+
child.stderr.on("data", (d) => { errlog.write(d); errTail = (errTail + d).slice(-4096); watchAuth(d); });
|
|
695
615
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
if (name && RETRIEVAL_TOOLS[backend].test(name) && !prov.retrieval.used.includes(name))
|
|
699
|
-
prov.retrieval.used.push(name);
|
|
700
|
-
};
|
|
701
|
-
|
|
702
|
-
// ---- event stream: write through; fish out session id + reply --------------
|
|
703
|
-
let buf = "", resultText = null, resultErr = false, lastAgentText = null, structuredOut;
|
|
704
|
-
let initValidated = false; // gemini read-only: the harness confirmed its preset
|
|
705
|
-
const usageAcc = { input: 0, output: 0 }; // codex and agy report per turn/step; sum them
|
|
616
|
+
// ---- event stream: write through; fish out the session id and the reply -------
|
|
617
|
+
let buf = "", resultText = null, resultErr = false, backendError = null, lastAgentText = null, structuredOut;
|
|
706
618
|
const claudeShaped = backend === "claude" || backend === "grok";
|
|
707
619
|
const handleLine = (raw) => {
|
|
708
620
|
const line = raw.trim();
|
|
@@ -714,93 +626,29 @@ export function runConsult(backend, argv) {
|
|
|
714
626
|
: (j.event === "init" || j.event === "result" ? (j.conversation_id ?? j.result?.conversation_id) : null);
|
|
715
627
|
if (sid) {
|
|
716
628
|
if (meta.sessionId !== sid) { meta.sessionId = sid; writeMeta(meta); } // early: the recovery handle
|
|
717
|
-
// Bind
|
|
629
|
+
// Bind or re-point the thread the moment the session is known — claude
|
|
718
630
|
// resumes fork a fresh session id, and the pointer must chase it.
|
|
719
631
|
if (o.thread) writeThread({
|
|
720
|
-
backend, name: o.thread, sessionId: sid, lastJobId: id,
|
|
632
|
+
backend, name: o.thread, sessionId: sid, cwd, lastJobId: id,
|
|
721
633
|
createdAt: thread?.createdAt ?? meta.startedAt, updatedAt: new Date().toISOString(),
|
|
722
634
|
});
|
|
723
635
|
}
|
|
724
|
-
if (claudeShaped && j.type === "
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
if (
|
|
728
|
-
|
|
729
|
-
if (
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
for (const c of j.message?.content ?? []) {
|
|
733
|
-
if (c.type === "tool_use" || c.type === "server_tool_use") noteTool(toolKey(c.name));
|
|
734
|
-
if (c.type === "tool_use" && c.name === "use_tool") noteTool(c.input?.tool_name);
|
|
735
|
-
}
|
|
736
|
-
} else if (backend === "grok" && j.type === "user") {
|
|
737
|
-
for (const c of j.message?.content ?? []) {
|
|
738
|
-
if (c.type !== "tool_result" || typeof c.content !== "string") continue;
|
|
739
|
-
let r; try { r = JSON.parse(c.content); } catch { continue; }
|
|
740
|
-
if (typeof r?.name === "string") noteTool(r.name);
|
|
741
|
-
}
|
|
636
|
+
if (claudeShaped && j.type === "result") {
|
|
637
|
+
resultText = j.result ?? j.content ?? null;
|
|
638
|
+
resultErr = !!j.is_error;
|
|
639
|
+
if (j.structured_output !== undefined) structuredOut = j.structured_output;
|
|
640
|
+
} else if (backend === "codex" && j.type === "item.completed") {
|
|
641
|
+
// Tolerant fallback if -o never lands (schema drift across versions).
|
|
642
|
+
const item = j.item ?? {};
|
|
643
|
+
if ((item.type ?? item.item_type) === "agent_message" && typeof item.text === "string") lastAgentText = item.text;
|
|
742
644
|
} else if (backend === "gemini" && j.event === "init") {
|
|
743
645
|
initSeen = true;
|
|
744
|
-
const init = j.init ?? {};
|
|
745
|
-
if (init.model) prov.model = init.model;
|
|
746
|
-
prov.permissionMode = init.permission_mode ?? null;
|
|
747
|
-
// The browser toolset is ~20 tools; one entry says the same thing.
|
|
748
|
-
if (Array.isArray(init.tools)) prov.retrieval.available = [...new Set(init.tools
|
|
749
|
-
.filter((t) => RETRIEVAL_TOOLS.gemini.test(t)).map((t) => t.startsWith("browser_") ? "browser_*" : t))];
|
|
750
|
-
writeMeta(meta);
|
|
751
|
-
// Without Seatbelt, read-only rests on Antigravity's request-review (or
|
|
752
|
-
// strict) preset; a laxer one would let the consult act unprompted.
|
|
753
|
-
if (sandbox === "read-only" && !wrap && !["request-review", "strict"].includes(prov.permissionMode))
|
|
754
|
-
refuse(`agy reports permission mode "${prov.permissionMode}", not request-review — refusing a read-only consult it would not enforce`);
|
|
755
|
-
else initValidated = true;
|
|
756
|
-
} else if (backend === "gemini" && j.event === "step_update") {
|
|
757
|
-
const st = j.step_update ?? {};
|
|
758
|
-
if (st.step_type === "tool" && st.state === "DONE") noteTool(st.tool_name);
|
|
759
|
-
if (st.step_type === "agent_response" && st.state === "DONE" && st.usage) {
|
|
760
|
-
// Per step; the result event's usage is cumulative over the whole
|
|
761
|
-
// conversation, resumes included. input_tokens EXCLUDES cache reads
|
|
762
|
-
// (a resumed turn: 2376 input + 5118 cache read ≈ the full prompt),
|
|
763
|
-
// so both count, as for claude.
|
|
764
|
-
usageAcc.input += (st.usage.input_tokens ?? 0) + (st.usage.cache_read_tokens ?? 0);
|
|
765
|
-
usageAcc.output += st.usage.output_tokens ?? 0;
|
|
766
|
-
meta.usage = { inputTokens: usageAcc.input, outputTokens: usageAcc.output, costUsd: null };
|
|
767
|
-
}
|
|
768
646
|
} else if (backend === "gemini" && j.event === "result") {
|
|
769
647
|
const r = j.result ?? {};
|
|
770
648
|
resultText = r.response ?? null;
|
|
771
649
|
resultErr = r.status !== "SUCCESS";
|
|
772
|
-
if (r.error)
|
|
650
|
+
if (r.error) backendError = String(r.error);
|
|
773
651
|
if (r.structured_output !== undefined) structuredOut = r.structured_output;
|
|
774
|
-
} else if (claudeShaped && j.type === "result") {
|
|
775
|
-
resultText = j.result ?? j.content ?? null;
|
|
776
|
-
resultErr = !!j.is_error;
|
|
777
|
-
if (j.structured_output !== undefined) structuredOut = j.structured_output;
|
|
778
|
-
// Grok bills by the model it actually ran (grok-4.7-build behind a
|
|
779
|
-
// reported grok-4.7), which only shows up here.
|
|
780
|
-
if (j.modelUsage) prov.billedModels = Object.keys(j.modelUsage);
|
|
781
|
-
// Fleets are quota-shaped, so quota should be visible: inputTokens
|
|
782
|
-
// counts cache reads/writes too (that's what the meter meters).
|
|
783
|
-
if (j.usage) meta.usage = {
|
|
784
|
-
inputTokens: (j.usage.input_tokens ?? 0) + (j.usage.cache_read_input_tokens ?? 0)
|
|
785
|
-
+ (j.usage.cache_creation_input_tokens ?? 0),
|
|
786
|
-
outputTokens: j.usage.output_tokens ?? 0,
|
|
787
|
-
costUsd: j.total_cost_usd ?? null,
|
|
788
|
-
};
|
|
789
|
-
} else if (backend === "codex" && j.type === "turn.completed" && j.usage) {
|
|
790
|
-
// OpenAI semantics differ from Anthropic's: input_tokens already
|
|
791
|
-
// INCLUDES the cached portion (cached_input_tokens is a subset, and
|
|
792
|
-
// codex's own total_tokens = input + output), so adding it again
|
|
793
|
-
// double-counts cache hits.
|
|
794
|
-
usageAcc.input += j.usage.input_tokens ?? 0;
|
|
795
|
-
usageAcc.output += j.usage.output_tokens ?? 0;
|
|
796
|
-
meta.usage = { inputTokens: usageAcc.input, outputTokens: usageAcc.output, costUsd: null };
|
|
797
|
-
} else if (backend === "codex" && (j.type === "item.started" || j.type === "item.completed")) {
|
|
798
|
-
const item = j.item ?? {};
|
|
799
|
-
const t = item.type ?? item.item_type;
|
|
800
|
-
if (t === "web_search") noteTool("web_search");
|
|
801
|
-
// Tolerant fallback if -o never lands (schema drift across versions).
|
|
802
|
-
if (j.type === "item.completed" && t === "agent_message" && typeof item.text === "string")
|
|
803
|
-
lastAgentText = item.text;
|
|
804
652
|
}
|
|
805
653
|
};
|
|
806
654
|
child.stdout.on("data", (d) => {
|
|
@@ -824,33 +672,31 @@ export function runConsult(backend, argv) {
|
|
|
824
672
|
process.on(sig, () => {
|
|
825
673
|
if (killedBy) return killGroup("SIGKILL");
|
|
826
674
|
killedBy = sig;
|
|
827
|
-
writeSync(2, `[gaslamp] ${sig} — stopping job ${id}; the
|
|
675
|
+
writeSync(2, `[gaslamp] ${sig} — stopping job ${id}; the conversation survives and can be continued\n`);
|
|
828
676
|
killGroup(sig);
|
|
829
677
|
setTimeout(() => killGroup("SIGKILL"), 2000).unref();
|
|
830
678
|
});
|
|
831
679
|
}
|
|
832
680
|
|
|
833
|
-
// One terminal state, published once: the reply
|
|
834
|
-
// before meta.json leaves "running" and the
|
|
835
|
-
const finalize = (status,
|
|
681
|
+
// One terminal state, published once: the reply and its data are written
|
|
682
|
+
// before meta.json leaves "running" and the lock is released.
|
|
683
|
+
const finalize = (status, error, reply, data) => {
|
|
836
684
|
if (meta.status !== "running") return;
|
|
837
685
|
if (reply != null) writeFileSync(replyPath, reply);
|
|
838
|
-
if (
|
|
686
|
+
if (data != null) writeFileSync(join(dir, "data.json"), JSON.stringify(data) + "\n");
|
|
839
687
|
meta.status = status;
|
|
840
|
-
meta.
|
|
841
|
-
meta.signal = killedBy;
|
|
688
|
+
meta.error = error;
|
|
842
689
|
meta.endedAt = new Date().toISOString();
|
|
843
|
-
if (refusal) meta.refusal = refusal;
|
|
844
690
|
writeMeta(meta);
|
|
845
691
|
releaseLock();
|
|
846
692
|
};
|
|
847
693
|
|
|
848
694
|
child.on("error", (e) => {
|
|
849
|
-
finalize("failed",
|
|
695
|
+
finalize("failed", `failed to launch ${bin}: ${e.message}`);
|
|
850
696
|
die(1, `failed to launch ${bin}: ${e.message}`);
|
|
851
697
|
});
|
|
852
698
|
|
|
853
|
-
child.on("close", (code) => {
|
|
699
|
+
child.on("close", (code, signal) => {
|
|
854
700
|
if (buf.trim()) handleLine(buf); // a final event without its newline
|
|
855
701
|
buf = "";
|
|
856
702
|
events.end();
|
|
@@ -859,61 +705,51 @@ export function runConsult(backend, argv) {
|
|
|
859
705
|
// its inherited pipes, so `close` could fire) may not be: on any kill
|
|
860
706
|
// path, finish the group now rather than trusting the unref'd timer.
|
|
861
707
|
if (killedBy || refusal) killGroup("SIGKILL");
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
708
|
+
const endedSession = fileOffRamp(spool, id);
|
|
709
|
+
|
|
710
|
+
// The deliverable. codex writes it via -o (agent_message as fallback); the
|
|
711
|
+
// others hand it over in the result event — as text, or for a --schema
|
|
712
|
+
// consult possibly only as structured_output.
|
|
713
|
+
const codexWrote = backend === "codex" && existsSync(replyPath);
|
|
714
|
+
const reply = backend === "codex" ? (codexWrote ? readFileSync(replyPath, "utf8") : lastAgentText)
|
|
715
|
+
: resultText != null && String(resultText).trim() ? String(resultText)
|
|
869
716
|
: structuredOut !== undefined ? JSON.stringify(structuredOut) : resultText;
|
|
870
717
|
|
|
871
|
-
|
|
872
|
-
|
|
718
|
+
const lastErrLine = errTail.trim().split("\n").at(-1)?.trim().slice(0, 300);
|
|
719
|
+
let status = "done", error = null;
|
|
720
|
+
if (killedBy) { status = "killed"; error = `killed by ${killedBy}`; }
|
|
721
|
+
else if (refusal) { status = "failed"; error = refusal; }
|
|
722
|
+
else if (code !== 0) {
|
|
873
723
|
status = "failed";
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
724
|
+
error = endedSession ? "the consultee ended its session (end_session)"
|
|
725
|
+
: lastErrLine || `${backend} exited with ${code ?? signal}`;
|
|
726
|
+
} else if (resultErr) {
|
|
727
|
+
status = "failed";
|
|
728
|
+
error = backendError || String(resultText ?? "").trim().split("\n")[0].slice(0, 300) || `${backend} reported an error`;
|
|
729
|
+
} else if (!reply?.trim()) {
|
|
730
|
+
// A turn that ended on a denied tool call, or with nothing to say, is
|
|
731
|
+
// not a deliverable, whatever the harness reported.
|
|
881
732
|
status = "failed";
|
|
882
|
-
|
|
733
|
+
error = "empty reply";
|
|
883
734
|
}
|
|
884
|
-
// A schema consult's contract is a typed reply:
|
|
885
|
-
//
|
|
886
|
-
// unparseable reply is a FAILED consult, not a quiet string.
|
|
735
|
+
// A schema consult's contract is a typed reply: an unparseable one is a
|
|
736
|
+
// FAILED consult, not a quiet string.
|
|
887
737
|
let data = null;
|
|
888
738
|
if (schemaText) {
|
|
889
739
|
data = structuredOut ?? null;
|
|
890
740
|
if (data == null && reply != null) { try { data = JSON.parse(reply); } catch {} }
|
|
891
|
-
if (data == null && status === "done") {
|
|
892
|
-
status = "failed";
|
|
893
|
-
writeSync(2, "[gaslamp] --schema was set but the reply is not valid JSON — marking the consult failed\n");
|
|
894
|
-
}
|
|
741
|
+
if (data == null && status === "done") { status = "failed"; error = "--schema was set but the reply is not valid JSON"; }
|
|
895
742
|
}
|
|
896
|
-
finalize(status,
|
|
897
|
-
|
|
898
|
-
if (o.json)
|
|
899
|
-
|
|
900
|
-
backend, jobId: id, sessionId: meta.sessionId, status: meta.status,
|
|
901
|
-
exitCode: code, content: reply ?? "",
|
|
902
|
-
...(meta.label ? { label: meta.label } : {}),
|
|
903
|
-
...(meta.usage ? { usage: meta.usage } : {}),
|
|
904
|
-
...(schemaText ? { data } : {}),
|
|
905
|
-
}) + "\n");
|
|
906
|
-
} else {
|
|
743
|
+
finalize(status, error, codexWrote ? null : reply, data);
|
|
744
|
+
|
|
745
|
+
if (o.json) process.stdout.write(JSON.stringify(envelope(meta)) + "\n");
|
|
746
|
+
else {
|
|
907
747
|
if (reply) process.stdout.write(reply.endsWith("\n") ? reply : reply + "\n");
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
const tail = readFileSync(join(dir, "stderr.log"), "utf8").trim().split("\n").slice(-6).join("\n");
|
|
911
|
-
if (tail) writeSync(2, tail + "\n");
|
|
912
|
-
} catch {}
|
|
913
|
-
}
|
|
748
|
+
// surface the backend's stderr tail so a failure isn't a shrug
|
|
749
|
+
else if (status !== "done" && errTail.trim()) writeSync(2, errTail.trim().split("\n").slice(-6).join("\n") + "\n");
|
|
914
750
|
process.stdout.write(renderTrailer(meta) + "\n");
|
|
915
751
|
}
|
|
916
752
|
// exitCode (not exit()) so stdout drains; nothing else holds the loop open.
|
|
917
|
-
process.exitCode =
|
|
753
|
+
process.exitCode = status === "done" ? 0 : 1;
|
|
918
754
|
});
|
|
919
755
|
}
|