gaslamp 1.1.0 → 2.1.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/src/jobs.mjs ADDED
@@ -0,0 +1,273 @@
1
+ // jobs.mjs — durable consult records, plus the `jobs` / `poll` readers.
2
+ //
3
+ // Every consult writes through to a job dir as it runs:
4
+ //
5
+ // $GASLAMP_HOME/jobs/<id>/
6
+ // meta.json backend, status, sessionId, pids, timing, exit
7
+ // prompt.md the prompt as sent
8
+ // events.jsonl raw backend event stream (claude stream-json / codex --json)
9
+ // reply.md final reply text
10
+ // stderr.log backend stderr
11
+ //
12
+ // The record is passive durability, not a daemon: the blocking `gaslamp
13
+ // <backend>` process is the only process. If the caller's harness kills it,
14
+ // the record still holds the session id (captured early, the moment the
15
+ // backend reports it) — recovery is `--resume`, not orphaned children.
16
+
17
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ export const HOME = () => process.env.GASLAMP_HOME || join(homedir(), ".gaslamp");
22
+ export const jobsDir = () => join(HOME(), "jobs");
23
+ export const locksDir = () => join(HOME(), "locks");
24
+ export const jobDir = (id) => join(jobsDir(), id);
25
+
26
+ // `kill(pid, 0)` probes liveness without signaling; EPERM means alive-but-not-ours.
27
+ export const alive = (pid) => {
28
+ try { process.kill(pid, 0); return true; }
29
+ catch (e) { return e.code === "EPERM"; }
30
+ };
31
+
32
+ // Ids embed a local timestamp, so they sort chronologically and read at a
33
+ // glance: cl-20260612-141233-9af2 (claude), cx-… (codex), fl-… (fleet).
34
+ function stampAndRand() {
35
+ const d = new Date();
36
+ const p = (n) => String(n).padStart(2, "0");
37
+ const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
38
+ const rand = Math.random().toString(16).slice(2, 6).padEnd(4, "0");
39
+ return `${stamp}-${rand}`;
40
+ }
41
+
42
+ export const newJobId = (backend) => `${backend === "claude" ? "cl" : "cx"}-${stampAndRand()}`;
43
+ export const newFleetId = () => `fl-${stampAndRand()}`;
44
+
45
+ export const looksLikeJobId = (s) => /^(cl|cx)-\d{8}-\d{6}-[0-9a-f]{4}$/.test(String(s));
46
+ export const looksLikeFleetId = (s) => /^fl-\d{8}-\d{6}-[0-9a-f]{4}$/.test(String(s));
47
+
48
+ // A fleet record lives alongside the consult records under jobs/, but it is
49
+ // grouping metadata only — never the authority on a child's state (a killed
50
+ // parent leaves it stale). `poll <fleet-id>` recomputes from the child records.
51
+ export const fleetDir = (id) => join(jobsDir(), id);
52
+ export function writeFleetMeta(meta) {
53
+ writeFileSync(join(fleetDir(meta.id), "meta.json"), JSON.stringify(meta, null, 2) + "\n");
54
+ }
55
+ export function readFleetMeta(id) {
56
+ try { return JSON.parse(readFileSync(join(fleetDir(id), "meta.json"), "utf8")); }
57
+ catch { return null; }
58
+ }
59
+ // The manifest stores each task IN FULL (prompt included): it is what makes a
60
+ // fleet resumable — a never-started child can only rerun from the record.
61
+ export function createFleet(meta, tasks) {
62
+ mkdirSync(fleetDir(meta.id), { recursive: true });
63
+ writeFileSync(join(fleetDir(meta.id), "manifest.jsonl"),
64
+ tasks.map((t) => JSON.stringify({
65
+ index: t.index, label: t.label, prompt: t.prompt,
66
+ model: t.model ?? null, sandbox: t.sandbox ?? null, cwd: t.cwd ?? null,
67
+ resume: t.resume ?? null, thread: t.thread ?? null, effort: t.effort ?? null,
68
+ schema: t.schema ?? null, raw: t.raw ?? false,
69
+ })).join("\n") + (tasks.length ? "\n" : ""));
70
+ writeFleetMeta(meta);
71
+ }
72
+
73
+ export function readManifest(id) {
74
+ try {
75
+ return readFileSync(join(fleetDir(id), "manifest.jsonl"), "utf8")
76
+ .split("\n").filter(Boolean).map((l) => JSON.parse(l));
77
+ } catch { return null; }
78
+ }
79
+
80
+ export function readMeta(id) {
81
+ try { return JSON.parse(readFileSync(join(jobDir(id), "meta.json"), "utf8")); }
82
+ catch { return null; }
83
+ }
84
+
85
+ export function writeMeta(meta) {
86
+ writeFileSync(join(jobDir(meta.id), "meta.json"), JSON.stringify(meta, null, 2) + "\n");
87
+ }
88
+
89
+ export function createJob(meta, prompt) {
90
+ mkdirSync(jobDir(meta.id), { recursive: true });
91
+ writeFileSync(join(jobDir(meta.id), "prompt.md"), prompt);
92
+ writeMeta(meta);
93
+ }
94
+
95
+ export function readReply(id) {
96
+ try { return readFileSync(join(jobDir(id), "reply.md"), "utf8"); }
97
+ catch { return null; }
98
+ }
99
+
100
+ // Newest first. The timestamp follows the 2-char backend prefix, so compare
101
+ // past it — pure lexical order would interleave cl-/cx- wrongly.
102
+ export function listJobIds() {
103
+ let ids;
104
+ try { ids = readdirSync(jobsDir()); } catch { return []; }
105
+ return ids.filter(looksLikeJobId).sort((a, b) => b.slice(3).localeCompare(a.slice(3)));
106
+ }
107
+
108
+ // A meta that says "running" is only as honest as its pid: if the wrapper was
109
+ // SIGKILLed (harness teardown), nothing finalized the record. Report `stale`
110
+ // rather than lying about liveness.
111
+ export function liveStatus(meta) {
112
+ if (meta.status !== "running") return meta.status;
113
+ return meta.pid && alive(meta.pid) ? "running" : "stale";
114
+ }
115
+
116
+ const fmtTok = (n) => n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
117
+ const fmtUsage = (u) =>
118
+ `tok: ${fmtTok(u.inputTokens ?? 0)}→${fmtTok(u.outputTokens ?? 0)}` +
119
+ (u.costUsd != null ? ` · $${u.costUsd.toFixed(2)}` : "");
120
+
121
+ // The one-line, greppable record of a consult — printed after every reply and
122
+ // by `poll`. Always carries the two handles that matter: job id and session id.
123
+ export function renderTrailer(meta, status = meta.status) {
124
+ const dir = jobDir(meta.id);
125
+ const bits = [];
126
+ if (status === "running") {
127
+ bits.push("RUNNING", `job: ${meta.id}`, `watch: tail -f ${join(dir, "events.jsonl")}`);
128
+ } else {
129
+ if (status === "failed") bits.push(`FAILED${meta.exitCode != null ? ` (exit ${meta.exitCode})` : ""}`);
130
+ if (status === "killed") bits.push(`KILLED${meta.signal ? ` (${meta.signal})` : ""}`);
131
+ if (status === "stale") bits.push("STALE (wrapper died mid-run)");
132
+ bits.push(`job: ${meta.id}`, `session: ${meta.sessionId ?? "?"}`);
133
+ if (meta.thread) bits.push(`thread: ${meta.thread}`);
134
+ // a thread pointer chases the freshest session id; prefer it as the handle
135
+ if (meta.sessionId) bits.push(`resume: gaslamp ${meta.backend} ${meta.thread ? `--thread ${meta.thread}` : `--resume ${meta.sessionId}`}`);
136
+ if (meta.usage) bits.push(fmtUsage(meta.usage));
137
+ if (status !== "done") bits.push(`log: ${join(dir, "stderr.log")}`);
138
+ }
139
+ return `[gaslamp] ${bits.join(" · ")}`;
140
+ }
141
+
142
+ function promptPreview(id, n = 44) {
143
+ try {
144
+ const line = readFileSync(join(jobDir(id), "prompt.md"), "utf8")
145
+ .split("\n").find((l) => l.trim()) || "";
146
+ const s = line.trim();
147
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
148
+ } catch { return ""; }
149
+ }
150
+
151
+ function fmtDur(ms) {
152
+ const s = Math.max(0, Math.round(ms / 1000));
153
+ return s < 100 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
154
+ }
155
+
156
+ // gaslamp jobs [-n N] [--json] — newest first, one line per consult.
157
+ export function runJobs(argv = []) {
158
+ let n = 20;
159
+ const i = argv.indexOf("-n");
160
+ if (i >= 0) n = Number(argv[i + 1]) || n;
161
+ const ids = listJobIds().slice(0, n);
162
+ if (argv.includes("--json")) {
163
+ const list = ids.map((id) => {
164
+ const m = readMeta(id);
165
+ return m && {
166
+ jobId: m.id, backend: m.backend, status: liveStatus(m), sessionId: m.sessionId ?? null,
167
+ label: m.label ?? null, thread: m.thread ?? null,
168
+ startedAt: m.startedAt ?? null, endedAt: m.endedAt ?? null,
169
+ usage: m.usage ?? null, promptPreview: promptPreview(id, 80),
170
+ };
171
+ }).filter(Boolean);
172
+ process.stdout.write(JSON.stringify(list) + "\n");
173
+ return;
174
+ }
175
+ if (!ids.length) { console.log(`no jobs yet under ${jobsDir()}`); return; }
176
+ for (const id of ids) {
177
+ const m = readMeta(id);
178
+ if (!m) continue;
179
+ const status = liveStatus(m);
180
+ const dur = m.endedAt
181
+ ? fmtDur(new Date(m.endedAt) - new Date(m.startedAt))
182
+ : fmtDur(Date.now() - new Date(m.startedAt)) + (status === "running" ? "…" : "");
183
+ const tags = (m.label ? `[${m.label}] ` : "") + (m.thread ? `@${m.thread} ` : "");
184
+ console.log([
185
+ id.padEnd(23),
186
+ status.padEnd(8),
187
+ (m.sessionId ?? "-").slice(0, 8).padEnd(9),
188
+ dur.padStart(8),
189
+ ` ${tags}${promptPreview(id)}`,
190
+ ].join(" "));
191
+ }
192
+ }
193
+
194
+ // gaslamp poll <job|fleet|--last> [--json] — print one record's reply/status.
195
+ // Exit: 0 done · 1 failed/killed/stale · 2 usage · 10 still running.
196
+ export function runPoll(argv = []) {
197
+ const json = argv.includes("--json");
198
+ let id = argv.find((a) => !a.startsWith("-"));
199
+ if (!id || argv.includes("--last")) id = listJobIds()[0];
200
+ if (!id) { console.error(`gaslamp poll: no jobs under ${jobsDir()}`); process.exitCode = 2; return; }
201
+ if (looksLikeFleetId(id)) return pollFleet(id, json);
202
+ const m = readMeta(id);
203
+ if (!m) { console.error(`gaslamp poll: no job record ${id}`); process.exitCode = 2; return; }
204
+ const status = liveStatus(m);
205
+ const reply = status === "running" ? null : readReply(id);
206
+ if (json) {
207
+ // The consult envelope shape, recomputed from the record.
208
+ let data = null;
209
+ if (m.schema && reply != null) { try { data = JSON.parse(reply); } catch {} }
210
+ process.stdout.write(JSON.stringify({
211
+ backend: m.backend, jobId: m.id, sessionId: m.sessionId ?? null, status,
212
+ exitCode: m.exitCode ?? null, content: reply ?? "",
213
+ ...(m.label ? { label: m.label } : {}),
214
+ ...(m.thread ? { thread: m.thread } : {}),
215
+ ...(m.usage ? { usage: m.usage } : {}),
216
+ ...(m.schema ? { data } : {}),
217
+ }) + "\n");
218
+ process.exitCode = status === "running" ? 10 : status === "done" ? 0 : 1;
219
+ return;
220
+ }
221
+ if (status === "running") { console.log(renderTrailer(m, status)); process.exitCode = 10; return; }
222
+ if (reply) process.stdout.write(reply.endsWith("\n") ? reply : reply + "\n");
223
+ console.log(renderTrailer(m, status));
224
+ process.exitCode = status === "done" ? 0 : 1;
225
+ }
226
+
227
+ // gaslamp poll <fleet-id> — recompute every child's state from its own record
228
+ // (the fleet meta is grouping, not authority) and print each reply.
229
+ // Exit: 0 all done · 1 some failed/killed · 2 usage · 10 some still running.
230
+ function pollFleet(id, json = false) {
231
+ const fm = readFleetMeta(id);
232
+ if (!fm) { console.error(`gaslamp poll: no fleet record ${id} under ${jobsDir()}`); process.exitCode = 2; return; }
233
+ const children = (fm.children || []).filter((c) => c && c.jobId);
234
+ let running = 0, done = 0, other = 0;
235
+ const out = [], results = [];
236
+ for (const c of children) {
237
+ const m = readMeta(c.jobId);
238
+ const status = m ? liveStatus(m) : "missing";
239
+ if (status === "running") running++;
240
+ else if (status === "done") done++;
241
+ else other++;
242
+ const reply = status === "done" ? readReply(c.jobId) : null;
243
+ if (json) {
244
+ let data = null;
245
+ if (m?.schema && reply != null) { try { data = JSON.parse(reply); } catch {} }
246
+ results.push({
247
+ index: c.index, label: c.label ?? null, jobId: c.jobId,
248
+ sessionId: m?.sessionId ?? null, status, content: reply ?? "",
249
+ ...(m?.usage ? { usage: m.usage } : {}),
250
+ ...(m?.schema ? { data } : {}),
251
+ });
252
+ continue;
253
+ }
254
+ const sid = m?.sessionId ? ` · ${m.sessionId.slice(0, 8)}` : "";
255
+ out.push(`\n━━ [${c.label ?? c.index}] ${status} · ${c.jobId}${sid} ━━`);
256
+ if (reply?.trim()) out.push(reply.trimEnd());
257
+ else if (status !== "running") {
258
+ out.push(`(no reply — ${status})`);
259
+ if (m?.sessionId) out.push(`resume: gaslamp ${m.backend} --resume ${m.sessionId}`);
260
+ }
261
+ }
262
+ if (json) {
263
+ process.stdout.write(JSON.stringify({
264
+ fleetId: id, backend: fm.backend,
265
+ counts: { done, running, other }, results,
266
+ }) + "\n");
267
+ } else {
268
+ if (out.length) process.stdout.write(out.join("\n") + "\n");
269
+ console.log(`[gaslamp fleet] ${id} · ${fm.backend} · ${done}/${children.length} done` +
270
+ (running ? ` · ${running} running` : "") + (other ? ` · ${other} failed/killed` : ""));
271
+ }
272
+ process.exitCode = running ? 10 : (children.length && done === children.length) ? 0 : 1;
273
+ }
package/src/setup.mjs CHANGED
@@ -1,128 +1,153 @@
1
- // setup.mjs — register both consultation channels under the name `gaslamp`.
1
+ // setup.mjs — allowlist the gaslamp CLI for Claude Code; optionally install
2
+ // the gaslamp skill for both agents.
2
3
  //
3
- // Two directions:
4
- // Codex → Claude : Codex gets a `gaslamp` tool that hands off to this server.
5
- // Claude → Codex : Claude gets Codex's native `codex mcp-server` (its `codex`
6
- // / `codex-reply` tools), registered as `gaslamp`.
4
+ // gaslamp has no MCP servers and nothing to register. Base setup does one
5
+ // thing: allowlist the command in ~/.claude/settings.json so a consult doesn't
6
+ // stall on a permission prompt mid-call. (Codex needs no analog — shell
7
+ // commands are governed by approval_policy.)
7
8
  //
8
- // Idempotent: removes prior registrations (including the legacy `claude` /
9
- // `codex` names) before re-adding.
9
+ // It does NOT touch your agent instructions. A CLI can't self-advertise the way
10
+ // MCP tools did, so each agent needs a one-line note in its instructions to know
11
+ // gaslamp exists — but appending to your personal CLAUDE.md / AGENTS.md is yours
12
+ // to do (the README has the text to paste), not a surprise setup springs.
10
13
  //
11
- // gaslamp setup register the published package (Codex spawns
12
- // `npx -y gaslamp serve`). Survives node upgrades —
13
- // no absolute, version-pinned paths.
14
- // gaslamp setup --local register THIS checkout (Codex spawns
15
- // `<node> <abs>/bin/gaslamp.mjs serve`). Use before
16
- // the package is published, or for from-source dev.
17
-
18
- import { spawnSync } from "node:child_process";
19
- import { readFileSync, writeFileSync } from "node:fs";
14
+ // --skill is the opt-in richer alternative: both CLIs speak the same SKILL.md
15
+ // standard (~/.claude/skills/, $CODEX_HOME/skills/), so one flag installs a
16
+ // direction-aware skill on each side — Claude's copy teaches consulting Codex,
17
+ // Codex's copy teaches consulting Claude. It only ever writes gaslamp's OWN
18
+ // skill directory, so reruns are idempotent overwrites of our own file.
19
+ //
20
+ // gaslamp setup published install (`gaslamp` on PATH)
21
+ // gaslamp setup --local THIS checkout (absolute bin path; from-source dev)
22
+ // gaslamp setup --skill also install the gaslamp skill for both agents
23
+
24
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
25
  import { homedir } from "node:os";
21
26
  import { dirname, join, resolve } from "node:path";
22
27
  import { fileURLToPath } from "node:url";
23
28
  import { which } from "./which.mjs";
24
29
 
25
- function run(label, cmd, args) {
26
- process.stdout.write(` ${label}: ${cmd} ${args.join(" ")}\n`);
27
- const r = spawnSync(cmd, args, { stdio: "inherit" });
28
- return r.status === 0;
30
+ // Add an entry to permissions.allow in a settings.json text. Returns the new
31
+ // text, or null if the existing text isn't valid JSON (never clobber).
32
+ export function addAllow(text, entry) {
33
+ let obj = {};
34
+ if (text && text.trim()) {
35
+ try { obj = JSON.parse(text); } catch { return null; }
36
+ }
37
+ obj.permissions ??= {};
38
+ obj.permissions.allow ??= [];
39
+ if (!obj.permissions.allow.includes(entry)) obj.permissions.allow.push(entry);
40
+ return JSON.stringify(obj, null, 2) + "\n";
29
41
  }
30
42
 
31
- // `codex mcp add` cannot persist per-server timeouts (its `-c` flag is a runtime
32
- // override that never lands in the block), so we patch ~/.codex/config.toml
33
- // directly. Without this, Codex's MCP client kills any tools/call that runs past
34
- // its default tool_timeout_sec — a substantial consult (30-45 min) dies with
35
- // `timed out awaiting tools/call after 120s` while the gaslamp child keeps
36
- // working, orphaned. The server itself has no timeout; this is the client side.
37
- // Idempotent: sets the keys in-place if present, inserts them if not.
38
- function patchCodexTimeouts() {
39
- const tool = process.env.GASLAMP_TOOL_TIMEOUT_SEC || "100000"; // ~27.8h — matches Claude Code's own default MCP tool timeout (1e8 ms), so both directions are symmetric
40
- const startup = process.env.GASLAMP_STARTUP_TIMEOUT_SEC || "30"; // headroom for npx cold-start
41
- const cfgPath = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "config.toml");
42
-
43
- let text;
44
- try { text = readFileSync(cfgPath, "utf8"); }
45
- catch { console.warn(` (could not read ${cfgPath}; skipped timeout patch)`); return; }
46
-
47
- const lines = text.split("\n");
48
- const header = lines.findIndex((l) => l.trim() === "[mcp_servers.gaslamp]");
49
- if (header < 0) { console.warn(" (no [mcp_servers.gaslamp] block found; skipped timeout patch)"); return; }
50
-
51
- // Block runs from the header to the next table header (line starting with `[`).
52
- let end = lines.length;
53
- for (let i = header + 1; i < lines.length; i++) { if (/^\s*\[/.test(lines[i])) { end = i; break; } }
54
- const block = lines.slice(header, end);
55
-
56
- const setKey = (key, val) => {
57
- const i = block.findIndex((l) => new RegExp(`^\\s*${key}\\s*=`).test(l));
58
- if (i >= 0) { block[i] = `${key} = ${val}`; return; }
59
- let at = block.length; // insert before trailing blank lines
60
- while (at > 1 && block[at - 1].trim() === "") at--;
61
- block.splice(at, 0, `${key} = ${val}`);
62
- };
63
- setKey("tool_timeout_sec", tool);
64
- setKey("startup_timeout_sec", startup);
65
-
66
- writeFileSync(cfgPath, [...lines.slice(0, header), ...block, ...lines.slice(end)].join("\n"));
67
- process.stdout.write(` timeouts: tool_timeout_sec=${tool}s startup_timeout_sec=${startup}s in ${cfgPath}\n`);
43
+ // The skill text, parameterized by which agent is reading it: `other` is the
44
+ // backend this copy teaches its reader to consult.
45
+ export function skillMd(other) {
46
+ const Other = other === "codex" ? "Codex" : "Claude";
47
+ const backgroundHint = other === "codex"
48
+ ? "Run a consult as a background shell task (`run_in_background`) and keep working — the reply lands when it finishes, and several can run in parallel."
49
+ : "Run a consult in your background terminal and check back between steps — several can run in parallel.";
50
+ return `---
51
+ name: gaslamp
52
+ description: Consult ${Other} (the other coding agent) from the shell for a second pair of eyes — verify a fix or proof, spar on a design, review a diff, diagnose with fresh context — or fan out a fleet of parallel consults. Use when work would benefit from an independent take, when a claim deserves adversarial checking before you rely on it, or when explicitly asked to consult ${Other} / get a second opinion / hand work off.
53
+ ---
54
+
55
+ # Consulting ${Other} via gaslamp
56
+
57
+ One blocking consult:
58
+
59
+ gaslamp ${other} "<prompt>"
60
+ gaslamp ${other} - < notes.md # whole prompt from a file
61
+ git diff | gaslamp ${other} "review for races:" # prompt + piped evidence
62
+
63
+ ${backgroundHint} A consult is one hop — the consulted agent cannot consult back — so you own the synthesis.
64
+
65
+ ## Evidence beats framing
66
+
67
+ Send raw evidence: errors, diffs, failing output, actual constraints — not just your reading of them. Piped stdin rides along as a \`<stdin>\` block. Framing-only prompts make ${Other} inherit your blind spots; the fresh-context advantage works on facts.
68
+
69
+ ## Multi-turn: threads
70
+
71
+ gaslamp ${other} --thread api-spar "opening take: ..."
72
+ gaslamp ${other} --thread api-spar "counterpoint: ..." # same session, continued
73
+ gaslamp threads # list named threads
74
+
75
+ A thread is continue-if-exists-else-start. Prefer threads over raw \`--resume\` for any conversation you might come back to — the name stays valid even as session ids churn.
76
+
77
+ ## Typed replies: --schema
78
+
79
+ gaslamp ${other} --json --schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["correct","broken","unsure"]},"reasons":{"type":"array","items":{"type":"string"}}},"required":["verdict","reasons"]}' "Is this fix correct? ..."
80
+
81
+ The \`--json\` envelope then carries \`data\` (the parsed object). A reply that fails to parse marks the consult failed — trust the exit code. \`--schema\` also takes a file path.
82
+
83
+ ## Fleets: parallel fan-out
84
+
85
+ gaslamp fleet ${other} -n 5 "spot the worst bug: ..." # 5 independent takes; you vote
86
+ gaslamp fleet ${other} - < tasks.jsonl # per line: {"prompt", model?, sandbox?, effort?, schema?, thread?, label?, raw?}
87
+ gaslamp fleet ${other} -n 4 --stream --json "..." # JSONL, replies as they land
88
+ gaslamp fleet --resume <fleet-id> # finish an interrupted fleet
89
+
90
+ Fleets default to \`--sandbox read-only\` (N writers in one cwd race) — pass \`--sandbox\` to opt into writes. Duplicate resume/thread targets are refused up front.
91
+
92
+ ## Reading the records
93
+
94
+ gaslamp jobs [--json] # recent consults: status, label, thread, tokens
95
+ gaslamp poll <job|--last> [--json] # one reply; exit 10 = still running
96
+ gaslamp tail <job|--last> # follow a running consult's events live
97
+
98
+ Every consult writes through to ~/.gaslamp/jobs/<id>/ and its session is recorded within seconds — a killed consult costs the in-flight turn, not the session. Recover with \`--resume <session|job>\` or the thread name.
99
+
100
+ ## Knobs
101
+
102
+ \`--model\`, \`--effort\`, \`--sandbox\` (omit to use ${Other}'s own config), \`--label\` (tags the job record), \`--raw\` (drop the consult preamble), \`--cwd\`. Exit codes: 0 ok, 1 failed/killed, 2 usage, 3 nested (one-hop), 4 no network, 5 session busy.
103
+ `;
68
104
  }
69
105
 
70
- // Quietly remove an existing registration (ignore "not found" noise).
71
- function remove(cli, name, scopeArgs = []) {
72
- spawnSync(cli, ["mcp", "remove", name, ...scopeArgs], { stdio: "ignore" });
106
+ // Install the skill for both agents. Both CLIs read the same SKILL.md format;
107
+ // each side gets the copy pointing at the OTHER backend.
108
+ export function installSkills() {
109
+ const targets = [
110
+ { dir: join(homedir(), ".claude", "skills", "gaslamp"), other: "codex" },
111
+ { dir: join(process.env.CODEX_HOME || join(homedir(), ".codex"), "skills", "gaslamp"), other: "claude" },
112
+ ];
113
+ return targets.map(({ dir, other }) => {
114
+ mkdirSync(dir, { recursive: true });
115
+ const path = join(dir, "SKILL.md");
116
+ writeFileSync(path, skillMd(other));
117
+ return path;
118
+ });
73
119
  }
74
120
 
75
121
  export function runSetup(argv = []) {
76
122
  const local = argv.includes("--local") || argv.includes("-l");
123
+ const skill = argv.includes("--skill");
124
+ if (!which("claude")) console.warn("setup: `claude` not on PATH — `gaslamp claude` consults will fail until it is (or set CLAUDE_BIN).");
125
+ if (!which("codex")) console.warn("setup: `codex` not on PATH — `gaslamp codex` consults will fail until it is (or set CODEX_BIN).");
77
126
 
78
- const codex = which("codex");
79
- const claude = which("claude");
80
- if (!codex) { console.error("setup: `codex` not found on PATH. Install Codex first."); process.exitCode = 1; return; }
81
- if (!claude) { console.error("setup: `claude` not found on PATH. Install Claude Code first."); process.exitCode = 1; return; }
82
- if (!local && !which("npx")) {
83
- console.error("setup: `npx` not found on PATH — the published registration spawns `npx -y gaslamp serve`.");
84
- console.error(" Install Node's npm/npx, or use `gaslamp setup --local` to register this checkout instead.");
85
- process.exitCode = 1; return;
86
- }
127
+ const cmd = local
128
+ ? resolve(join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "gaslamp.mjs"))
129
+ : "gaslamp";
130
+ console.log(`mode: ${local ? `local checkout (${cmd})` : "published (`gaslamp` on PATH)"}\n`);
87
131
 
88
- console.log(`codex: ${codex}`);
89
- console.log(`claude: ${claude}`);
90
- console.log(`mode: ${local ? "local checkout" : "published (npx)"}\n`);
91
-
92
- // --- Codex -> Claude: codex gets a `gaslamp` tool that hands off to Claude ---
93
- remove("codex", "gaslamp");
94
- remove("codex", "claude"); // legacy name
95
- let serveCmd;
96
- if (local) {
97
- const node = which("node") || process.execPath;
98
- const shim = resolve(join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "gaslamp.mjs"));
99
- serveCmd = ["mcp", "add", "gaslamp", "--", node, shim, "serve"];
132
+ const settingsPath = join(homedir(), ".claude", "settings.json");
133
+ const entry = `Bash(${cmd}:*)`;
134
+ const next = addAllow(existsSync(settingsPath) ? readFileSync(settingsPath, "utf8") : "", entry);
135
+ if (next == null) {
136
+ console.warn(` ! ${settingsPath} is not valid JSON — add ${entry} to permissions.allow yourself`);
100
137
  } else {
101
- // Bare `npx` (not an absolute path) so whatever node is active resolves the
102
- // latest published gaslamp at spawn time — survives nvm/node upgrades.
103
- serveCmd = ["mcp", "add", "gaslamp", "--", "npx", "-y", "gaslamp", "serve"];
138
+ mkdirSync(dirname(settingsPath), { recursive: true });
139
+ writeFileSync(settingsPath, next);
140
+ console.log(` allowlist: ${entry} → ${settingsPath}`);
104
141
  }
105
- const okCodex = run("codex -> claude", "codex", serveCmd);
106
- if (okCodex) patchCodexTimeouts(); // codex mcp add can't persist these; do it ourselves
107
-
108
- // --- Claude -> Codex: claude gets gaslamp's codex tools (user scope) --------
109
- // `--env GASLAMP_NESTED=1` tags every Codex spawned as a Claude consult as
110
- // nested, so gaslamp's server.mjs refuses if that consulted Codex tries to
111
- // hand work back to Claude — a consult is one hop (GASLAMP_ALLOW_RECURSION=1
112
- // opts back in). Placement is safe: the server name `gaslamp` precedes --env
113
- // and `--` follows it, so the name is never misread as a KEY=VALUE pair.
114
- remove("claude", "gaslamp", ["-s", "user"]);
115
- remove("claude", "codex", ["-s", "user"]); // legacy name
116
- const okClaude = run("claude -> codex", "claude",
117
- ["mcp", "add", "gaslamp", "-s", "user", "--env", "GASLAMP_NESTED=1", "--", "codex", "mcp-server"]);
118
-
119
- console.log();
120
- if (okCodex && okClaude) {
121
- console.log("registered both directions as `gaslamp`.");
122
- console.log("→ restart Claude Code so it loads the server.");
123
- console.log(" verify: codex mcp list and claude mcp list");
142
+
143
+ if (skill) {
144
+ for (const path of installSkills()) console.log(` skill: ${path}`);
145
+ console.log("\ndone. both agents now discover gaslamp through the skill; a CLAUDE.md /");
146
+ console.log("AGENTS.md note is optional on top (the README has the text).");
124
147
  } else {
125
- console.error("setup: one or both registrations failed (see above).");
126
- process.exitCode = 1;
148
+ console.log("\ndone. one thing left, yours to do: add a short consult note to your");
149
+ console.log("agent instructions (~/.claude/CLAUDE.md, ~/.codex/AGENTS.md) so each agent");
150
+ console.log("knows the command exists — see the README for the text to paste. Or run");
151
+ console.log("`gaslamp setup --skill` to install the gaslamp skill for both agents instead.");
127
152
  }
128
153
  }
package/src/tail.mjs ADDED
@@ -0,0 +1,93 @@
1
+ // tail.mjs — gaslamp tail <job|--last>: render a consult's event stream as
2
+ // uniform one-line entries, following while the job runs.
3
+ //
4
+ // A flashlight, not an event UI: raw events.jsonl is write-through but
5
+ // backend-shaped (claude stream-json vs codex --json); this normalizes both
6
+ // into "what is it doing right now" lines and nothing more. `poll`, `jobs`,
7
+ // and the durable records stay the product surface.
8
+
9
+ import { readFileSync, writeSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { jobDir, jobsDir, listJobIds, liveStatus, looksLikeFleetId, looksLikeJobId, readMeta, renderTrailer } from "./jobs.mjs";
12
+
13
+ const die = (code, msg) => { writeSync(2, `gaslamp: ${msg}\n`); process.exit(code); };
14
+
15
+ const short = (s) => String(s ?? "?").slice(0, 8);
16
+ const preview = (v, n = 100) => {
17
+ const s = (typeof v === "string" ? v : v == null ? "" : JSON.stringify(v)).replace(/\s+/g, " ").trim();
18
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
19
+ };
20
+
21
+ // One event → one line (or several, or none — noise is dropped).
22
+ function renderEvent(backend, j) {
23
+ if (backend === "claude") {
24
+ if (j.type === "system" && j.subtype === "init")
25
+ return [`init · session ${short(j.session_id)}${j.model ? ` · ${j.model}` : ""}`];
26
+ if (j.type === "assistant" || j.type === "user") {
27
+ const lines = [];
28
+ for (const p of j.message?.content ?? []) {
29
+ if (p.type === "tool_use") lines.push(`tool → ${p.name} ${preview(p.input, 80)}`);
30
+ else if (p.type === "text" && p.text?.trim()) lines.push(`text · ${preview(p.text)}`);
31
+ else if (p.type === "tool_result") lines.push(`tool ← ${preview(p.content, 80)}`);
32
+ }
33
+ return lines;
34
+ }
35
+ if (j.type === "result")
36
+ return [`result · ${j.is_error ? "error" : "ok"}${j.total_cost_usd != null ? ` · $${j.total_cost_usd.toFixed(2)}` : ""}`];
37
+ return []; // rate limits, hook responses, stream noise
38
+ }
39
+ // codex
40
+ if (j.type === "thread.started") return [`init · thread ${short(j.thread_id ?? j.threadId)}`];
41
+ if (j.type === "turn.completed")
42
+ return [`turn done${j.usage ? ` · tok ${j.usage.input_tokens ?? 0}→${j.usage.output_tokens ?? 0}` : ""}`];
43
+ if (j.type === "item.completed") {
44
+ const it = j.item ?? {};
45
+ const t = it.type ?? it.item_type;
46
+ if (t === "agent_message") return [`text · ${preview(it.text)}`];
47
+ if (t === "reasoning") return [`reasoning · ${preview(it.text ?? it.summary ?? "")}`];
48
+ if (t === "command_execution")
49
+ return [`tool → ${preview(it.command, 80)}${it.exit_code != null ? ` (exit ${it.exit_code})` : ""}`];
50
+ return t ? [String(t)] : [];
51
+ }
52
+ return [];
53
+ }
54
+
55
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
56
+
57
+ export async function runTail(argv = []) {
58
+ let id = argv.find((a) => !a.startsWith("-"));
59
+ if (!id || argv.includes("--last")) id = listJobIds()[0];
60
+ if (!id) die(2, `tail: no jobs under ${jobsDir()}`);
61
+ if (looksLikeFleetId(id)) die(2, "tail follows one consult — point it at a child job id (gaslamp poll <fleet-id> lists them)");
62
+ if (!looksLikeJobId(id)) die(2, `tail: "${id}" is not a job id`);
63
+ const m0 = readMeta(id);
64
+ if (!m0) die(2, `tail: no job record ${id}`);
65
+
66
+ const path = join(jobDir(id), "events.jsonl");
67
+ let seen = 0; // chars consumed; events files are small — reread is fine
68
+ const drain = (backend) => {
69
+ let text = "";
70
+ try { text = readFileSync(path, "utf8"); } catch { return; }
71
+ const fresh = text.slice(seen);
72
+ const complete = fresh.lastIndexOf("\n");
73
+ if (complete < 0) return;
74
+ seen += complete + 1;
75
+ for (const line of fresh.slice(0, complete).split("\n")) {
76
+ if (!line.trim()) continue;
77
+ let j; try { j = JSON.parse(line); } catch { continue; }
78
+ for (const out of renderEvent(backend, j)) process.stdout.write(out + "\n");
79
+ }
80
+ };
81
+
82
+ let meta = m0, status = liveStatus(m0);
83
+ drain(meta.backend);
84
+ while (status === "running") {
85
+ await sleep(400);
86
+ meta = readMeta(id) ?? meta;
87
+ status = liveStatus(meta);
88
+ drain(meta.backend);
89
+ }
90
+ drain(meta.backend); // final flush after the record settled
91
+ process.stdout.write(renderTrailer(meta, status) + "\n");
92
+ process.exitCode = status === "done" ? 0 : 1;
93
+ }