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/fleet.mjs DELETED
@@ -1,457 +0,0 @@
1
- // fleet.mjs — fan out a *fleet* of consults from one command.
2
- //
3
- // gaslamp fleet codex -n 8 "review this diff" 8 fresh sessions, same prompt
4
- // gaslamp fleet codex - < tasks.jsonl one task per line (see below)
5
- // gaslamp fleet claude --concurrency 4 … bound the parallelism
6
- //
7
- // The fleet is a thin bounded-concurrency runner over the SAME single-consult
8
- // CLI: it shells out to `gaslamp <backend> --json …` once per task, never
9
- // reaching into consult.mjs. Each child is exactly today's tested consult — its
10
- // own job record, its own kill-group, its own resumable session — so a killed
11
- // fleet costs in-flight turns, not sessions: resume each child by its session.
12
- //
13
- // This keeps faith with the 2.0 principle (asynchrony is the harness's job):
14
- // the harness still backgrounds the whole `gaslamp fleet …` as ONE blocking
15
- // call and is notified when it finishes. The fleet just does the bounded
16
- // fan-out the harness would otherwise make you hand-roll across N background
17
- // tasks. It earns its place chiefly on Codex's behalf — Claude Code has the
18
- // Workflow tool, Codex has nothing, so `gaslamp fleet claude` is the only way
19
- // for Codex to fire a fleet of claudes from one command.
20
- //
21
- // Two things the fleet does that a single consult does not, both for safety at
22
- // N: it defaults the consults to `--sandbox read-only` (N write-capable agents
23
- // in one cwd is a race factory — pass --sandbox to opt into writes), and it
24
- // refuses a manifest that resumes the same session twice (they'd deadlock on
25
- // the session lock).
26
- //
27
- // Task sources (first that matches wins):
28
- // stdin (`-`, or piped with no prompt arg): one task per non-empty line. A
29
- // line starting with `{` is a JSON spec
30
- // {prompt, model?, sandbox?, cwd?, resume?, label?} (maps 1:1 to a Workflow
31
- // agent() call); any other line is a bare prompt. Fleet flags are the
32
- // default; a per-task field overrides.
33
- // prompt arg + -n N: replicate the prompt across N fresh sessions.
34
- // prompt arg alone: a fleet of one (a slow single consult; allowed).
35
-
36
- import { spawn } from "node:child_process";
37
- import { dirname, join } from "node:path";
38
- import { fileURLToPath } from "node:url";
39
- import { readFileSync, writeSync } from "node:fs";
40
- import {
41
- createFleet, liveStatus, looksLikeFleetId, newFleetId, readFleetMeta,
42
- readManifest, readMeta, readReply, writeFleetMeta,
43
- } from "./jobs.mjs";
44
- import { BACKEND_NAMES, isBackend } from "./backends.mjs";
45
- import { readThread } from "./threads.mjs";
46
- import { readStdin } from "./stdin.mjs";
47
-
48
- const GASLAMP_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "gaslamp.mjs");
49
- // Quota-shaped, not CPU-shaped: the bottleneck is API rate limits, not cores
50
- // (Workflow's min(16, cores-2) is wrong here). Raise it with --concurrency.
51
- const DEFAULT_CONCURRENCY = 4;
52
-
53
- const die = (code, msg) => { writeSync(2, `gaslamp: ${msg}\n`); process.exit(code); };
54
- const note = (msg) => writeSync(2, `${msg}\n`);
55
-
56
- const num = (flag, s) => {
57
- const n = Number(s);
58
- if (!Number.isInteger(n)) die(2, `${flag} needs an integer, got "${s}"`);
59
- return n;
60
- };
61
-
62
- function parseArgs(argv) {
63
- const o = { json: false, count: null, concurrency: DEFAULT_CONCURRENCY, prompt: null, stdin: false };
64
- for (let i = 0; i < argv.length; i++) {
65
- const a = argv[i];
66
- const val = () => { if (i + 1 >= argv.length) die(2, `${a} needs a value`); return argv[++i]; };
67
- if (a === "-n" || a === "--count") o.count = num(a, val());
68
- else if (a === "--concurrency" || a === "-j") o.concurrency = num(a, val());
69
- else if (a === "--model" || a === "-m") o.model = val();
70
- else if (a === "--sandbox" || a === "-s") o.sandbox = val();
71
- else if (a === "--effort" || a === "-e") o.effort = val();
72
- else if (a === "--cwd" || a === "-C") o.cwd = val();
73
- else if (a === "--schema") o.schema = val();
74
- else if (a === "--raw") o.raw = true;
75
- else if (a === "--json") o.json = true;
76
- else if (a === "--stream") o.stream = true;
77
- else if (a === "-") o.stdin = true;
78
- else if (a.startsWith("-") && a.length > 1) die(2, `unknown flag ${a} (see gaslamp --help)`);
79
- else if (o.prompt == null) o.prompt = a;
80
- else die(2, "more than one prompt argument — use -n to replicate, or a JSONL manifest on stdin");
81
- }
82
- if (o.concurrency < 1) die(2, "--concurrency must be >= 1");
83
- if (o.count != null && o.count < 1) die(2, "-n must be >= 1");
84
- return o;
85
- }
86
-
87
- // Flags + sources → a flat list of normalized specs, then a finalize pass that
88
- // stamps index + a stable label and rejects the two footguns (duplicate labels;
89
- // duplicate resume targets, which would deadlock on the session lock).
90
- function buildTasks(backend, o) {
91
- // Default to read-only unless the caller opts into writes (fleet-wide or
92
- // per-task). N writers in one cwd race; one consult deferring to config is
93
- // fine, N is not.
94
- const baseSandbox = o.sandbox ?? "read-only";
95
- const norm = (spec, i) => {
96
- if (typeof spec === "string") spec = { prompt: spec };
97
- if (!spec || typeof spec.prompt !== "string" || !spec.prompt.trim())
98
- die(2, `task ${i + 1} has no prompt`);
99
- // A manifest `schema` may be an inline JSON object (the natural way to
100
- // write it on a JSONL line) — normalize to the string the CLI flag takes.
101
- let schema = spec.schema ?? o.schema ?? null;
102
- if (schema != null && typeof schema === "object") schema = JSON.stringify(schema);
103
- return {
104
- prompt: spec.prompt,
105
- model: spec.model ?? o.model,
106
- sandbox: spec.sandbox ?? baseSandbox,
107
- effort: spec.effort ?? o.effort,
108
- cwd: spec.cwd ?? o.cwd,
109
- resume: spec.resume ?? null,
110
- thread: spec.thread ?? null,
111
- label: spec.label ?? null,
112
- schema,
113
- raw: spec.raw ?? o.raw ?? false,
114
- };
115
- };
116
-
117
- let raw;
118
- const stdinRequested = o.stdin || (o.prompt == null && !process.stdin.isTTY);
119
- if (stdinRequested) {
120
- const lines = readStdin().split("\n").map((l) => l.trim()).filter(Boolean);
121
- if (!lines.length) die(2, "no tasks on stdin");
122
- raw = lines.map((line, i) => norm(line.startsWith("{") ? parseJsonLine(line, i) : line, i));
123
- } else {
124
- if (o.prompt == null) die(2, "no tasks — pass a prompt with -n N, or a JSONL manifest on stdin");
125
- // Prompt arg + piped stdin: stdin is evidence for every replica, same
126
- // <stdin> convention as a single consult (git diff | gaslamp fleet …).
127
- let prompt = o.prompt;
128
- if (!process.stdin.isTTY) {
129
- const evidence = readStdin({ evidence: true });
130
- if (evidence.trim())
131
- prompt += "\n\n<stdin>\n" + (evidence.endsWith("\n") ? evidence : evidence + "\n") + "</stdin>";
132
- }
133
- raw = Array.from({ length: o.count ?? 1 }, () => norm({ prompt }, 0));
134
- }
135
-
136
- const width = String(raw.length).length;
137
- const seenLabel = new Set(), seenResume = new Set(), seenThread = new Set();
138
- raw.forEach((t, i) => {
139
- t.index = i;
140
- if (t.label == null) t.label = `task-${String(i + 1).padStart(width, "0")}`;
141
- else if (seenLabel.has(t.label)) die(2, `duplicate task label "${t.label}" — labels must be unique`);
142
- seenLabel.add(t.label);
143
- if (t.thread) {
144
- if (t.resume) die(2, `task ${i + 1} has both thread and resume — a thread IS a resume handle`);
145
- if (seenThread.has(t.thread))
146
- die(2, `two tasks use the same thread (${t.thread}) — they would deadlock on the session lock (or race to bind it); give each its own thread`);
147
- seenThread.add(t.thread);
148
- // a BOUND thread is a resume in disguise: feed its session into the
149
- // duplicate-resume check so thread-vs-resume collisions are caught too
150
- const bound = readThread(backend, t.thread)?.sessionId;
151
- if (bound) t._threadSession = bound;
152
- }
153
- const target = t.resume ?? t._threadSession;
154
- if (target) {
155
- if (seenResume.has(target))
156
- die(2, `two tasks resume the same session (${target}) — they would deadlock on the session lock; give each its own session`);
157
- seenResume.add(target);
158
- }
159
- });
160
- return raw;
161
- }
162
-
163
- function parseJsonLine(line, i) {
164
- try { return JSON.parse(line); }
165
- catch (e) { die(2, `task ${i + 1} is not valid JSON: ${e.message}`); }
166
- }
167
-
168
- // argv for one child `gaslamp <backend> --json …`. Prompt rides on stdin (no
169
- // argv-size or quoting traps), exactly as a hand-run consult would.
170
- function childArgs(backend, task) {
171
- const args = [GASLAMP_BIN, backend, "--json"];
172
- if (task.resume) args.push("--resume", task.resume);
173
- if (task.thread) args.push("--thread", task.thread);
174
- if (task.model) args.push("--model", task.model);
175
- if (task.sandbox) args.push("--sandbox", task.sandbox);
176
- if (task.effort) args.push("--effort", task.effort);
177
- if (task.cwd) args.push("--cwd", task.cwd);
178
- if (task.schema) args.push("--schema", task.schema);
179
- if (task.raw) args.push("--raw");
180
- // the child records the fleet's label too, so `gaslamp jobs` correlates
181
- args.push("--label", task.label, "-");
182
- return args;
183
- }
184
-
185
- const tail = (s, n = 6) => (s || "").trim().split("\n").slice(-n).join("\n");
186
-
187
- // Spawn one child consult; resolve with an enriched result. Never rejects — a
188
- // launch/parse failure resolves to a failed result so one bad task can't sink
189
- // the fleet. `onStart(jobId)` fires the moment the child reports its id (the
190
- // gated "started" receipt), so the parent record names in-flight children even
191
- // if the fleet is killed before they finish.
192
- function runChild(backend, task, live, onStart) {
193
- return new Promise((resolve) => {
194
- const child = spawn(process.execPath, childArgs(backend, task), {
195
- stdio: ["pipe", "pipe", "pipe"], detached: false,
196
- // GASLAMP_FLEET makes the child emit the early started receipt. It is NOT
197
- // nested (a fleet sits where a hand-run consult sits), so no GASLAMP_NESTED.
198
- env: { ...process.env, GASLAMP_FLEET: "1" },
199
- });
200
- live.add(child);
201
-
202
- let out = "", err = "", buf = "", envelope = null, started = false;
203
- child.stdout.on("data", (d) => {
204
- out += d;
205
- buf += d.toString();
206
- let nl;
207
- while ((nl = buf.indexOf("\n")) >= 0) {
208
- const line = buf.slice(0, nl).trim();
209
- buf = buf.slice(nl + 1);
210
- if (!line) continue;
211
- let j; try { j = JSON.parse(line); } catch { continue; }
212
- if (j.type === "started" && !started) { started = true; onStart?.(j.jobId); }
213
- else if (j.backend && j.status) envelope = j; // the final consult envelope
214
- }
215
- });
216
- child.stderr.on("data", (d) => { err += d; });
217
- child.stdin.on("error", () => {});
218
- child.stdin.write(task.prompt);
219
- child.stdin.end();
220
-
221
- const fail = (extra) => resolve({
222
- index: task.index, label: task.label, jobId: extra.jobId ?? null, sessionId: null,
223
- status: "failed", exitCode: null, content: "", error: extra.error, stderrTail: tail(err),
224
- startedAt: null, endedAt: null,
225
- });
226
- child.on("error", (e) => { live.delete(child); fail({ error: e.message }); });
227
- child.on("close", () => {
228
- live.delete(child);
229
- if (!envelope) return fail({ error: tail(err) || "child produced no envelope" });
230
- const m = envelope.jobId ? readMeta(envelope.jobId) : null;
231
- resolve({
232
- index: task.index, label: task.label,
233
- jobId: envelope.jobId, sessionId: envelope.sessionId,
234
- status: envelope.status, exitCode: envelope.exitCode ?? null,
235
- content: envelope.content ?? "", error: envelope.status === "done" ? null : tail(err),
236
- stderrTail: envelope.status === "done" ? null : tail(err),
237
- startedAt: m?.startedAt ?? null, endedAt: m?.endedAt ?? null,
238
- ...("data" in envelope ? { data: envelope.data } : {}),
239
- ...(envelope.usage ? { usage: envelope.usage } : {}),
240
- });
241
- });
242
- });
243
- }
244
-
245
- // Bounded worker pool, preserving input order in the results array.
246
- async function pool(items, limit, worker) {
247
- const results = new Array(items.length);
248
- let next = 0;
249
- const run = async () => {
250
- while (true) {
251
- const i = next++;
252
- if (i >= items.length) return;
253
- results[i] = await worker(items[i], i);
254
- }
255
- };
256
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
257
- return results;
258
- }
259
-
260
- // A fleet of consults is still a consult: refuse if nested (a fleet OF fleets
261
- // is exactly the recursion we guard), and refuse a network-disabled sandbox.
262
- function guards() {
263
- if (process.env.GASLAMP_NESTED && !process.env.GASLAMP_ALLOW_RECURSION)
264
- die(3, "this agent is itself a gaslamp consult — a consult is one hop. (GASLAMP_ALLOW_RECURSION=1 opts out.)");
265
- if (process.env.CODEX_SANDBOX_NETWORK_DISABLED === "1")
266
- die(4, "network is disabled in this sandbox (CODEX_SANDBOX_NETWORK_DISABLED=1) — consulted agents could reach neither Anthropic nor OpenAI. Re-run with network access.");
267
- }
268
-
269
- const tryParse = (s) => { try { return JSON.parse(s); } catch { return null; } };
270
-
271
- // One result, machine shape (fleet --json results / --stream result lines).
272
- const shapeResult = (r) => ({
273
- index: r.index, label: r.label, jobId: r.jobId, sessionId: r.sessionId,
274
- status: r.status, exitCode: r.exitCode ?? null, content: r.content ?? "",
275
- error: r.error ?? null, stderrTail: r.stderrTail ?? null,
276
- startedAt: r.startedAt ?? null, endedAt: r.endedAt ?? null,
277
- ...(r.replayed ? { replayed: true } : {}),
278
- ...("data" in r ? { data: r.data } : {}),
279
- ...(r.usage ? { usage: r.usage } : {}),
280
- });
281
-
282
- // One result, human block.
283
- function renderBlock(backend, r) {
284
- const sid = r.sessionId ? ` · ${r.sessionId.slice(0, 8)}` : "";
285
- const lines = [`\n━━ [${r.label}] ${r.status}${r.replayed ? " (replayed)" : ""} · ${r.jobId ?? "?"}${sid} ━━`];
286
- if (r.content?.trim()) lines.push(r.content.trimEnd());
287
- else {
288
- lines.push(`(no reply — ${r.status})`);
289
- if (r.stderrTail) lines.push(r.stderrTail);
290
- if (r.sessionId) lines.push(`resume: gaslamp ${backend} --resume ${r.sessionId}`);
291
- }
292
- return lines.join("\n");
293
- }
294
-
295
- export async function runFleet(backend, argv) {
296
- if (backend === "--resume") return runFleetResume(argv[0] ?? null, argv.slice(1));
297
- if (!isBackend(backend))
298
- die(2, `fleet needs a backend: gaslamp fleet <${BACKEND_NAMES.join("|")}|--resume fl-…> … (got "${backend ?? ""}")`);
299
-
300
- const o = parseArgs(argv);
301
- guards();
302
- const tasks = buildTasks(backend, o);
303
- await driveFleet(backend, tasks, [], o, null);
304
- }
305
-
306
- // gaslamp fleet --resume <fleet-id> — finish an interrupted fleet, Workflow-
307
- // resume style: done children replay their recorded replies; a killed child
308
- // whose session was captured is resumed with a fixed "finish and deliver"
309
- // nudge (the session already holds the prompt and the partial work); anything
310
- // else — failed, sessionless, never started — reruns fresh from the stored
311
- // manifest. A new fleet record is written, pointing back at the old one.
312
- const RESUME_NUDGE =
313
- "The previous consult turn was interrupted mid-flight. Finish the task and deliver the final reply.";
314
-
315
- async function runFleetResume(oldId, argv) {
316
- const o = parseArgs(argv);
317
- if (o.prompt != null || o.count != null || o.stdin)
318
- die(2, "fleet --resume takes no prompt or manifest — it reruns the one stored with the fleet");
319
- guards();
320
- if (!oldId || !looksLikeFleetId(oldId)) die(2, `fleet --resume needs a fleet id (fl-…), got "${oldId ?? ""}"`);
321
- const fm = readFleetMeta(oldId);
322
- if (!fm) die(2, `no fleet record ${oldId}`);
323
- const manifest = readManifest(oldId);
324
- if (!manifest || manifest.some((t) => typeof t.prompt !== "string"))
325
- die(2, `fleet ${oldId} predates full-prompt manifests — resume its children individually (gaslamp poll ${oldId})`);
326
-
327
- const replayed = [], tasks = [];
328
- for (const t of manifest) {
329
- const child = (fm.children || [])[t.index];
330
- const m = child?.jobId ? readMeta(child.jobId) : null;
331
- const status = m ? liveStatus(m) : "missing";
332
- if (status === "running")
333
- die(2, `fleet ${oldId} still has a running child (${child.jobId}) — wait for it or kill it first`);
334
- if (status === "done") {
335
- const reply = readReply(m.id) ?? "";
336
- replayed.push({
337
- index: t.index, label: t.label, jobId: m.id, sessionId: m.sessionId ?? null,
338
- status: "done", exitCode: m.exitCode ?? null, content: reply,
339
- error: null, stderrTail: null, startedAt: m.startedAt ?? null, endedAt: m.endedAt ?? null,
340
- replayed: true,
341
- ...(m.usage ? { usage: m.usage } : {}),
342
- ...(m.schema ? { data: tryParse(reply) } : {}),
343
- });
344
- } else if ((status === "killed" || status === "stale") && (m?.sessionId || t.thread)) {
345
- // the interrupted turn is lost, the session is not: nudge it to conclude
346
- // (threads resume by name so the pointer keeps chasing forked ids)
347
- tasks.push({ ...t, prompt: RESUME_NUDGE, resume: t.thread ? null : m.sessionId, nudged: true });
348
- } else {
349
- tasks.push({ ...t });
350
- }
351
- }
352
- if (!tasks.length) note(`[gaslamp fleet] nothing left to run — all ${replayed.length} children were done; replaying`);
353
- await driveFleet(fm.backend, tasks, replayed, o, oldId);
354
- }
355
-
356
- // The engine under both entries: spawn the live tasks, merge in replays,
357
- // write the new fleet record through, emit results (streamed or collected).
358
- async function driveFleet(backend, tasks, replayed, o, resumedFrom) {
359
- const total = tasks.length + replayed.length;
360
- const concurrency = Math.max(1, Math.min(o.concurrency, tasks.length));
361
-
362
- const fleetId = newFleetId();
363
- const meta = {
364
- id: fleetId, kind: "fleet", backend,
365
- tasks: total, concurrency, resumedFrom,
366
- sandbox: o.sandbox ?? "read-only (default)",
367
- children: [], // [{ index, label, jobId, status }] — filled as they start/finish
368
- counts: { done: 0, failed: 0, killed: 0 },
369
- status: "running", pid: process.pid,
370
- startedAt: new Date().toISOString(), endedAt: null,
371
- };
372
- for (const r of replayed)
373
- meta.children[r.index] = { index: r.index, label: r.label, jobId: r.jobId, sessionId: r.sessionId, status: "done", replayed: true };
374
- createFleet(meta, tasks);
375
-
376
- note(`[gaslamp fleet] → ${backend} · ${total} task${total === 1 ? "" : "s"} · concurrency ${concurrency} · fleet ${fleetId}` +
377
- (resumedFrom ? ` · resumed from ${resumedFrom} (${replayed.length} replayed)` : ""));
378
- if (!o.sandbox && tasks.length && !resumedFrom)
379
- note(`[gaslamp fleet] consults run --sandbox read-only by default (N writers in one cwd race); pass --sandbox to change`);
380
-
381
- // ---- signals: forward to live children; each kills its own backend group ----
382
- // Children are NOT detached, so they share our group and a terminal SIGINT
383
- // reaches them too; we also forward explicitly for a harness signal sent to
384
- // our pid alone. Each child's own handler does the graceful backend teardown.
385
- const live = new Set();
386
- let killedBy = null;
387
- const forward = (sig) => { for (const c of live) { try { c.kill(sig); } catch {} } };
388
- for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
389
- process.on(sig, () => {
390
- if (killedBy) return forward("SIGKILL");
391
- killedBy = sig;
392
- note(`[gaslamp fleet] ${sig} — stopping ${live.size} in-flight consult${live.size === 1 ? "" : "s"}; each session survives, recover with --resume`);
393
- forward(sig);
394
- setTimeout(() => forward("SIGKILL"), 5000).unref();
395
- });
396
- }
397
-
398
- // ---- run ------------------------------------------------------------------
399
- // --stream prints each result the moment it lands (completion order; replays
400
- // first) instead of collecting until the end — the caller's harness can peek
401
- // partial output mid-run and start synthesizing early.
402
- const emitStream = (r) => {
403
- if (o.json) process.stdout.write(JSON.stringify({ type: "result", ...shapeResult(r) }) + "\n");
404
- else process.stdout.write(renderBlock(backend, r) + "\n");
405
- };
406
- if (o.stream) replayed.forEach(emitStream);
407
-
408
- let finished = 0;
409
- const liveResults = await pool(tasks, concurrency, async (task) => {
410
- const onStart = (jobId) => {
411
- meta.children[task.index] = { index: task.index, label: task.label, jobId, status: "running" };
412
- writeFleetMeta(meta); // write-through: a killed fleet's record still names its children
413
- };
414
- const r = await runChild(backend, task, live, onStart);
415
- finished++;
416
- meta.children[task.index] = { index: task.index, label: r.label, jobId: r.jobId, sessionId: r.sessionId, status: r.status };
417
- writeFleetMeta(meta);
418
- note(`[gaslamp fleet] ${r.status === "done" ? "✓" : "✗"} ${finished}/${tasks.length} · ${r.jobId ?? "?"} · ${r.label} · ${r.status}`);
419
- if (o.stream) emitStream(r);
420
- return r;
421
- });
422
-
423
- const results = [...replayed, ...liveResults].sort((a, b) => a.index - b.index);
424
- for (const r of results) {
425
- if (r.status === "done") meta.counts.done++;
426
- else if (r.status === "killed") meta.counts.killed++;
427
- else meta.counts.failed++;
428
- }
429
- meta.status = killedBy ? "killed" : meta.counts.done === total ? "done" : "failed";
430
- meta.endedAt = new Date().toISOString();
431
- writeFleetMeta(meta);
432
-
433
- // ---- output ----------------------------------------------------------------
434
- // Collected: manifest order (--json: one object; human: blocks + summary).
435
- // Streamed: results already emitted; close with a summary line only
436
- // (--json --stream is explicit JSONL — result lines, then fleet.done).
437
- if (o.json) {
438
- if (o.stream) {
439
- process.stdout.write(JSON.stringify({ type: "fleet.done", fleetId, backend, tasks: total, counts: meta.counts }) + "\n");
440
- } else {
441
- process.stdout.write(JSON.stringify({
442
- fleetId, backend, tasks: total, counts: meta.counts,
443
- ...(resumedFrom ? { resumedFrom } : {}),
444
- results: results.map(shapeResult),
445
- }) + "\n");
446
- }
447
- } else {
448
- const out = o.stream ? [] : results.map((r) => renderBlock(backend, r));
449
- out.push(`\n[gaslamp fleet] ${fleetId} · ${meta.counts.done}/${total} done` +
450
- (meta.counts.failed ? ` · ${meta.counts.failed} failed` : "") +
451
- (meta.counts.killed ? ` · ${meta.counts.killed} killed` : "") +
452
- ` · poll: gaslamp poll ${fleetId}`);
453
- process.stdout.write(out.join("\n") + "\n");
454
- }
455
-
456
- process.exitCode = meta.status === "done" ? 0 : 1;
457
- }
package/src/tail.mjs DELETED
@@ -1,104 +0,0 @@
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 — which grok's streaming-messages-json
6
- // mirrors — vs codex --json vs agy stream-json); this normalizes them all
7
- // into "what is it doing right now" lines and nothing more. `poll`, `jobs`,
8
- // and the durable records stay the product surface.
9
-
10
- import { readFileSync, writeSync } from "node:fs";
11
- import { join } from "node:path";
12
- import { jobDir, jobsDir, listJobIds, liveStatus, looksLikeFleetId, looksLikeJobId, readMeta, renderTrailer } from "./jobs.mjs";
13
-
14
- const die = (code, msg) => { writeSync(2, `gaslamp: ${msg}\n`); process.exit(code); };
15
-
16
- const short = (s) => String(s ?? "?").slice(0, 8);
17
- const preview = (v, n = 100) => {
18
- const s = (typeof v === "string" ? v : v == null ? "" : JSON.stringify(v)).replace(/\s+/g, " ").trim();
19
- return s.length > n ? s.slice(0, n - 1) + "…" : s;
20
- };
21
-
22
- // One event → one line (or several, or none — noise is dropped).
23
- function renderEvent(backend, j) {
24
- if (backend === "gemini") {
25
- if (j.event === "init")
26
- return [`init · conversation ${short(j.conversation_id)}${j.init?.model ? ` · ${j.init.model}` : ""}`];
27
- const st = j.step_update;
28
- if (st?.step_type === "tool" && st.state === "DONE")
29
- return [`tool → ${st.tool_name ?? "?"} ${preview(st.tool_info?.parameters, 80)}`];
30
- if (j.event === "result")
31
- return [`result · ${j.result?.status ?? "?"}${j.result?.error ? ` · ${preview(j.result.error, 80)}` : ""}`];
32
- return []; // text deltas, step bookkeeping
33
- }
34
- if (backend === "claude" || backend === "grok") {
35
- if (j.type === "system" && j.subtype === "init")
36
- return [`init · session ${short(j.session_id)}${j.model ? ` · ${j.model}` : ""}`];
37
- if (j.type === "assistant" || j.type === "user") {
38
- const lines = [];
39
- for (const p of j.message?.content ?? []) {
40
- if (p.type === "tool_use" || p.type === "server_tool_use") lines.push(`tool → ${p.name} ${preview(p.input, 80)}`);
41
- else if (p.type === "text" && p.text?.trim()) lines.push(`text · ${preview(p.text)}`);
42
- else if (p.type === "tool_result") lines.push(`tool ← ${preview(p.content, 80)}`);
43
- }
44
- return lines;
45
- }
46
- if (j.type === "result")
47
- return [`result · ${j.is_error ? "error" : "ok"}${j.total_cost_usd != null ? ` · $${j.total_cost_usd.toFixed(2)}` : ""}`];
48
- return []; // rate limits, hook responses, stream noise
49
- }
50
- // codex
51
- if (j.type === "thread.started") return [`init · thread ${short(j.thread_id ?? j.threadId)}`];
52
- if (j.type === "turn.completed")
53
- return [`turn done${j.usage ? ` · tok ${j.usage.input_tokens ?? 0}→${j.usage.output_tokens ?? 0}` : ""}`];
54
- if (j.type === "item.completed") {
55
- const it = j.item ?? {};
56
- const t = it.type ?? it.item_type;
57
- if (t === "agent_message") return [`text · ${preview(it.text)}`];
58
- if (t === "reasoning") return [`reasoning · ${preview(it.text ?? it.summary ?? "")}`];
59
- if (t === "command_execution")
60
- return [`tool → ${preview(it.command, 80)}${it.exit_code != null ? ` (exit ${it.exit_code})` : ""}`];
61
- return t ? [String(t)] : [];
62
- }
63
- return [];
64
- }
65
-
66
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
67
-
68
- export async function runTail(argv = []) {
69
- let id = argv.find((a) => !a.startsWith("-"));
70
- if (!id || argv.includes("--last")) id = listJobIds()[0];
71
- if (!id) die(2, `tail: no jobs under ${jobsDir()}`);
72
- if (looksLikeFleetId(id)) die(2, "tail follows one consult — point it at a child job id (gaslamp poll <fleet-id> lists them)");
73
- if (!looksLikeJobId(id)) die(2, `tail: "${id}" is not a job id`);
74
- const m0 = readMeta(id);
75
- if (!m0) die(2, `tail: no job record ${id}`);
76
-
77
- const path = join(jobDir(id), "events.jsonl");
78
- let seen = 0; // chars consumed; events files are small — reread is fine
79
- const drain = (backend) => {
80
- let text = "";
81
- try { text = readFileSync(path, "utf8"); } catch { return; }
82
- const fresh = text.slice(seen);
83
- const complete = fresh.lastIndexOf("\n");
84
- if (complete < 0) return;
85
- seen += complete + 1;
86
- for (const line of fresh.slice(0, complete).split("\n")) {
87
- if (!line.trim()) continue;
88
- let j; try { j = JSON.parse(line); } catch { continue; }
89
- for (const out of renderEvent(backend, j)) process.stdout.write(out + "\n");
90
- }
91
- };
92
-
93
- let meta = m0, status = liveStatus(m0);
94
- drain(meta.backend);
95
- while (status === "running") {
96
- await sleep(400);
97
- meta = readMeta(id) ?? meta;
98
- status = liveStatus(meta);
99
- drain(meta.backend);
100
- }
101
- drain(meta.backend); // final flush after the record settled
102
- process.stdout.write(renderTrailer(meta, status) + "\n");
103
- process.exitCode = status === "done" ? 0 : 1;
104
- }