gaslamp 1.0.1 → 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/README.md +235 -30
- package/bin/gaslamp.mjs +122 -33
- package/package.json +5 -10
- package/src/consult.mjs +467 -0
- package/src/fleet.mjs +455 -0
- package/src/jobs.mjs +273 -0
- package/src/setup.mjs +130 -100
- package/src/tail.mjs +93 -0
- package/src/threads.mjs +56 -0
- package/src/doctor.mjs +0 -61
- package/src/server.mjs +0 -242
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,123 +1,153 @@
|
|
|
1
|
-
// setup.mjs —
|
|
1
|
+
// setup.mjs — allowlist the gaslamp CLI for Claude Code; optionally install
|
|
2
|
+
// the gaslamp skill for both agents.
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
138
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
139
|
+
writeFileSync(settingsPath, next);
|
|
140
|
+
console.log(` allowlist: ${entry} → ${settingsPath}`);
|
|
104
141
|
}
|
|
105
|
-
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
remove("claude", "codex", ["-s", "user"]); // legacy name
|
|
111
|
-
const okClaude = run("claude -> codex", "claude",
|
|
112
|
-
["mcp", "add", "gaslamp", "-s", "user", "--", "codex", "mcp-server"]);
|
|
113
|
-
|
|
114
|
-
console.log();
|
|
115
|
-
if (okCodex && okClaude) {
|
|
116
|
-
console.log("registered both directions as `gaslamp`.");
|
|
117
|
-
console.log("→ restart Claude Code so it loads the server.");
|
|
118
|
-
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).");
|
|
119
147
|
} else {
|
|
120
|
-
console.
|
|
121
|
-
|
|
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.");
|
|
122
152
|
}
|
|
123
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
|
+
}
|