gaslamp 2.2.3 → 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 -295
- 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 +331 -534
- package/src/jobs.mjs +79 -171
- package/src/setup.mjs +40 -165
- 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 { appendFileSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
32
|
+
import { appendFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
40
33
|
import { homedir, tmpdir } from "node:os";
|
|
41
|
-
import { dirname, join } from "node:path";
|
|
42
|
-
import {
|
|
43
|
-
import { BACKENDS } from "./backends.mjs";
|
|
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,94 +97,98 @@ function backendEnv(backend) {
|
|
|
123
97
|
return env;
|
|
124
98
|
}
|
|
125
99
|
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
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
|
+
}
|
|
137
132
|
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
const useSeatbelt = () => process.platform === "darwin" && existsSync(SANDBOX_EXEC)
|
|
148
|
-
&& !process.env.CODEX_SANDBOX && process.env.GASLAMP_GEMINI_SEATBELT !== "0";
|
|
149
|
-
function agySeatbeltProfile() {
|
|
133
|
+
const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
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) {
|
|
150
142
|
const q = (p) => '"' + p.replace(/[\\"]/g, (c) => "\\" + c) + '"';
|
|
151
|
-
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] : [])];
|
|
152
145
|
return [
|
|
153
146
|
"(version 1)",
|
|
154
147
|
"(allow default)",
|
|
155
148
|
"(deny file-write*)",
|
|
156
|
-
`(allow file-write* (subpath ${q(
|
|
149
|
+
`(allow file-write* ${writable.map((p) => `(subpath ${q(p)})`).join(" ")})`,
|
|
157
150
|
`(deny file-write* ${["settings.json", "keybindings.json"].map((f) => `(literal ${q(join(state, f))})`).join(" ")} ` +
|
|
158
151
|
`${["rules", "plugins", "skills", "updater"].map((d) => `(subpath ${q(join(state, d))})`).join(" ")})`,
|
|
159
152
|
].join("\n");
|
|
160
153
|
}
|
|
161
154
|
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
// its settings.json, the settings shared with the Antigravity app, and the
|
|
177
|
-
// per-project files (all of them — which one applies is not knowable here).
|
|
178
|
-
function agyPermissionState() {
|
|
179
|
-
const root = join(homedir(), ".gemini");
|
|
180
|
-
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;
|
|
181
169
|
try {
|
|
182
|
-
for (const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
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
|
+
}
|
|
194
184
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
185
|
+
unlinkSync(spool);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
writeSync(2, `[gaslamp] could not file off-ramp records from ${spool}: ${e.message}\n`);
|
|
198
188
|
}
|
|
199
|
-
return
|
|
189
|
+
return ended;
|
|
200
190
|
}
|
|
201
191
|
|
|
202
|
-
// The consult preamble: the one piece of framing a consulted agent needs.
|
|
203
|
-
// Workflow subagents are told their final text IS the return value; a consult
|
|
204
|
-
// deserves the same, or it replies conversationally and promises work instead
|
|
205
|
-
// of delivering it. Fixed and tiny by design — not a context-injection hook.
|
|
206
|
-
// claude carries it as --append-system-prompt; codex (no such flag) gets it
|
|
207
|
-
// prepended to the prompt as a <gaslamp_consult> block. --raw drops it.
|
|
208
|
-
const PREAMBLE =
|
|
209
|
-
"You are being consulted by another coding agent via gaslamp; this is one hop, and you cannot consult back. " +
|
|
210
|
-
"Your final message is returned verbatim to the calling agent as the consult's result — make it the deliverable " +
|
|
211
|
-
"itself (findings, verdict, diff, answer), not a conversational reply or a promise of future work. " +
|
|
212
|
-
"Distinguish what you verified from what you infer.";
|
|
213
|
-
|
|
214
192
|
function resolveBin(backend) {
|
|
215
193
|
if (backend === "gemini") {
|
|
216
194
|
for (const c of [process.env.AGY_BIN, join(homedir(), ".local/bin/agy")].filter(Boolean))
|
|
@@ -251,12 +229,11 @@ function parseArgs(argv) {
|
|
|
251
229
|
else if (a === "--model" || a === "-m") o.model = val();
|
|
252
230
|
else if (a === "--sandbox" || a === "-s") o.sandbox = val();
|
|
253
231
|
else if (a === "--effort" || a === "-e") o.effort = val();
|
|
254
|
-
else if (a === "--label" || a === "-l") o.label = val();
|
|
255
232
|
else if (a === "--cwd" || a === "-C") o.cwd = val();
|
|
256
233
|
else if (a === "--schema") o.schema = val();
|
|
257
|
-
else if (a === "--raw") o.raw = true;
|
|
258
234
|
else if (a === "--json") o.json = true;
|
|
259
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");
|
|
260
237
|
else if (a.startsWith("-") && a.length > 1) die(2, `unknown flag ${a} (see gaslamp --help)`);
|
|
261
238
|
else if (o.prompt == null) o.prompt = a;
|
|
262
239
|
else die(2, "more than one prompt argument — quote the prompt, or pipe it on stdin");
|
|
@@ -264,6 +241,18 @@ function parseArgs(argv) {
|
|
|
264
241
|
return o;
|
|
265
242
|
}
|
|
266
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
|
+
|
|
267
256
|
// --schema takes inline JSON (anything starting with "{") or a file path.
|
|
268
257
|
// Returns the schema text, checked to parse — a bad schema should die here as
|
|
269
258
|
// a usage error, not forty seconds into a consult as a backend error.
|
|
@@ -280,9 +269,9 @@ function resolveSchema(raw) {
|
|
|
280
269
|
// OpenAI's structured-output endpoint only accepts STRICT schemas: every
|
|
281
270
|
// object node must carry additionalProperties:false and a `required` listing
|
|
282
271
|
// EVERY key in properties (both verified live — it 400s otherwise). Normalize
|
|
283
|
-
// on the way to codex;
|
|
284
|
-
// this costs is optional-by-omission — spell optional fields
|
|
285
|
-
// ({"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.
|
|
286
275
|
export function strictify(node) {
|
|
287
276
|
if (Array.isArray(node)) return node.map(strictify);
|
|
288
277
|
if (node === null || typeof node !== "object") return node;
|
|
@@ -300,25 +289,29 @@ export function strictify(node) {
|
|
|
300
289
|
return out;
|
|
301
290
|
}
|
|
302
291
|
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
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()}`);
|
|
309
297
|
if (m.backend !== backend)
|
|
310
|
-
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}`);
|
|
311
302
|
if (!m.sessionId)
|
|
312
|
-
die(2, `job ${
|
|
313
|
-
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;
|
|
314
305
|
}
|
|
315
306
|
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
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) {
|
|
320
313
|
mkdirSync(locksDir(), { recursive: true });
|
|
321
|
-
const path = join(locksDir(), `${backend}--${
|
|
314
|
+
const path = join(locksDir(), `${backend}--${key}`);
|
|
322
315
|
const payload = JSON.stringify({ pid: process.pid, job: jobId, at: new Date().toISOString() }) + "\n";
|
|
323
316
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
324
317
|
try { writeFileSync(path, payload, { flag: "wx" }); return path; }
|
|
@@ -327,11 +320,11 @@ function acquireLock(backend, sid, jobId) {
|
|
|
327
320
|
let holder = null;
|
|
328
321
|
try { holder = JSON.parse(readFileSync(path, "utf8")); } catch { /* malformed = stale */ }
|
|
329
322
|
if (holder?.pid && alive(holder.pid))
|
|
330
|
-
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.`);
|
|
331
324
|
try { unlinkSync(path); } catch { /* raced another reaper */ }
|
|
332
325
|
}
|
|
333
326
|
}
|
|
334
|
-
die(5, `could not acquire
|
|
327
|
+
die(5, `could not acquire ${path}`);
|
|
335
328
|
}
|
|
336
329
|
|
|
337
330
|
// ---- model tiers --------------------------------------------------------------
|
|
@@ -388,147 +381,102 @@ function resolveModel(backend, model, bin, env) {
|
|
|
388
381
|
return model;
|
|
389
382
|
}
|
|
390
383
|
|
|
391
|
-
// ----
|
|
392
|
-
// Vendors can change the model or the system prompt behind a stable product
|
|
393
|
-
// name, so every job records what it can about who actually answered.
|
|
394
|
-
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
395
|
-
const PKG_VERSION = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
|
|
396
|
-
let revisionMemo;
|
|
397
|
-
function gaslampRevision() {
|
|
398
|
-
if (revisionMemo !== undefined) return revisionMemo;
|
|
399
|
-
try {
|
|
400
|
-
const git = (...a) => execFileSync("git", ["-C", PKG_ROOT, ...a], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
|
|
401
|
-
const rev = git("rev-parse", "--short=12", "HEAD");
|
|
402
|
-
revisionMemo = git("status", "--porcelain", "--untracked-files=no") ? `${rev}+dirty` : rev;
|
|
403
|
-
} catch { revisionMemo = "unknown"; }
|
|
404
|
-
return revisionMemo;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function harnessVersion(bin, env) {
|
|
408
|
-
try {
|
|
409
|
-
const out = execFileSync(bin, ["--version"], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"], env });
|
|
410
|
-
return out.split("\n").find((l) => l.trim())?.trim() || "unknown";
|
|
411
|
-
} catch { return "unknown"; }
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// Grok writes each session's full system prompt next to its transcript —
|
|
415
|
-
// the one backend whose vendor prompt is knowable, so it is hashed into the
|
|
416
|
-
// record (a change between jobs is visible without storing the text twice).
|
|
417
|
-
function grokSystemPrompt(sessionId, cwd) {
|
|
418
|
-
const root = join(process.env.GROK_HOME || join(homedir(), ".grok"), "sessions");
|
|
419
|
-
const candidates = [join(root, encodeURIComponent(cwd), sessionId, "system_prompt.txt")];
|
|
420
|
-
try { for (const d of readdirSync(root)) candidates.push(join(root, d, sessionId, "system_prompt.txt")); } catch {}
|
|
421
|
-
for (const path of candidates) {
|
|
422
|
-
try {
|
|
423
|
-
const text = readFileSync(path, "utf8");
|
|
424
|
-
return { known: true, sha256: createHash("sha256").update(text).digest("hex"), chars: text.length, path };
|
|
425
|
-
} catch {}
|
|
426
|
-
}
|
|
427
|
-
return { known: false };
|
|
428
|
-
}
|
|
384
|
+
// ---- backend argv ---------------------------------------------------------------
|
|
429
385
|
|
|
430
|
-
function
|
|
431
|
-
// Print mode's -p takes the prompt as an argument; streaming input reads it
|
|
432
|
-
// from stdin as one {"event":"user"} line and ends the session at EOF, which
|
|
433
|
-
// keeps the prompt-on-stdin contract (no argv-size or quoting traps).
|
|
434
|
-
const args = ["--input-format", "stream-json", "--output-format", "stream-json", "--model", model];
|
|
435
|
-
if (o.effort) args.push("--effort", o.effort);
|
|
436
|
-
if (resumeSid) args.push("--conversation", resumeSid);
|
|
437
|
-
if (schemaText) args.push("--json-schema", schemaText);
|
|
438
|
-
// read-only adds no flag: the default request-review preset already denies
|
|
439
|
-
// writes, shell and web reads in print mode. Before spawn, the permission
|
|
440
|
-
// files are checked for grants that would let anything else through; after,
|
|
441
|
-
// the init event's permission_mode must confirm the preset (fail closed).
|
|
442
|
-
if (sandbox === "danger-full-access") args.push("--dangerously-skip-permissions");
|
|
443
|
-
return args;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
function grokArgs(o, resumeSid, schemaText, sandbox) {
|
|
447
|
-
// streaming-messages-json is Anthropic's stream-json shape (system init with
|
|
448
|
-
// session_id and model first, result last) — the claude parser reads it.
|
|
449
|
-
const args = ["--prompt-file", "/dev/stdin", "--output-format", "streaming-messages-json", "--no-auto-update"];
|
|
450
|
-
// --rules appends to the system prompt: claude's --append-system-prompt.
|
|
451
|
-
if (!o.raw) args.push("--rules", PREAMBLE);
|
|
452
|
-
if (schemaText) args.push("--json-schema", schemaText);
|
|
453
|
-
// The kernel sandbox (Seatbelt / Landlock) is the read-only guarantee. The
|
|
454
|
-
// permission layer is not: under --permission-mode default a headless run
|
|
455
|
-
// denied the write tool but ran an arbitrary `touch` through the shell
|
|
456
|
-
// (probed on 1.0.62). read-only still writes ~/.grok (sessions) and temp dirs.
|
|
457
|
-
if (sandbox === "read-only") args.push("--sandbox", "read-only", "--permission-mode", "default");
|
|
458
|
-
else if (sandbox === "workspace-write") args.push("--sandbox", "workspace", "--permission-mode", "acceptEdits");
|
|
459
|
-
else if (sandbox === "danger-full-access") args.push("--sandbox", "off", "--always-approve");
|
|
460
|
-
if (resumeSid) args.push("--resume", resumeSid);
|
|
461
|
-
if (o.model) args.push("-m", o.model);
|
|
462
|
-
if (o.effort) args.push("--effort", o.effort);
|
|
463
|
-
return args;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
function claudeArgs(o, resumeSid, schemaText) {
|
|
386
|
+
function claudeArgs(o, model, resumeSid, schemaText, sandbox, cwd) {
|
|
467
387
|
// stream-json (not plain json) so events land incrementally: the session id
|
|
468
388
|
// arrives in the init event within seconds, and the write-through record
|
|
469
389
|
// survives a killed wrapper. Plain json would buffer everything to the end.
|
|
470
|
-
const args = ["-p", "--output-format", "stream-json", "--verbose"];
|
|
471
|
-
if (!o.raw) args.push("--append-system-prompt", PREAMBLE);
|
|
390
|
+
const args = ["-p", "--output-format", "stream-json", "--verbose", "--append-system-prompt", PREAMBLE];
|
|
472
391
|
// Native structured output: the result event carries the parsed object in
|
|
473
392
|
// `structured_output` alongside the JSON text in `result`.
|
|
474
393
|
if (schemaText) args.push("--json-schema", schemaText);
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
args.push("--permission-mode", "
|
|
479
|
-
|
|
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")
|
|
480
402
|
args.push("--dangerously-skip-permissions");
|
|
481
|
-
}
|
|
482
|
-
// else: no permission flag — the consulted Claude uses the user's own
|
|
483
|
-
// ~/.claude config, mirroring Codex deferring to config.toml.
|
|
484
|
-
|
|
485
403
|
if (resumeSid) args.push("--resume", resumeSid);
|
|
486
|
-
if (
|
|
404
|
+
if (model) args.push("--model", model);
|
|
487
405
|
if (o.effort) args.push("--effort", o.effort);
|
|
488
406
|
return args; // prompt goes on stdin
|
|
489
407
|
}
|
|
490
408
|
|
|
491
|
-
function codexArgs(o, resumeSid, replyPath, schemaPath) {
|
|
409
|
+
function codexArgs(o, model, resumeSid, replyPath, schemaPath, sandbox) {
|
|
492
410
|
const args = ["exec"];
|
|
493
411
|
if (resumeSid) args.push("resume", resumeSid);
|
|
494
412
|
// -o is the authoritative reply text (the JSONL item schema is less stable);
|
|
495
413
|
// --json is the session-id channel (thread.started arrives up front).
|
|
496
414
|
args.push("--json", "-o", replyPath, "--skip-git-repo-check");
|
|
497
415
|
// Native structured output; codex wants a FILE, so the schema is materialized
|
|
498
|
-
// in the job dir (works on `exec resume` too
|
|
416
|
+
// in the job dir (works on `exec resume` too).
|
|
499
417
|
if (schemaPath) args.push("--output-schema", schemaPath);
|
|
500
418
|
// Force the sentinel into the consulted Codex's shell env (see header).
|
|
501
419
|
args.push("-c", 'shell_environment_policy.set.GASLAMP_NESTED="1"');
|
|
502
420
|
// `codex exec resume` has no -s flag; -c sandbox_mode works on both forms.
|
|
503
|
-
if (
|
|
504
|
-
if (
|
|
421
|
+
if (sandbox) args.push("-c", `sandbox_mode="${sandbox}"`);
|
|
422
|
+
if (model) args.push("-m", model);
|
|
505
423
|
if (o.effort) args.push("-c", `model_reasoning_effort="${o.effort}"`);
|
|
506
424
|
args.push("-"); // prompt on stdin, always — no argv-size or quoting traps
|
|
507
425
|
return args;
|
|
508
426
|
}
|
|
509
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
|
+
|
|
510
462
|
export function runConsult(backend, argv) {
|
|
511
463
|
const o = parseArgs(argv);
|
|
512
464
|
|
|
513
465
|
// ---- preflights -----------------------------------------------------------
|
|
514
466
|
if (process.env.GASLAMP_NESTED && !ALLOW_RECURSION)
|
|
515
467
|
die(3, "this agent is itself a gaslamp consult — a consult is one hop. (GASLAMP_ALLOW_RECURSION=1 opts out.)");
|
|
516
|
-
// Codex sets this in sandboxed shell envs when network is off.
|
|
517
|
-
//
|
|
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.
|
|
518
470
|
if (process.env.CODEX_SANDBOX_NETWORK_DISABLED === "1")
|
|
519
|
-
die(4, "network is disabled in this sandbox (CODEX_SANDBOX_NETWORK_DISABLED=1)
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
if (
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
}
|
|
529
|
-
// claude/codex: omitted = the consulted agent's own config. gemini/grok:
|
|
530
|
-
// omitted = read-only (BACKENDS.defaultSandbox).
|
|
531
|
-
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);
|
|
532
480
|
|
|
533
481
|
// ---- prompt (argv, or stdin via `-` / piped-with-no-arg) -------------------
|
|
534
482
|
let prompt = o.prompt;
|
|
@@ -537,8 +485,8 @@ export function runConsult(backend, argv) {
|
|
|
537
485
|
prompt = readStdin();
|
|
538
486
|
} else if (!process.stdin.isTTY) {
|
|
539
487
|
// Prompt on argv AND piped stdin: stdin is evidence — the diff, the log,
|
|
540
|
-
// the failing output — appended as a <stdin> block
|
|
541
|
-
//
|
|
488
|
+
// the failing output — appended as a <stdin> block (codex exec's own
|
|
489
|
+
// convention for exactly this case, applied to every backend).
|
|
542
490
|
const evidence = readStdin({ evidence: true });
|
|
543
491
|
if (evidence.trim())
|
|
544
492
|
prompt += "\n\n<stdin>\n" + (evidence.endsWith("\n") ? evidence : evidence + "\n") + "</stdin>";
|
|
@@ -547,131 +495,91 @@ export function runConsult(backend, argv) {
|
|
|
547
495
|
|
|
548
496
|
const schemaText = o.schema ? resolveSchema(o.schema) : null;
|
|
549
497
|
|
|
550
|
-
// ---- thread
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
560
|
-
const thread = o.thread ? readThread(backend, o.thread) : null;
|
|
561
|
-
const resumeSid = o.resume ? resolveResume(backend, o.resume) : thread?.sessionId ?? null;
|
|
562
|
-
// Everything that runs the harness synchronously (the tier lookup can take
|
|
563
|
-
// agy ~2s, the version probe a few ms) happens before the lock is taken and
|
|
564
|
-
// the record exists: no signal handler is installed yet, and a kill here
|
|
565
|
-
// 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.
|
|
566
507
|
const bin = resolveBin(backend);
|
|
567
508
|
const env = backendEnv(backend);
|
|
568
|
-
let grants = null, agySettings = null, wrap = null;
|
|
569
|
-
let enforcement = sandbox === "read-only" ? {
|
|
570
|
-
claude: "permission-mode default + read allowlist", codex: "codex read-only sandbox",
|
|
571
|
-
grok: "grok --sandbox read-only (kernel)", gemini: null,
|
|
572
|
-
}[backend] : null;
|
|
573
|
-
if (backend === "gemini" && sandbox === "read-only") {
|
|
574
|
-
const state = agyPermissionState();
|
|
575
|
-
grants = state.grants.map((g) => g.rule);
|
|
576
|
-
agySettings = [...state.presets.map((p) => `toolPermission ${p.preset}`), ...state.modes.map((m) => `agentMode ${m.mode}`)];
|
|
577
|
-
const lax = state.presets.filter((p) => AGY_LAX_PRESETS.has(p.preset));
|
|
578
|
-
const modes = state.modes.filter((m) => AGY_LAX_MODES.has(m.mode));
|
|
579
|
-
const open = state.grants.filter((g) => !AGY_READ_GRANT.test(g.rule));
|
|
580
|
-
if (useSeatbelt()) {
|
|
581
|
-
wrap = [SANDBOX_EXEC, "-p", agySeatbeltProfile()];
|
|
582
|
-
enforcement = "seatbelt (sandbox-exec)";
|
|
583
|
-
} else if (lax.length || modes.length || open.length)
|
|
584
|
-
die(1, "Antigravity's settings would let a read-only consult act unprompted — " +
|
|
585
|
-
[...lax.map((p) => `toolPermission "${p.preset}" (${p.file})`),
|
|
586
|
-
...modes.map((m) => `agentMode "${m.mode}" (${m.file})`),
|
|
587
|
-
...open.map((g) => `${g.rule} (${g.file})`)].join(", ") +
|
|
588
|
-
". Remove them, or pass --sandbox danger-full-access to consult without the guarantee.");
|
|
589
|
-
else enforcement = "agy request-review preset";
|
|
590
|
-
}
|
|
591
509
|
const model = resolveModel(backend, o.model, bin, env);
|
|
592
|
-
const version = harnessVersion(bin, env);
|
|
593
510
|
const id = newJobId(backend);
|
|
594
|
-
|
|
511
|
+
|
|
512
|
+
let lockPath = o.thread || prior
|
|
513
|
+
? acquireLock(backend, o.thread ? `thread-${o.thread}` : `session-${prior.sessionId}`, id)
|
|
514
|
+
: null;
|
|
595
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;
|
|
596
537
|
|
|
597
538
|
// ---- job record ------------------------------------------------------------
|
|
598
539
|
const dir = jobDir(id);
|
|
599
540
|
const meta = {
|
|
600
|
-
id, backend, status: "running",
|
|
601
|
-
sessionId: resumeSid,
|
|
602
|
-
|
|
603
|
-
model, sandbox, effort: o.effort ?? null,
|
|
604
|
-
cwd: o.cwd || process.cwd(),
|
|
605
|
-
pid: process.pid, childPid: null, exitCode: null, signal: null,
|
|
606
|
-
startedAt: new Date().toISOString(), endedAt: null,
|
|
607
|
-
promptChars: prompt.length, schema: !!schemaText, raw: !!o.raw,
|
|
608
|
-
provenance: {
|
|
609
|
-
provider: facts.provider, harness: facts.harness, harnessVersion: version,
|
|
610
|
-
// `model` is what the backend reported answering with; unknown until it
|
|
611
|
-
// does (codex never reports one in its event stream).
|
|
612
|
-
model: "unknown", modelRequested: o.model ?? null, modelPassed: model,
|
|
613
|
-
permissionMode: null, sandbox, enforcement,
|
|
614
|
-
...(grants ? { grants, agySettings } : {}),
|
|
615
|
-
systemPrompt: { known: false },
|
|
616
|
-
retrieval: { available: null, used: [] },
|
|
617
|
-
gaslamp: { version: PKG_VERSION, revision: gaslampRevision() },
|
|
618
|
-
},
|
|
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,
|
|
619
544
|
};
|
|
620
545
|
createJob(meta, prompt);
|
|
621
546
|
|
|
622
547
|
// ---- spawn -----------------------------------------------------------------
|
|
623
548
|
const replyPath = join(dir, "reply.md");
|
|
624
|
-
// schema.json
|
|
625
|
-
//
|
|
626
|
-
// 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.
|
|
627
551
|
const schemaPath = schemaText ? join(dir, "schema.json") : null;
|
|
628
552
|
if (schemaPath) {
|
|
629
|
-
const sent = backend === "codex"
|
|
630
|
-
? JSON.stringify(strictify(JSON.parse(schemaText)), null, 2)
|
|
631
|
-
: schemaText;
|
|
553
|
+
const sent = backend === "codex" ? JSON.stringify(strictify(JSON.parse(schemaText)), null, 2) : schemaText;
|
|
632
554
|
writeFileSync(schemaPath, sent.endsWith("\n") ? sent : sent + "\n");
|
|
633
555
|
}
|
|
634
|
-
const args = backend === "claude" ? claudeArgs(
|
|
635
|
-
: backend === "codex" ? codexArgs(
|
|
636
|
-
: backend === "gemini" ? geminiArgs(o, resumeSid, schemaText
|
|
637
|
-
: grokArgs(o, resumeSid, schemaText, sandbox);
|
|
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);
|
|
638
560
|
|
|
639
561
|
const spool = join(tmpdir(), `gaslamp-${id}.end-conversation.jsonl`);
|
|
640
562
|
env.END_CONVERSATION_SPOOL = spool;
|
|
641
|
-
// sandbox-exec applies the profile and execs
|
|
642
|
-
//
|
|
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;
|
|
643
566
|
const [cmd, argv0] = wrap ? [wrap[0], [...wrap.slice(1), bin, ...args]] : [bin, args];
|
|
644
567
|
log("spawn", cmd, JSON.stringify(argv0));
|
|
645
568
|
// detached → the child leads its own process group, so signal forwarding can
|
|
646
569
|
// kill the whole tree (backend + whatever it spawned), never just the top.
|
|
647
|
-
const child = spawn(cmd, argv0, { cwd
|
|
648
|
-
meta.childPid = child.pid ?? null;
|
|
649
|
-
writeMeta(meta);
|
|
570
|
+
const child = spawn(cmd, argv0, { cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
650
571
|
const killGroup = (sig) => {
|
|
651
572
|
try { process.kill(-child.pid, sig); }
|
|
652
573
|
catch { try { child.kill(sig); } catch {} }
|
|
653
574
|
};
|
|
654
575
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
// Early machine receipt for the fleet runner (gated on GASLAMP_FLEET, which the
|
|
658
|
-
// fleet sets on its children): the parent records each child's job id the
|
|
659
|
-
// moment it spawns — durable even if the fleet is killed mid-run — without
|
|
660
|
-
// scraping the human stderr breadcrumb. The lone --json line is the envelope
|
|
661
|
-
// (it carries `backend`/`status`); this receipt carries `type:"started"`.
|
|
662
|
-
if (o.json && process.env.GASLAMP_FLEET === "1")
|
|
663
|
-
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`);
|
|
664
578
|
|
|
665
579
|
child.stdin.on("error", () => {}); // EPIPE if the child dies before reading
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
if (backend === "gemini") {
|
|
670
|
-
child.stdin.write(JSON.stringify({ event: "user", message: { content: preamble + prompt } }) + "\n");
|
|
671
|
-
} else {
|
|
672
|
-
if (backend === "codex") child.stdin.write(preamble);
|
|
673
|
-
child.stdin.write(prompt);
|
|
674
|
-
}
|
|
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);
|
|
675
583
|
child.stdin.end();
|
|
676
584
|
|
|
677
585
|
const events = createWriteStream(join(dir, "events.jsonl"), { flags: "a" });
|
|
@@ -681,40 +589,32 @@ export function runConsult(backend, argv) {
|
|
|
681
589
|
child.stdout.setEncoding("utf8");
|
|
682
590
|
child.stderr.setEncoding("utf8");
|
|
683
591
|
|
|
684
|
-
// A consult the harness can't
|
|
592
|
+
// A consult the harness can't run honestly is stopped, not left to run.
|
|
685
593
|
let refusal = null;
|
|
686
|
-
const
|
|
594
|
+
const stop = (why) => {
|
|
687
595
|
if (refusal) return;
|
|
688
596
|
refusal = why;
|
|
689
|
-
writeSync(2, `[gaslamp] ${why}\n`);
|
|
690
597
|
killGroup("SIGTERM");
|
|
691
598
|
setTimeout(() => killGroup("SIGKILL"), 2000).unref();
|
|
692
599
|
};
|
|
693
600
|
// `agy` without a cached sign-in prints a Google sign-in URL and waits for a
|
|
694
|
-
// pasted code forever in print mode
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
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".
|
|
698
605
|
let initSeen = false, preInit = "";
|
|
699
606
|
const AUTH_NOTICE = /Authentication required/i;
|
|
700
|
-
const notSignedIn = () =>
|
|
607
|
+
const notSignedIn = () => stop("agy is not signed in — run `agy` once in a terminal to sign in, then retry");
|
|
701
608
|
const watchAuth = (text) => {
|
|
702
609
|
if (backend !== "gemini" || initSeen) return;
|
|
703
610
|
preInit = (preInit + text).slice(-4096);
|
|
704
611
|
if (AUTH_NOTICE.test(preInit)) notSignedIn();
|
|
705
612
|
};
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
const prov = meta.provenance;
|
|
709
|
-
const noteTool = (name) => {
|
|
710
|
-
if (name && RETRIEVAL_TOOLS[backend].test(name) && !prov.retrieval.used.includes(name))
|
|
711
|
-
prov.retrieval.used.push(name);
|
|
712
|
-
};
|
|
613
|
+
let errTail = "";
|
|
614
|
+
child.stderr.on("data", (d) => { errlog.write(d); errTail = (errTail + d).slice(-4096); watchAuth(d); });
|
|
713
615
|
|
|
714
|
-
// ---- event stream: write through; fish out session id
|
|
715
|
-
let buf = "", resultText = null, resultErr = false, lastAgentText = null, structuredOut;
|
|
716
|
-
let initValidated = false; // gemini read-only: the harness confirmed its preset
|
|
717
|
-
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;
|
|
718
618
|
const claudeShaped = backend === "claude" || backend === "grok";
|
|
719
619
|
const handleLine = (raw) => {
|
|
720
620
|
const line = raw.trim();
|
|
@@ -726,93 +626,29 @@ export function runConsult(backend, argv) {
|
|
|
726
626
|
: (j.event === "init" || j.event === "result" ? (j.conversation_id ?? j.result?.conversation_id) : null);
|
|
727
627
|
if (sid) {
|
|
728
628
|
if (meta.sessionId !== sid) { meta.sessionId = sid; writeMeta(meta); } // early: the recovery handle
|
|
729
|
-
// Bind
|
|
629
|
+
// Bind or re-point the thread the moment the session is known — claude
|
|
730
630
|
// resumes fork a fresh session id, and the pointer must chase it.
|
|
731
631
|
if (o.thread) writeThread({
|
|
732
|
-
backend, name: o.thread, sessionId: sid, lastJobId: id,
|
|
632
|
+
backend, name: o.thread, sessionId: sid, cwd, lastJobId: id,
|
|
733
633
|
createdAt: thread?.createdAt ?? meta.startedAt, updatedAt: new Date().toISOString(),
|
|
734
634
|
});
|
|
735
635
|
}
|
|
736
|
-
if (claudeShaped && j.type === "
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
if (
|
|
740
|
-
|
|
741
|
-
if (
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
for (const c of j.message?.content ?? []) {
|
|
745
|
-
if (c.type === "tool_use" || c.type === "server_tool_use") noteTool(toolKey(c.name));
|
|
746
|
-
if (c.type === "tool_use" && c.name === "use_tool") noteTool(c.input?.tool_name);
|
|
747
|
-
}
|
|
748
|
-
} else if (backend === "grok" && j.type === "user") {
|
|
749
|
-
for (const c of j.message?.content ?? []) {
|
|
750
|
-
if (c.type !== "tool_result" || typeof c.content !== "string") continue;
|
|
751
|
-
let r; try { r = JSON.parse(c.content); } catch { continue; }
|
|
752
|
-
if (typeof r?.name === "string") noteTool(r.name);
|
|
753
|
-
}
|
|
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;
|
|
754
644
|
} else if (backend === "gemini" && j.event === "init") {
|
|
755
645
|
initSeen = true;
|
|
756
|
-
const init = j.init ?? {};
|
|
757
|
-
if (init.model) prov.model = init.model;
|
|
758
|
-
prov.permissionMode = init.permission_mode ?? null;
|
|
759
|
-
// The browser toolset is ~20 tools; one entry says the same thing.
|
|
760
|
-
if (Array.isArray(init.tools)) prov.retrieval.available = [...new Set(init.tools
|
|
761
|
-
.filter((t) => RETRIEVAL_TOOLS.gemini.test(t)).map((t) => t.startsWith("browser_") ? "browser_*" : t))];
|
|
762
|
-
writeMeta(meta);
|
|
763
|
-
// Without Seatbelt, read-only rests on Antigravity's request-review (or
|
|
764
|
-
// strict) preset; a laxer one would let the consult act unprompted.
|
|
765
|
-
if (sandbox === "read-only" && !wrap && !["request-review", "strict"].includes(prov.permissionMode))
|
|
766
|
-
refuse(`agy reports permission mode "${prov.permissionMode}", not request-review — refusing a read-only consult it would not enforce`);
|
|
767
|
-
else initValidated = true;
|
|
768
|
-
} else if (backend === "gemini" && j.event === "step_update") {
|
|
769
|
-
const st = j.step_update ?? {};
|
|
770
|
-
if (st.step_type === "tool" && st.state === "DONE") noteTool(st.tool_name);
|
|
771
|
-
if (st.step_type === "agent_response" && st.state === "DONE" && st.usage) {
|
|
772
|
-
// Per step; the result event's usage is cumulative over the whole
|
|
773
|
-
// conversation, resumes included. input_tokens EXCLUDES cache reads
|
|
774
|
-
// (a resumed turn: 2376 input + 5118 cache read ≈ the full prompt),
|
|
775
|
-
// so both count, as for claude.
|
|
776
|
-
usageAcc.input += (st.usage.input_tokens ?? 0) + (st.usage.cache_read_tokens ?? 0);
|
|
777
|
-
usageAcc.output += st.usage.output_tokens ?? 0;
|
|
778
|
-
meta.usage = { inputTokens: usageAcc.input, outputTokens: usageAcc.output, costUsd: null };
|
|
779
|
-
}
|
|
780
646
|
} else if (backend === "gemini" && j.event === "result") {
|
|
781
647
|
const r = j.result ?? {};
|
|
782
648
|
resultText = r.response ?? null;
|
|
783
649
|
resultErr = r.status !== "SUCCESS";
|
|
784
|
-
if (r.error)
|
|
650
|
+
if (r.error) backendError = String(r.error);
|
|
785
651
|
if (r.structured_output !== undefined) structuredOut = r.structured_output;
|
|
786
|
-
} else if (claudeShaped && j.type === "result") {
|
|
787
|
-
resultText = j.result ?? j.content ?? null;
|
|
788
|
-
resultErr = !!j.is_error;
|
|
789
|
-
if (j.structured_output !== undefined) structuredOut = j.structured_output;
|
|
790
|
-
// Grok bills by the model it actually ran (grok-4.7-build behind a
|
|
791
|
-
// reported grok-4.7), which only shows up here.
|
|
792
|
-
if (j.modelUsage) prov.billedModels = Object.keys(j.modelUsage);
|
|
793
|
-
// Fleets are quota-shaped, so quota should be visible: inputTokens
|
|
794
|
-
// counts cache reads/writes too (that's what the meter meters).
|
|
795
|
-
if (j.usage) meta.usage = {
|
|
796
|
-
inputTokens: (j.usage.input_tokens ?? 0) + (j.usage.cache_read_input_tokens ?? 0)
|
|
797
|
-
+ (j.usage.cache_creation_input_tokens ?? 0),
|
|
798
|
-
outputTokens: j.usage.output_tokens ?? 0,
|
|
799
|
-
costUsd: j.total_cost_usd ?? null,
|
|
800
|
-
};
|
|
801
|
-
} else if (backend === "codex" && j.type === "turn.completed" && j.usage) {
|
|
802
|
-
// OpenAI semantics differ from Anthropic's: input_tokens already
|
|
803
|
-
// INCLUDES the cached portion (cached_input_tokens is a subset, and
|
|
804
|
-
// codex's own total_tokens = input + output), so adding it again
|
|
805
|
-
// double-counts cache hits.
|
|
806
|
-
usageAcc.input += j.usage.input_tokens ?? 0;
|
|
807
|
-
usageAcc.output += j.usage.output_tokens ?? 0;
|
|
808
|
-
meta.usage = { inputTokens: usageAcc.input, outputTokens: usageAcc.output, costUsd: null };
|
|
809
|
-
} else if (backend === "codex" && (j.type === "item.started" || j.type === "item.completed")) {
|
|
810
|
-
const item = j.item ?? {};
|
|
811
|
-
const t = item.type ?? item.item_type;
|
|
812
|
-
if (t === "web_search") noteTool("web_search");
|
|
813
|
-
// Tolerant fallback if -o never lands (schema drift across versions).
|
|
814
|
-
if (j.type === "item.completed" && t === "agent_message" && typeof item.text === "string")
|
|
815
|
-
lastAgentText = item.text;
|
|
816
652
|
}
|
|
817
653
|
};
|
|
818
654
|
child.stdout.on("data", (d) => {
|
|
@@ -836,33 +672,31 @@ export function runConsult(backend, argv) {
|
|
|
836
672
|
process.on(sig, () => {
|
|
837
673
|
if (killedBy) return killGroup("SIGKILL");
|
|
838
674
|
killedBy = sig;
|
|
839
|
-
writeSync(2, `[gaslamp] ${sig} — stopping job ${id}; the
|
|
675
|
+
writeSync(2, `[gaslamp] ${sig} — stopping job ${id}; the conversation survives and can be continued\n`);
|
|
840
676
|
killGroup(sig);
|
|
841
677
|
setTimeout(() => killGroup("SIGKILL"), 2000).unref();
|
|
842
678
|
});
|
|
843
679
|
}
|
|
844
680
|
|
|
845
|
-
// One terminal state, published once: the reply
|
|
846
|
-
// before meta.json leaves "running" and the
|
|
847
|
-
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) => {
|
|
848
684
|
if (meta.status !== "running") return;
|
|
849
685
|
if (reply != null) writeFileSync(replyPath, reply);
|
|
850
|
-
if (
|
|
686
|
+
if (data != null) writeFileSync(join(dir, "data.json"), JSON.stringify(data) + "\n");
|
|
851
687
|
meta.status = status;
|
|
852
|
-
meta.
|
|
853
|
-
meta.signal = killedBy;
|
|
688
|
+
meta.error = error;
|
|
854
689
|
meta.endedAt = new Date().toISOString();
|
|
855
|
-
if (refusal) meta.refusal = refusal;
|
|
856
690
|
writeMeta(meta);
|
|
857
691
|
releaseLock();
|
|
858
692
|
};
|
|
859
693
|
|
|
860
694
|
child.on("error", (e) => {
|
|
861
|
-
finalize("failed",
|
|
695
|
+
finalize("failed", `failed to launch ${bin}: ${e.message}`);
|
|
862
696
|
die(1, `failed to launch ${bin}: ${e.message}`);
|
|
863
697
|
});
|
|
864
698
|
|
|
865
|
-
child.on("close", (code) => {
|
|
699
|
+
child.on("close", (code, signal) => {
|
|
866
700
|
if (buf.trim()) handleLine(buf); // a final event without its newline
|
|
867
701
|
buf = "";
|
|
868
702
|
events.end();
|
|
@@ -871,88 +705,51 @@ export function runConsult(backend, argv) {
|
|
|
871
705
|
// its inherited pipes, so `close` could fire) may not be: on any kill
|
|
872
706
|
// path, finish the group now rather than trusting the unref'd timer.
|
|
873
707
|
if (killedBy || refusal) killGroup("SIGKILL");
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
if (line.trim()) writeSync(2, "[gaslamp] ignored an off-ramp spool line not addressed to the log\n");
|
|
883
|
-
continue;
|
|
884
|
-
}
|
|
885
|
-
const { log: dest, ...record } = r;
|
|
886
|
-
record.via = `gaslamp ${id}`;
|
|
887
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
888
|
-
appendFileSync(dest, JSON.stringify(record) + "\n");
|
|
889
|
-
filed.push(record);
|
|
890
|
-
}
|
|
891
|
-
unlinkSync(spool);
|
|
892
|
-
} catch (e) {
|
|
893
|
-
writeSync(2, `[gaslamp] could not file off-ramp records from ${spool}: ${e.message}\n`);
|
|
894
|
-
}
|
|
895
|
-
if (filed.length) {
|
|
896
|
-
meta.offRamp = filed;
|
|
897
|
-
for (const r of filed.filter((r) => r.scope === "main"))
|
|
898
|
-
writeSync(2, `[gaslamp] the consultee ended its session (end_session scope=main${r.reason ? `: ${r.reason}` : ""})\n`);
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
// The deliverable. codex writes it via -o (agent_message as fallback);
|
|
903
|
-
// the others hand it over in the result event — as text, or for a
|
|
904
|
-
// --schema consult possibly only as structured_output.
|
|
905
|
-
let reply;
|
|
906
|
-
if (backend === "codex") reply = existsSync(replyPath) ? readFileSync(replyPath, "utf8") : lastAgentText;
|
|
907
|
-
else reply = resultText != null && String(resultText).trim() ? String(resultText)
|
|
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)
|
|
908
716
|
: structuredOut !== undefined ? JSON.stringify(structuredOut) : resultText;
|
|
909
717
|
|
|
910
|
-
|
|
911
|
-
|
|
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) {
|
|
912
723
|
status = "failed";
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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.
|
|
920
732
|
status = "failed";
|
|
921
|
-
|
|
733
|
+
error = "empty reply";
|
|
922
734
|
}
|
|
923
|
-
// A schema consult's contract is a typed reply:
|
|
924
|
-
//
|
|
925
|
-
// 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.
|
|
926
737
|
let data = null;
|
|
927
738
|
if (schemaText) {
|
|
928
739
|
data = structuredOut ?? null;
|
|
929
740
|
if (data == null && reply != null) { try { data = JSON.parse(reply); } catch {} }
|
|
930
|
-
if (data == null && status === "done") {
|
|
931
|
-
status = "failed";
|
|
932
|
-
writeSync(2, "[gaslamp] --schema was set but the reply is not valid JSON — marking the consult failed\n");
|
|
933
|
-
}
|
|
741
|
+
if (data == null && status === "done") { status = "failed"; error = "--schema was set but the reply is not valid JSON"; }
|
|
934
742
|
}
|
|
935
|
-
finalize(status,
|
|
936
|
-
|
|
937
|
-
if (o.json)
|
|
938
|
-
|
|
939
|
-
backend, jobId: id, sessionId: meta.sessionId, status: meta.status,
|
|
940
|
-
exitCode: code, content: reply ?? "",
|
|
941
|
-
...(meta.label ? { label: meta.label } : {}),
|
|
942
|
-
...(meta.usage ? { usage: meta.usage } : {}),
|
|
943
|
-
...(schemaText ? { data } : {}),
|
|
944
|
-
}) + "\n");
|
|
945
|
-
} else {
|
|
743
|
+
finalize(status, error, codexWrote ? null : reply, data);
|
|
744
|
+
|
|
745
|
+
if (o.json) process.stdout.write(JSON.stringify(envelope(meta)) + "\n");
|
|
746
|
+
else {
|
|
946
747
|
if (reply) process.stdout.write(reply.endsWith("\n") ? reply : reply + "\n");
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
const tail = readFileSync(join(dir, "stderr.log"), "utf8").trim().split("\n").slice(-6).join("\n");
|
|
950
|
-
if (tail) writeSync(2, tail + "\n");
|
|
951
|
-
} catch {}
|
|
952
|
-
}
|
|
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");
|
|
953
750
|
process.stdout.write(renderTrailer(meta) + "\n");
|
|
954
751
|
}
|
|
955
752
|
// exitCode (not exit()) so stdout drains; nothing else holds the loop open.
|
|
956
|
-
process.exitCode =
|
|
753
|
+
process.exitCode = status === "done" ? 0 : 1;
|
|
957
754
|
});
|
|
958
755
|
}
|