cyberlegion 0.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/bin/cyberlegion.mjs +6 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +720 -0
- package/dist/index.d.mts +656 -0
- package/dist/index.mjs +28 -0
- package/dist/watch-DA8I44dv.mjs +1403 -0
- package/package.json +61 -0
|
@@ -0,0 +1,1403 @@
|
|
|
1
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
//#region src/identity.ts
|
|
7
|
+
const realExec = (cmd, args) => {
|
|
8
|
+
try {
|
|
9
|
+
return execFileSync(cmd, args, {
|
|
10
|
+
encoding: "utf8",
|
|
11
|
+
stdio: [
|
|
12
|
+
"ignore",
|
|
13
|
+
"pipe",
|
|
14
|
+
"ignore"
|
|
15
|
+
]
|
|
16
|
+
}).trim();
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const nowIso = (ctx) => new Date(ctx.now?.() ?? Date.now()).toISOString();
|
|
22
|
+
function randomId() {
|
|
23
|
+
return randomBytes(8).toString("hex");
|
|
24
|
+
}
|
|
25
|
+
function loadAgent(store, id) {
|
|
26
|
+
return store.getAgent(id);
|
|
27
|
+
}
|
|
28
|
+
function saveAgent(store, rec) {
|
|
29
|
+
store.putAgent(rec);
|
|
30
|
+
}
|
|
31
|
+
function listAgents(store) {
|
|
32
|
+
return store.listAgents();
|
|
33
|
+
}
|
|
34
|
+
/** Detect the harness: explicit wins, then env probes, then the tmux pane command. */
|
|
35
|
+
function detectHarness(explicit, ctx) {
|
|
36
|
+
if (explicit) {
|
|
37
|
+
if (explicit === "claude" || explicit === "cursor" || explicit === "codex") return explicit;
|
|
38
|
+
throw new Error(`unknown --harness "${explicit}" (expected claude | cursor | codex)`);
|
|
39
|
+
}
|
|
40
|
+
const env = ctx.env ?? process.env;
|
|
41
|
+
if (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT) return "claude";
|
|
42
|
+
if (Object.keys(env).some((k) => k.startsWith("CURSOR"))) return "cursor";
|
|
43
|
+
if (Object.keys(env).some((k) => k.startsWith("CODEX"))) return "codex";
|
|
44
|
+
const pane = env.TMUX_PANE;
|
|
45
|
+
if (pane) {
|
|
46
|
+
const cmd = (ctx.exec ?? realExec)("tmux", [
|
|
47
|
+
"display-message",
|
|
48
|
+
"-p",
|
|
49
|
+
"-t",
|
|
50
|
+
pane,
|
|
51
|
+
"#{pane_current_command}"
|
|
52
|
+
]);
|
|
53
|
+
if (cmd?.includes("cursor")) return "cursor";
|
|
54
|
+
if (cmd?.includes("codex")) return "codex";
|
|
55
|
+
if (cmd?.includes("claude")) return "claude";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Recover the calling agent's own id. In tmux the pane index is authoritative — a pane with no
|
|
60
|
+
* pane entry is simply unregistered and must NOT adopt `$CYBERLEGION_AGENT_ID` (that env fallback
|
|
61
|
+
* applies only when there is no `$TMUX_PANE`). There is no shared bare "self" file — self-id is
|
|
62
|
+
* always pane-keyed or explicit via the env var.
|
|
63
|
+
*/
|
|
64
|
+
function resolveSelfId(ctx) {
|
|
65
|
+
const env = ctx.env ?? process.env;
|
|
66
|
+
const pane = env.TMUX_PANE;
|
|
67
|
+
if (pane) return ctx.store.resolvePaneId(pane);
|
|
68
|
+
return env.CYBERLEGION_AGENT_ID || void 0;
|
|
69
|
+
}
|
|
70
|
+
/** Register (or idempotently refresh) this session's identity. */
|
|
71
|
+
function register(ctx, input) {
|
|
72
|
+
ctx.store.ensureMarker();
|
|
73
|
+
const env = ctx.env ?? process.env;
|
|
74
|
+
const harness = detectHarness(input.harness, ctx);
|
|
75
|
+
if (!harness) throw new Error("could not detect harness — pass --harness claude|cursor|codex");
|
|
76
|
+
const existingId = resolveSelfId(ctx);
|
|
77
|
+
const existing = existingId ? loadAgent(ctx.store, existingId) : void 0;
|
|
78
|
+
const id = existing?.id ?? existingId ?? randomId();
|
|
79
|
+
const ts = nowIso(ctx);
|
|
80
|
+
const pane = env.TMUX_PANE;
|
|
81
|
+
const exec = ctx.exec ?? realExec;
|
|
82
|
+
const rec = {
|
|
83
|
+
id,
|
|
84
|
+
handle: input.handle ?? existing?.handle ?? id.slice(0, 6),
|
|
85
|
+
harness,
|
|
86
|
+
cwd: process.cwd(),
|
|
87
|
+
worktree: existing?.worktree ?? gitWorktree(exec),
|
|
88
|
+
tmux: pane ? {
|
|
89
|
+
pane,
|
|
90
|
+
window: exec("tmux", [
|
|
91
|
+
"display-message",
|
|
92
|
+
"-p",
|
|
93
|
+
"-t",
|
|
94
|
+
pane,
|
|
95
|
+
"#{window_id}"
|
|
96
|
+
]) ?? void 0,
|
|
97
|
+
session: env.TMUX?.split(",")[0]
|
|
98
|
+
} : null,
|
|
99
|
+
status: "active",
|
|
100
|
+
createdAt: existing?.createdAt ?? ts,
|
|
101
|
+
lastSeen: ts,
|
|
102
|
+
...existing?.brief ? { brief: existing.brief } : {},
|
|
103
|
+
...existing?.spawnedBy ? { spawnedBy: existing.spawnedBy } : {}
|
|
104
|
+
};
|
|
105
|
+
saveAgent(ctx.store, rec);
|
|
106
|
+
if (pane) ctx.store.putPaneIndex(pane, id);
|
|
107
|
+
return rec;
|
|
108
|
+
}
|
|
109
|
+
function gitWorktree(exec) {
|
|
110
|
+
const root = exec("git", ["rev-parse", "--show-toplevel"]);
|
|
111
|
+
if (!root) return null;
|
|
112
|
+
return {
|
|
113
|
+
root,
|
|
114
|
+
branch: exec("git", [
|
|
115
|
+
"rev-parse",
|
|
116
|
+
"--abbrev-ref",
|
|
117
|
+
"HEAD"
|
|
118
|
+
]) ?? void 0
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Resolve a recipient argument (id or handle) to an agent id. */
|
|
122
|
+
function resolveRecipient(store, to) {
|
|
123
|
+
if (loadAgent(store, to)) return to;
|
|
124
|
+
const match = listAgents(store).find((a) => a.handle === to);
|
|
125
|
+
if (!match) throw new Error(`no agent addressable as "${to}"`);
|
|
126
|
+
return match.id;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Resolve a unit reference by id, handle, or its worktree branch (the unit↔CR join key: an
|
|
130
|
+
* `AgentRecord.worktree.branch` equals the SDD `<cr-ref>` it maps to, when spawned for one). Used
|
|
131
|
+
* by verbs that address "the unit working on CR X" as well as "the unit named X".
|
|
132
|
+
*/
|
|
133
|
+
function resolveAgent(store, ref) {
|
|
134
|
+
const byId = loadAgent(store, ref);
|
|
135
|
+
if (byId) return byId;
|
|
136
|
+
const agents = listAgents(store);
|
|
137
|
+
const byHandle = agents.find((a) => a.handle === ref);
|
|
138
|
+
if (byHandle) return byHandle;
|
|
139
|
+
const byBranch = agents.find((a) => a.worktree?.branch === ref);
|
|
140
|
+
if (byBranch) return byBranch;
|
|
141
|
+
throw new Error(`no agent addressable as "${ref}" (tried id, handle, and worktree branch/CR)`);
|
|
142
|
+
}
|
|
143
|
+
function bumpLastSeen(ctx, id) {
|
|
144
|
+
const rec = loadAgent(ctx.store, id);
|
|
145
|
+
if (!rec) return;
|
|
146
|
+
rec.lastSeen = nowIso(ctx);
|
|
147
|
+
saveAgent(ctx.store, rec);
|
|
148
|
+
}
|
|
149
|
+
/** Refresh this session's own last-seen if it is registered — best-effort, never throws. */
|
|
150
|
+
function touch(ctx) {
|
|
151
|
+
const id = resolveSelfId(ctx);
|
|
152
|
+
if (id) bumpLastSeen(ctx, id);
|
|
153
|
+
}
|
|
154
|
+
const STALE_MS = 900 * 1e3;
|
|
155
|
+
/** Mark agents whose pane is gone or whose last-seen is stale as exited. */
|
|
156
|
+
function prune(ctx) {
|
|
157
|
+
const exec = ctx.exec ?? realExec;
|
|
158
|
+
const now = ctx.now?.() ?? Date.now();
|
|
159
|
+
const changed = [];
|
|
160
|
+
for (const rec of listAgents(ctx.store)) {
|
|
161
|
+
if (rec.status === "exited") continue;
|
|
162
|
+
const paneGone = rec.tmux?.pane ? exec("tmux", [
|
|
163
|
+
"has-session",
|
|
164
|
+
"-t",
|
|
165
|
+
rec.tmux.pane
|
|
166
|
+
]) === null && !(exec("tmux", [
|
|
167
|
+
"list-panes",
|
|
168
|
+
"-a",
|
|
169
|
+
"-F",
|
|
170
|
+
"#{pane_id}"
|
|
171
|
+
]) ?? "").split("\n").includes(rec.tmux.pane) : false;
|
|
172
|
+
const stale = now - new Date(rec.lastSeen).getTime() > STALE_MS;
|
|
173
|
+
if (paneGone || stale) {
|
|
174
|
+
rec.status = "exited";
|
|
175
|
+
saveAgent(ctx.store, rec);
|
|
176
|
+
changed.push(rec);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return changed;
|
|
180
|
+
}
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/console/mux-probe.ts
|
|
183
|
+
const KNOWN_MUX = [
|
|
184
|
+
"tmux",
|
|
185
|
+
"herdr",
|
|
186
|
+
"screen",
|
|
187
|
+
"none"
|
|
188
|
+
];
|
|
189
|
+
function isKnownMux(v) {
|
|
190
|
+
return v != null && KNOWN_MUX.includes(v);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Two-mode multiplexer detection.
|
|
194
|
+
*
|
|
195
|
+
* Fast-path: `$CYBERLEGION_MUX` (tmux | herdr | screen | none) is trusted outright — this also
|
|
196
|
+
* serves as an OVERRIDE (`=none` forces no-mux even inside a real multiplexer). `$CYBERLEGION_MUX_PANE`
|
|
197
|
+
* carries the pane id alongside it.
|
|
198
|
+
*
|
|
199
|
+
* Discovery (else): walk the process ancestry from `$$` via `ps -o ppid=,comm= -p <pid>`, since the
|
|
200
|
+
* tool's own shell may not be the human's pane. `$TMUX`/`$HERDR_ENV` are NOT trusted alone — they
|
|
201
|
+
* are used only as a fast-positive hint the ancestry walk falls back to when the walk itself is
|
|
202
|
+
* inconclusive (e.g. `ps` unavailable), never as a substitute for it.
|
|
203
|
+
*/
|
|
204
|
+
function probeMultiplexer(exec, env, opts = {}) {
|
|
205
|
+
if (isKnownMux(env.CYBERLEGION_MUX)) return {
|
|
206
|
+
mux: env.CYBERLEGION_MUX,
|
|
207
|
+
...env.CYBERLEGION_MUX_PANE ? { pane: env.CYBERLEGION_MUX_PANE } : {},
|
|
208
|
+
via: "env"
|
|
209
|
+
};
|
|
210
|
+
if (opts.discover === false) return {
|
|
211
|
+
mux: "none",
|
|
212
|
+
via: "ancestry"
|
|
213
|
+
};
|
|
214
|
+
return discoverByAncestry(exec, env);
|
|
215
|
+
}
|
|
216
|
+
const MUX_COMM = [
|
|
217
|
+
{
|
|
218
|
+
re: /^tmux(:|$)/,
|
|
219
|
+
mux: "tmux",
|
|
220
|
+
pane: (env) => env.TMUX_PANE
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
re: /^herdr(:|$)/,
|
|
224
|
+
mux: "herdr",
|
|
225
|
+
pane: (env) => env.HERDR_PANE
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
re: /^screen(:|$)/,
|
|
229
|
+
mux: "screen",
|
|
230
|
+
pane: () => void 0
|
|
231
|
+
}
|
|
232
|
+
];
|
|
233
|
+
const MAX_ANCESTORS = 32;
|
|
234
|
+
function walkAncestry(exec, env) {
|
|
235
|
+
let pid = process.pid;
|
|
236
|
+
const seen = /* @__PURE__ */ new Set();
|
|
237
|
+
for (let i = 0; i < MAX_ANCESTORS; i++) {
|
|
238
|
+
if (seen.has(pid)) break;
|
|
239
|
+
seen.add(pid);
|
|
240
|
+
const line = exec("ps", [
|
|
241
|
+
"-o",
|
|
242
|
+
"ppid=,comm=",
|
|
243
|
+
"-p",
|
|
244
|
+
String(pid)
|
|
245
|
+
]);
|
|
246
|
+
if (!line) break;
|
|
247
|
+
const trimmed = line.trim();
|
|
248
|
+
const spaceIdx = trimmed.indexOf(" ");
|
|
249
|
+
const ppidStr = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
|
|
250
|
+
const comm = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
|
|
251
|
+
const ppid = Number.parseInt(ppidStr, 10);
|
|
252
|
+
for (const entry of MUX_COMM) if (entry.re.test(comm)) return {
|
|
253
|
+
mux: entry.mux,
|
|
254
|
+
pane: entry.pane(env),
|
|
255
|
+
via: "ancestry"
|
|
256
|
+
};
|
|
257
|
+
if (!Number.isFinite(ppid) || ppid <= 1) break;
|
|
258
|
+
pid = ppid;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function discoverByAncestry(exec, env) {
|
|
262
|
+
const found = walkAncestry(exec, env);
|
|
263
|
+
if (found) return found;
|
|
264
|
+
if (env.TMUX) return {
|
|
265
|
+
mux: "tmux",
|
|
266
|
+
pane: env.TMUX_PANE,
|
|
267
|
+
via: "ancestry"
|
|
268
|
+
};
|
|
269
|
+
if (env.HERDR_ENV) return {
|
|
270
|
+
mux: "herdr",
|
|
271
|
+
pane: env.HERDR_PANE,
|
|
272
|
+
via: "ancestry"
|
|
273
|
+
};
|
|
274
|
+
return {
|
|
275
|
+
mux: "none",
|
|
276
|
+
via: "ancestry"
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
//#endregion
|
|
280
|
+
//#region src/console/session.herdr.ts
|
|
281
|
+
/**
|
|
282
|
+
* herdr backend — detected via `$HERDR_ENV`. herdr (https://herdr.dev) is an agent-aware terminal
|
|
283
|
+
* multiplexer that also reports real busy-state (working / idle / blocked / done); this adapter
|
|
284
|
+
* only drives its pane lifecycle, not the state feed. Talks to herdr's own CLI (`herdr pane ...`)
|
|
285
|
+
* rather than its Unix-socket API, so it composes with this codebase's synchronous `Exec`
|
|
286
|
+
* convention exactly like the tmux adapter — no new client/transport needed.
|
|
287
|
+
*
|
|
288
|
+
* herdr is optional/experimental and is not installed in this environment; this follows herdr's
|
|
289
|
+
* documented CLI reference (split/run/read/close/focus) but is unverified against a live binary.
|
|
290
|
+
*/
|
|
291
|
+
const herdrSessionAdapter = {
|
|
292
|
+
name: "herdr",
|
|
293
|
+
open(exec, opts) {
|
|
294
|
+
const id = exec("herdr", [
|
|
295
|
+
"pane",
|
|
296
|
+
"split",
|
|
297
|
+
"--current",
|
|
298
|
+
"--direction",
|
|
299
|
+
opts.at === "pane:down" ? "down" : "right",
|
|
300
|
+
"--cwd",
|
|
301
|
+
opts.cwd
|
|
302
|
+
]);
|
|
303
|
+
if (!id) throw new Error("herdr pane split failed");
|
|
304
|
+
const target = { id };
|
|
305
|
+
exec("herdr", [
|
|
306
|
+
"pane",
|
|
307
|
+
"run",
|
|
308
|
+
id,
|
|
309
|
+
opts.launch
|
|
310
|
+
]);
|
|
311
|
+
return target;
|
|
312
|
+
},
|
|
313
|
+
send(exec, target, text) {
|
|
314
|
+
exec("herdr", [
|
|
315
|
+
"pane",
|
|
316
|
+
"run",
|
|
317
|
+
target.id,
|
|
318
|
+
text
|
|
319
|
+
]);
|
|
320
|
+
},
|
|
321
|
+
read(exec, target, opts) {
|
|
322
|
+
const args = [
|
|
323
|
+
"pane",
|
|
324
|
+
"read",
|
|
325
|
+
target.id,
|
|
326
|
+
"--source",
|
|
327
|
+
"visible"
|
|
328
|
+
];
|
|
329
|
+
if (opts?.lines != null) args.push("--lines", String(opts.lines));
|
|
330
|
+
return exec("herdr", args) ?? "";
|
|
331
|
+
},
|
|
332
|
+
focus(exec, target) {
|
|
333
|
+
exec("herdr", [
|
|
334
|
+
"pane",
|
|
335
|
+
"focus",
|
|
336
|
+
target.id
|
|
337
|
+
]);
|
|
338
|
+
},
|
|
339
|
+
teardown(exec, target) {
|
|
340
|
+
exec("herdr", [
|
|
341
|
+
"pane",
|
|
342
|
+
"close",
|
|
343
|
+
target.id
|
|
344
|
+
]);
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/console/session.tmux.ts
|
|
349
|
+
/** tmux backend — detected via `$TMUX`. */
|
|
350
|
+
const tmuxSessionAdapter = {
|
|
351
|
+
name: "tmux",
|
|
352
|
+
open(exec, opts) {
|
|
353
|
+
const pane = exec("tmux", opts.at === "pane:down" ? [
|
|
354
|
+
"split-window",
|
|
355
|
+
"-v",
|
|
356
|
+
"-c",
|
|
357
|
+
opts.cwd,
|
|
358
|
+
"-P",
|
|
359
|
+
"-F",
|
|
360
|
+
"#{pane_id}"
|
|
361
|
+
] : opts.at === "tab" || opts.at === "window" ? [
|
|
362
|
+
"new-window",
|
|
363
|
+
"-c",
|
|
364
|
+
opts.cwd,
|
|
365
|
+
"-P",
|
|
366
|
+
"-F",
|
|
367
|
+
"#{pane_id}"
|
|
368
|
+
] : [
|
|
369
|
+
"split-window",
|
|
370
|
+
"-h",
|
|
371
|
+
"-c",
|
|
372
|
+
opts.cwd,
|
|
373
|
+
"-P",
|
|
374
|
+
"-F",
|
|
375
|
+
"#{pane_id}"
|
|
376
|
+
]);
|
|
377
|
+
if (!pane) throw new Error("tmux split-window failed");
|
|
378
|
+
const target = { id: pane };
|
|
379
|
+
tmuxSessionAdapter.send(exec, target, opts.launch);
|
|
380
|
+
return target;
|
|
381
|
+
},
|
|
382
|
+
send(exec, target, text) {
|
|
383
|
+
exec("tmux", [
|
|
384
|
+
"send-keys",
|
|
385
|
+
"-t",
|
|
386
|
+
target.id,
|
|
387
|
+
text,
|
|
388
|
+
"Enter"
|
|
389
|
+
]);
|
|
390
|
+
},
|
|
391
|
+
read(exec, target, opts) {
|
|
392
|
+
const args = [
|
|
393
|
+
"capture-pane",
|
|
394
|
+
"-p",
|
|
395
|
+
"-t",
|
|
396
|
+
target.id
|
|
397
|
+
];
|
|
398
|
+
if (opts?.lines != null) args.push("-S", `-${opts.lines}`);
|
|
399
|
+
return exec("tmux", args) ?? "";
|
|
400
|
+
},
|
|
401
|
+
focus(exec, target) {
|
|
402
|
+
exec("tmux", [
|
|
403
|
+
"select-pane",
|
|
404
|
+
"-t",
|
|
405
|
+
target.id
|
|
406
|
+
]);
|
|
407
|
+
},
|
|
408
|
+
teardown(exec, target) {
|
|
409
|
+
exec("tmux", [
|
|
410
|
+
"kill-pane",
|
|
411
|
+
"-t",
|
|
412
|
+
target.id
|
|
413
|
+
]);
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/console/index.ts
|
|
418
|
+
/**
|
|
419
|
+
* Backend selection via the two-mode mux probe (`$CYBERLEGION_MUX` fast-path/override, else
|
|
420
|
+
* ancestry discovery from `$$` falling back to the `$TMUX`/`$HERDR_ENV` hint when the walk is
|
|
421
|
+
* inconclusive) — tmux/herdr map to their existing adapters; anything else is a clear error.
|
|
422
|
+
*/
|
|
423
|
+
function selectSessionAdapter(env, exec = realExec) {
|
|
424
|
+
const probe = probeMultiplexer(exec, env);
|
|
425
|
+
if (probe.mux === "tmux") return tmuxSessionAdapter;
|
|
426
|
+
if (probe.mux === "herdr") return herdrSessionAdapter;
|
|
427
|
+
throw new Error("spawn requires a session backend — run inside tmux ($TMUX) or herdr ($HERDR_ENV=1)");
|
|
428
|
+
}
|
|
429
|
+
//#endregion
|
|
430
|
+
//#region src/console/worktree.ts
|
|
431
|
+
/** The only worktree backend at MVP — plain `git worktree`. */
|
|
432
|
+
const gitWorktreeAdapter = {
|
|
433
|
+
add(exec, opts) {
|
|
434
|
+
if (exec("git", [
|
|
435
|
+
"-C",
|
|
436
|
+
opts.primaryRoot,
|
|
437
|
+
"worktree",
|
|
438
|
+
"add",
|
|
439
|
+
"-b",
|
|
440
|
+
opts.branch,
|
|
441
|
+
opts.path
|
|
442
|
+
]) === null) throw new Error(`git worktree add failed for ${opts.path}`);
|
|
443
|
+
return {
|
|
444
|
+
root: resolve(opts.path),
|
|
445
|
+
branch: opts.branch
|
|
446
|
+
};
|
|
447
|
+
},
|
|
448
|
+
remove(exec, path, opts) {
|
|
449
|
+
if (exec("git", [
|
|
450
|
+
"-C",
|
|
451
|
+
opts.primaryRoot,
|
|
452
|
+
"worktree",
|
|
453
|
+
"remove",
|
|
454
|
+
path,
|
|
455
|
+
"--force"
|
|
456
|
+
]) === null) throw new Error(`git worktree remove failed for ${path}`);
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
/**
|
|
460
|
+
* Resolve the primary checkout's root regardless of whether the caller's cwd is the primary
|
|
461
|
+
* checkout or a linked worktree — `--git-common-dir` always points at the main repo's `.git`.
|
|
462
|
+
*/
|
|
463
|
+
function resolvePrimaryRoot(exec) {
|
|
464
|
+
const commonDir = exec("git", [
|
|
465
|
+
"rev-parse",
|
|
466
|
+
"--path-format=absolute",
|
|
467
|
+
"--git-common-dir"
|
|
468
|
+
]);
|
|
469
|
+
if (!commonDir) throw new Error("cannot resolve the primary checkout — not inside a git repository");
|
|
470
|
+
return dirname(commonDir);
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Refuse the primary checkout: a spawned unit's resolved worktree root must never be the primary
|
|
474
|
+
* checkout itself. A unit is never run in the primary checkout.
|
|
475
|
+
*/
|
|
476
|
+
function assertDistinctFromPrimary(worktreeRoot, primaryRoot) {
|
|
477
|
+
if (resolve(worktreeRoot) === resolve(primaryRoot)) throw new Error("refusing to run a unit in the primary checkout — spawn a worktree distinct from the primary checkout");
|
|
478
|
+
}
|
|
479
|
+
//#endregion
|
|
480
|
+
//#region src/paths.ts
|
|
481
|
+
/** The tracked marker file that makes a hub root initialized (see ensureMarker). */
|
|
482
|
+
const MARKER_FILE = "config.json";
|
|
483
|
+
/** Walk up from `cwd` to the nearest git repo root; fall back to `cwd`. */
|
|
484
|
+
function projectRoot(cwd = process.cwd()) {
|
|
485
|
+
let dir = resolve(cwd);
|
|
486
|
+
for (;;) {
|
|
487
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
488
|
+
const parent = dirname(dir);
|
|
489
|
+
if (parent === dir) return resolve(cwd);
|
|
490
|
+
dir = parent;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function makeDefaultExec(cwd) {
|
|
494
|
+
return (cmd, args) => {
|
|
495
|
+
try {
|
|
496
|
+
return execFileSync(cmd, args, {
|
|
497
|
+
encoding: "utf8",
|
|
498
|
+
stdio: [
|
|
499
|
+
"ignore",
|
|
500
|
+
"pipe",
|
|
501
|
+
"ignore"
|
|
502
|
+
],
|
|
503
|
+
cwd
|
|
504
|
+
}).trim();
|
|
505
|
+
} catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Resolve the cyberlegion hub root. Precedence: explicit --root/--space, then `$CYBERLEGION_ROOT`,
|
|
512
|
+
* then the GLOBAL hub `~/.agents/cyberlegion` (addressable across every project and worktree
|
|
513
|
+
* boundary — identity/mail/registry state lives here by default; `--space` isolates it), falling
|
|
514
|
+
* back to a project-local `.agents/cyberlegion` only when no home directory is resolvable.
|
|
515
|
+
*/
|
|
516
|
+
function resolveRoot(opts = {}) {
|
|
517
|
+
const env = opts.env ?? process.env;
|
|
518
|
+
const explicit = opts.root ?? opts.space ?? env.CYBERLEGION_ROOT;
|
|
519
|
+
if (explicit) return resolve(explicit);
|
|
520
|
+
const home = homedir();
|
|
521
|
+
if (home) return join(home, ".agents", "cyberlegion");
|
|
522
|
+
return join(projectRoot(opts.cwd), ".agents", "cyberlegion");
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Resolve the primary checkout's project-local cyberlegion dir — where spawned unit worktrees
|
|
526
|
+
* live (never the global hub), keyed off `git worktree`'s common-dir so it resolves the same from
|
|
527
|
+
* the primary checkout or from any of its linked worktrees.
|
|
528
|
+
*/
|
|
529
|
+
function resolveProjectLocalRoot(opts = {}) {
|
|
530
|
+
const exec = opts.exec ?? makeDefaultExec(opts.cwd);
|
|
531
|
+
let primary;
|
|
532
|
+
try {
|
|
533
|
+
primary = resolvePrimaryRoot(exec);
|
|
534
|
+
} catch {
|
|
535
|
+
primary = void 0;
|
|
536
|
+
}
|
|
537
|
+
return join(primary ?? projectRoot(opts.cwd), ".agents", "cyberlegion");
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Create the tracked `config.json` marker at `root` (a hub root) if it does not already exist.
|
|
541
|
+
* Callers pass the root under which the marker should be created/ensured; this mkdir's it as
|
|
542
|
+
* needed. Idempotent — never overwrites an existing marker.
|
|
543
|
+
*/
|
|
544
|
+
function ensureMarker(root) {
|
|
545
|
+
mkdirSync(root, { recursive: true });
|
|
546
|
+
const marker = join(root, MARKER_FILE);
|
|
547
|
+
if (!existsSync(marker)) writeFileSync(marker, `${JSON.stringify({ version: 1 }, null, 2)}\n`);
|
|
548
|
+
}
|
|
549
|
+
const paths = {
|
|
550
|
+
agentsDir: (root) => join(root, "agents"),
|
|
551
|
+
agentFile: (root, id) => join(root, "agents", `${id}.json`),
|
|
552
|
+
panesDir: (root) => join(root, "panes"),
|
|
553
|
+
paneFile: (root, pane) => join(root, "panes", `${sanitizePane(pane)}.id`),
|
|
554
|
+
inboxDir: (root, id) => join(root, "inbox", id),
|
|
555
|
+
inboxReadDir: (root, id) => join(root, "inbox", id, "read"),
|
|
556
|
+
dataDir: (root, id) => join(root, "data", id),
|
|
557
|
+
briefFile: (root, id) => join(root, "data", id, "brief.md"),
|
|
558
|
+
resultFile: (root, id) => join(root, "data", id, "result.json"),
|
|
559
|
+
worktreeDir: (root, id) => join(root, "worktrees", id)
|
|
560
|
+
};
|
|
561
|
+
/** tmux pane ids look like "%3"; make them filesystem-safe. */
|
|
562
|
+
function sanitizePane(pane) {
|
|
563
|
+
return pane.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
564
|
+
}
|
|
565
|
+
//#endregion
|
|
566
|
+
//#region src/session.ts
|
|
567
|
+
/** How each harness's own CLI is launched in the new pane. */
|
|
568
|
+
const LAUNCH_MAP = {
|
|
569
|
+
claude: "claude",
|
|
570
|
+
cursor: "cursor-agent",
|
|
571
|
+
codex: "codex"
|
|
572
|
+
};
|
|
573
|
+
/**
|
|
574
|
+
* Launch a new peer session as a genuine sibling unit: create a real git worktree distinct from
|
|
575
|
+
* the primary checkout (refuse the primary checkout), open a session backend (tmux or herdr) with
|
|
576
|
+
* its cwd set to that worktree, pre-register the peer, and drop its brief as a file the peer's own
|
|
577
|
+
* SessionStart hook reads — never typed into its prompt.
|
|
578
|
+
*
|
|
579
|
+
* Spawned unit worktrees live project-local (`<primary>/.agents/cyberlegion/worktrees/<id>`), even
|
|
580
|
+
* though the registry/mailbox itself lives in the global hub — the hub addresses units across
|
|
581
|
+
* project and worktree boundaries, but a unit's checkout is necessarily project-local.
|
|
582
|
+
*/
|
|
583
|
+
function spawn(ctx, input) {
|
|
584
|
+
ctx.store.ensureMarker();
|
|
585
|
+
const env = ctx.env ?? process.env;
|
|
586
|
+
const exec = ctx.exec ?? realExec;
|
|
587
|
+
const sessionAdapter = selectSessionAdapter(env, exec);
|
|
588
|
+
const harness = input.harness;
|
|
589
|
+
if (!harness || !(harness in LAUNCH_MAP)) throw new Error(`spawn needs a --harness in the launch map (${Object.keys(LAUNCH_MAP).join(" | ")})`);
|
|
590
|
+
const brief = resolveBrief(input);
|
|
591
|
+
if (brief == null) throw new Error("spawn needs a brief — pass --task <text>, --task - (stdin), or --brief-file <path>");
|
|
592
|
+
const id = randomId();
|
|
593
|
+
const primaryRoot = resolvePrimaryRoot(exec);
|
|
594
|
+
const branch = input.branch ?? `cyberlegion/unit-${id}`;
|
|
595
|
+
const worktreePath = input.worktreePath ?? paths.worktreeDir(join(primaryRoot, ".agents", "cyberlegion"), id);
|
|
596
|
+
const worktree = gitWorktreeAdapter.add(exec, {
|
|
597
|
+
primaryRoot,
|
|
598
|
+
path: worktreePath,
|
|
599
|
+
branch
|
|
600
|
+
});
|
|
601
|
+
assertDistinctFromPrimary(worktree.root, primaryRoot);
|
|
602
|
+
ensureMarker(join(worktree.root, ".agents", "cyberlegion"));
|
|
603
|
+
const launch = input.command ?? LAUNCH_MAP[harness];
|
|
604
|
+
const target = sessionAdapter.open(exec, {
|
|
605
|
+
cwd: worktree.root,
|
|
606
|
+
launch: `${muxEnvPrefix(sessionAdapter.name)}${launch}`,
|
|
607
|
+
at: input.at
|
|
608
|
+
});
|
|
609
|
+
const ts = new Date(ctx.now?.() ?? Date.now()).toISOString();
|
|
610
|
+
const rec = {
|
|
611
|
+
id,
|
|
612
|
+
handle: input.handle ?? id.slice(0, 6),
|
|
613
|
+
harness,
|
|
614
|
+
cwd: worktree.root,
|
|
615
|
+
worktree,
|
|
616
|
+
tmux: sessionAdapter.name === "tmux" ? { pane: target.id } : null,
|
|
617
|
+
status: "spawning",
|
|
618
|
+
createdAt: ts,
|
|
619
|
+
lastSeen: ts,
|
|
620
|
+
brief: paths.briefFile(ctx.store.root, id),
|
|
621
|
+
...resolveSelfId(ctx) ? { spawnedBy: resolveSelfId(ctx) } : {}
|
|
622
|
+
};
|
|
623
|
+
saveAgent(ctx.store, rec);
|
|
624
|
+
ctx.store.putPaneIndex(target.id, id);
|
|
625
|
+
ctx.store.writeBrief(id, brief);
|
|
626
|
+
return {
|
|
627
|
+
agent: rec,
|
|
628
|
+
pane: target.id,
|
|
629
|
+
launch
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* The env prefix typed ahead of the launch command so the spawned peer inherits the caller's
|
|
634
|
+
* multiplexer fast-path and never has to run its own ancestry discovery (`$CYBERLEGION_MUX` /
|
|
635
|
+
* `$CYBERLEGION_MUX_PANE`). `VAR=val cmd` scopes the vars to that one process (and its children)
|
|
636
|
+
* without needing `export`. tmux natively sets `$TMUX_PANE` for a pane's own processes, so the
|
|
637
|
+
* pane var is expanded by the child's own shell rather than baked in here.
|
|
638
|
+
*/
|
|
639
|
+
function muxEnvPrefix(muxName) {
|
|
640
|
+
if (muxName === "tmux") return "CYBERLEGION_MUX=tmux CYBERLEGION_MUX_PANE=$TMUX_PANE ";
|
|
641
|
+
if (muxName === "herdr") return "CYBERLEGION_MUX=herdr ";
|
|
642
|
+
return "";
|
|
643
|
+
}
|
|
644
|
+
/** Resolve a spawn brief from --brief-file, --task -, or --task <text>; null if no source given. */
|
|
645
|
+
function resolveBrief(input, readStdin = () => readFileSync(0, "utf8")) {
|
|
646
|
+
if (input.briefFile) return readFileSync(input.briefFile, "utf8");
|
|
647
|
+
if (input.task === "-") return readStdin();
|
|
648
|
+
if (input.task != null && input.task !== "") return input.task;
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
//#endregion
|
|
652
|
+
//#region src/agentdef/realize.ts
|
|
653
|
+
const DEFAULT_HARNESS = "claude";
|
|
654
|
+
/** POSIX single-quote a value for safe inclusion in a shell command line. */
|
|
655
|
+
function shellQuote(value) {
|
|
656
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
657
|
+
}
|
|
658
|
+
/** Build the harness launch invocation for a def — explicit `model`/`harness` win over the def's
|
|
659
|
+
* own tags, which win over the harness default. Every harness gets the same shape (bin,
|
|
660
|
+
* `--model` when known, `--append-system-prompt` carrying the def's instructions body) since the
|
|
661
|
+
* exact per-harness flag surface is realized later by the launching caller, not by cyberlegion. */
|
|
662
|
+
function realizeLaunch(def, opts = {}) {
|
|
663
|
+
const harness = opts.harness ?? def.harness ?? DEFAULT_HARNESS;
|
|
664
|
+
const model = opts.model ?? def.model;
|
|
665
|
+
const parts = [LAUNCH_MAP[harness]];
|
|
666
|
+
if (model) parts.push("--model", shellQuote(model));
|
|
667
|
+
if (def.instructions) parts.push("--append-system-prompt", shellQuote(def.instructions));
|
|
668
|
+
return {
|
|
669
|
+
harness,
|
|
670
|
+
command: parts.join(" ")
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
/** Build the instruction string a parent model uses to spawn its OWN harness Task subagent for
|
|
674
|
+
* `def` — cyberlegion never invokes the Task tool itself. Names `subagent_type: <def.name>` for a
|
|
675
|
+
* harness that recognizes it, but always inlines the model + full instructions too so the same
|
|
676
|
+
* instruction string is correct even when the harness has no such named subagent type. */
|
|
677
|
+
function realizeSubagentInstruction(def, opts) {
|
|
678
|
+
const lines = [`Spawn a subagent (subagent_type: ${def.name} if your harness recognizes that name, else a generic one).`];
|
|
679
|
+
if (def.model) lines.push(`Model: ${def.model}.`);
|
|
680
|
+
if (def.effort) lines.push(`Effort: ${def.effort}.`);
|
|
681
|
+
lines.push(`Read the brief at ${opts.briefFile} and write its result JSON to ${opts.resultFile}.`);
|
|
682
|
+
if (def.instructions) lines.push("", "Instructions:", def.instructions);
|
|
683
|
+
return lines.join("\n");
|
|
684
|
+
}
|
|
685
|
+
//#endregion
|
|
686
|
+
//#region src/agentdef/resolve.ts
|
|
687
|
+
function parseAgentDefFile(text) {
|
|
688
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n)?([\s\S]*)$/.exec(text);
|
|
689
|
+
if (!m) return {
|
|
690
|
+
fm: {},
|
|
691
|
+
instructions: text.trim()
|
|
692
|
+
};
|
|
693
|
+
const [, block, rest] = m;
|
|
694
|
+
const fm = {};
|
|
695
|
+
const lines = block.split("\n");
|
|
696
|
+
let i = 0;
|
|
697
|
+
while (i < lines.length) {
|
|
698
|
+
const line = lines[i].replace(/\r$/, "");
|
|
699
|
+
if (line.trim() === "" || line.trim().startsWith("#")) {
|
|
700
|
+
i++;
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
if (line.length - line.trimStart().length > 0) {
|
|
704
|
+
i++;
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const idx = line.indexOf(":");
|
|
708
|
+
if (idx === -1) {
|
|
709
|
+
i++;
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
const key = line.slice(0, idx).trim();
|
|
713
|
+
let value = line.slice(idx + 1).trim();
|
|
714
|
+
i++;
|
|
715
|
+
if (value === ">" || value === ">-" || value === "|" || value === "|-") {
|
|
716
|
+
const parts = [];
|
|
717
|
+
while (i < lines.length) {
|
|
718
|
+
const l = lines[i].replace(/\r$/, "");
|
|
719
|
+
if (l.trim() === "") {
|
|
720
|
+
i++;
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
if (l.length - l.trimStart().length === 0) break;
|
|
724
|
+
parts.push(l.trim());
|
|
725
|
+
i++;
|
|
726
|
+
}
|
|
727
|
+
value = parts.join(value.startsWith("|") ? "\n" : " ");
|
|
728
|
+
}
|
|
729
|
+
fm[key] = unquote(value);
|
|
730
|
+
}
|
|
731
|
+
return {
|
|
732
|
+
fm,
|
|
733
|
+
instructions: rest.trim()
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
function unquote(v) {
|
|
737
|
+
if (v.length >= 2 && (v.startsWith("\"") && v.endsWith("\"") || v.startsWith("'") && v.endsWith("'"))) return v.slice(1, -1);
|
|
738
|
+
return v;
|
|
739
|
+
}
|
|
740
|
+
function toBool(v) {
|
|
741
|
+
if (v === void 0) return void 0;
|
|
742
|
+
if (v === "true") return true;
|
|
743
|
+
if (v === "false") return false;
|
|
744
|
+
}
|
|
745
|
+
function toAgentDef(path, text, fallbackName) {
|
|
746
|
+
const { fm, instructions } = parseAgentDefFile(text);
|
|
747
|
+
return {
|
|
748
|
+
name: fm.name ?? fallbackName,
|
|
749
|
+
description: fm.description,
|
|
750
|
+
model: fm.model,
|
|
751
|
+
effort: fm.effort,
|
|
752
|
+
harness: fm.harness,
|
|
753
|
+
warm: toBool(fm.warm),
|
|
754
|
+
interactive: toBool(fm.interactive),
|
|
755
|
+
instructions,
|
|
756
|
+
path
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
/** The project's own `.agents/agents/` dir — the convention `agent list`/name-search scans. */
|
|
760
|
+
function projectAgentsDir(cwd) {
|
|
761
|
+
return join(projectRoot(cwd), ".agents", "agents");
|
|
762
|
+
}
|
|
763
|
+
/** Resolve one agent definition, by explicit file or by name search. Throws a clear error when
|
|
764
|
+
* neither locates a def — never returns a partial/empty result. */
|
|
765
|
+
function resolveAgentDef(input) {
|
|
766
|
+
if (input.file) {
|
|
767
|
+
const path = resolve(input.file);
|
|
768
|
+
if (!existsSync(path)) throw new Error(`agent def file "${input.file}" does not exist`);
|
|
769
|
+
return toAgentDef(path, readFileSync(path, "utf8"), basename(path).replace(/\.md$/, ""));
|
|
770
|
+
}
|
|
771
|
+
if (!input.name) throw new Error("resolveAgentDef needs a --agent <name> or --agent-file <path>");
|
|
772
|
+
const roots = [...input.searchRoots ?? [], projectAgentsDir(input.cwd)];
|
|
773
|
+
for (const root of roots) {
|
|
774
|
+
const candidate = join(root, `${input.name}.md`);
|
|
775
|
+
if (existsSync(candidate)) return toAgentDef(candidate, readFileSync(candidate, "utf8"), input.name);
|
|
776
|
+
}
|
|
777
|
+
throw new Error(`no agent definition named "${input.name}" found under .agents/agents/`);
|
|
778
|
+
}
|
|
779
|
+
/** List every resolvable agent def under `.agents/agents/` (+ any extra search roots). Definitive
|
|
780
|
+
* empty array when the dir is absent or empty — never throws for "none found" here (unlike a
|
|
781
|
+
* named resolve, listing is inherently zero-or-more). */
|
|
782
|
+
function listAgentDefs(input = {}) {
|
|
783
|
+
const roots = [...input.searchRoots ?? [], projectAgentsDir(input.cwd)];
|
|
784
|
+
const seen = /* @__PURE__ */ new Set();
|
|
785
|
+
const defs = [];
|
|
786
|
+
for (const root of roots) {
|
|
787
|
+
if (!existsSync(root)) continue;
|
|
788
|
+
for (const file of readdirSync(root)) {
|
|
789
|
+
if (!file.endsWith(".md")) continue;
|
|
790
|
+
const path = join(root, file);
|
|
791
|
+
if (seen.has(path)) continue;
|
|
792
|
+
seen.add(path);
|
|
793
|
+
defs.push(toAgentDef(path, readFileSync(path, "utf8"), file.replace(/\.md$/, "")));
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return defs;
|
|
797
|
+
}
|
|
798
|
+
//#endregion
|
|
799
|
+
//#region src/decommission.ts
|
|
800
|
+
/**
|
|
801
|
+
* Tear a unit down and reap its registry record — the deterministic inverse of `spawn`. Refuses
|
|
802
|
+
* the primary checkout (absolute — `--force` never overrides it) and a dirty worktree unless
|
|
803
|
+
* `--force`. Teardown always precedes reap: an already-gone worktree or pane is tolerated, but a
|
|
804
|
+
* genuine worktree-removal failure aborts and leaves the record intact so the operation is
|
|
805
|
+
* retryable.
|
|
806
|
+
*/
|
|
807
|
+
function decommission(ctx, input) {
|
|
808
|
+
const rec = loadAgent(ctx.store, input.id);
|
|
809
|
+
if (!rec) throw new Error(`no unit registered as agents/${input.id}.json — nothing to decommission`);
|
|
810
|
+
const exec = ctx.exec ?? realExec;
|
|
811
|
+
const env = ctx.env ?? process.env;
|
|
812
|
+
const worktreeRoot = rec.worktree?.root;
|
|
813
|
+
const primaryRoot = worktreeRoot ? resolvePrimaryRoot(exec) : void 0;
|
|
814
|
+
if (worktreeRoot && resolve(worktreeRoot) === resolve(primaryRoot)) throw new Error(`refusing to decommission "${input.id}" — its worktree is the primary checkout; --force does not override this`);
|
|
815
|
+
const worktreeExists = worktreeRoot != null && existsSync(worktreeRoot);
|
|
816
|
+
if (worktreeExists && !input.force && isDirty(exec, worktreeRoot)) throw new Error(`unit "${input.id}" has uncommitted changes in its worktree — pass --force to discard them`);
|
|
817
|
+
const pane = rec.tmux?.pane ?? ctx.store.findPaneByAgentId(input.id);
|
|
818
|
+
if (worktreeExists) try {
|
|
819
|
+
gitWorktreeAdapter.remove(exec, worktreeRoot, { primaryRoot });
|
|
820
|
+
} catch (err) {
|
|
821
|
+
throw new Error(`decommission "${input.id}" aborted — worktree removal failed, record left intact for retry: ${err instanceof Error ? err.message : String(err)}`);
|
|
822
|
+
}
|
|
823
|
+
if (pane) try {
|
|
824
|
+
selectSessionAdapter(env, exec).teardown(exec, { id: pane });
|
|
825
|
+
} catch {}
|
|
826
|
+
ctx.store.removeAgent(input.id);
|
|
827
|
+
if (pane) ctx.store.removePaneIndex(pane);
|
|
828
|
+
ctx.store.removeAgentData(input.id);
|
|
829
|
+
return {
|
|
830
|
+
agent: rec,
|
|
831
|
+
worktreeRoot,
|
|
832
|
+
pane
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
function isDirty(exec, worktreeRoot) {
|
|
836
|
+
return !!exec("git", [
|
|
837
|
+
"-C",
|
|
838
|
+
worktreeRoot,
|
|
839
|
+
"status",
|
|
840
|
+
"--porcelain"
|
|
841
|
+
]);
|
|
842
|
+
}
|
|
843
|
+
//#endregion
|
|
844
|
+
//#region src/message.ts
|
|
845
|
+
/** Write one message into the recipient's inbox. Collision-free by <epochMs>-<hex>. */
|
|
846
|
+
function send(ctx, input) {
|
|
847
|
+
const toId = resolveRecipient(ctx.store, input.to);
|
|
848
|
+
const from = loadAgent(ctx.store, input.fromId);
|
|
849
|
+
const ts = ctx.now?.() ?? Date.now();
|
|
850
|
+
const msg = {
|
|
851
|
+
id: `${ts}-${randomBytes(3).toString("hex")}`,
|
|
852
|
+
from: input.fromId,
|
|
853
|
+
fromHandle: from?.handle ?? input.fromId,
|
|
854
|
+
to: toId,
|
|
855
|
+
...input.subject ? { subject: input.subject } : {},
|
|
856
|
+
body: input.body,
|
|
857
|
+
...input.thread ? { thread: input.thread } : {},
|
|
858
|
+
...input.replyTo ? { replyTo: input.replyTo } : {},
|
|
859
|
+
ts,
|
|
860
|
+
sentAt: new Date(ts).toISOString()
|
|
861
|
+
};
|
|
862
|
+
ctx.store.putMessage(toId, msg);
|
|
863
|
+
return msg;
|
|
864
|
+
}
|
|
865
|
+
/** Resolve a message body from the --body flag, a --body-file path, or stdin (--body-file -). */
|
|
866
|
+
function resolveBody(body, bodyFile, readStdin = () => readFileSync(0, "utf8")) {
|
|
867
|
+
if (body != null) return body;
|
|
868
|
+
if (bodyFile) return bodyFile === "-" ? readStdin() : readFileSync(bodyFile, "utf8");
|
|
869
|
+
throw new Error("provide --body <text> or --body-file <path|->");
|
|
870
|
+
}
|
|
871
|
+
/** List the caller's mail, chronological (lexical id sort == time order). */
|
|
872
|
+
function inbox(ctx, q) {
|
|
873
|
+
const snap = ctx.store.listInbox(q.meId);
|
|
874
|
+
const unread = snap.unread.map((m) => ({
|
|
875
|
+
...m,
|
|
876
|
+
read: false
|
|
877
|
+
}));
|
|
878
|
+
const acked = q.unread ? [] : snap.read.map((m) => ({
|
|
879
|
+
...m,
|
|
880
|
+
read: true
|
|
881
|
+
}));
|
|
882
|
+
let items = [...unread, ...acked];
|
|
883
|
+
if (q.from) items = items.filter((m) => m.from === q.from || m.fromHandle === q.from);
|
|
884
|
+
if (q.thread) items = items.filter((m) => m.thread === q.thread);
|
|
885
|
+
return items.sort((a, b) => a.id.localeCompare(b.id));
|
|
886
|
+
}
|
|
887
|
+
/** Peek at a message (unread or already-acked) without changing its state. */
|
|
888
|
+
function peek(ctx, meId, msgId) {
|
|
889
|
+
const snap = ctx.store.listInbox(meId);
|
|
890
|
+
return [...snap.unread, ...snap.read].find((m) => m.id === msgId);
|
|
891
|
+
}
|
|
892
|
+
/** Acknowledge a message by moving it out of the unread set. Errors if not currently unread. */
|
|
893
|
+
function ack(ctx, meId, msgId) {
|
|
894
|
+
return ctx.store.ackMessage(meId, msgId);
|
|
895
|
+
}
|
|
896
|
+
/** Permanently remove a message (unread or already-acked) from the caller's inbox. */
|
|
897
|
+
function deleteMessage(ctx, meId, msgId) {
|
|
898
|
+
ctx.store.removeMessage(meId, msgId);
|
|
899
|
+
}
|
|
900
|
+
//#endregion
|
|
901
|
+
//#region src/wake/await.ts
|
|
902
|
+
/** Internal poll interval — not user-facing (the round-trip cost of one `mail await` cycle). */
|
|
903
|
+
const POLL_MS$1 = 1e3;
|
|
904
|
+
const DEFAULT_TIMEOUT_MS = 6e5;
|
|
905
|
+
const DEFAULT_MAX_WAIT_S = 240;
|
|
906
|
+
/**
|
|
907
|
+
* Block on the caller's UNREAD inbox until a message matching `thread` (and optional `from`)
|
|
908
|
+
* arrives, polling on a fixed ~1s internal interval. Three unambiguous outcomes:
|
|
909
|
+
*
|
|
910
|
+
* - `matched` — a message arrived; it is printed by the caller AND acked here (moved to read/), so
|
|
911
|
+
* it leaves the unread set. Block-then-read: each await consumes exactly its round.
|
|
912
|
+
* - `waiting` — this call's internal poll cycle hit its `maxWaitS` cap (default 240s, always well
|
|
913
|
+
* under a harness tool-timeout SIGKILL) with no match yet. A clean, non-error return the caller
|
|
914
|
+
* re-arms on (calls `awaitReply` again) — NOT the same as giving up.
|
|
915
|
+
* - `timed-out` — the caller's overall `timeoutMs` (default 600_000ms / 600s; `0` = wait forever)
|
|
916
|
+
* elapsed across re-arms with no match. The caller should exit non-zero.
|
|
917
|
+
*/
|
|
918
|
+
async function awaitReply(ctx, input) {
|
|
919
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
920
|
+
const maxWaitMs = (input.maxWaitS ?? DEFAULT_MAX_WAIT_S) * 1e3;
|
|
921
|
+
const now = input.now ?? (() => Date.now());
|
|
922
|
+
const sleep = input.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
923
|
+
const start = now();
|
|
924
|
+
const cycleDeadline = start + maxWaitMs;
|
|
925
|
+
for (;;) {
|
|
926
|
+
const items = inbox(ctx, {
|
|
927
|
+
meId: input.meId,
|
|
928
|
+
unread: true,
|
|
929
|
+
thread: input.thread,
|
|
930
|
+
from: input.from
|
|
931
|
+
});
|
|
932
|
+
if (items.length > 0) {
|
|
933
|
+
const message = items[0];
|
|
934
|
+
ack(ctx, input.meId, message.id);
|
|
935
|
+
return {
|
|
936
|
+
kind: "matched",
|
|
937
|
+
message
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
const elapsed = now() - start;
|
|
941
|
+
if (timeoutMs !== 0 && elapsed >= timeoutMs) return { kind: "timed-out" };
|
|
942
|
+
if (now() >= cycleDeadline) return { kind: "waiting" };
|
|
943
|
+
await sleep(POLL_MS$1);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
//#endregion
|
|
947
|
+
//#region src/dispatch/prep.ts
|
|
948
|
+
/** The instruction a caller uses when no agent def is given — still names both slot paths so a
|
|
949
|
+
* generic Task-tool spawn (or a human) can carry out the same contract. */
|
|
950
|
+
function genericInstruction(role, briefFile, resultFile) {
|
|
951
|
+
const lines = [`Spawn a subagent${role ? ` (role: ${role})` : ""}.`];
|
|
952
|
+
lines.push(`Read the brief at ${briefFile} and write its result JSON to ${resultFile}.`);
|
|
953
|
+
return lines.join("\n");
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Allocate a dispatch: mint an id (thread defaults to it), write the brief into the Store, compute
|
|
957
|
+
* the result-slot path, and build the instruction from a resolved agent def (or a generic one).
|
|
958
|
+
* Spawns nothing — the envelope is handed to whatever spawns next (a Task tool, `channel()`, a
|
|
959
|
+
* human).
|
|
960
|
+
*/
|
|
961
|
+
function prep(ctx, input, readStdin) {
|
|
962
|
+
const id = randomId();
|
|
963
|
+
const thread = input.thread ?? id;
|
|
964
|
+
const briefText = resolveBody(input.briefText, input.briefFile, readStdin);
|
|
965
|
+
ctx.store.writeBrief(id, briefText);
|
|
966
|
+
const briefFile = paths.briefFile(ctx.store.root, id);
|
|
967
|
+
const resultFile = ctx.store.resultPath(id);
|
|
968
|
+
let def;
|
|
969
|
+
if (input.agent || input.agentFile) def = resolveAgentDef({
|
|
970
|
+
name: input.agent,
|
|
971
|
+
file: input.agentFile
|
|
972
|
+
});
|
|
973
|
+
return {
|
|
974
|
+
id,
|
|
975
|
+
thread,
|
|
976
|
+
briefFile,
|
|
977
|
+
resultFile,
|
|
978
|
+
instruction: def ? realizeSubagentInstruction(def, {
|
|
979
|
+
briefFile,
|
|
980
|
+
resultFile
|
|
981
|
+
}) : genericInstruction(input.role, briefFile, resultFile)
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
//#endregion
|
|
985
|
+
//#region src/dispatch/verdict.ts
|
|
986
|
+
function primitiveTypeOf(v) {
|
|
987
|
+
if (Array.isArray(v)) return "array";
|
|
988
|
+
return typeof v;
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Parse `body` as JSON and, when `schemaFile` is given, structurally check it against a minimal
|
|
992
|
+
* required/type schema. Any failure — invalid JSON, an unreadable/invalid schema file, a missing
|
|
993
|
+
* required key, or a type mismatch — is `{ ok: false }`, never a pass.
|
|
994
|
+
*/
|
|
995
|
+
function validateVerdict(body, schemaFile) {
|
|
996
|
+
let value;
|
|
997
|
+
try {
|
|
998
|
+
value = JSON.parse(body);
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
return {
|
|
1001
|
+
ok: false,
|
|
1002
|
+
error: `verdict body is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
if (!schemaFile) return {
|
|
1006
|
+
ok: true,
|
|
1007
|
+
value
|
|
1008
|
+
};
|
|
1009
|
+
let schema;
|
|
1010
|
+
try {
|
|
1011
|
+
schema = JSON.parse(readFileSync(schemaFile, "utf8"));
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
return {
|
|
1014
|
+
ok: false,
|
|
1015
|
+
error: `verdict schema "${schemaFile}" is not readable/valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return {
|
|
1019
|
+
ok: false,
|
|
1020
|
+
error: "verdict body must be a JSON object to validate against a schema"
|
|
1021
|
+
};
|
|
1022
|
+
const obj = value;
|
|
1023
|
+
for (const key of schema.required ?? []) if (!(key in obj)) return {
|
|
1024
|
+
ok: false,
|
|
1025
|
+
error: `verdict is missing required key "${key}"`
|
|
1026
|
+
};
|
|
1027
|
+
for (const [key, expected] of Object.entries(schema.properties ?? {})) {
|
|
1028
|
+
if (!(key in obj)) continue;
|
|
1029
|
+
const actual = primitiveTypeOf(obj[key]);
|
|
1030
|
+
if (actual !== expected) return {
|
|
1031
|
+
ok: false,
|
|
1032
|
+
error: `verdict key "${key}" expected type "${expected}" but got "${actual}"`
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
return {
|
|
1036
|
+
ok: true,
|
|
1037
|
+
value
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
//#endregion
|
|
1041
|
+
//#region src/dispatch/channel.ts
|
|
1042
|
+
/** Thrown by a `--wait` channel call when its internal poll-cycle cap is hit with no reply yet — a
|
|
1043
|
+
* clean, re-armable sentinel (mirrors `mail await`'s "waiting" outcome), never a failure. */
|
|
1044
|
+
var DispatchWaitingError = class extends Error {
|
|
1045
|
+
thread;
|
|
1046
|
+
constructor(thread) {
|
|
1047
|
+
super(`waiting — no reply on thread "${thread}" yet; re-run to keep waiting`);
|
|
1048
|
+
this.thread = thread;
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
/**
|
|
1052
|
+
* `prep()` + spawn a peer session realized from the same agent def + (optionally) `awaitReply` on
|
|
1053
|
+
* the thread. Requires `--agent`/`--agent-file` (a channel peer needs a real launch to realize).
|
|
1054
|
+
*/
|
|
1055
|
+
async function channel(ctx, input, readStdin) {
|
|
1056
|
+
if (!input.agent && !input.agentFile) throw new Error("dispatch channel needs --agent <name> or --agent-file <path> to realize the peer launch");
|
|
1057
|
+
const envelope = prep(ctx, input, readStdin);
|
|
1058
|
+
const realized = realizeLaunch(resolveAgentDef({
|
|
1059
|
+
name: input.agent,
|
|
1060
|
+
file: input.agentFile
|
|
1061
|
+
}));
|
|
1062
|
+
spawn(ctx, {
|
|
1063
|
+
harness: realized.harness,
|
|
1064
|
+
command: realized.command,
|
|
1065
|
+
briefFile: envelope.briefFile,
|
|
1066
|
+
at: input.at
|
|
1067
|
+
});
|
|
1068
|
+
if (!input.wait) return envelope;
|
|
1069
|
+
const meId = resolveSelfId(ctx);
|
|
1070
|
+
if (!meId) throw new Error("dispatch channel --wait needs an identity — run `cyberlegion identity register` first");
|
|
1071
|
+
const outcome = await awaitReply({
|
|
1072
|
+
store: ctx.store,
|
|
1073
|
+
now: ctx.now
|
|
1074
|
+
}, {
|
|
1075
|
+
meId,
|
|
1076
|
+
thread: envelope.thread,
|
|
1077
|
+
timeoutMs: input.timeoutMs,
|
|
1078
|
+
maxWaitS: input.maxWaitS
|
|
1079
|
+
});
|
|
1080
|
+
if (outcome.kind === "waiting") throw new DispatchWaitingError(envelope.thread);
|
|
1081
|
+
if (outcome.kind === "timed-out") throw new Error(`no reply on thread "${envelope.thread}" within timeout`);
|
|
1082
|
+
const validated = validateVerdict(outcome.message.body, input.verdictSchema);
|
|
1083
|
+
if (!validated.ok) throw new Error(validated.error);
|
|
1084
|
+
return {
|
|
1085
|
+
id: envelope.id,
|
|
1086
|
+
verdict: validated.value,
|
|
1087
|
+
body: outcome.message.body,
|
|
1088
|
+
ts: outcome.message.ts
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
//#endregion
|
|
1092
|
+
//#region src/dispatch/collect.ts
|
|
1093
|
+
/** Read + validate a dispatch's result file. Throws (fail-loud) when the file is not yet written,
|
|
1094
|
+
* or when it fails the optional verdict schema. */
|
|
1095
|
+
function collect(ctx, id, verdictSchema) {
|
|
1096
|
+
const body = ctx.store.readResult(id);
|
|
1097
|
+
if (body === void 0) throw new Error(`no result written yet for dispatch "${id}" — the subagent has not written its result file`);
|
|
1098
|
+
const validated = validateVerdict(body, verdictSchema);
|
|
1099
|
+
if (!validated.ok) throw new Error(validated.error);
|
|
1100
|
+
return {
|
|
1101
|
+
id,
|
|
1102
|
+
verdict: validated.value,
|
|
1103
|
+
body,
|
|
1104
|
+
ts: ctx.now?.() ?? Date.now()
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
//#endregion
|
|
1108
|
+
//#region src/install.ts
|
|
1109
|
+
const hookCommand = (event) => `cyberlegion mail hook --event ${event}`;
|
|
1110
|
+
const VENDORS = {
|
|
1111
|
+
claude: {
|
|
1112
|
+
file: ".claude/settings.json",
|
|
1113
|
+
shape: "claude",
|
|
1114
|
+
events: {
|
|
1115
|
+
SessionStart: "SessionStart",
|
|
1116
|
+
PostToolUse: "PostToolUse"
|
|
1117
|
+
}
|
|
1118
|
+
},
|
|
1119
|
+
cursor: {
|
|
1120
|
+
file: ".cursor/hooks.json",
|
|
1121
|
+
shape: "cursor",
|
|
1122
|
+
events: { SessionStart: "sessionStart" }
|
|
1123
|
+
},
|
|
1124
|
+
codex: {
|
|
1125
|
+
file: ".codex/hooks.json",
|
|
1126
|
+
shape: "cursor",
|
|
1127
|
+
events: {
|
|
1128
|
+
SessionStart: "SessionStart",
|
|
1129
|
+
PostToolUse: "PostToolUse"
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
function readJson(file) {
|
|
1134
|
+
if (!existsSync(file)) return {};
|
|
1135
|
+
try {
|
|
1136
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
1137
|
+
} catch {
|
|
1138
|
+
return {};
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
function writeJson$1(file, data) {
|
|
1142
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1143
|
+
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
1144
|
+
}
|
|
1145
|
+
/** Register the surfacing hook into one harness's config, idempotently. */
|
|
1146
|
+
function install(harness, projectDir = process.cwd()) {
|
|
1147
|
+
const spec = VENDORS[harness];
|
|
1148
|
+
if (!spec) throw new Error(`unknown harness "${harness}" (expected claude | cursor | codex)`);
|
|
1149
|
+
const file = join(projectDir, spec.file);
|
|
1150
|
+
const settings = readJson(file);
|
|
1151
|
+
const results = [];
|
|
1152
|
+
for (const [canonical, vendorEvent] of Object.entries(spec.events)) {
|
|
1153
|
+
const command = hookCommand(canonical);
|
|
1154
|
+
const status = spec.shape === "claude" ? upsertClaude(settings, vendorEvent, command) : upsertCursor(settings, vendorEvent, command);
|
|
1155
|
+
results.push({
|
|
1156
|
+
harness,
|
|
1157
|
+
event: canonical,
|
|
1158
|
+
vendorEvent,
|
|
1159
|
+
file,
|
|
1160
|
+
status
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
writeJson$1(file, settings);
|
|
1164
|
+
return results;
|
|
1165
|
+
}
|
|
1166
|
+
function upsertClaude(settings, event, command) {
|
|
1167
|
+
const hooks = settings.hooks ??= {};
|
|
1168
|
+
const groups = hooks[event] ??= [];
|
|
1169
|
+
if (groups.some((g) => g.hooks?.some((h) => h.command === command))) return "already present";
|
|
1170
|
+
const group = { hooks: [{
|
|
1171
|
+
type: "command",
|
|
1172
|
+
command
|
|
1173
|
+
}] };
|
|
1174
|
+
if (event === "PostToolUse") group.matcher = "Write|Edit";
|
|
1175
|
+
groups.push(group);
|
|
1176
|
+
return "registered";
|
|
1177
|
+
}
|
|
1178
|
+
function upsertCursor(settings, event, command) {
|
|
1179
|
+
if (settings.version == null) settings.version = 1;
|
|
1180
|
+
const hooks = settings.hooks ??= {};
|
|
1181
|
+
const list = hooks[event] ??= [];
|
|
1182
|
+
if (list.some((h) => h.command === command)) return "already present";
|
|
1183
|
+
list.push({ command });
|
|
1184
|
+
return "registered";
|
|
1185
|
+
}
|
|
1186
|
+
//#endregion
|
|
1187
|
+
//#region src/output.ts
|
|
1188
|
+
function stringifyCell(v) {
|
|
1189
|
+
if (v == null) return "";
|
|
1190
|
+
const s = String(v);
|
|
1191
|
+
return /[,\n"]/.test(s) ? `"${s.replace(/"/g, "\"\"")}"` : s;
|
|
1192
|
+
}
|
|
1193
|
+
/** One TOON-encoded object: `key: value` lines, blank/undefined fields dropped. */
|
|
1194
|
+
function toonObject(fields) {
|
|
1195
|
+
return Object.entries(fields).filter(([, v]) => v != null).map(([k, v]) => `${k}: ${v}`).join("\n");
|
|
1196
|
+
}
|
|
1197
|
+
/** One TOON-encoded list: a `name[N]{field,...}:` header plus one row per item, then an aggregate
|
|
1198
|
+
* summary line. Definitive on empty — still emits `name[0]{...}:` plus the summary line. */
|
|
1199
|
+
function toonList(name, items, fields, summary) {
|
|
1200
|
+
return [
|
|
1201
|
+
`${name}[${items.length}]{${fields.map((f) => f.key).join(",")}}:`,
|
|
1202
|
+
...items.map((item) => fields.map((f) => stringifyCell(f.get(item))).join(",")).map((r) => ` ${r}`),
|
|
1203
|
+
summary
|
|
1204
|
+
].join("\n");
|
|
1205
|
+
}
|
|
1206
|
+
/** Print a command's result to stdout in the requested format. */
|
|
1207
|
+
function emit(format, payload) {
|
|
1208
|
+
if (format === "json") console.log(JSON.stringify(payload.json, null, 2));
|
|
1209
|
+
else console.log(payload.toon);
|
|
1210
|
+
}
|
|
1211
|
+
/** A stderr-only next-step suggestion — never part of the machine-readable stdout result. */
|
|
1212
|
+
function nextStep(msg) {
|
|
1213
|
+
console.error(`→ ${msg}`);
|
|
1214
|
+
}
|
|
1215
|
+
/** A structured, fail-loud error: stderr + exit 1. Never prompts, never partially writes. */
|
|
1216
|
+
function fail(msg) {
|
|
1217
|
+
console.error(JSON.stringify({ error: msg }));
|
|
1218
|
+
process.exit(1);
|
|
1219
|
+
}
|
|
1220
|
+
//#endregion
|
|
1221
|
+
//#region src/runtime/inject-inbox.ts
|
|
1222
|
+
const EVENTS = ["SessionStart", "PostToolUse"];
|
|
1223
|
+
/**
|
|
1224
|
+
* Resolve the calling agent, gather its pending brief + unread mail, and return the
|
|
1225
|
+
* SessionStart-style injection payload — or null when there is nothing to inject (an
|
|
1226
|
+
* unregistered caller or an empty inbox never fails the harness hook).
|
|
1227
|
+
*/
|
|
1228
|
+
function injectInbox(ctx, event) {
|
|
1229
|
+
if (!EVENTS.includes(event)) throw new Error(`unsupported --event "${event}" (expected ${EVENTS.join(" | ")})`);
|
|
1230
|
+
const meId = resolveSelfId(ctx);
|
|
1231
|
+
if (!meId) return null;
|
|
1232
|
+
const parts = [];
|
|
1233
|
+
const rec = loadAgent(ctx.store, meId);
|
|
1234
|
+
if (rec?.status === "spawning") {
|
|
1235
|
+
const brief = ctx.store.readBrief(meId);
|
|
1236
|
+
if (brief) parts.push(`## Your brief\n\n${brief.trim()}`);
|
|
1237
|
+
rec.status = "active";
|
|
1238
|
+
saveAgent(ctx.store, rec);
|
|
1239
|
+
}
|
|
1240
|
+
const unread = inbox({ store: ctx.store }, {
|
|
1241
|
+
meId,
|
|
1242
|
+
unread: true
|
|
1243
|
+
});
|
|
1244
|
+
if (unread.length > 0) {
|
|
1245
|
+
const lines = unread.map((m) => `- **${m.fromHandle}**${m.subject ? ` — ${m.subject}` : ""}: ${m.body} \`(${m.id})\``);
|
|
1246
|
+
parts.push(`## Unread mail (${unread.length})\n\n${lines.join("\n")}`);
|
|
1247
|
+
}
|
|
1248
|
+
if (parts.length === 0) return null;
|
|
1249
|
+
return { hookSpecificOutput: {
|
|
1250
|
+
hookEventName: event,
|
|
1251
|
+
additionalContext: parts.join("\n\n")
|
|
1252
|
+
} };
|
|
1253
|
+
}
|
|
1254
|
+
//#endregion
|
|
1255
|
+
//#region src/store/file-store.ts
|
|
1256
|
+
function readMessages(dir) {
|
|
1257
|
+
if (!existsSync(dir)) return [];
|
|
1258
|
+
return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => JSON.parse(readFileSync(join(dir, f), "utf8")));
|
|
1259
|
+
}
|
|
1260
|
+
function writeJson(file, data) {
|
|
1261
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1262
|
+
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
1263
|
+
}
|
|
1264
|
+
function writeText(file, text) {
|
|
1265
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1266
|
+
writeFileSync(file, text);
|
|
1267
|
+
}
|
|
1268
|
+
/** The on-disk `Store` implementation — current per-writer sharded `.json` layout (ADR-0020):
|
|
1269
|
+
* one file per message/agent, collision-free filenames, ack = atomic rename into `read/`. */
|
|
1270
|
+
var FileStore = class {
|
|
1271
|
+
root;
|
|
1272
|
+
constructor(root) {
|
|
1273
|
+
this.root = root;
|
|
1274
|
+
}
|
|
1275
|
+
ensureMarker() {
|
|
1276
|
+
ensureMarker(this.root);
|
|
1277
|
+
}
|
|
1278
|
+
putMessage(toId, msg) {
|
|
1279
|
+
const dir = paths.inboxDir(this.root, toId);
|
|
1280
|
+
mkdirSync(dir, { recursive: true });
|
|
1281
|
+
writeFileSync(join(dir, `${msg.id}.json`), `${JSON.stringify(msg, null, 2)}\n`);
|
|
1282
|
+
}
|
|
1283
|
+
listInbox(id) {
|
|
1284
|
+
return {
|
|
1285
|
+
unread: readMessages(paths.inboxDir(this.root, id)),
|
|
1286
|
+
read: readMessages(paths.inboxReadDir(this.root, id))
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
ackMessage(id, msgId) {
|
|
1290
|
+
const src = join(paths.inboxDir(this.root, id), `${msgId}.json`);
|
|
1291
|
+
if (!existsSync(src)) throw new Error(`"${msgId}" is not an unread message in this inbox`);
|
|
1292
|
+
const msg = JSON.parse(readFileSync(src, "utf8"));
|
|
1293
|
+
const dir = paths.inboxReadDir(this.root, id);
|
|
1294
|
+
mkdirSync(dir, { recursive: true });
|
|
1295
|
+
renameSync(src, join(dir, `${msgId}.json`));
|
|
1296
|
+
return msg;
|
|
1297
|
+
}
|
|
1298
|
+
removeMessage(id, msgId) {
|
|
1299
|
+
const unreadFile = join(paths.inboxDir(this.root, id), `${msgId}.json`);
|
|
1300
|
+
if (existsSync(unreadFile)) {
|
|
1301
|
+
rmSync(unreadFile);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
const readFile = join(paths.inboxReadDir(this.root, id), `${msgId}.json`);
|
|
1305
|
+
if (existsSync(readFile)) {
|
|
1306
|
+
rmSync(readFile);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
throw new Error(`"${msgId}" is not a message in this inbox`);
|
|
1310
|
+
}
|
|
1311
|
+
putAgent(rec) {
|
|
1312
|
+
writeJson(paths.agentFile(this.root, rec.id), rec);
|
|
1313
|
+
}
|
|
1314
|
+
getAgent(id) {
|
|
1315
|
+
const file = paths.agentFile(this.root, id);
|
|
1316
|
+
if (!existsSync(file)) return void 0;
|
|
1317
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
1318
|
+
}
|
|
1319
|
+
listAgents() {
|
|
1320
|
+
const dir = paths.agentsDir(this.root);
|
|
1321
|
+
if (!existsSync(dir)) return [];
|
|
1322
|
+
return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => JSON.parse(readFileSync(join(dir, f), "utf8"))).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
1323
|
+
}
|
|
1324
|
+
removeAgent(id) {
|
|
1325
|
+
rmSync(paths.agentFile(this.root, id), { force: true });
|
|
1326
|
+
}
|
|
1327
|
+
removeAgentData(id) {
|
|
1328
|
+
rmSync(paths.dataDir(this.root, id), {
|
|
1329
|
+
recursive: true,
|
|
1330
|
+
force: true
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
putPaneIndex(pane, agentId) {
|
|
1334
|
+
writeText(paths.paneFile(this.root, pane), agentId);
|
|
1335
|
+
}
|
|
1336
|
+
resolvePaneId(pane) {
|
|
1337
|
+
const file = paths.paneFile(this.root, pane);
|
|
1338
|
+
return existsSync(file) ? readFileSync(file, "utf8").trim() : void 0;
|
|
1339
|
+
}
|
|
1340
|
+
findPaneByAgentId(agentId) {
|
|
1341
|
+
const dir = paths.panesDir(this.root);
|
|
1342
|
+
if (!existsSync(dir)) return void 0;
|
|
1343
|
+
for (const f of readdirSync(dir)) {
|
|
1344
|
+
if (!f.endsWith(".id")) continue;
|
|
1345
|
+
if (readFileSync(join(dir, f), "utf8").trim() === agentId) return f.slice(0, -3);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
removePaneIndex(pane) {
|
|
1349
|
+
rmSync(paths.paneFile(this.root, pane), { force: true });
|
|
1350
|
+
}
|
|
1351
|
+
writeBrief(agentId, text) {
|
|
1352
|
+
writeText(paths.briefFile(this.root, agentId), text);
|
|
1353
|
+
}
|
|
1354
|
+
readBrief(agentId) {
|
|
1355
|
+
const file = paths.briefFile(this.root, agentId);
|
|
1356
|
+
return existsSync(file) ? readFileSync(file, "utf8") : void 0;
|
|
1357
|
+
}
|
|
1358
|
+
resultPath(id) {
|
|
1359
|
+
return paths.resultFile(this.root, id);
|
|
1360
|
+
}
|
|
1361
|
+
writeResult(id, value) {
|
|
1362
|
+
writeJson(paths.resultFile(this.root, id), value);
|
|
1363
|
+
}
|
|
1364
|
+
readResult(id) {
|
|
1365
|
+
const file = paths.resultFile(this.root, id);
|
|
1366
|
+
return existsSync(file) ? readFileSync(file, "utf8") : void 0;
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
//#endregion
|
|
1370
|
+
//#region src/wake/watch.ts
|
|
1371
|
+
const POLL_MS = 1e3;
|
|
1372
|
+
/**
|
|
1373
|
+
* A continuous foreground observer: prints each NEW matching message as it arrives, polling on a
|
|
1374
|
+
* fixed ~1s interval, until interrupted (or `maxIterations` in tests). Never acks — a message
|
|
1375
|
+
* `watch` prints stays unread and still surfaces later in `mail inbox`/`mail await`.
|
|
1376
|
+
*/
|
|
1377
|
+
async function watchMail(ctx, input, onMessage, opts = {}) {
|
|
1378
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1379
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1380
|
+
for (const m of inbox(ctx, {
|
|
1381
|
+
meId: input.meId,
|
|
1382
|
+
thread: input.thread,
|
|
1383
|
+
from: input.from
|
|
1384
|
+
})) seen.add(m.id);
|
|
1385
|
+
let iterations = 0;
|
|
1386
|
+
for (;;) {
|
|
1387
|
+
const items = inbox(ctx, {
|
|
1388
|
+
meId: input.meId,
|
|
1389
|
+
thread: input.thread,
|
|
1390
|
+
from: input.from
|
|
1391
|
+
});
|
|
1392
|
+
for (const m of items) {
|
|
1393
|
+
if (seen.has(m.id)) continue;
|
|
1394
|
+
seen.add(m.id);
|
|
1395
|
+
onMessage(m);
|
|
1396
|
+
}
|
|
1397
|
+
iterations++;
|
|
1398
|
+
if (opts.maxIterations != null && iterations >= opts.maxIterations) return;
|
|
1399
|
+
await sleep(POLL_MS);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
//#endregion
|
|
1403
|
+
export { resolveProjectLocalRoot as A, loadAgent as B, resolveAgentDef as C, ensureMarker as D, spawn as E, selectSessionAdapter as F, resolveRecipient as G, realExec as H, probeMultiplexer as I, resolveSelfId as K, bumpLastSeen as L, assertDistinctFromPrimary as M, gitWorktreeAdapter as N, paths as O, resolvePrimaryRoot as P, detectHarness as R, listAgentDefs as S, realizeSubagentInstruction as T, register as U, prune as V, resolveAgent as W, inbox as _, fail as a, send as b, toonObject as c, DispatchWaitingError as d, channel as f, deleteMessage as g, ack as h, emit as i, resolveRoot as j, projectRoot as k, install as l, awaitReply as m, FileStore as n, nextStep as o, prep as p, touch as q, injectInbox as r, toonList as s, watchMail as t, collect as u, peek as v, realizeLaunch as w, decommission as x, resolveBody as y, listAgents as z };
|