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/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, sessionId, pids, timing, exit
7
- // prompt.md the prompt as sent
8
- // events.jsonl raw backend event stream (claude stream-json / codex --json /
9
- // agy stream-json / grok streaming-messages-json)
10
- // reply.md final reply text
11
- // stderr.log backend stderr
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 early, the moment the
16
- // backend reports it) — recovery is `--resume`, not orphaned children.
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), fl-… (fleet).
37
- function stampAndRand() {
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
- export function readReply(id) {
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 fmtTok = (n) => n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
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 one-line, greppable record of a consult — printed after every reply and
125
- // by `poll`. Always carries the two handles that matter: job id and session id.
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
- const dir = jobDir(meta.id);
100
+ if (status === "running") return `[gaslamp] RUNNING · job ${meta.id}`;
128
101
  const bits = [];
129
- if (status === "running") {
130
- bits.push("RUNNING", `job: ${meta.id}`, `watch: tail -f ${join(dir, "events.jsonl")}`);
131
- } else {
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
- try {
152
- const line = readFileSync(join(jobDir(id), "prompt.md"), "utf8")
153
- .split("\n").find((l) => l.trim()) || "";
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 ids = listJobIds().slice(0, n);
149
+ const metas = listJobIds().slice(0, n).map(readMeta).filter(Boolean);
170
150
  if (argv.includes("--json")) {
171
- const list = ids.map((id) => {
172
- const m = readMeta(id);
173
- return m && {
174
- jobId: m.id, backend: m.backend, status: liveStatus(m), sessionId: m.sessionId ?? null,
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 (!ids.length) { console.log(`no jobs yet under ${jobsDir()}`); return; }
184
- for (const id of ids) {
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(8),
195
- (m.sessionId ?? "-").slice(0, 8).padEnd(9),
196
- dur.padStart(8),
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|fleet|--last> [--json] — print one record's reply/status.
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
- const reply = status === "running" ? null : readReply(id);
214
- if (json) {
215
- // The consult envelope shape, recomputed from the record.
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
- if (status === "running") { console.log(renderTrailer(m, status)); process.exitCode = 10; return; }
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 — allowlist the gaslamp CLI for Claude Code; optionally install
2
- // the gaslamp skill for both agents.
1
+ // setup.mjs — make gaslamp known to both agents, and runnable without a prompt.
3
2
  //
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.)
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 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.
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, resolve } from "node:path";
16
+ import { dirname, join } from "node:path";
27
17
  import { fileURLToPath } from "node:url";
28
- import { which } from "./which.mjs";
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
- // 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. Also reaches Gemini and Grok as read-only consult targets. 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} / Gemini / Grok / 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
- ## 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(argv = []) {
163
- const local = argv.includes("--local") || argv.includes("-l");
164
- const skill = argv.includes("--skill");
165
- if (!which("claude")) console.warn("setup: `claude` not on PATH — `gaslamp claude` consults will fail until it is (or set CLAUDE_BIN).");
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 entry = `Bash(${cmd}:*)`;
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.warn(` ! ${settingsPath} is not valid JSON — add ${entry} to permissions.allow yourself`);
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(` allowlist: ${entry} → ${settingsPath}`);
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 session aliases: the handle you can actually remember.
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, bound
4
- // the moment a fresh consult reports its session and re-pointed on every
5
- // consult after. `--thread spar` therefore means: continue the spar if it
6
- // exists, start (and bind) it if it doesn't — idempotent, no fishing UUIDs
7
- // out of old trailers. This matters doubly on claude, where every resume
8
- // forks a NEW session id: the pointer tracks the latest one automatically,
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
- export function listThreads() {
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 spars, latest activity first.
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(24),
50
- String(t.backend ?? "?").padEnd(7),
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
  }