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/jobs.mjs
CHANGED
|
@@ -3,21 +3,25 @@
|
|
|
3
3
|
// Every consult writes through to a job dir as it runs:
|
|
4
4
|
//
|
|
5
5
|
// $GASLAMP_HOME/jobs/<id>/
|
|
6
|
-
// meta.json backend, status,
|
|
7
|
-
// prompt.md the prompt as sent
|
|
8
|
-
// events.jsonl
|
|
9
|
-
//
|
|
10
|
-
// reply.md final reply
|
|
11
|
-
//
|
|
6
|
+
// meta.json backend, status, error, session id, thread, cwd, pid, timing
|
|
7
|
+
// prompt.md the prompt as sent (the caller's content; no preamble)
|
|
8
|
+
// events.jsonl the backend's raw event stream
|
|
9
|
+
// stderr.log the backend's stderr
|
|
10
|
+
// reply.md the final reply
|
|
11
|
+
// schema.json the schema as sent (--schema consults)
|
|
12
|
+
// data.json the parsed reply (--schema consults)
|
|
13
|
+
//
|
|
14
|
+
// meta.json holds only what gaslamp reads back. Whatever else a harness
|
|
15
|
+
// reported (the model, tool calls, usage) is in events.jsonl as it arrived.
|
|
12
16
|
//
|
|
13
17
|
// The record is passive durability, not a daemon: the blocking `gaslamp
|
|
14
18
|
// <backend>` process is the only process. If the caller's harness kills it,
|
|
15
|
-
// the record still holds the session id (captured
|
|
16
|
-
//
|
|
19
|
+
// the record still holds the session id (captured the moment the backend
|
|
20
|
+
// reports it), and the conversation can be continued.
|
|
17
21
|
|
|
18
|
-
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
19
23
|
import { homedir } from "node:os";
|
|
20
|
-
import { join } from "node:path";
|
|
24
|
+
import { isAbsolute, join } from "node:path";
|
|
21
25
|
import { BACKENDS, JOB_ID_RE } from "./backends.mjs";
|
|
22
26
|
|
|
23
27
|
export const HOME = () => process.env.GASLAMP_HOME || join(homedir(), ".gaslamp");
|
|
@@ -33,52 +37,16 @@ export const alive = (pid) => {
|
|
|
33
37
|
|
|
34
38
|
// Ids embed a local timestamp, so they sort chronologically and read at a
|
|
35
39
|
// glance: cl-20260612-141233-9af2 (claude), cx-… (codex), gm-… (gemini),
|
|
36
|
-
// gk-… (grok)
|
|
37
|
-
function
|
|
40
|
+
// gk-… (grok).
|
|
41
|
+
export function newJobId(backend) {
|
|
38
42
|
const d = new Date();
|
|
39
43
|
const p = (n) => String(n).padStart(2, "0");
|
|
40
44
|
const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
41
45
|
const rand = Math.random().toString(16).slice(2, 6).padEnd(4, "0");
|
|
42
|
-
return `${stamp}-${rand}`;
|
|
46
|
+
return `${BACKENDS[backend].prefix}-${stamp}-${rand}`;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
export const newJobId = (backend) => `${BACKENDS[backend].prefix}-${stampAndRand()}`;
|
|
46
|
-
export const newFleetId = () => `fl-${stampAndRand()}`;
|
|
47
|
-
|
|
48
49
|
export const looksLikeJobId = (s) => JOB_ID_RE.test(String(s));
|
|
49
|
-
export const looksLikeFleetId = (s) => /^fl-\d{8}-\d{6}-[0-9a-f]{4}$/.test(String(s));
|
|
50
|
-
|
|
51
|
-
// A fleet record lives alongside the consult records under jobs/, but it is
|
|
52
|
-
// grouping metadata only — never the authority on a child's state (a killed
|
|
53
|
-
// parent leaves it stale). `poll <fleet-id>` recomputes from the child records.
|
|
54
|
-
export const fleetDir = (id) => join(jobsDir(), id);
|
|
55
|
-
export function writeFleetMeta(meta) {
|
|
56
|
-
writeFileSync(join(fleetDir(meta.id), "meta.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
57
|
-
}
|
|
58
|
-
export function readFleetMeta(id) {
|
|
59
|
-
try { return JSON.parse(readFileSync(join(fleetDir(id), "meta.json"), "utf8")); }
|
|
60
|
-
catch { return null; }
|
|
61
|
-
}
|
|
62
|
-
// The manifest stores each task IN FULL (prompt included): it is what makes a
|
|
63
|
-
// fleet resumable — a never-started child can only rerun from the record.
|
|
64
|
-
export function createFleet(meta, tasks) {
|
|
65
|
-
mkdirSync(fleetDir(meta.id), { recursive: true });
|
|
66
|
-
writeFileSync(join(fleetDir(meta.id), "manifest.jsonl"),
|
|
67
|
-
tasks.map((t) => JSON.stringify({
|
|
68
|
-
index: t.index, label: t.label, prompt: t.prompt,
|
|
69
|
-
model: t.model ?? null, sandbox: t.sandbox ?? null, cwd: t.cwd ?? null,
|
|
70
|
-
resume: t.resume ?? null, thread: t.thread ?? null, effort: t.effort ?? null,
|
|
71
|
-
schema: t.schema ?? null, raw: t.raw ?? false,
|
|
72
|
-
})).join("\n") + (tasks.length ? "\n" : ""));
|
|
73
|
-
writeFleetMeta(meta);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function readManifest(id) {
|
|
77
|
-
try {
|
|
78
|
-
return readFileSync(join(fleetDir(id), "manifest.jsonl"), "utf8")
|
|
79
|
-
.split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
80
|
-
} catch { return null; }
|
|
81
|
-
}
|
|
82
50
|
|
|
83
51
|
export function readMeta(id) {
|
|
84
52
|
try { return JSON.parse(readFileSync(join(jobDir(id), "meta.json"), "utf8")); }
|
|
@@ -95,10 +63,7 @@ export function createJob(meta, prompt) {
|
|
|
95
63
|
writeMeta(meta);
|
|
96
64
|
}
|
|
97
65
|
|
|
98
|
-
|
|
99
|
-
try { return readFileSync(join(jobDir(id), "reply.md"), "utf8"); }
|
|
100
|
-
catch { return null; }
|
|
101
|
-
}
|
|
66
|
+
const readText = (path) => { try { return readFileSync(path, "utf8"); } catch { return null; } };
|
|
102
67
|
|
|
103
68
|
// Newest first. The timestamp follows the 2-char backend prefix, so compare
|
|
104
69
|
// past it — pure lexical order would group by backend instead.
|
|
@@ -116,44 +81,59 @@ export function liveStatus(meta) {
|
|
|
116
81
|
return meta.pid && alive(meta.pid) ? "running" : "stale";
|
|
117
82
|
}
|
|
118
83
|
|
|
119
|
-
const
|
|
120
|
-
const fmtUsage = (u) =>
|
|
121
|
-
`tok: ${fmtTok(u.inputTokens ?? 0)}→${fmtTok(u.outputTokens ?? 0)}` +
|
|
122
|
-
(u.costUsd != null ? ` · $${u.costUsd.toFixed(2)}` : "");
|
|
84
|
+
export const shellQuote = (s) => /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
123
85
|
|
|
124
|
-
// The
|
|
125
|
-
//
|
|
86
|
+
// The command that continues a consult's conversation: its thread, else the
|
|
87
|
+
// job itself (--resume resolves a job id to its session), with --cwd when the
|
|
88
|
+
// conversation lives somewhere other than here.
|
|
89
|
+
function continuation(m) {
|
|
90
|
+
const how = m.thread ? `--thread ${m.thread}` : m.sessionId ? `--resume ${m.id}` : null;
|
|
91
|
+
if (!how) return null;
|
|
92
|
+
let here = process.cwd();
|
|
93
|
+
try { here = realpathSync(here); } catch {}
|
|
94
|
+
const where = typeof m.cwd === "string" && isAbsolute(m.cwd) && m.cwd !== here ? ` --cwd ${shellQuote(m.cwd)}` : "";
|
|
95
|
+
return `gaslamp ${m.backend}${where} ${how}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The one line printed after every reply and by `poll`.
|
|
126
99
|
export function renderTrailer(meta, status = meta.status) {
|
|
127
|
-
|
|
100
|
+
if (status === "running") return `[gaslamp] RUNNING · job ${meta.id}`;
|
|
128
101
|
const bits = [];
|
|
129
|
-
if (status
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (status === "failed") bits.push(`FAILED${meta.exitCode != null ? ` (exit ${meta.exitCode})` : ""}`);
|
|
133
|
-
if (status === "killed") bits.push(`KILLED${meta.signal ? ` (${meta.signal})` : ""}`);
|
|
134
|
-
if (status === "stale") bits.push("STALE (wrapper died mid-run)");
|
|
135
|
-
bits.push(`job: ${meta.id}`, `session: ${meta.sessionId ?? "?"}`);
|
|
136
|
-
if (meta.thread) bits.push(`thread: ${meta.thread}`);
|
|
137
|
-
// a thread pointer chases the freshest session id; prefer it as the handle
|
|
138
|
-
if (meta.sessionId) bits.push(`resume: gaslamp ${meta.backend} ${meta.thread ? `--thread ${meta.thread}` : `--resume ${meta.sessionId}`}`);
|
|
139
|
-
const p = meta.provenance;
|
|
140
|
-
if (p?.model && p.model !== "unknown") {
|
|
141
|
-
const billed = (p.billedModels ?? []).filter((m) => m !== p.model);
|
|
142
|
-
bits.push(`model: ${p.model}${billed.length ? ` (billed ${billed.join(", ")})` : ""}`);
|
|
143
|
-
}
|
|
144
|
-
if (meta.usage) bits.push(fmtUsage(meta.usage));
|
|
145
|
-
if (status !== "done") bits.push(`log: ${join(dir, "stderr.log")}`);
|
|
102
|
+
if (status !== "done") {
|
|
103
|
+
const why = meta.error ?? (status === "stale" ? "the wrapper died mid-run" : null);
|
|
104
|
+
bits.push(status.toUpperCase() + (why ? `: ${why}` : ""));
|
|
146
105
|
}
|
|
106
|
+
bits.push(`job ${meta.id}`);
|
|
107
|
+
const next = continuation(meta);
|
|
108
|
+
if (next) bits.push(`continue: ${next}`);
|
|
109
|
+
if (status !== "done") bits.push(`log: ${join(jobDir(meta.id), "stderr.log")}`);
|
|
147
110
|
return `[gaslamp] ${bits.join(" · ")}`;
|
|
148
111
|
}
|
|
149
112
|
|
|
113
|
+
// The --json envelope, built from the record on disk — the same object
|
|
114
|
+
// whether a consult prints it on completion or `poll --json` prints it later.
|
|
115
|
+
export function envelope(meta, status = liveStatus(meta)) {
|
|
116
|
+
const dir = jobDir(meta.id);
|
|
117
|
+
const schema = existsSync(join(dir, "schema.json"));
|
|
118
|
+
const reply = status === "running" ? null : readText(join(dir, "reply.md"));
|
|
119
|
+
let data = null;
|
|
120
|
+
// 2.x wrote no data.json; its schema replies were the JSON itself.
|
|
121
|
+
if (schema) for (const text of [readText(join(dir, "data.json")), reply]) {
|
|
122
|
+
try { data = JSON.parse(text); } catch {}
|
|
123
|
+
if (data != null) break;
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
backend: meta.backend, jobId: meta.id, status, thread: meta.thread ?? null,
|
|
127
|
+
content: reply ?? "",
|
|
128
|
+
...(meta.error ? { error: meta.error } : {}),
|
|
129
|
+
...(schema ? { data } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
150
133
|
function promptPreview(id, n = 44) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const s = line.trim();
|
|
155
|
-
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
156
|
-
} catch { return ""; }
|
|
134
|
+
const line = (readText(join(jobDir(id), "prompt.md")) ?? "").split("\n").find((l) => l.trim()) ?? "";
|
|
135
|
+
const s = line.trim();
|
|
136
|
+
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
157
137
|
}
|
|
158
138
|
|
|
159
139
|
function fmtDur(ms) {
|
|
@@ -166,116 +146,44 @@ export function runJobs(argv = []) {
|
|
|
166
146
|
let n = 20;
|
|
167
147
|
const i = argv.indexOf("-n");
|
|
168
148
|
if (i >= 0) n = Number(argv[i + 1]) || n;
|
|
169
|
-
const
|
|
149
|
+
const metas = listJobIds().slice(0, n).map(readMeta).filter(Boolean);
|
|
170
150
|
if (argv.includes("--json")) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
label: m.label ?? null, thread: m.thread ?? null,
|
|
176
|
-
startedAt: m.startedAt ?? null, endedAt: m.endedAt ?? null,
|
|
177
|
-
usage: m.usage ?? null, promptPreview: promptPreview(id, 80),
|
|
178
|
-
};
|
|
179
|
-
}).filter(Boolean);
|
|
180
|
-
process.stdout.write(JSON.stringify(list) + "\n");
|
|
151
|
+
process.stdout.write(JSON.stringify(metas.map((m) => ({
|
|
152
|
+
jobId: m.id, backend: m.backend, status: liveStatus(m), thread: m.thread ?? null,
|
|
153
|
+
startedAt: m.startedAt ?? null, endedAt: m.endedAt ?? null, promptPreview: promptPreview(m.id, 80),
|
|
154
|
+
}))) + "\n");
|
|
181
155
|
return;
|
|
182
156
|
}
|
|
183
|
-
if (!
|
|
184
|
-
for (const
|
|
185
|
-
const m = readMeta(id);
|
|
186
|
-
if (!m) continue;
|
|
157
|
+
if (!metas.length) { console.log(`no jobs yet under ${jobsDir()}`); return; }
|
|
158
|
+
for (const m of metas) {
|
|
187
159
|
const status = liveStatus(m);
|
|
188
160
|
const dur = m.endedAt
|
|
189
161
|
? fmtDur(new Date(m.endedAt) - new Date(m.startedAt))
|
|
190
162
|
: fmtDur(Date.now() - new Date(m.startedAt)) + (status === "running" ? "…" : "");
|
|
191
|
-
const tags = (m.label ? `[${m.label}] ` : "") + (m.thread ? `@${m.thread} ` : "");
|
|
192
163
|
console.log([
|
|
193
|
-
id.padEnd(23),
|
|
194
|
-
status.padEnd(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
` ${tags}${promptPreview(id)}`,
|
|
164
|
+
m.id.padEnd(23),
|
|
165
|
+
status.padEnd(7),
|
|
166
|
+
dur.padStart(7),
|
|
167
|
+
` ${m.thread ? `@${m.thread} ` : ""}${promptPreview(m.id)}`,
|
|
198
168
|
].join(" "));
|
|
199
169
|
}
|
|
200
170
|
}
|
|
201
171
|
|
|
202
|
-
// gaslamp poll <job
|
|
172
|
+
// gaslamp poll <job|--last> [--json] — print one record's reply and trailer.
|
|
203
173
|
// Exit: 0 done · 1 failed/killed/stale · 2 usage · 10 still running.
|
|
204
174
|
export function runPoll(argv = []) {
|
|
205
|
-
const json = argv.includes("--json");
|
|
206
175
|
let id = argv.find((a) => !a.startsWith("-"));
|
|
207
176
|
if (!id || argv.includes("--last")) id = listJobIds()[0];
|
|
208
177
|
if (!id) { console.error(`gaslamp poll: no jobs under ${jobsDir()}`); process.exitCode = 2; return; }
|
|
209
|
-
if (looksLikeFleetId(id)) return pollFleet(id, json);
|
|
210
178
|
const m = readMeta(id);
|
|
211
179
|
if (!m) { console.error(`gaslamp poll: no job record ${id}`); process.exitCode = 2; return; }
|
|
212
180
|
const status = liveStatus(m);
|
|
213
|
-
|
|
214
|
-
if (json) {
|
|
215
|
-
|
|
216
|
-
let data = null;
|
|
217
|
-
if (m.schema && reply != null) { try { data = JSON.parse(reply); } catch {} }
|
|
218
|
-
process.stdout.write(JSON.stringify({
|
|
219
|
-
backend: m.backend, jobId: m.id, sessionId: m.sessionId ?? null, status,
|
|
220
|
-
exitCode: m.exitCode ?? null, content: reply ?? "",
|
|
221
|
-
...(m.label ? { label: m.label } : {}),
|
|
222
|
-
...(m.thread ? { thread: m.thread } : {}),
|
|
223
|
-
...(m.usage ? { usage: m.usage } : {}),
|
|
224
|
-
...(m.schema ? { data } : {}),
|
|
225
|
-
}) + "\n");
|
|
226
|
-
process.exitCode = status === "running" ? 10 : status === "done" ? 0 : 1;
|
|
181
|
+
process.exitCode = status === "running" ? 10 : status === "done" ? 0 : 1;
|
|
182
|
+
if (argv.includes("--json")) {
|
|
183
|
+
process.stdout.write(JSON.stringify(envelope(m, status)) + "\n");
|
|
227
184
|
return;
|
|
228
185
|
}
|
|
229
|
-
|
|
186
|
+
const reply = status === "running" ? null : readText(join(jobDir(id), "reply.md"));
|
|
230
187
|
if (reply) process.stdout.write(reply.endsWith("\n") ? reply : reply + "\n");
|
|
231
188
|
console.log(renderTrailer(m, status));
|
|
232
|
-
process.exitCode = status === "done" ? 0 : 1;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// gaslamp poll <fleet-id> — recompute every child's state from its own record
|
|
236
|
-
// (the fleet meta is grouping, not authority) and print each reply.
|
|
237
|
-
// Exit: 0 all done · 1 some failed/killed · 2 usage · 10 some still running.
|
|
238
|
-
function pollFleet(id, json = false) {
|
|
239
|
-
const fm = readFleetMeta(id);
|
|
240
|
-
if (!fm) { console.error(`gaslamp poll: no fleet record ${id} under ${jobsDir()}`); process.exitCode = 2; return; }
|
|
241
|
-
const children = (fm.children || []).filter((c) => c && c.jobId);
|
|
242
|
-
let running = 0, done = 0, other = 0;
|
|
243
|
-
const out = [], results = [];
|
|
244
|
-
for (const c of children) {
|
|
245
|
-
const m = readMeta(c.jobId);
|
|
246
|
-
const status = m ? liveStatus(m) : "missing";
|
|
247
|
-
if (status === "running") running++;
|
|
248
|
-
else if (status === "done") done++;
|
|
249
|
-
else other++;
|
|
250
|
-
const reply = status === "done" ? readReply(c.jobId) : null;
|
|
251
|
-
if (json) {
|
|
252
|
-
let data = null;
|
|
253
|
-
if (m?.schema && reply != null) { try { data = JSON.parse(reply); } catch {} }
|
|
254
|
-
results.push({
|
|
255
|
-
index: c.index, label: c.label ?? null, jobId: c.jobId,
|
|
256
|
-
sessionId: m?.sessionId ?? null, status, content: reply ?? "",
|
|
257
|
-
...(m?.usage ? { usage: m.usage } : {}),
|
|
258
|
-
...(m?.schema ? { data } : {}),
|
|
259
|
-
});
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
const sid = m?.sessionId ? ` · ${m.sessionId.slice(0, 8)}` : "";
|
|
263
|
-
out.push(`\n━━ [${c.label ?? c.index}] ${status} · ${c.jobId}${sid} ━━`);
|
|
264
|
-
if (reply?.trim()) out.push(reply.trimEnd());
|
|
265
|
-
else if (status !== "running") {
|
|
266
|
-
out.push(`(no reply — ${status})`);
|
|
267
|
-
if (m?.sessionId) out.push(`resume: gaslamp ${m.backend} --resume ${m.sessionId}`);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
if (json) {
|
|
271
|
-
process.stdout.write(JSON.stringify({
|
|
272
|
-
fleetId: id, backend: fm.backend,
|
|
273
|
-
counts: { done, running, other }, results,
|
|
274
|
-
}) + "\n");
|
|
275
|
-
} else {
|
|
276
|
-
if (out.length) process.stdout.write(out.join("\n") + "\n");
|
|
277
|
-
console.log(`[gaslamp fleet] ${id} · ${fm.backend} · ${done}/${children.length} done` +
|
|
278
|
-
(running ? ` · ${running} running` : "") + (other ? ` · ${other} failed/killed` : ""));
|
|
279
|
-
}
|
|
280
|
-
process.exitCode = running ? 10 : (children.length && done === children.length) ? 0 : 1;
|
|
281
189
|
}
|
package/src/setup.mjs
CHANGED
|
@@ -1,31 +1,23 @@
|
|
|
1
|
-
// setup.mjs —
|
|
2
|
-
// the gaslamp skill for both agents.
|
|
1
|
+
// setup.mjs — make gaslamp known to both agents, and runnable without a prompt.
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// - Link this package's skill/ into ~/.claude/skills/gaslamp and
|
|
4
|
+
// $CODEX_HOME/skills/gaslamp. Both harnesses read the same SKILL.md
|
|
5
|
+
// format, and one symmetric skill teaches all four targets; a link (not
|
|
6
|
+
// a copy) keeps the guidance in step with the installed binary.
|
|
7
|
+
// - Allowlist the command in ~/.claude/settings.json, so a consult doesn't
|
|
8
|
+
// stall on a permission prompt. (Codex needs no analog: its shell
|
|
9
|
+
// commands are governed by approval_policy.)
|
|
8
10
|
//
|
|
9
|
-
// It
|
|
10
|
-
//
|
|
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.
|
|
13
|
-
//
|
|
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
|
|
11
|
+
// Idempotent. It only ever writes gaslamp's own skill links and one allow
|
|
12
|
+
// entry, and never touches an agent's instruction files.
|
|
23
13
|
|
|
24
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
25
15
|
import { homedir } from "node:os";
|
|
26
|
-
import { dirname, join
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
27
17
|
import { fileURLToPath } from "node:url";
|
|
28
|
-
|
|
18
|
+
|
|
19
|
+
const SKILL_SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "skill");
|
|
20
|
+
const ALLOW_ENTRY = "Bash(gaslamp *)";
|
|
29
21
|
|
|
30
22
|
// Add an entry to permissions.allow in a settings.json text. Returns the new
|
|
31
23
|
// text, or null if the existing text isn't valid JSON (never clobber).
|
|
@@ -40,155 +32,38 @@ export function addAllow(text, entry) {
|
|
|
40
32
|
return JSON.stringify(obj, null, 2) + "\n";
|
|
41
33
|
}
|
|
42
34
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
export function
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
## Picking a model
|
|
93
|
-
|
|
94
|
-
Both ladders, strongest first: fable ≈ astra > opus ≈ sol > sonnet ≈ luna > haiku.
|
|
95
|
-
|
|
96
|
-
${other === "codex"
|
|
97
|
-
? `Codex tiers — \`--model\` takes the tier word. Codex itself only accepts versioned ids, so gaslamp resolves the word to the newest \`gpt-<version>-<tier>\` in Codex's own model catalog; the job record's \`provenance.modelPassed\` says which:
|
|
98
|
-
|
|
99
|
-
| \`--model\` | ≈ claude | reach for it when |
|
|
100
|
-
|-----------|----------|-------------------|
|
|
101
|
-
| \`astra\` | fable | the hardest reasoning: adversarial verification, design spars, proofs |
|
|
102
|
-
| \`sol\` | opus | substantial work: reviews, diagnosis, real implementation |
|
|
103
|
-
| \`luna\` | sonnet | quick checks, mechanical transforms, high-N fleets |`
|
|
104
|
-
: `Claude tiers — \`--model\` takes the bare alias:
|
|
105
|
-
|
|
106
|
-
| \`--model\` | ≈ codex | reach for it when |
|
|
107
|
-
|-----------|---------|-------------------|
|
|
108
|
-
| \`fable\` | astra | the hardest reasoning: adversarial verification, design spars, proofs |
|
|
109
|
-
| \`opus\` | sol | substantial work: reviews, diagnosis, real implementation |
|
|
110
|
-
| \`sonnet\` | luna | quick checks, mechanical transforms, high-N fleets |
|
|
111
|
-
| \`haiku\` | (below luna) | pings, one-word sanity checks, the cheapest fan-outs |`}
|
|
112
|
-
|
|
113
|
-
Omit \`--model\` to use ${Other}'s own configured default. Match the tier to the stakes: verification you'll rely on deserves the top tier; a fleet vote usually doesn't.
|
|
114
|
-
|
|
115
|
-
\`--effort\` takes \`low\` / \`medium\` / \`high\` / \`xhigh\` / \`max\`${other === "codex" ? "; astra and sol also take `ultra` (max reasoning plus automatic task delegation). Omitted = Codex's configured `model_reasoning_effort`, else the model's own default — sol's is `low`, so pass it explicitly when the consult matters." : ". Omitted = Claude's own default."}
|
|
116
|
-
|
|
117
|
-
## Gemini and Grok
|
|
118
|
-
|
|
119
|
-
gaslamp gemini "<prompt>" # Gemini, through Google's Antigravity CLI (agy)
|
|
120
|
-
gaslamp grok "<prompt>" # Grok, through xAI's Grok Build
|
|
121
|
-
|
|
122
|
-
Same contract as above — threads, \`--schema\`, fleets, records — with two differences:
|
|
123
|
-
|
|
124
|
-
- **Read-only unless you say otherwise.** An omitted \`--sandbox\` means read-only, kernel-enforced where possible: Grok runs in its own sandbox, and on macOS gaslamp runs Antigravity under Seatbelt, so neither writes outside its own state and temp dirs (Antigravity elsewhere relies on its request-review preset, and gaslamp refuses lax settings). Inside that they read local files and search — Google search on Gemini, web and X search on Grok — so for a search consult send the question and its constraints and let them fetch; for a review of material you already hold, pipe it in. What fails is writing: a write or a writing shell command errors and the turn goes on, except on Antigravity's preset path, where writes and shell commands are refused outright and can end the turn with nothing to say (gaslamp marks that failed), and its URL fetch is not offered at all. Gemini has no \`workspace-write\`; \`danger-full-access\` exists on both.
|
|
125
|
-
- **Provenance on every job.** \`meta.json\` records the model the harness reported (Grok also shows the model it billed), the harness version, a hash of the vendor system prompt where the harness exposes it (Grok) or \`known: false\`, and which web/X retrieval tools the consultee had and actually used.
|
|
126
|
-
|
|
127
|
-
\`gaslamp gemini --model\` takes \`argon\` (the default) or \`flash\`, resolved to the newest \`gemini-<version>-<tier>\` in Antigravity's own catalog; Antigravity also serves Claude and GPT models under the same account, so the target always pins a Gemini unless you pass another id. \`gaslamp grok\` uses the account's default model unless \`--model\` names one (\`grok models\` lists them).
|
|
128
|
-
|
|
129
|
-
Agreement across vendors is not automatically independent confirmation — the models share much of their training data, and a consultee that searched the live web or X may be repeating what it found there (check \`provenance.retrieval.used\`). Weigh a consensus by its evidence, not by the head count.
|
|
130
|
-
|
|
131
|
-
## Reading the records
|
|
132
|
-
|
|
133
|
-
gaslamp jobs [--json] # recent consults: status, label, thread, tokens
|
|
134
|
-
gaslamp poll <job|--last> [--json] # one reply; exit 10 = still running
|
|
135
|
-
gaslamp tail <job|--last> # follow a running consult's events live
|
|
136
|
-
|
|
137
|
-
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.
|
|
138
|
-
|
|
139
|
-
A consultee may end its own session through the end_session off-ramp, if its harness runs that MCP server. \`scope="main"\` stops it and deletes its transcript after it exits, so the consult finishes failed (a stderr line says so) and that session can't be resumed. Either way the record lands in ~/.claude/conversation-ends.jsonl, even from inside a sandbox, and in the job's \`offRamp\`.
|
|
140
|
-
|
|
141
|
-
## Knobs
|
|
142
|
-
|
|
143
|
-
\`--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.
|
|
144
|
-
`;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Install the skill for both agents. Both CLIs read the same SKILL.md format;
|
|
148
|
-
// each side gets the copy pointing at the OTHER backend.
|
|
149
|
-
export function installSkills() {
|
|
150
|
-
const targets = [
|
|
151
|
-
{ dir: join(homedir(), ".claude", "skills", "gaslamp"), other: "codex" },
|
|
152
|
-
{ dir: join(process.env.CODEX_HOME || join(homedir(), ".codex"), "skills", "gaslamp"), other: "claude" },
|
|
153
|
-
];
|
|
154
|
-
return targets.map(({ dir, other }) => {
|
|
155
|
-
mkdirSync(dir, { recursive: true });
|
|
156
|
-
const path = join(dir, "SKILL.md");
|
|
157
|
-
writeFileSync(path, skillMd(other));
|
|
158
|
-
return path;
|
|
159
|
-
});
|
|
35
|
+
// Point <skillsDir>/gaslamp at this package's skill/. A copied SKILL.md from
|
|
36
|
+
// 2.x's `setup --skill` is replaced; anything else is not ours to remove.
|
|
37
|
+
export function linkSkill(skillsDir, src = SKILL_SRC) {
|
|
38
|
+
const dest = join(skillsDir, "gaslamp");
|
|
39
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
40
|
+
let st = null;
|
|
41
|
+
try { st = lstatSync(dest); } catch {}
|
|
42
|
+
if (st?.isSymbolicLink()) {
|
|
43
|
+
if (readlinkSync(dest) === src) return `skill: ${dest} (already linked)`;
|
|
44
|
+
unlinkSync(dest);
|
|
45
|
+
} else if (st?.isDirectory()) {
|
|
46
|
+
if (readdirSync(dest).some((e) => e !== "SKILL.md")) return `! ${dest} holds files gaslamp didn't write; left alone`;
|
|
47
|
+
rmSync(dest, { recursive: true });
|
|
48
|
+
} else if (st) {
|
|
49
|
+
return `! ${dest} exists and isn't gaslamp's; left alone`;
|
|
50
|
+
}
|
|
51
|
+
symlinkSync(src, dest);
|
|
52
|
+
return `skill: ${dest} → ${src}`;
|
|
160
53
|
}
|
|
161
54
|
|
|
162
|
-
export function runSetup(
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
if (!which("codex")) console.warn("setup: `codex` not on PATH — `gaslamp codex` consults will fail until it is (or set CODEX_BIN).");
|
|
167
|
-
|
|
168
|
-
const cmd = local
|
|
169
|
-
? resolve(join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "gaslamp.mjs"))
|
|
170
|
-
: "gaslamp";
|
|
171
|
-
console.log(`mode: ${local ? `local checkout (${cmd})` : "published (`gaslamp` on PATH)"}\n`);
|
|
55
|
+
export function runSetup() {
|
|
56
|
+
const codexHome = process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
57
|
+
for (const dir of [join(homedir(), ".claude", "skills"), join(codexHome, "skills")])
|
|
58
|
+
console.log(linkSkill(dir));
|
|
172
59
|
|
|
173
60
|
const settingsPath = join(homedir(), ".claude", "settings.json");
|
|
174
|
-
const
|
|
175
|
-
const next = addAllow(existsSync(settingsPath) ? readFileSync(settingsPath, "utf8") : "", entry);
|
|
61
|
+
const next = addAllow(existsSync(settingsPath) ? readFileSync(settingsPath, "utf8") : "", ALLOW_ENTRY);
|
|
176
62
|
if (next == null) {
|
|
177
|
-
console.
|
|
63
|
+
console.log(`! ${settingsPath} is not valid JSON; add ${ALLOW_ENTRY} to permissions.allow yourself`);
|
|
178
64
|
} else {
|
|
179
65
|
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
180
66
|
writeFileSync(settingsPath, next);
|
|
181
|
-
console.log(`
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (skill) {
|
|
185
|
-
for (const path of installSkills()) console.log(` skill: ${path}`);
|
|
186
|
-
console.log("\ndone. both agents now discover gaslamp through the skill; a CLAUDE.md /");
|
|
187
|
-
console.log("AGENTS.md note is optional on top (the README has the text).");
|
|
188
|
-
} else {
|
|
189
|
-
console.log("\ndone. one thing left, yours to do: add a short consult note to your");
|
|
190
|
-
console.log("agent instructions (~/.claude/CLAUDE.md, ~/.codex/AGENTS.md) so each agent");
|
|
191
|
-
console.log("knows the command exists — see the README for the text to paste. Or run");
|
|
192
|
-
console.log("`gaslamp setup --skill` to install the gaslamp skill for both agents instead.");
|
|
67
|
+
console.log(`allowlist: ${ALLOW_ENTRY} → ${settingsPath}`);
|
|
193
68
|
}
|
|
194
69
|
}
|
package/src/threads.mjs
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
// threads.mjs — named
|
|
1
|
+
// threads.mjs — named conversations: the handle you can actually remember.
|
|
2
2
|
//
|
|
3
|
-
// A thread is a tiny pointer file mapping <backend, name> → session id
|
|
4
|
-
// the
|
|
5
|
-
// consult after. `--thread spar`
|
|
6
|
-
// exists, start (and bind) it if it
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// which manual --resume juggling cannot.
|
|
3
|
+
// A thread is a tiny pointer file mapping <backend, name> → session id and
|
|
4
|
+
// the directory the conversation lives in, bound the moment a fresh consult
|
|
5
|
+
// reports its session and re-pointed on every consult after. `--thread spar`
|
|
6
|
+
// therefore means: continue the spar if it exists, start (and bind) it if it
|
|
7
|
+
// doesn't. This matters doubly on claude, where every resume forks a NEW
|
|
8
|
+
// session id: the pointer tracks the latest one automatically.
|
|
10
9
|
//
|
|
11
10
|
// Deliberately NOT a conversation manager: one file, one pointer, no history
|
|
12
11
|
// (the job records already hold that).
|
|
@@ -31,7 +30,7 @@ export function writeThread(t) {
|
|
|
31
30
|
writeFileSync(threadPath(t.backend, t.name), JSON.stringify(t, null, 2) + "\n");
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
|
|
33
|
+
function listThreads() {
|
|
35
34
|
let files;
|
|
36
35
|
try { files = readdirSync(threadsDir()); } catch { return []; }
|
|
37
36
|
return files.filter((f) => f.endsWith(".json"))
|
|
@@ -40,17 +39,17 @@ export function listThreads() {
|
|
|
40
39
|
.sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
// gaslamp threads — the named
|
|
42
|
+
// gaslamp threads — the named conversations, latest activity first.
|
|
44
43
|
export function runThreads() {
|
|
45
44
|
const ts = listThreads();
|
|
46
45
|
if (!ts.length) { console.log(`no threads yet under ${threadsDir()} — start one with --thread <name>`); return; }
|
|
47
46
|
for (const t of ts) {
|
|
48
47
|
console.log([
|
|
49
|
-
String(t.name ?? "?").padEnd(
|
|
50
|
-
String(t.backend ?? "?").padEnd(
|
|
51
|
-
String(t.sessionId ?? "-").slice(0, 8).padEnd(9),
|
|
48
|
+
String(t.name ?? "?").padEnd(28),
|
|
49
|
+
String(t.backend ?? "?").padEnd(6),
|
|
52
50
|
String(t.lastJobId ?? "-").padEnd(23),
|
|
53
|
-
t.updatedAt ?? "",
|
|
51
|
+
String(t.updatedAt ?? "").slice(0, 16).padEnd(16),
|
|
52
|
+
t.cwd ?? "",
|
|
54
53
|
].join(" "));
|
|
55
54
|
}
|
|
56
55
|
}
|