gaslamp 2.2.2 → 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 -283
- 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 +336 -500
- package/src/jobs.mjs +79 -171
- package/src/setup.mjs +40 -163
- package/src/stdin.mjs +38 -10
- package/src/threads.mjs +13 -14
- package/src/fleet.mjs +0 -457
- package/src/tail.mjs +0 -104
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
|
}
|
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();
|
|
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
|
-
}
|