cyberlegion 0.1.0 → 0.2.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/dist/cli.mjs +379 -216
- package/dist/index.d.mts +57 -279
- package/dist/index.mjs +4 -28
- package/dist/{watch-BuYsdwhx.mjs → watch-nkm6F1_f.mjs} +696 -745
- package/package.json +7 -5
- package/readme.md +35 -30
|
@@ -1,56 +1,38 @@
|
|
|
1
1
|
import { basename, dirname, join, resolve } from "node:path";
|
|
2
|
+
import { callerPane, currentPane, herdrMuxAdapter, nodeExec, probeMultiplexer, tmuxMuxAdapter } from "cyber-mux";
|
|
2
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
+
import { assertDistinctFromPrimary, gitWorktreeAdapter, resolvePrimaryRoot } from "cyber-mux/worktree";
|
|
4
5
|
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
5
7
|
import { homedir } from "node:os";
|
|
6
|
-
//#region src/
|
|
7
|
-
/** The only worktree backend at MVP — plain `git worktree`. */
|
|
8
|
-
const gitWorktreeAdapter = {
|
|
9
|
-
add(exec, opts) {
|
|
10
|
-
if (exec("git", [
|
|
11
|
-
"-C",
|
|
12
|
-
opts.primaryRoot,
|
|
13
|
-
"worktree",
|
|
14
|
-
"add",
|
|
15
|
-
"-b",
|
|
16
|
-
opts.branch,
|
|
17
|
-
opts.path
|
|
18
|
-
]) === null) throw new Error(`git worktree add failed for ${opts.path}`);
|
|
19
|
-
return {
|
|
20
|
-
root: resolve(opts.path),
|
|
21
|
-
branch: opts.branch
|
|
22
|
-
};
|
|
23
|
-
},
|
|
24
|
-
remove(exec, path, opts) {
|
|
25
|
-
if (exec("git", [
|
|
26
|
-
"-C",
|
|
27
|
-
opts.primaryRoot,
|
|
28
|
-
"worktree",
|
|
29
|
-
"remove",
|
|
30
|
-
path,
|
|
31
|
-
"--force"
|
|
32
|
-
]) === null) throw new Error(`git worktree remove failed for ${path}`);
|
|
33
|
-
}
|
|
34
|
-
};
|
|
8
|
+
//#region src/mux-env.ts
|
|
35
9
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
*
|
|
50
|
-
*
|
|
10
|
+
* Transitional env-normalization seam (mux.feature: "a pane carrying only the legacy fast-path vars
|
|
11
|
+
* is still honored"). cyber-mux's own fast-path reads `$CYBER_MUX`/`$CYBER_MUX_PANE` — its
|
|
12
|
+
* `currentPane` hardcodes those names (no `envPrefix` override), and `probeMultiplexer`'s `envPrefix`
|
|
13
|
+
* option renames the WHOLE pair together, so neither can be steered to fall back onto a
|
|
14
|
+
* differently-named legacy pair. Every call into cyber-mux that reads the fast-path
|
|
15
|
+
* (`probeMultiplexer`, `currentPane`, `callerPane`, `resolveMuxAdapter`) must be handed this seam's
|
|
16
|
+
* output instead of the raw env.
|
|
17
|
+
*
|
|
18
|
+
* When `$CYBER_MUX`/`$CYBER_MUX_PANE` are both absent and either legacy `$CYBERLEGION_MUX`/
|
|
19
|
+
* `$CYBERLEGION_MUX_PANE` var is present, copies the legacy pair onto the current names. Never
|
|
20
|
+
* overwrites an already-set current var — the current pair always wins outright the moment either
|
|
21
|
+
* half of it is set, exactly matching the frozen precedence chain (mux.feature: "the current
|
|
22
|
+
* fast-path vars win over the legacy pair when both are set").
|
|
23
|
+
*
|
|
24
|
+
* Transitional — deleted once no pre-migration pane (one that only ever exported the legacy pair) is
|
|
25
|
+
* still alive.
|
|
51
26
|
*/
|
|
52
|
-
function
|
|
53
|
-
|
|
27
|
+
function normalizeMuxEnv(env) {
|
|
28
|
+
const hasCurrent = env.CYBER_MUX !== void 0 || env.CYBER_MUX_PANE !== void 0;
|
|
29
|
+
const hasLegacy = env.CYBERLEGION_MUX !== void 0 || env.CYBERLEGION_MUX_PANE !== void 0;
|
|
30
|
+
if (hasCurrent || !hasLegacy) return env;
|
|
31
|
+
return {
|
|
32
|
+
...env,
|
|
33
|
+
CYBER_MUX: env.CYBERLEGION_MUX,
|
|
34
|
+
CYBER_MUX_PANE: env.CYBERLEGION_MUX_PANE
|
|
35
|
+
};
|
|
54
36
|
}
|
|
55
37
|
//#endregion
|
|
56
38
|
//#region src/paths.ts
|
|
@@ -132,7 +114,7 @@ const paths = {
|
|
|
132
114
|
inboxReadDir: (root, id) => join(root, "inbox", id, "read"),
|
|
133
115
|
dataDir: (root, id) => join(root, "data", id),
|
|
134
116
|
briefFile: (root, id) => join(root, "data", id, "brief.md"),
|
|
135
|
-
|
|
117
|
+
mainPaneFile: (root) => join(root, "main-pane.id")
|
|
136
118
|
};
|
|
137
119
|
/**
|
|
138
120
|
* Where a spawned unit's own git worktree is checked out by default — a sibling of the primary
|
|
@@ -154,20 +136,18 @@ function sanitizePane(pane) {
|
|
|
154
136
|
}
|
|
155
137
|
//#endregion
|
|
156
138
|
//#region src/identity.ts
|
|
157
|
-
const realExec =
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
};
|
|
139
|
+
const realExec = nodeExec;
|
|
140
|
+
/** `currentPane`, narrowed to a backend the registry can actually store a locator under. A caller
|
|
141
|
+
* inside a pane-carrying-but-unstorable multiplexer (wezterm/zellij) is simply unpaned here — it can
|
|
142
|
+
* still resolve an identity via the `$CYBERLEGION_AGENT_ID` env fallback, it just cannot bind a pane
|
|
143
|
+
* locator to it. */
|
|
144
|
+
function storablePane(env) {
|
|
145
|
+
const cur = currentPane(normalizeMuxEnv(env));
|
|
146
|
+
return cur && (cur.mux === "tmux" || cur.mux === "herdr") ? {
|
|
147
|
+
mux: cur.mux,
|
|
148
|
+
pane: cur.pane
|
|
149
|
+
} : void 0;
|
|
150
|
+
}
|
|
171
151
|
const nowIso = (ctx) => new Date(ctx.now?.() ?? Date.now()).toISOString();
|
|
172
152
|
function randomId() {
|
|
173
153
|
return randomBytes(8).toString("hex");
|
|
@@ -206,15 +186,16 @@ function detectHarness(explicit, ctx) {
|
|
|
206
186
|
}
|
|
207
187
|
}
|
|
208
188
|
/**
|
|
209
|
-
* Recover the calling agent's own id.
|
|
210
|
-
* pane entry is simply unregistered and must NOT
|
|
211
|
-
*
|
|
212
|
-
*
|
|
189
|
+
* Recover the calling agent's own id, mux-agnostically. Inside any multiplexer pane (tmux or herdr)
|
|
190
|
+
* the pane index is authoritative — a pane with no pane entry is simply unregistered and must NOT
|
|
191
|
+
* adopt `$CYBERLEGION_AGENT_ID` (that env fallback applies ONLY when the session is in no
|
|
192
|
+
* multiplexer pane at all). There is no shared bare "self" file — self-id is always pane-keyed or
|
|
193
|
+
* explicit via the env var.
|
|
213
194
|
*/
|
|
214
195
|
function resolveSelfId(ctx) {
|
|
215
196
|
const env = ctx.env ?? process.env;
|
|
216
|
-
const
|
|
217
|
-
if (
|
|
197
|
+
const cur = currentPane(normalizeMuxEnv(env));
|
|
198
|
+
if (cur) return ctx.store.resolvePaneId(cur.pane);
|
|
218
199
|
return env.CYBERLEGION_AGENT_ID || void 0;
|
|
219
200
|
}
|
|
220
201
|
/** Register (or idempotently refresh) this session's identity. */
|
|
@@ -227,7 +208,7 @@ function register(ctx, input) {
|
|
|
227
208
|
const existing = existingId ? loadAgent(ctx.store, existingId) : void 0;
|
|
228
209
|
const id = existing?.id ?? existingId ?? randomId();
|
|
229
210
|
const ts = nowIso(ctx);
|
|
230
|
-
const
|
|
211
|
+
const cur = storablePane(env);
|
|
231
212
|
const exec = ctx.exec ?? realExec;
|
|
232
213
|
const rec = {
|
|
233
214
|
id,
|
|
@@ -235,17 +216,7 @@ function register(ctx, input) {
|
|
|
235
216
|
harness,
|
|
236
217
|
cwd: process.cwd(),
|
|
237
218
|
worktree: existing?.worktree ?? gitWorktree(exec),
|
|
238
|
-
|
|
239
|
-
pane,
|
|
240
|
-
window: exec("tmux", [
|
|
241
|
-
"display-message",
|
|
242
|
-
"-p",
|
|
243
|
-
"-t",
|
|
244
|
-
pane,
|
|
245
|
-
"#{window_id}"
|
|
246
|
-
]) ?? void 0,
|
|
247
|
-
session: env.TMUX?.split(",")[0]
|
|
248
|
-
} : null,
|
|
219
|
+
pane: cur ? paneLocator(cur, exec, env) : null,
|
|
249
220
|
status: "active",
|
|
250
221
|
createdAt: existing?.createdAt ?? ts,
|
|
251
222
|
lastSeen: ts,
|
|
@@ -253,9 +224,29 @@ function register(ctx, input) {
|
|
|
253
224
|
...existing?.spawnedBy ? { spawnedBy: existing.spawnedBy } : {}
|
|
254
225
|
};
|
|
255
226
|
saveAgent(ctx.store, rec);
|
|
256
|
-
if (
|
|
227
|
+
if (cur) ctx.store.putPaneIndex(cur.pane, id);
|
|
257
228
|
return rec;
|
|
258
229
|
}
|
|
230
|
+
/** Build the record's pane locator. `window`/`session` are tmux-only (herdr's pane id is
|
|
231
|
+
* self-contained and its CLI needs nothing more to address a pane). */
|
|
232
|
+
function paneLocator(cur, exec, env) {
|
|
233
|
+
if (cur.mux === "tmux") return {
|
|
234
|
+
mux: "tmux",
|
|
235
|
+
id: cur.pane,
|
|
236
|
+
window: exec("tmux", [
|
|
237
|
+
"display-message",
|
|
238
|
+
"-p",
|
|
239
|
+
"-t",
|
|
240
|
+
cur.pane,
|
|
241
|
+
"#{window_id}"
|
|
242
|
+
]) ?? void 0,
|
|
243
|
+
session: env.TMUX?.split(",")[0]
|
|
244
|
+
};
|
|
245
|
+
return {
|
|
246
|
+
mux: cur.mux,
|
|
247
|
+
id: cur.pane
|
|
248
|
+
};
|
|
249
|
+
}
|
|
259
250
|
/** Derive a standing record's stable id from its handle — same slug rule as `sanitizePane`, prefixed
|
|
260
251
|
* so it reads distinct from a random 16-hex session id or a pane pointer. */
|
|
261
252
|
function standingId(handle) {
|
|
@@ -275,7 +266,7 @@ function registerStanding(ctx, input) {
|
|
|
275
266
|
id,
|
|
276
267
|
handle: input.handle,
|
|
277
268
|
kind: "standing",
|
|
278
|
-
|
|
269
|
+
pane: null,
|
|
279
270
|
harness: void 0,
|
|
280
271
|
cwd: process.cwd(),
|
|
281
272
|
status: "active",
|
|
@@ -302,18 +293,102 @@ function gitWorktree(exec) {
|
|
|
302
293
|
function preferStanding(matches) {
|
|
303
294
|
return matches.find((a) => a.kind === "standing") ?? matches[0];
|
|
304
295
|
}
|
|
296
|
+
/** Split a handle's matches into live and exited. An exited unit's pane is gone and its inbox has
|
|
297
|
+
* no reader, so a *name* must never resolve to one — a handle is reusable across units, and the
|
|
298
|
+
* dead holders of it outnumber the live one over time. Standing records never exit. An explicit id
|
|
299
|
+
* still resolves either way: naming a unit outright is a deliberate choice, unlike reaching for a
|
|
300
|
+
* handle and silently landing on a corpse. */
|
|
301
|
+
function matchHandle(agents, handle) {
|
|
302
|
+
const matched = agents.filter((a) => a.handle === handle);
|
|
303
|
+
return {
|
|
304
|
+
live: matched.filter((a) => a.status !== "exited"),
|
|
305
|
+
exited: matched.filter((a) => a.status === "exited")
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/** Fail loudly when a handle names only the dead — never fall through to a corpse. */
|
|
309
|
+
function unaddressable(ref, exited, tried) {
|
|
310
|
+
if (exited.length > 0) {
|
|
311
|
+
const dead = exited.map((a) => `${a.id.slice(0, 6)}${a.pane ? ` (${a.pane.id})` : ""}`).join(", ");
|
|
312
|
+
return /* @__PURE__ */ new Error(`"${ref}" matches only exited unit(s) — ${dead} — which have no reader. Address a live unit ('cyberlegion unit who'), or run 'cyberlegion unit register --standing --handle ${ref}' for a durable inbox.`);
|
|
313
|
+
}
|
|
314
|
+
return /* @__PURE__ */ new Error(`no agent addressable as "${ref}" (tried ${tried})`);
|
|
315
|
+
}
|
|
305
316
|
/** Resolve a handle to its standing owner record's id — never falls back to a live session agent
|
|
306
317
|
* sharing that handle, so `--owner` can never be pointed at a session's inbox by mistake. */
|
|
307
318
|
function resolveStandingOwner(store, handle) {
|
|
308
319
|
const match = listAgents(store).find((a) => a.handle === handle && a.kind === "standing");
|
|
309
|
-
if (!match) throw new Error(`no standing owner "${handle}" — run 'cyberlegion
|
|
320
|
+
if (!match) throw new Error(`no standing owner "${handle}" — run 'cyberlegion unit register --standing --handle ${handle}'`);
|
|
310
321
|
return match.id;
|
|
311
322
|
}
|
|
312
|
-
/**
|
|
323
|
+
/**
|
|
324
|
+
* Bind the caller's own unit as a standing owner's presence — the live unit standing in for a
|
|
325
|
+
* durable record that has no session of its own. Order matters: resolve the standing record first
|
|
326
|
+
* (an unknown handle throws via `resolveStandingOwner` — fail-loud, never auto-mints), THEN gate on
|
|
327
|
+
* spawn capability, THEN resolve the caller's own id — so an unknown handle or a caller with no
|
|
328
|
+
* multiplexer never touches the pointer. Last claim wins: a plain overwrite, no merge.
|
|
329
|
+
*/
|
|
330
|
+
function claimPresence(ctx, handle) {
|
|
331
|
+
const rec = loadAgent(ctx.store, resolveStandingOwner(ctx.store, handle));
|
|
332
|
+
if (!rec) throw new Error(`no standing owner "${handle}" — run 'cyberlegion unit register --standing --handle ${handle}'`);
|
|
333
|
+
if (probeMultiplexer(ctx.exec ?? realExec, normalizeMuxEnv(ctx.env ?? process.env)).mux === "none") throw new Error("claiming a presence needs a multiplexer to open panes");
|
|
334
|
+
const selfId = resolveSelfId(ctx);
|
|
335
|
+
if (!selfId) throw new Error("no identity in this session — run `cyberlegion unit register` first");
|
|
336
|
+
rec.presence = selfId;
|
|
337
|
+
saveAgent(ctx.store, rec);
|
|
338
|
+
return rec;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Unbind a standing owner's presence. The unknown-handle throw (via `resolveStandingOwner`) wins
|
|
342
|
+
* over this call's own tolerance: `--clear` is forgiving about *nothing being bound* (a no-op, never
|
|
343
|
+
* an error), never about *the owner not existing* — a typo'd handle fails loudly instead of silently
|
|
344
|
+
* reporting a clear it never performed.
|
|
345
|
+
*/
|
|
346
|
+
function clearPresence(ctx, handle) {
|
|
347
|
+
const rec = loadAgent(ctx.store, resolveStandingOwner(ctx.store, handle));
|
|
348
|
+
if (!rec) throw new Error(`no standing owner "${handle}" — run 'cyberlegion unit register --standing --handle ${handle}'`);
|
|
349
|
+
if (rec.presence !== void 0) {
|
|
350
|
+
rec.presence = void 0;
|
|
351
|
+
saveAgent(ctx.store, rec);
|
|
352
|
+
}
|
|
353
|
+
return rec;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* The live-only presence rule, keyed off a standing record the caller ALREADY HOLDS — the pointer
|
|
357
|
+
* records a unit id, and that unit can exit while the standing record it stands in for never does,
|
|
358
|
+
* so a presence whose unit is missing or `status: exited` reads as no presence bound, exactly as if
|
|
359
|
+
* none were ever claimed. Nothing here self-heals the stale pointer; it stays inert until re-claimed
|
|
360
|
+
* via `claimPresence`.
|
|
361
|
+
*
|
|
362
|
+
* Separate from `resolvePresence` because this one is INCAPABLE of throwing: the delivery doorbell
|
|
363
|
+
* must never fail a send that already landed durably, so it cannot resolve the presence through a
|
|
364
|
+
* handle-resolving path that throws when the standing record races away underneath it (a concurrent
|
|
365
|
+
* `unit close`/`decommission` between loading the recipient and reading its presence). It is also
|
|
366
|
+
* strictly cheaper — no O(n) registry scan to re-find a record the caller is holding.
|
|
367
|
+
*/
|
|
368
|
+
function presenceOf(store, rec) {
|
|
369
|
+
if (!rec.presence) return void 0;
|
|
370
|
+
const presenceUnit = loadAgent(store, rec.presence);
|
|
371
|
+
if (!presenceUnit || presenceUnit.status === "exited") return void 0;
|
|
372
|
+
return presenceUnit;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Resolve a standing owner's presence by handle, live-only (`presenceOf`). The handle-keyed front
|
|
376
|
+
* door for the CLI read path: it keeps the fail-loud unknown-handle throw (via `resolveStandingOwner`)
|
|
377
|
+
* so `unit claim <handle> --show` on a typo'd handle errors rather than reporting a definitive `none`
|
|
378
|
+
* for an owner that does not exist.
|
|
379
|
+
*/
|
|
380
|
+
function resolvePresence(store, handle) {
|
|
381
|
+
const rec = loadAgent(store, resolveStandingOwner(store, handle));
|
|
382
|
+
if (!rec) return void 0;
|
|
383
|
+
return presenceOf(store, rec);
|
|
384
|
+
}
|
|
385
|
+
/** Resolve a recipient argument (id or handle) to an agent id. A handle resolves to live units
|
|
386
|
+
* only — mail addressed to an exited unit lands in an inbox nobody reads. */
|
|
313
387
|
function resolveRecipient(store, to) {
|
|
314
388
|
if (loadAgent(store, to)) return to;
|
|
315
|
-
const
|
|
316
|
-
|
|
389
|
+
const { live, exited } = matchHandle(listAgents(store), to);
|
|
390
|
+
const match = preferStanding(live);
|
|
391
|
+
if (!match) throw unaddressable(to, exited, "id and handle");
|
|
317
392
|
return match.id;
|
|
318
393
|
}
|
|
319
394
|
/**
|
|
@@ -325,11 +400,13 @@ function resolveAgent(store, ref) {
|
|
|
325
400
|
const byId = loadAgent(store, ref);
|
|
326
401
|
if (byId) return byId;
|
|
327
402
|
const agents = listAgents(store);
|
|
328
|
-
const
|
|
403
|
+
const { live, exited } = matchHandle(agents, ref);
|
|
404
|
+
const byHandle = preferStanding(live);
|
|
329
405
|
if (byHandle) return byHandle;
|
|
330
|
-
const
|
|
406
|
+
const byBranchAll = agents.filter((a) => a.worktree?.branch === ref);
|
|
407
|
+
const byBranch = byBranchAll.find((a) => a.status !== "exited");
|
|
331
408
|
if (byBranch) return byBranch;
|
|
332
|
-
throw
|
|
409
|
+
throw unaddressable(ref, [...exited, ...byBranchAll.filter((a) => a.status === "exited")].filter((a, i, all) => all.findIndex((b) => b.id === a.id) === i), "id, handle, and worktree branch/CR");
|
|
333
410
|
}
|
|
334
411
|
function bumpLastSeen(ctx, id) {
|
|
335
412
|
const rec = loadAgent(ctx.store, id);
|
|
@@ -343,24 +420,100 @@ function touch(ctx) {
|
|
|
343
420
|
if (id) bumpLastSeen(ctx, id);
|
|
344
421
|
}
|
|
345
422
|
const STALE_MS = 900 * 1e3;
|
|
346
|
-
/**
|
|
423
|
+
/** The per-mux session adapters `prune` consults for pane liveness — each answers with its own
|
|
424
|
+
* backend primitive so a herdr pane is never probed with a tmux query, and vice versa. */
|
|
425
|
+
const PANE_ADAPTERS = {
|
|
426
|
+
tmux: tmuxMuxAdapter,
|
|
427
|
+
herdr: herdrMuxAdapter
|
|
428
|
+
};
|
|
429
|
+
/** Map a backend-reported agent string to a known harness — substring-matched like the tmux
|
|
430
|
+
* pane-command probe in `detectHarness`; anything else is unclassifiable. */
|
|
431
|
+
function harnessFromAgent(agent) {
|
|
432
|
+
if (!agent) return void 0;
|
|
433
|
+
if (agent.includes("cursor")) return "cursor";
|
|
434
|
+
if (agent.includes("codex")) return "codex";
|
|
435
|
+
if (agent.includes("claude")) return "claude";
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Adopt half of reconcile-against-mux: mint a record for each live pane with a detectable harness
|
|
439
|
+
* and no matching record — bind pane→id, handle from the pane's reported cwd basename (sanitized;
|
|
440
|
+
* `id.slice(0, 6)` when the backend reports no cwd), status active, lastSeen now. A pane is bound —
|
|
441
|
+
* and never adopted — when its pane index resolves to an existing record or any record (any status,
|
|
442
|
+
* exited included) bears it; resurrecting an exited record is the in-pane session's own `register`
|
|
443
|
+
* via the pane pointer, never reconcile's. tmux panes carry no harness signal, so only herdr panes
|
|
444
|
+
* are adoptable today.
|
|
445
|
+
*/
|
|
446
|
+
function adopt(ctx, panes) {
|
|
447
|
+
const agents = listAgents(ctx.store);
|
|
448
|
+
const adopted = [];
|
|
449
|
+
for (const pane of panes) {
|
|
450
|
+
if (pane.mux !== "tmux" && pane.mux !== "herdr") continue;
|
|
451
|
+
const harness = harnessFromAgent(pane.harness);
|
|
452
|
+
if (!harness) continue;
|
|
453
|
+
const boundId = ctx.store.resolvePaneId(pane.id);
|
|
454
|
+
if (boundId && loadAgent(ctx.store, boundId)) continue;
|
|
455
|
+
if (agents.some((a) => a.pane?.mux === pane.mux && a.pane.id === pane.id)) continue;
|
|
456
|
+
const id = randomId();
|
|
457
|
+
const ts = nowIso(ctx);
|
|
458
|
+
const rec = {
|
|
459
|
+
id,
|
|
460
|
+
handle: pane.cwd ? sanitizePane(basename(pane.cwd)) : id.slice(0, 6),
|
|
461
|
+
harness,
|
|
462
|
+
cwd: pane.cwd ?? "",
|
|
463
|
+
pane: {
|
|
464
|
+
mux: pane.mux,
|
|
465
|
+
id: pane.id
|
|
466
|
+
},
|
|
467
|
+
status: "active",
|
|
468
|
+
createdAt: ts,
|
|
469
|
+
lastSeen: ts
|
|
470
|
+
};
|
|
471
|
+
saveAgent(ctx.store, rec);
|
|
472
|
+
ctx.store.putPaneIndex(pane.id, id);
|
|
473
|
+
adopted.push(rec);
|
|
474
|
+
}
|
|
475
|
+
return adopted;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Cull dead records against the current mux's live pane set (the mux the caller is actually inside,
|
|
479
|
+
* per `currentPane`) — mux-scoped: it never declares the *other* mux's records dead, since it can't
|
|
480
|
+
* enumerate them. Standing records are exempt; a `pane: null` record can't be pane-culled by
|
|
481
|
+
* enumeration (left to `prune`'s staleness timer). Outside any multiplexer pane there is nothing to
|
|
482
|
+
* enumerate, so it culls nothing. With `adopt` set (the `who --reconcile` path — `prune` stays
|
|
483
|
+
* cull-only), the same live set also feeds adoption of unbound harness-bearing panes.
|
|
484
|
+
*/
|
|
485
|
+
function reconcile(ctx, opts) {
|
|
486
|
+
const exec = ctx.exec ?? realExec;
|
|
487
|
+
const cur = storablePane(ctx.env ?? process.env);
|
|
488
|
+
if (!cur) return [];
|
|
489
|
+
const panes = PANE_ADAPTERS[cur.mux].listPanes(exec);
|
|
490
|
+
const live = new Set(panes.map((p) => p.id));
|
|
491
|
+
const changed = [];
|
|
492
|
+
for (const rec of listAgents(ctx.store)) {
|
|
493
|
+
if (rec.kind === "standing") continue;
|
|
494
|
+
if (rec.status === "exited") continue;
|
|
495
|
+
if (!rec.pane) continue;
|
|
496
|
+
if (rec.pane.mux !== cur.mux) continue;
|
|
497
|
+
if (!live.has(rec.pane.id)) {
|
|
498
|
+
rec.status = "exited";
|
|
499
|
+
saveAgent(ctx.store, rec);
|
|
500
|
+
changed.push(rec);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (opts?.adopt) changed.push(...adopt(ctx, panes));
|
|
504
|
+
return changed;
|
|
505
|
+
}
|
|
506
|
+
/** Mark agents whose pane is gone or whose last-seen is stale as exited. Reconcile-culls against the
|
|
507
|
+
* current mux's live set first, then falls through to the per-record paneExists + staleness check
|
|
508
|
+
* (covers the other mux and sessions outside any multiplexer pane). */
|
|
347
509
|
function prune(ctx) {
|
|
348
510
|
const exec = ctx.exec ?? realExec;
|
|
349
511
|
const now = ctx.now?.() ?? Date.now();
|
|
350
|
-
const changed =
|
|
512
|
+
const changed = reconcile(ctx);
|
|
351
513
|
for (const rec of listAgents(ctx.store)) {
|
|
352
514
|
if (rec.kind === "standing") continue;
|
|
353
515
|
if (rec.status === "exited") continue;
|
|
354
|
-
const paneGone = rec.
|
|
355
|
-
"has-session",
|
|
356
|
-
"-t",
|
|
357
|
-
rec.tmux.pane
|
|
358
|
-
]) === null && !(exec("tmux", [
|
|
359
|
-
"list-panes",
|
|
360
|
-
"-a",
|
|
361
|
-
"-F",
|
|
362
|
-
"#{pane_id}"
|
|
363
|
-
]) ?? "").split("\n").includes(rec.tmux.pane) : false;
|
|
516
|
+
const paneGone = rec.pane ? !PANE_ADAPTERS[rec.pane.mux].paneExists(exec, { id: rec.pane.id }) : false;
|
|
364
517
|
const stale = now - new Date(rec.lastSeen).getTime() > STALE_MS;
|
|
365
518
|
if (paneGone || stale) {
|
|
366
519
|
rec.status = "exited";
|
|
@@ -371,356 +524,186 @@ function prune(ctx) {
|
|
|
371
524
|
return changed;
|
|
372
525
|
}
|
|
373
526
|
//#endregion
|
|
374
|
-
//#region src/
|
|
375
|
-
const KNOWN_MUX = [
|
|
376
|
-
"tmux",
|
|
377
|
-
"herdr",
|
|
378
|
-
"screen",
|
|
379
|
-
"none"
|
|
380
|
-
];
|
|
381
|
-
function isKnownMux(v) {
|
|
382
|
-
return v != null && KNOWN_MUX.includes(v);
|
|
383
|
-
}
|
|
527
|
+
//#region src/mux-select.ts
|
|
384
528
|
/**
|
|
385
|
-
*
|
|
529
|
+
* Backend selection via cyber-mux's two-mode mux probe, normalized through the transitional
|
|
530
|
+
* `$CYBERLEGION_MUX*` → `$CYBER_MUX*` env seam (`mux-env.ts`) — tmux/herdr map to their existing
|
|
531
|
+
* cyber-mux adapters.
|
|
386
532
|
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
* are used only as a fast-positive hint the ancestry walk falls back to when the walk itself is
|
|
394
|
-
* inconclusive (e.g. `ps` unavailable), never as a substitute for it.
|
|
533
|
+
* cyber-mux detects MORE backends than `unit/registry`'s `AgentRecord.pane` can carry a locator
|
|
534
|
+
* under (`'tmux' | 'herdr'` only). A DETECTED wezterm/zellij is refused HERE, before anything opens,
|
|
535
|
+
* naming the backend it found (mux.feature: "a detected backend a unit record cannot carry is
|
|
536
|
+
* refused before opening anything") — driving it would open a real pane no record could name,
|
|
537
|
+
* stranding a live session `prune` can never reap and no caller can nudge. Anything else (`none`,
|
|
538
|
+
* `screen`) falls through to the plain "no backend" refusal, unchanged from before the migration.
|
|
395
539
|
*/
|
|
396
|
-
function
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (opts.discover === false) return {
|
|
403
|
-
mux: "none",
|
|
404
|
-
via: "ancestry"
|
|
405
|
-
};
|
|
406
|
-
return discoverByAncestry(exec, env);
|
|
407
|
-
}
|
|
408
|
-
const MUX_COMM = [
|
|
409
|
-
{
|
|
410
|
-
re: /^tmux(:|$)/,
|
|
411
|
-
mux: "tmux",
|
|
412
|
-
pane: (env) => env.TMUX_PANE
|
|
413
|
-
},
|
|
414
|
-
{
|
|
415
|
-
re: /^herdr(:|$)/,
|
|
416
|
-
mux: "herdr",
|
|
417
|
-
pane: (env) => env.HERDR_PANE
|
|
418
|
-
},
|
|
419
|
-
{
|
|
420
|
-
re: /^screen(:|$)/,
|
|
421
|
-
mux: "screen",
|
|
422
|
-
pane: () => void 0
|
|
423
|
-
}
|
|
424
|
-
];
|
|
425
|
-
const MAX_ANCESTORS = 32;
|
|
426
|
-
function walkAncestry(exec, env) {
|
|
427
|
-
let pid = process.pid;
|
|
428
|
-
const seen = /* @__PURE__ */ new Set();
|
|
429
|
-
for (let i = 0; i < MAX_ANCESTORS; i++) {
|
|
430
|
-
if (seen.has(pid)) break;
|
|
431
|
-
seen.add(pid);
|
|
432
|
-
const line = exec("ps", [
|
|
433
|
-
"-o",
|
|
434
|
-
"ppid=,comm=",
|
|
435
|
-
"-p",
|
|
436
|
-
String(pid)
|
|
437
|
-
]);
|
|
438
|
-
if (!line) break;
|
|
439
|
-
const trimmed = line.trim();
|
|
440
|
-
const spaceIdx = trimmed.indexOf(" ");
|
|
441
|
-
const ppidStr = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
|
|
442
|
-
const comm = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
|
|
443
|
-
const ppid = Number.parseInt(ppidStr, 10);
|
|
444
|
-
for (const entry of MUX_COMM) if (entry.re.test(comm)) return {
|
|
445
|
-
mux: entry.mux,
|
|
446
|
-
pane: entry.pane(env),
|
|
447
|
-
via: "ancestry"
|
|
448
|
-
};
|
|
449
|
-
if (!Number.isFinite(ppid) || ppid <= 1) break;
|
|
450
|
-
pid = ppid;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
function discoverByAncestry(exec, env) {
|
|
454
|
-
const found = walkAncestry(exec, env);
|
|
455
|
-
if (found) return found;
|
|
456
|
-
if (env.TMUX) return {
|
|
457
|
-
mux: "tmux",
|
|
458
|
-
pane: env.TMUX_PANE,
|
|
459
|
-
via: "ancestry"
|
|
460
|
-
};
|
|
461
|
-
if (env.HERDR_ENV) return {
|
|
462
|
-
mux: "herdr",
|
|
463
|
-
pane: env.HERDR_PANE,
|
|
464
|
-
via: "ancestry"
|
|
465
|
-
};
|
|
466
|
-
return {
|
|
467
|
-
mux: "none",
|
|
468
|
-
via: "ancestry"
|
|
469
|
-
};
|
|
540
|
+
function selectSessionAdapter(env, exec = realExec) {
|
|
541
|
+
const probe = probeMultiplexer(exec, normalizeMuxEnv(env));
|
|
542
|
+
if (probe.mux === "tmux") return tmuxMuxAdapter;
|
|
543
|
+
if (probe.mux === "herdr") return herdrMuxAdapter;
|
|
544
|
+
if (probe.mux === "wezterm" || probe.mux === "zellij") throw new Error(`spawn detected ${probe.mux}, a backend unit/registry cannot store a pane locator under (only tmux and herdr) — refusing before opening anything`);
|
|
545
|
+
throw new Error("spawn requires a session backend — run inside tmux ($TMUX) or herdr ($HERDR_ENV=1)");
|
|
470
546
|
}
|
|
471
547
|
//#endregion
|
|
472
|
-
//#region src/
|
|
548
|
+
//#region src/workspace-label.ts
|
|
473
549
|
/**
|
|
474
|
-
*
|
|
475
|
-
* multiplexer that also reports real busy-state (working / idle / blocked / done); this adapter
|
|
476
|
-
* only drives its pane lifecycle, not the state feed. Talks to herdr's own CLI (`herdr pane ...`)
|
|
477
|
-
* rather than its Unix-socket API, so it composes with this codebase's synchronous `Exec`
|
|
478
|
-
* convention exactly like the tmux adapter — no new client/transport needed.
|
|
550
|
+
* Workspace labels — the short, human-scannable name a `workspace` placement opens under.
|
|
479
551
|
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
552
|
+
* A unit's own visible space is what a human scans to find a session, so a bare backend default
|
|
553
|
+
* ("workspace 4") costs a search every time. A label is `<code>-<subject>`, capped at 30 characters
|
|
554
|
+
* INCLUDING the code, so it stays readable in a sidebar/status bar at a glance.
|
|
555
|
+
*
|
|
556
|
+
* The code is a NieR YoRHa unit class picked from the brief's leading action word in one fixed
|
|
557
|
+
* order, so the same brief always yields the same code:
|
|
558
|
+
* - `A2-` — the action tears down or reverts (A2 is the rogue/attack unit)
|
|
559
|
+
* - `9S-` — the action is read-only recon (9S is the Scanner)
|
|
560
|
+
* - `2B-` — everything else: build-and-change work, the default (2B is the battle unit)
|
|
482
561
|
*/
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
opts.launch
|
|
539
|
-
]);
|
|
540
|
-
return {
|
|
541
|
-
target,
|
|
542
|
-
worktree
|
|
543
|
-
};
|
|
544
|
-
},
|
|
545
|
-
send(exec, target, text) {
|
|
546
|
-
exec("herdr", [
|
|
547
|
-
"pane",
|
|
548
|
-
"run",
|
|
549
|
-
target.id,
|
|
550
|
-
text
|
|
551
|
-
]);
|
|
552
|
-
},
|
|
553
|
-
read(exec, target, opts) {
|
|
554
|
-
const args = [
|
|
555
|
-
"pane",
|
|
556
|
-
"read",
|
|
557
|
-
target.id,
|
|
558
|
-
"--source",
|
|
559
|
-
"visible"
|
|
560
|
-
];
|
|
561
|
-
if (opts?.lines != null) args.push("--lines", String(opts.lines));
|
|
562
|
-
return exec("herdr", args) ?? "";
|
|
563
|
-
},
|
|
564
|
-
focus(exec, target) {
|
|
565
|
-
exec("herdr", [
|
|
566
|
-
"pane",
|
|
567
|
-
"focus",
|
|
568
|
-
target.id
|
|
569
|
-
]);
|
|
570
|
-
},
|
|
571
|
-
teardown(exec, target) {
|
|
572
|
-
exec("herdr", [
|
|
573
|
-
"pane",
|
|
574
|
-
"close",
|
|
575
|
-
target.id
|
|
576
|
-
]);
|
|
577
|
-
}
|
|
578
|
-
};
|
|
562
|
+
/** Total label budget, code included. */
|
|
563
|
+
const LABEL_CAP = 30;
|
|
564
|
+
/** Every code is the same width, so the subject budget is a constant. */
|
|
565
|
+
const CODE_WIDTH = 3;
|
|
566
|
+
/** Leading actions that tear down or revert — the `A2-` class, checked first. */
|
|
567
|
+
const TEARDOWN_ACTIONS = new Set([
|
|
568
|
+
"clean",
|
|
569
|
+
"cleanup",
|
|
570
|
+
"decommission",
|
|
571
|
+
"delete",
|
|
572
|
+
"deprecate",
|
|
573
|
+
"disable",
|
|
574
|
+
"drop",
|
|
575
|
+
"prune",
|
|
576
|
+
"purge",
|
|
577
|
+
"remove",
|
|
578
|
+
"retire",
|
|
579
|
+
"revert",
|
|
580
|
+
"revoke",
|
|
581
|
+
"rollback",
|
|
582
|
+
"teardown",
|
|
583
|
+
"undo",
|
|
584
|
+
"uninstall",
|
|
585
|
+
"unregister"
|
|
586
|
+
]);
|
|
587
|
+
/** Leading actions that only look and report — the `9S-` class, checked after teardown. */
|
|
588
|
+
const RECON_ACTIONS = new Set([
|
|
589
|
+
"analyze",
|
|
590
|
+
"assess",
|
|
591
|
+
"audit",
|
|
592
|
+
"check",
|
|
593
|
+
"compare",
|
|
594
|
+
"debug",
|
|
595
|
+
"diagnose",
|
|
596
|
+
"evaluate",
|
|
597
|
+
"examine",
|
|
598
|
+
"explain",
|
|
599
|
+
"explore",
|
|
600
|
+
"find",
|
|
601
|
+
"identify",
|
|
602
|
+
"inspect",
|
|
603
|
+
"investigate",
|
|
604
|
+
"locate",
|
|
605
|
+
"measure",
|
|
606
|
+
"profile",
|
|
607
|
+
"read",
|
|
608
|
+
"reproduce",
|
|
609
|
+
"research",
|
|
610
|
+
"review",
|
|
611
|
+
"scan",
|
|
612
|
+
"survey",
|
|
613
|
+
"trace",
|
|
614
|
+
"triage",
|
|
615
|
+
"verify"
|
|
616
|
+
]);
|
|
579
617
|
/**
|
|
580
|
-
* `
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
618
|
+
* Leading actions that build or change — the `2B-` class. `2B-` is also the code for a brief whose
|
|
619
|
+
* lead matches nothing at all, but the two cases differ in the SUBJECT: a recognized action is
|
|
620
|
+
* dropped from it (the code already says "build"), an unrecognized leading word is kept (it is the
|
|
621
|
+
* subject's own first noun, and dropping it would strip real signal).
|
|
584
622
|
*/
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
623
|
+
const BUILD_ACTIONS = new Set([
|
|
624
|
+
"add",
|
|
625
|
+
"backfill",
|
|
626
|
+
"build",
|
|
627
|
+
"create",
|
|
628
|
+
"document",
|
|
629
|
+
"enable",
|
|
630
|
+
"extend",
|
|
631
|
+
"fix",
|
|
632
|
+
"generalize",
|
|
633
|
+
"harden",
|
|
634
|
+
"implement",
|
|
635
|
+
"introduce",
|
|
636
|
+
"make",
|
|
637
|
+
"migrate",
|
|
638
|
+
"move",
|
|
639
|
+
"port",
|
|
640
|
+
"refactor",
|
|
641
|
+
"rename",
|
|
642
|
+
"replace",
|
|
643
|
+
"rework",
|
|
644
|
+
"ship",
|
|
645
|
+
"split",
|
|
646
|
+
"support",
|
|
647
|
+
"swap",
|
|
648
|
+
"update",
|
|
649
|
+
"wire",
|
|
650
|
+
"write"
|
|
651
|
+
]);
|
|
652
|
+
/** Dropped when it leads the subject — an article carries no identifying signal. */
|
|
653
|
+
const LEADING_ARTICLES = new Set([
|
|
654
|
+
"a",
|
|
655
|
+
"an",
|
|
656
|
+
"the"
|
|
657
|
+
]);
|
|
595
658
|
/**
|
|
596
|
-
*
|
|
597
|
-
*
|
|
659
|
+
* Resolve the label a `workspace` placement opens under. Total width never exceeds `LABEL_CAP`, and
|
|
660
|
+
* the result always carries a code — a brief that yields no usable subject falls back to the unit's
|
|
661
|
+
* own short id rather than to a bare code.
|
|
598
662
|
*/
|
|
599
|
-
function
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
663
|
+
function deriveWorkspaceLabel(input) {
|
|
664
|
+
const briefWords = tokenize(firstNonEmptyLine(input.brief));
|
|
665
|
+
const lead = briefWords[0];
|
|
666
|
+
let code = "2B-";
|
|
667
|
+
let leadIsAction = false;
|
|
668
|
+
if (lead && TEARDOWN_ACTIONS.has(lead)) {
|
|
669
|
+
code = "A2-";
|
|
670
|
+
leadIsAction = true;
|
|
671
|
+
} else if (lead && RECON_ACTIONS.has(lead)) {
|
|
672
|
+
code = "9S-";
|
|
673
|
+
leadIsAction = true;
|
|
674
|
+
} else if (lead && BUILD_ACTIONS.has(lead)) leadIsAction = true;
|
|
675
|
+
let subjectWords;
|
|
676
|
+
if (input.handle) subjectWords = tokenize(input.handle);
|
|
677
|
+
else {
|
|
678
|
+
subjectWords = leadIsAction ? briefWords.slice(1) : briefWords.slice();
|
|
679
|
+
if (subjectWords[0] && LEADING_ARTICLES.has(subjectWords[0])) subjectWords.shift();
|
|
605
680
|
}
|
|
606
|
-
|
|
607
|
-
return
|
|
681
|
+
const subject = fitWords(subjectWords, LABEL_CAP - CODE_WIDTH) || input.id.slice(0, 6);
|
|
682
|
+
return `${code}${subject}`;
|
|
608
683
|
}
|
|
609
|
-
/**
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
} catch {
|
|
618
|
-
throw new Error(`herdr worktree create returned unparseable output: ${out.slice(0, 200)}`);
|
|
619
|
-
}
|
|
620
|
-
const result = parsed?.result;
|
|
621
|
-
const paneId = result?.root_pane?.pane_id;
|
|
622
|
-
const path = result?.worktree?.path;
|
|
623
|
-
const branch = result?.worktree?.branch;
|
|
624
|
-
if (typeof paneId !== "string" || paneId === "") throw new Error(`herdr worktree create output had no result.root_pane.pane_id: ${out.slice(0, 200)}`);
|
|
625
|
-
if (typeof path !== "string" || path === "" || typeof branch !== "string" || branch === "") throw new Error(`herdr worktree create output had no result.worktree.{path,branch}: ${out.slice(0, 200)}`);
|
|
626
|
-
return {
|
|
627
|
-
paneId,
|
|
628
|
-
worktree: {
|
|
629
|
-
root: resolve(path),
|
|
630
|
-
branch
|
|
631
|
-
}
|
|
632
|
-
};
|
|
684
|
+
/** The brief's first line with any content — a leading blank line is not the subject. */
|
|
685
|
+
function firstNonEmptyLine(brief) {
|
|
686
|
+
for (const line of brief.split("\n")) if (line.trim() !== "") return line;
|
|
687
|
+
return "";
|
|
688
|
+
}
|
|
689
|
+
/** Lowercase alphanumeric runs; every other character is a separator, never part of a word. */
|
|
690
|
+
function tokenize(text) {
|
|
691
|
+
return text.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w !== "");
|
|
633
692
|
}
|
|
634
|
-
//#endregion
|
|
635
|
-
//#region src/console/session.tmux.ts
|
|
636
|
-
/** tmux backend — detected via `$TMUX`. */
|
|
637
|
-
const tmuxSessionAdapter = {
|
|
638
|
-
name: "tmux",
|
|
639
|
-
open(exec, opts) {
|
|
640
|
-
const args = opts.at === "workspace" ? [
|
|
641
|
-
"new-session",
|
|
642
|
-
"-d",
|
|
643
|
-
"-c",
|
|
644
|
-
opts.cwd,
|
|
645
|
-
"-P",
|
|
646
|
-
"-F",
|
|
647
|
-
"#{pane_id}"
|
|
648
|
-
] : opts.at === "pane:down" ? [
|
|
649
|
-
"split-window",
|
|
650
|
-
"-v",
|
|
651
|
-
"-c",
|
|
652
|
-
opts.cwd,
|
|
653
|
-
"-P",
|
|
654
|
-
"-F",
|
|
655
|
-
"#{pane_id}"
|
|
656
|
-
] : opts.at === "tab" || opts.at === "window" ? [
|
|
657
|
-
"new-window",
|
|
658
|
-
"-c",
|
|
659
|
-
opts.cwd,
|
|
660
|
-
"-P",
|
|
661
|
-
"-F",
|
|
662
|
-
"#{pane_id}"
|
|
663
|
-
] : [
|
|
664
|
-
"split-window",
|
|
665
|
-
"-h",
|
|
666
|
-
"-c",
|
|
667
|
-
opts.cwd,
|
|
668
|
-
"-P",
|
|
669
|
-
"-F",
|
|
670
|
-
"#{pane_id}"
|
|
671
|
-
];
|
|
672
|
-
const pane = exec("tmux", args);
|
|
673
|
-
if (!pane) throw new Error(`tmux ${args[0]} failed`);
|
|
674
|
-
const target = { id: pane };
|
|
675
|
-
tmuxSessionAdapter.send(exec, target, opts.launch);
|
|
676
|
-
return target;
|
|
677
|
-
},
|
|
678
|
-
send(exec, target, text) {
|
|
679
|
-
exec("tmux", [
|
|
680
|
-
"send-keys",
|
|
681
|
-
"-t",
|
|
682
|
-
target.id,
|
|
683
|
-
text,
|
|
684
|
-
"Enter"
|
|
685
|
-
]);
|
|
686
|
-
},
|
|
687
|
-
read(exec, target, opts) {
|
|
688
|
-
const args = [
|
|
689
|
-
"capture-pane",
|
|
690
|
-
"-p",
|
|
691
|
-
"-t",
|
|
692
|
-
target.id
|
|
693
|
-
];
|
|
694
|
-
if (opts?.lines != null) args.push("-S", `-${opts.lines}`);
|
|
695
|
-
return exec("tmux", args) ?? "";
|
|
696
|
-
},
|
|
697
|
-
focus(exec, target) {
|
|
698
|
-
exec("tmux", [
|
|
699
|
-
"select-pane",
|
|
700
|
-
"-t",
|
|
701
|
-
target.id
|
|
702
|
-
]);
|
|
703
|
-
},
|
|
704
|
-
teardown(exec, target) {
|
|
705
|
-
exec("tmux", [
|
|
706
|
-
"kill-pane",
|
|
707
|
-
"-t",
|
|
708
|
-
target.id
|
|
709
|
-
]);
|
|
710
|
-
}
|
|
711
|
-
};
|
|
712
|
-
//#endregion
|
|
713
|
-
//#region src/console/index.ts
|
|
714
693
|
/**
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
694
|
+
* Join whole words with `-` while they fit `budget`, so the label ends on a complete word rather
|
|
695
|
+
* than mid-word. A single first word wider than the whole budget is hard-truncated — that is the
|
|
696
|
+
* only case where a label can end mid-word, and the alternative is no subject at all.
|
|
718
697
|
*/
|
|
719
|
-
function
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
698
|
+
function fitWords(words, budget) {
|
|
699
|
+
if (words.length === 0) return "";
|
|
700
|
+
let out = words[0].slice(0, budget);
|
|
701
|
+
for (const word of words.slice(1)) {
|
|
702
|
+
const next = `${out}-${word}`;
|
|
703
|
+
if (next.length > budget) break;
|
|
704
|
+
out = next;
|
|
705
|
+
}
|
|
706
|
+
return out;
|
|
724
707
|
}
|
|
725
708
|
//#endregion
|
|
726
709
|
//#region src/session.ts
|
|
@@ -731,6 +714,37 @@ const LAUNCH_MAP = {
|
|
|
731
714
|
codex: "codex"
|
|
732
715
|
};
|
|
733
716
|
/**
|
|
717
|
+
* Per-harness fresh-context ("reset") command `clear` injects into a warm peer's pane to return
|
|
718
|
+
* its conversation to a cold context without tearing the pane/session down — keyed on genuine
|
|
719
|
+
* fresh-context semantics, never on the literal word "clear".
|
|
720
|
+
*/
|
|
721
|
+
const RESET_MAP = {
|
|
722
|
+
claude: "/clear",
|
|
723
|
+
codex: "/clear",
|
|
724
|
+
copilot: "/clear",
|
|
725
|
+
cursor: "/new-chat"
|
|
726
|
+
};
|
|
727
|
+
/**
|
|
728
|
+
* Harnesses whose apparent "clear" command does NOT truly empty the model context — e.g. gemini's
|
|
729
|
+
* `/clear` wipes only the terminal screen, leaving the model's own context stale. There is no
|
|
730
|
+
* honest fresh-context command for these, so they are refused explicitly rather than lumped in
|
|
731
|
+
* with a harness that is merely unmapped.
|
|
732
|
+
*/
|
|
733
|
+
const FALSE_FRIEND_HARNESSES = new Set(["gemini"]);
|
|
734
|
+
/**
|
|
735
|
+
* Resolve `harness`'s own fresh-context command from the reset map. String-keyed (not `Harness`)
|
|
736
|
+
* so it also guards a harness present in `LAUNCH_MAP`/`identity.ts`'s `Harness` union but not yet
|
|
737
|
+
* given an honest reset mapping here. Throws rather than guessing:
|
|
738
|
+
* - a known false-friend harness (its "clear" only clears the screen, not the context)
|
|
739
|
+
* - any harness absent from the map entirely
|
|
740
|
+
*/
|
|
741
|
+
function resetCommandFor(harness) {
|
|
742
|
+
const mapped = RESET_MAP[harness];
|
|
743
|
+
if (mapped) return mapped;
|
|
744
|
+
if (FALSE_FRIEND_HARNESSES.has(harness)) throw new Error(`"${harness}" has no honest fresh-context command — its own "/clear" clears only the terminal screen, not the model context, so unit clear refuses to send a false-friend reset that would leave stale context behind`);
|
|
745
|
+
throw new Error(`"${harness}" is not in the reset map (${Object.keys(RESET_MAP).join(" | ")}) — unit clear refuses to guess a command`);
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
734
748
|
* Launch a new peer session as a genuine sibling unit: create a real git worktree distinct from
|
|
735
749
|
* the primary checkout (refuse the primary checkout), open a session backend (tmux or herdr) with
|
|
736
750
|
* its cwd set to that worktree, pre-register the peer, and drop its brief as a file the peer's own
|
|
@@ -746,6 +760,7 @@ function spawn(ctx, input) {
|
|
|
746
760
|
const env = ctx.env ?? process.env;
|
|
747
761
|
const exec = ctx.exec ?? realExec;
|
|
748
762
|
const sessionAdapter = selectSessionAdapter(env, exec);
|
|
763
|
+
const normalizedEnv = normalizeMuxEnv(env);
|
|
749
764
|
const harness = input.harness;
|
|
750
765
|
if (!harness || !(harness in LAUNCH_MAP)) throw new Error(`spawn needs a --harness in the launch map (${Object.keys(LAUNCH_MAP).join(" | ")})`);
|
|
751
766
|
const brief = resolveBrief(input);
|
|
@@ -755,6 +770,7 @@ function spawn(ctx, input) {
|
|
|
755
770
|
const primaryRoot = resolvePrimaryRoot(exec);
|
|
756
771
|
const launch = input.command ?? LAUNCH_MAP[harness];
|
|
757
772
|
const fullLaunch = `${muxEnvPrefix(sessionAdapter.name)}${launch}`;
|
|
773
|
+
const from = callerPane(sessionAdapter, normalizedEnv);
|
|
758
774
|
let cwd;
|
|
759
775
|
let worktree;
|
|
760
776
|
let target;
|
|
@@ -763,20 +779,25 @@ function spawn(ctx, input) {
|
|
|
763
779
|
cwd = resolve(input.cwd);
|
|
764
780
|
assertDistinctFromPrimary(cwd, primaryRoot);
|
|
765
781
|
worktree = null;
|
|
782
|
+
const at = input.at ?? "tab";
|
|
766
783
|
target = sessionAdapter.open(exec, {
|
|
767
784
|
cwd,
|
|
768
785
|
launch: fullLaunch,
|
|
769
|
-
at
|
|
786
|
+
at,
|
|
787
|
+
from,
|
|
788
|
+
...labelFor(at, input, brief, id)
|
|
770
789
|
});
|
|
771
790
|
} else {
|
|
772
791
|
const branch = input.branch ?? `cyberlegion/unit-${id}`;
|
|
792
|
+
const at = input.at ?? "workspace";
|
|
773
793
|
const worktreePath = input.worktreePath ?? resolveUnitWorktreePath(primaryRoot, id.slice(0, 6));
|
|
774
|
-
if (
|
|
775
|
-
const opened = sessionAdapter.
|
|
794
|
+
if (at === "workspace" && sessionAdapter.worktree) {
|
|
795
|
+
const opened = sessionAdapter.worktree.createInWorkspace(exec, {
|
|
776
796
|
primaryRoot,
|
|
777
797
|
branch,
|
|
778
798
|
path: worktreePath,
|
|
779
|
-
launch: fullLaunch
|
|
799
|
+
launch: fullLaunch,
|
|
800
|
+
...labelFor(at, input, brief, id)
|
|
780
801
|
});
|
|
781
802
|
assertDistinctFromPrimary(opened.worktree.root, primaryRoot);
|
|
782
803
|
ensureMarker(join(opened.worktree.root, ".agents", "cyberlegion"));
|
|
@@ -796,18 +817,24 @@ function spawn(ctx, input) {
|
|
|
796
817
|
target = sessionAdapter.open(exec, {
|
|
797
818
|
cwd,
|
|
798
819
|
launch: fullLaunch,
|
|
799
|
-
at
|
|
820
|
+
at,
|
|
821
|
+
from,
|
|
822
|
+
...labelFor(at, input, brief, id)
|
|
800
823
|
});
|
|
801
824
|
}
|
|
802
825
|
}
|
|
803
826
|
const ts = new Date(ctx.now?.() ?? Date.now()).toISOString();
|
|
827
|
+
const muxName = sessionAdapter.name;
|
|
804
828
|
const rec = {
|
|
805
829
|
id,
|
|
806
830
|
handle: input.handle ?? id.slice(0, 6),
|
|
807
831
|
harness,
|
|
808
832
|
cwd,
|
|
809
833
|
worktree,
|
|
810
|
-
|
|
834
|
+
pane: muxName === "tmux" || muxName === "herdr" ? {
|
|
835
|
+
mux: muxName,
|
|
836
|
+
id: target.id
|
|
837
|
+
} : null,
|
|
811
838
|
status: "spawning",
|
|
812
839
|
createdAt: ts,
|
|
813
840
|
lastSeen: ts,
|
|
@@ -824,17 +851,59 @@ function spawn(ctx, input) {
|
|
|
824
851
|
};
|
|
825
852
|
}
|
|
826
853
|
/**
|
|
854
|
+
* The label a `workspace` placement opens under — the short human-scannable name that makes a unit's
|
|
855
|
+
* own visible space findable by eye (`console/workspace-label.ts` owns the naming rule). Only the
|
|
856
|
+
* `workspace` placement carries one: a pane or tab lives inside a space the caller is already
|
|
857
|
+
* looking at, so it has nothing of its own to name. Spread into the adapter options, so a
|
|
858
|
+
* non-workspace placement passes no `label` key at all rather than an explicit `undefined`.
|
|
859
|
+
*
|
|
860
|
+
* The gate is load-bearing, not defensive: cyber-mux names whatever tier `at` opens — a workspace,
|
|
861
|
+
* a tab, or a pane — so passing a label on a `tab` placement would rename the caller's own tab.
|
|
862
|
+
* Exported so that gate is tested directly rather than only through a backend's arguments.
|
|
863
|
+
*/
|
|
864
|
+
function labelFor(at, input, brief, id) {
|
|
865
|
+
if (at !== "workspace") return {};
|
|
866
|
+
return { label: deriveWorkspaceLabel({
|
|
867
|
+
brief,
|
|
868
|
+
handle: input.handle,
|
|
869
|
+
id
|
|
870
|
+
}) };
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
827
873
|
* The env prefix typed ahead of the launch command so the spawned peer inherits the caller's
|
|
828
|
-
* multiplexer fast-path and never has to run its own ancestry discovery (`$
|
|
829
|
-
* `$
|
|
874
|
+
* multiplexer fast-path and never has to run its own ancestry discovery (`$CYBER_MUX` /
|
|
875
|
+
* `$CYBER_MUX_PANE`). `VAR=val cmd` scopes the vars to that one process (and its children)
|
|
830
876
|
* without needing `export`. tmux natively sets `$TMUX_PANE` for a pane's own processes, so the
|
|
831
877
|
* pane var is expanded by the child's own shell rather than baked in here.
|
|
832
878
|
*/
|
|
833
879
|
function muxEnvPrefix(muxName) {
|
|
834
|
-
if (muxName === "tmux") return "
|
|
835
|
-
if (muxName === "herdr") return "
|
|
880
|
+
if (muxName === "tmux") return "CYBER_MUX=tmux CYBER_MUX_PANE=$TMUX_PANE ";
|
|
881
|
+
if (muxName === "herdr") return "CYBER_MUX=herdr ";
|
|
836
882
|
return "";
|
|
837
883
|
}
|
|
884
|
+
/**
|
|
885
|
+
* Reset a warm peer's context to cold WITHOUT tearing anything down — injects the peer's own
|
|
886
|
+
* harness fresh-context command (`resetCommandFor`) into its pane through the session adapter.
|
|
887
|
+
* Warmth is the unit (pane/process stays warm — no cold-start), coldness is the context. The
|
|
888
|
+
* command is resolved (and any false-friend/unmapped harness throws) BEFORE anything is sent, so
|
|
889
|
+
* a fail-loud harness never has anything typed into its pane. Touches neither the registry record
|
|
890
|
+
* nor the worktree — `close` (`decommission`) owns teardown.
|
|
891
|
+
*/
|
|
892
|
+
function clearUnit(ctx, ref) {
|
|
893
|
+
const agent = resolveAgent(ctx.store, ref);
|
|
894
|
+
const pane = agent.pane?.id ?? ctx.store.findPaneByAgentId(agent.id);
|
|
895
|
+
if (!pane) throw new Error(`unit "${ref}" has no known session pane`);
|
|
896
|
+
if (!agent.harness) throw new Error(`unit "${ref}" has no harness on record — cannot resolve its reset command`);
|
|
897
|
+
const command = resetCommandFor(agent.harness);
|
|
898
|
+
const env = ctx.env ?? process.env;
|
|
899
|
+
const exec = ctx.exec ?? realExec;
|
|
900
|
+
selectSessionAdapter(env, exec).submit(exec, { id: pane }, command);
|
|
901
|
+
return {
|
|
902
|
+
agent,
|
|
903
|
+
pane,
|
|
904
|
+
command
|
|
905
|
+
};
|
|
906
|
+
}
|
|
838
907
|
/** Resolve a spawn brief from --brief-file, --task -, or --task <text>; null if no source given. */
|
|
839
908
|
function resolveBrief(input, readStdin = () => readFileSync(0, "utf8")) {
|
|
840
909
|
if (input.briefFile) return readFileSync(input.briefFile, "utf8");
|
|
@@ -864,18 +933,6 @@ function realizeLaunch(def, opts = {}) {
|
|
|
864
933
|
command: parts.join(" ")
|
|
865
934
|
};
|
|
866
935
|
}
|
|
867
|
-
/** Build the instruction string a parent model uses to spawn its OWN harness Task subagent for
|
|
868
|
-
* `def` — cyberlegion never invokes the Task tool itself. Names `subagent_type: <def.name>` for a
|
|
869
|
-
* harness that recognizes it, but always inlines the model + full instructions too so the same
|
|
870
|
-
* instruction string is correct even when the harness has no such named subagent type. */
|
|
871
|
-
function realizeSubagentInstruction(def, opts) {
|
|
872
|
-
const lines = [`Spawn a subagent (subagent_type: ${def.name} if your harness recognizes that name, else a generic one).`];
|
|
873
|
-
if (def.model) lines.push(`Model: ${def.model}.`);
|
|
874
|
-
if (def.effort) lines.push(`Effort: ${def.effort}.`);
|
|
875
|
-
lines.push(`Read the brief at ${opts.briefFile} and write its result JSON to ${opts.resultFile}.`);
|
|
876
|
-
if (def.instructions) lines.push("", "Instructions:", def.instructions);
|
|
877
|
-
return lines.join("\n");
|
|
878
|
-
}
|
|
879
936
|
//#endregion
|
|
880
937
|
//#region src/agentdef/resolve.ts
|
|
881
938
|
function parseAgentDefFile(text) {
|
|
@@ -1008,7 +1065,7 @@ function decommission(ctx, input) {
|
|
|
1008
1065
|
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`);
|
|
1009
1066
|
const worktreeExists = worktreeRoot != null && existsSync(worktreeRoot);
|
|
1010
1067
|
if (worktreeExists && !input.force && isDirty(exec, worktreeRoot)) throw new Error(`unit "${input.id}" has uncommitted changes in its worktree — pass --force to discard them`);
|
|
1011
|
-
const pane = rec.
|
|
1068
|
+
const pane = rec.pane?.id ?? ctx.store.findPaneByAgentId(input.id);
|
|
1012
1069
|
if (worktreeExists) try {
|
|
1013
1070
|
gitWorktreeAdapter.remove(exec, worktreeRoot, { primaryRoot });
|
|
1014
1071
|
} catch (err) {
|
|
@@ -1035,272 +1092,15 @@ function isDirty(exec, worktreeRoot) {
|
|
|
1035
1092
|
]);
|
|
1036
1093
|
}
|
|
1037
1094
|
//#endregion
|
|
1038
|
-
//#region src/
|
|
1039
|
-
|
|
1040
|
-
function
|
|
1041
|
-
|
|
1042
|
-
const from = loadAgent(ctx.store, input.fromId);
|
|
1043
|
-
const ts = ctx.now?.() ?? Date.now();
|
|
1044
|
-
const msg = {
|
|
1045
|
-
id: `${ts}-${randomBytes(3).toString("hex")}`,
|
|
1046
|
-
from: input.fromId,
|
|
1047
|
-
fromHandle: from?.handle ?? input.fromId,
|
|
1048
|
-
to: toId,
|
|
1049
|
-
...input.subject ? { subject: input.subject } : {},
|
|
1050
|
-
body: input.body,
|
|
1051
|
-
...input.thread ? { thread: input.thread } : {},
|
|
1052
|
-
...input.replyTo ? { replyTo: input.replyTo } : {},
|
|
1053
|
-
ts,
|
|
1054
|
-
sentAt: new Date(ts).toISOString()
|
|
1055
|
-
};
|
|
1056
|
-
ctx.store.putMessage(toId, msg);
|
|
1057
|
-
return msg;
|
|
1058
|
-
}
|
|
1059
|
-
/** Resolve a message body from the --body flag, a --body-file path, or stdin (--body-file -). */
|
|
1060
|
-
function resolveBody(body, bodyFile, readStdin = () => readFileSync(0, "utf8")) {
|
|
1061
|
-
if (body != null) return body;
|
|
1062
|
-
if (bodyFile) return bodyFile === "-" ? readStdin() : readFileSync(bodyFile, "utf8");
|
|
1063
|
-
throw new Error("provide --body <text> or --body-file <path|->");
|
|
1064
|
-
}
|
|
1065
|
-
/** List the caller's mail, chronological (lexical id sort == time order). */
|
|
1066
|
-
function inbox(ctx, q) {
|
|
1067
|
-
const snap = ctx.store.listInbox(q.meId);
|
|
1068
|
-
const unread = snap.unread.map((m) => ({
|
|
1069
|
-
...m,
|
|
1070
|
-
read: false
|
|
1071
|
-
}));
|
|
1072
|
-
const acked = q.unread ? [] : snap.read.map((m) => ({
|
|
1073
|
-
...m,
|
|
1074
|
-
read: true
|
|
1075
|
-
}));
|
|
1076
|
-
let items = [...unread, ...acked];
|
|
1077
|
-
if (q.from) items = items.filter((m) => m.from === q.from || m.fromHandle === q.from);
|
|
1078
|
-
if (q.thread) items = items.filter((m) => m.thread === q.thread);
|
|
1079
|
-
return items.sort((a, b) => a.id.localeCompare(b.id));
|
|
1080
|
-
}
|
|
1081
|
-
/** Peek at a message (unread or already-acked) without changing its state. */
|
|
1082
|
-
function peek(ctx, meId, msgId) {
|
|
1083
|
-
const snap = ctx.store.listInbox(meId);
|
|
1084
|
-
return [...snap.unread, ...snap.read].find((m) => m.id === msgId);
|
|
1085
|
-
}
|
|
1086
|
-
/** Acknowledge a message by moving it out of the unread set. Errors if not currently unread. */
|
|
1087
|
-
function ack(ctx, meId, msgId) {
|
|
1088
|
-
return ctx.store.ackMessage(meId, msgId);
|
|
1089
|
-
}
|
|
1090
|
-
/** Permanently remove a message (unread or already-acked) from the caller's inbox. */
|
|
1091
|
-
function deleteMessage(ctx, meId, msgId) {
|
|
1092
|
-
ctx.store.removeMessage(meId, msgId);
|
|
1093
|
-
}
|
|
1094
|
-
//#endregion
|
|
1095
|
-
//#region src/wake/await.ts
|
|
1096
|
-
/** Internal poll interval — not user-facing (the round-trip cost of one `mail await` cycle). */
|
|
1097
|
-
const POLL_MS$1 = 1e3;
|
|
1098
|
-
const DEFAULT_TIMEOUT_MS = 6e5;
|
|
1099
|
-
const DEFAULT_MAX_WAIT_S = 240;
|
|
1100
|
-
/**
|
|
1101
|
-
* Block on the caller's UNREAD inbox until a message matching `thread` (and optional `from`)
|
|
1102
|
-
* arrives, polling on a fixed ~1s internal interval. Three unambiguous outcomes:
|
|
1103
|
-
*
|
|
1104
|
-
* - `matched` — a message arrived; it is printed by the caller AND acked here (moved to read/), so
|
|
1105
|
-
* it leaves the unread set. Block-then-read: each await consumes exactly its round.
|
|
1106
|
-
* - `waiting` — this call's internal poll cycle hit its `maxWaitS` cap (default 240s, always well
|
|
1107
|
-
* under a harness tool-timeout SIGKILL) with no match yet. A clean, non-error return the caller
|
|
1108
|
-
* re-arms on (calls `awaitReply` again) — NOT the same as giving up.
|
|
1109
|
-
* - `timed-out` — the caller's overall `timeoutMs` (default 600_000ms / 600s; `0` = wait forever)
|
|
1110
|
-
* elapsed across re-arms with no match. The caller should exit non-zero.
|
|
1111
|
-
*/
|
|
1112
|
-
async function awaitReply(ctx, input) {
|
|
1113
|
-
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1114
|
-
const maxWaitMs = (input.maxWaitS ?? DEFAULT_MAX_WAIT_S) * 1e3;
|
|
1115
|
-
const now = input.now ?? (() => Date.now());
|
|
1116
|
-
const sleep = input.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1117
|
-
const start = now();
|
|
1118
|
-
const cycleDeadline = start + maxWaitMs;
|
|
1119
|
-
for (;;) {
|
|
1120
|
-
const items = inbox(ctx, {
|
|
1121
|
-
meId: input.meId,
|
|
1122
|
-
unread: true,
|
|
1123
|
-
thread: input.thread,
|
|
1124
|
-
from: input.from
|
|
1125
|
-
});
|
|
1126
|
-
if (items.length > 0) {
|
|
1127
|
-
const message = items[0];
|
|
1128
|
-
ack(ctx, input.meId, message.id);
|
|
1129
|
-
return {
|
|
1130
|
-
kind: "matched",
|
|
1131
|
-
message
|
|
1132
|
-
};
|
|
1133
|
-
}
|
|
1134
|
-
const elapsed = now() - start;
|
|
1135
|
-
if (timeoutMs !== 0 && elapsed >= timeoutMs) return { kind: "timed-out" };
|
|
1136
|
-
if (now() >= cycleDeadline) return { kind: "waiting" };
|
|
1137
|
-
await sleep(POLL_MS$1);
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
//#endregion
|
|
1141
|
-
//#region src/dispatch/prep.ts
|
|
1142
|
-
/** The instruction a caller uses when no agent def is given — still names both slot paths so a
|
|
1143
|
-
* generic Task-tool spawn (or a human) can carry out the same contract. */
|
|
1144
|
-
function genericInstruction(role, briefFile, resultFile) {
|
|
1145
|
-
const lines = [`Spawn a subagent${role ? ` (role: ${role})` : ""}.`];
|
|
1146
|
-
lines.push(`Read the brief at ${briefFile} and write its result JSON to ${resultFile}.`);
|
|
1147
|
-
return lines.join("\n");
|
|
1148
|
-
}
|
|
1149
|
-
/**
|
|
1150
|
-
* Allocate a dispatch: mint an id (thread defaults to it), write the brief into the Store, compute
|
|
1151
|
-
* the result-slot path, and build the instruction from a resolved agent def (or a generic one).
|
|
1152
|
-
* Spawns nothing — the envelope is handed to whatever spawns next (a Task tool, `channel()`, a
|
|
1153
|
-
* human).
|
|
1154
|
-
*/
|
|
1155
|
-
function prep(ctx, input, readStdin) {
|
|
1156
|
-
const id = randomId();
|
|
1157
|
-
const thread = input.thread ?? id;
|
|
1158
|
-
const briefText = resolveBody(input.briefText, input.briefFile, readStdin);
|
|
1159
|
-
ctx.store.writeBrief(id, briefText);
|
|
1160
|
-
const briefFile = paths.briefFile(ctx.store.root, id);
|
|
1161
|
-
const resultFile = ctx.store.resultPath(id);
|
|
1162
|
-
let def;
|
|
1163
|
-
if (input.agent || input.agentFile) def = resolveAgentDef({
|
|
1164
|
-
name: input.agent,
|
|
1165
|
-
file: input.agentFile
|
|
1166
|
-
});
|
|
1167
|
-
return {
|
|
1168
|
-
id,
|
|
1169
|
-
thread,
|
|
1170
|
-
briefFile,
|
|
1171
|
-
resultFile,
|
|
1172
|
-
instruction: def ? realizeSubagentInstruction(def, {
|
|
1173
|
-
briefFile,
|
|
1174
|
-
resultFile
|
|
1175
|
-
}) : genericInstruction(input.role, briefFile, resultFile)
|
|
1176
|
-
};
|
|
1177
|
-
}
|
|
1178
|
-
//#endregion
|
|
1179
|
-
//#region src/dispatch/verdict.ts
|
|
1180
|
-
function primitiveTypeOf(v) {
|
|
1181
|
-
if (Array.isArray(v)) return "array";
|
|
1182
|
-
return typeof v;
|
|
1183
|
-
}
|
|
1184
|
-
/**
|
|
1185
|
-
* Parse `body` as JSON and, when `schemaFile` is given, structurally check it against a minimal
|
|
1186
|
-
* required/type schema. Any failure — invalid JSON, an unreadable/invalid schema file, a missing
|
|
1187
|
-
* required key, or a type mismatch — is `{ ok: false }`, never a pass.
|
|
1188
|
-
*/
|
|
1189
|
-
function validateVerdict(body, schemaFile) {
|
|
1190
|
-
let value;
|
|
1191
|
-
try {
|
|
1192
|
-
value = JSON.parse(body);
|
|
1193
|
-
} catch (err) {
|
|
1194
|
-
return {
|
|
1195
|
-
ok: false,
|
|
1196
|
-
error: `verdict body is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
1197
|
-
};
|
|
1198
|
-
}
|
|
1199
|
-
if (!schemaFile) return {
|
|
1200
|
-
ok: true,
|
|
1201
|
-
value
|
|
1202
|
-
};
|
|
1203
|
-
let schema;
|
|
1204
|
-
try {
|
|
1205
|
-
schema = JSON.parse(readFileSync(schemaFile, "utf8"));
|
|
1206
|
-
} catch (err) {
|
|
1207
|
-
return {
|
|
1208
|
-
ok: false,
|
|
1209
|
-
error: `verdict schema "${schemaFile}" is not readable/valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
1210
|
-
};
|
|
1211
|
-
}
|
|
1212
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return {
|
|
1213
|
-
ok: false,
|
|
1214
|
-
error: "verdict body must be a JSON object to validate against a schema"
|
|
1215
|
-
};
|
|
1216
|
-
const obj = value;
|
|
1217
|
-
for (const key of schema.required ?? []) if (!(key in obj)) return {
|
|
1218
|
-
ok: false,
|
|
1219
|
-
error: `verdict is missing required key "${key}"`
|
|
1220
|
-
};
|
|
1221
|
-
for (const [key, expected] of Object.entries(schema.properties ?? {})) {
|
|
1222
|
-
if (!(key in obj)) continue;
|
|
1223
|
-
const actual = primitiveTypeOf(obj[key]);
|
|
1224
|
-
if (actual !== expected) return {
|
|
1225
|
-
ok: false,
|
|
1226
|
-
error: `verdict key "${key}" expected type "${expected}" but got "${actual}"`
|
|
1227
|
-
};
|
|
1228
|
-
}
|
|
1229
|
-
return {
|
|
1230
|
-
ok: true,
|
|
1231
|
-
value
|
|
1232
|
-
};
|
|
1233
|
-
}
|
|
1234
|
-
//#endregion
|
|
1235
|
-
//#region src/dispatch/channel.ts
|
|
1236
|
-
/** Thrown by a `--wait` channel call when its internal poll-cycle cap is hit with no reply yet — a
|
|
1237
|
-
* clean, re-armable sentinel (mirrors `mail await`'s "waiting" outcome), never a failure. */
|
|
1238
|
-
var DispatchWaitingError = class extends Error {
|
|
1239
|
-
thread;
|
|
1240
|
-
constructor(thread) {
|
|
1241
|
-
super(`waiting — no reply on thread "${thread}" yet; re-run to keep waiting`);
|
|
1242
|
-
this.thread = thread;
|
|
1243
|
-
}
|
|
1244
|
-
};
|
|
1245
|
-
/**
|
|
1246
|
-
* `prep()` + spawn a peer session realized from the same agent def + (optionally) `awaitReply` on
|
|
1247
|
-
* the thread. Requires `--agent`/`--agent-file` (a channel peer needs a real launch to realize).
|
|
1248
|
-
*/
|
|
1249
|
-
async function channel(ctx, input, readStdin) {
|
|
1250
|
-
if (!input.agent && !input.agentFile) throw new Error("dispatch channel needs --agent <name> or --agent-file <path> to realize the peer launch");
|
|
1251
|
-
const envelope = prep(ctx, input, readStdin);
|
|
1252
|
-
const realized = realizeLaunch(resolveAgentDef({
|
|
1253
|
-
name: input.agent,
|
|
1254
|
-
file: input.agentFile
|
|
1255
|
-
}));
|
|
1256
|
-
spawn(ctx, {
|
|
1257
|
-
harness: realized.harness,
|
|
1258
|
-
command: realized.command,
|
|
1259
|
-
briefFile: envelope.briefFile,
|
|
1260
|
-
at: input.at
|
|
1261
|
-
});
|
|
1262
|
-
if (!input.wait) return envelope;
|
|
1263
|
-
const meId = resolveSelfId(ctx);
|
|
1264
|
-
if (!meId) throw new Error("dispatch channel --wait needs an identity — run `cyberlegion identity register` first");
|
|
1265
|
-
const outcome = await awaitReply({
|
|
1266
|
-
store: ctx.store,
|
|
1267
|
-
now: ctx.now
|
|
1268
|
-
}, {
|
|
1269
|
-
meId,
|
|
1270
|
-
thread: envelope.thread,
|
|
1271
|
-
timeoutMs: input.timeoutMs,
|
|
1272
|
-
maxWaitS: input.maxWaitS
|
|
1273
|
-
});
|
|
1274
|
-
if (outcome.kind === "waiting") throw new DispatchWaitingError(envelope.thread);
|
|
1275
|
-
if (outcome.kind === "timed-out") throw new Error(`no reply on thread "${envelope.thread}" within timeout`);
|
|
1276
|
-
const validated = validateVerdict(outcome.message.body, input.verdictSchema);
|
|
1277
|
-
if (!validated.ok) throw new Error(validated.error);
|
|
1278
|
-
return {
|
|
1279
|
-
id: envelope.id,
|
|
1280
|
-
verdict: validated.value,
|
|
1281
|
-
body: outcome.message.body,
|
|
1282
|
-
ts: outcome.message.ts
|
|
1283
|
-
};
|
|
1095
|
+
//#region src/install.ts
|
|
1096
|
+
const PIN_TOKEN = /^[0-9A-Za-z][0-9A-Za-z._+-]*$/;
|
|
1097
|
+
function validatePin(pin) {
|
|
1098
|
+
if (!PIN_TOKEN.test(pin)) throw new Error(`invalid --pin "${pin}" — expected a version or dist-tag token like 0.2.0 or latest (no spaces, ranges, or shell metacharacters)`);
|
|
1284
1099
|
}
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
* or when it fails the optional verdict schema. */
|
|
1289
|
-
function collect(ctx, id, verdictSchema) {
|
|
1290
|
-
const body = ctx.store.readResult(id);
|
|
1291
|
-
if (body === void 0) throw new Error(`no result written yet for dispatch "${id}" — the subagent has not written its result file`);
|
|
1292
|
-
const validated = validateVerdict(body, verdictSchema);
|
|
1293
|
-
if (!validated.ok) throw new Error(validated.error);
|
|
1294
|
-
return {
|
|
1295
|
-
id,
|
|
1296
|
-
verdict: validated.value,
|
|
1297
|
-
body,
|
|
1298
|
-
ts: ctx.now?.() ?? Date.now()
|
|
1299
|
-
};
|
|
1100
|
+
const hookCommand = (event, pin) => pin ? `npx cyberlegion@${pin} mail hook --event ${event}` : `npx cyberlegion mail hook --event ${event}`;
|
|
1101
|
+
function hookTarget(command) {
|
|
1102
|
+
return command.match(/^(?:npx cyberlegion(?:@[^\s]+)?|cyberlegion) (mail hook --event \S+)$/)?.[1];
|
|
1300
1103
|
}
|
|
1301
|
-
//#endregion
|
|
1302
|
-
//#region src/install.ts
|
|
1303
|
-
const hookCommand = (event) => `cyberlegion mail hook --event ${event}`;
|
|
1304
1104
|
const VENDORS = {
|
|
1305
1105
|
claude: {
|
|
1306
1106
|
file: ".claude/settings.json",
|
|
@@ -1337,14 +1137,15 @@ function writeJson$1(file, data) {
|
|
|
1337
1137
|
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
1338
1138
|
}
|
|
1339
1139
|
/** Register the surfacing hook into one harness's config, idempotently. */
|
|
1340
|
-
function install(harness, projectDir = process.cwd()) {
|
|
1140
|
+
function install(harness, projectDir = process.cwd(), pin) {
|
|
1341
1141
|
const spec = VENDORS[harness];
|
|
1342
1142
|
if (!spec) throw new Error(`unknown harness "${harness}" (expected claude | cursor | codex)`);
|
|
1143
|
+
if (pin !== void 0) validatePin(pin);
|
|
1343
1144
|
const file = join(projectDir, spec.file);
|
|
1344
1145
|
const settings = readJson(file);
|
|
1345
1146
|
const results = [];
|
|
1346
1147
|
for (const [canonical, vendorEvent] of Object.entries(spec.events)) {
|
|
1347
|
-
const command = hookCommand(canonical);
|
|
1148
|
+
const command = hookCommand(canonical, pin);
|
|
1348
1149
|
const status = spec.shape === "claude" ? upsertClaude(settings, vendorEvent, command) : upsertCursor(settings, vendorEvent, command);
|
|
1349
1150
|
results.push({
|
|
1350
1151
|
harness,
|
|
@@ -1360,7 +1161,14 @@ function install(harness, projectDir = process.cwd()) {
|
|
|
1360
1161
|
function upsertClaude(settings, event, command) {
|
|
1361
1162
|
const hooks = settings.hooks ??= {};
|
|
1362
1163
|
const groups = hooks[event] ??= [];
|
|
1363
|
-
|
|
1164
|
+
const target = hookTarget(command);
|
|
1165
|
+
for (const g of groups) for (const h of g.hooks ?? []) {
|
|
1166
|
+
if (h.command === command) return "already present";
|
|
1167
|
+
if (target && hookTarget(h.command) === target) {
|
|
1168
|
+
h.command = command;
|
|
1169
|
+
return "already present";
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1364
1172
|
const group = { hooks: [{
|
|
1365
1173
|
type: "command",
|
|
1366
1174
|
command
|
|
@@ -1373,11 +1181,91 @@ function upsertCursor(settings, event, command) {
|
|
|
1373
1181
|
if (settings.version == null) settings.version = 1;
|
|
1374
1182
|
const hooks = settings.hooks ??= {};
|
|
1375
1183
|
const list = hooks[event] ??= [];
|
|
1376
|
-
|
|
1184
|
+
const target = hookTarget(command);
|
|
1185
|
+
for (const h of list) {
|
|
1186
|
+
if (h.command === command) return "already present";
|
|
1187
|
+
if (target && hookTarget(h.command) === target) {
|
|
1188
|
+
h.command = command;
|
|
1189
|
+
return "already present";
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1377
1192
|
list.push({ command });
|
|
1378
1193
|
return "registered";
|
|
1379
1194
|
}
|
|
1380
1195
|
//#endregion
|
|
1196
|
+
//#region src/message.ts
|
|
1197
|
+
/** Write one message into the recipient's inbox. Collision-free by <epochMs>-<hex>. */
|
|
1198
|
+
function send(ctx, input) {
|
|
1199
|
+
const toId = resolveRecipient(ctx.store, input.to);
|
|
1200
|
+
const from = loadAgent(ctx.store, input.fromId);
|
|
1201
|
+
const ts = ctx.now?.() ?? Date.now();
|
|
1202
|
+
const msg = {
|
|
1203
|
+
id: `${ts}-${randomBytes(3).toString("hex")}`,
|
|
1204
|
+
from: input.fromId,
|
|
1205
|
+
fromHandle: from?.handle ?? input.fromId,
|
|
1206
|
+
to: toId,
|
|
1207
|
+
...input.subject ? { subject: input.subject } : {},
|
|
1208
|
+
body: input.body,
|
|
1209
|
+
...input.thread ? { thread: input.thread } : {},
|
|
1210
|
+
...input.replyTo ? { replyTo: input.replyTo } : {},
|
|
1211
|
+
ts,
|
|
1212
|
+
sentAt: new Date(ts).toISOString()
|
|
1213
|
+
};
|
|
1214
|
+
ctx.store.putMessage(toId, msg);
|
|
1215
|
+
return msg;
|
|
1216
|
+
}
|
|
1217
|
+
/** Resolve a message body from the --body flag, a --body-file path, or stdin (--body-file -). */
|
|
1218
|
+
function resolveBody(body, bodyFile, readStdin = () => readFileSync(0, "utf8")) {
|
|
1219
|
+
if (body != null) return body;
|
|
1220
|
+
if (bodyFile) return bodyFile === "-" ? readStdin() : readFileSync(bodyFile, "utf8");
|
|
1221
|
+
throw new Error("provide --body <text> or --body-file <path|->");
|
|
1222
|
+
}
|
|
1223
|
+
/** List the caller's mail, chronological (lexical id sort == time order). */
|
|
1224
|
+
function inbox(ctx, q) {
|
|
1225
|
+
const snap = ctx.store.listInbox(q.meId);
|
|
1226
|
+
const unread = snap.unread.map((m) => ({
|
|
1227
|
+
...m,
|
|
1228
|
+
read: false
|
|
1229
|
+
}));
|
|
1230
|
+
const acked = q.unread ? [] : snap.read.map((m) => ({
|
|
1231
|
+
...m,
|
|
1232
|
+
read: true
|
|
1233
|
+
}));
|
|
1234
|
+
let items = [...unread, ...acked];
|
|
1235
|
+
if (q.from) items = items.filter((m) => m.from === q.from || m.fromHandle === q.from);
|
|
1236
|
+
if (q.thread) items = items.filter((m) => m.thread === q.thread);
|
|
1237
|
+
return items.sort((a, b) => a.id.localeCompare(b.id));
|
|
1238
|
+
}
|
|
1239
|
+
/** Peek at a message (unread or already-acked) without changing its state. */
|
|
1240
|
+
function peek(ctx, meId, msgId) {
|
|
1241
|
+
const snap = ctx.store.listInbox(meId);
|
|
1242
|
+
return [...snap.unread, ...snap.read].find((m) => m.id === msgId);
|
|
1243
|
+
}
|
|
1244
|
+
/** Acknowledge a message by moving it out of the unread set. Errors if not currently unread. */
|
|
1245
|
+
function ack(ctx, meId, msgId) {
|
|
1246
|
+
return ctx.store.ackMessage(meId, msgId);
|
|
1247
|
+
}
|
|
1248
|
+
/** Read and consume a message in one atomic step: returns the body and acks it if still unread.
|
|
1249
|
+
* Idempotent — an already-acked message is returned with `acked: false` rather than erroring (unlike
|
|
1250
|
+
* bare `ack`); an unknown message id throws. Backs `mail read --ack`. */
|
|
1251
|
+
function readAck(ctx, meId, msgId) {
|
|
1252
|
+
const snap = ctx.store.listInbox(meId);
|
|
1253
|
+
if (snap.unread.some((m) => m.id === msgId)) return {
|
|
1254
|
+
msg: ctx.store.ackMessage(meId, msgId),
|
|
1255
|
+
acked: true
|
|
1256
|
+
};
|
|
1257
|
+
const already = snap.read.find((m) => m.id === msgId);
|
|
1258
|
+
if (already) return {
|
|
1259
|
+
msg: already,
|
|
1260
|
+
acked: false
|
|
1261
|
+
};
|
|
1262
|
+
throw new Error(`"${msgId}" is not a message in this inbox`);
|
|
1263
|
+
}
|
|
1264
|
+
/** Permanently remove a message (unread or already-acked) from the caller's inbox. */
|
|
1265
|
+
function deleteMessage(ctx, meId, msgId) {
|
|
1266
|
+
ctx.store.removeMessage(meId, msgId);
|
|
1267
|
+
}
|
|
1268
|
+
//#endregion
|
|
1381
1269
|
//#region src/output.ts
|
|
1382
1270
|
function stringifyCell(v) {
|
|
1383
1271
|
if (v == null) return "";
|
|
@@ -1421,8 +1309,16 @@ const EVENTS = ["SessionStart", "PostToolUse"];
|
|
|
1421
1309
|
*/
|
|
1422
1310
|
function injectInbox(ctx, event) {
|
|
1423
1311
|
if (!EVENTS.includes(event)) throw new Error(`unsupported --event "${event}" (expected ${EVENTS.join(" | ")})`);
|
|
1424
|
-
|
|
1425
|
-
if (!meId)
|
|
1312
|
+
let meId = resolveSelfId(ctx);
|
|
1313
|
+
if (!meId) {
|
|
1314
|
+
if (currentPane(normalizeMuxEnv(ctx.env ?? process.env))) try {
|
|
1315
|
+
register(ctx, {});
|
|
1316
|
+
meId = resolveSelfId(ctx);
|
|
1317
|
+
} catch {
|
|
1318
|
+
return null;
|
|
1319
|
+
}
|
|
1320
|
+
if (!meId) return null;
|
|
1321
|
+
}
|
|
1426
1322
|
const parts = [];
|
|
1427
1323
|
const rec = loadAgent(ctx.store, meId);
|
|
1428
1324
|
if (rec?.status === "spawning") {
|
|
@@ -1439,18 +1335,25 @@ function injectInbox(ctx, event) {
|
|
|
1439
1335
|
const lines = unread.map((m) => `- **${m.fromHandle}**${m.subject ? ` — ${m.subject}` : ""}: ${m.body} \`(${m.id})\``);
|
|
1440
1336
|
parts.push(`## Unread mail (${unread.length})\n\n${lines.join("\n")}`);
|
|
1441
1337
|
}
|
|
1338
|
+
const cur = currentPane(normalizeMuxEnv(ctx.env ?? process.env));
|
|
1442
1339
|
if (rec && !rec.spawnedBy) try {
|
|
1443
|
-
const
|
|
1444
|
-
|
|
1445
|
-
const
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1340
|
+
const bound = ctx.store.getMainPane();
|
|
1341
|
+
if (!bound || cur?.pane === bound) {
|
|
1342
|
+
const standing = listAgents(ctx.store).filter((a) => a.kind === "standing");
|
|
1343
|
+
for (const owner of standing) {
|
|
1344
|
+
const ownerUnread = inbox({ store: ctx.store }, {
|
|
1345
|
+
meId: owner.id,
|
|
1346
|
+
unread: true
|
|
1347
|
+
});
|
|
1348
|
+
if (ownerUnread.length === 0) continue;
|
|
1349
|
+
const lines = ownerUnread.map((m) => `- **${m.fromHandle}**${m.subject ? ` — ${m.subject}` : ""}: ${m.body} \`(${m.id})\``);
|
|
1350
|
+
parts.push(`## Owner mail — ${owner.handle} (${ownerUnread.length})\n\n${lines.join("\n")}`);
|
|
1351
|
+
}
|
|
1452
1352
|
}
|
|
1453
1353
|
} catch {}
|
|
1354
|
+
if (rec && !rec.spawnedBy) try {
|
|
1355
|
+
if (cur ? !ctx.store.getMainPane() : !listAgents(ctx.store).some((a) => a.kind === "standing")) parts.push("## Legion setup\n\nThis pane has no owner inbox bound yet — run `cyberlegion init` to register the surfacing hook and bind this pane as the owner live presence.");
|
|
1356
|
+
} catch {}
|
|
1454
1357
|
if (parts.length === 0) return null;
|
|
1455
1358
|
return { hookSpecificOutput: {
|
|
1456
1359
|
hookEventName: event,
|
|
@@ -1561,18 +1464,66 @@ var FileStore = class {
|
|
|
1561
1464
|
const file = paths.briefFile(this.root, agentId);
|
|
1562
1465
|
return existsSync(file) ? readFileSync(file, "utf8") : void 0;
|
|
1563
1466
|
}
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1467
|
+
setMainPane(pane) {
|
|
1468
|
+
const file = paths.mainPaneFile(this.root);
|
|
1469
|
+
if (pane) {
|
|
1470
|
+
writeText(file, pane);
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
rmSync(file, { force: true });
|
|
1569
1474
|
}
|
|
1570
|
-
|
|
1571
|
-
const file = paths.
|
|
1572
|
-
return existsSync(file) ? readFileSync(file, "utf8") : void 0;
|
|
1475
|
+
getMainPane() {
|
|
1476
|
+
const file = paths.mainPaneFile(this.root);
|
|
1477
|
+
return existsSync(file) ? readFileSync(file, "utf8").trim() : void 0;
|
|
1573
1478
|
}
|
|
1574
1479
|
};
|
|
1575
1480
|
//#endregion
|
|
1481
|
+
//#region src/wake/await.ts
|
|
1482
|
+
/** Internal poll interval — not user-facing (the round-trip cost of one `mail await` cycle). */
|
|
1483
|
+
const POLL_MS$1 = 1e3;
|
|
1484
|
+
const DEFAULT_TIMEOUT_MS = 6e5;
|
|
1485
|
+
const DEFAULT_MAX_WAIT_S = 240;
|
|
1486
|
+
/**
|
|
1487
|
+
* Block on the caller's UNREAD inbox until a message matching `thread` (and optional `from`)
|
|
1488
|
+
* arrives, polling on a fixed ~1s internal interval. Three unambiguous outcomes:
|
|
1489
|
+
*
|
|
1490
|
+
* - `matched` — a message arrived; it is printed by the caller AND acked here (moved to read/), so
|
|
1491
|
+
* it leaves the unread set. Block-then-read: each await consumes exactly its round.
|
|
1492
|
+
* - `waiting` — this call's internal poll cycle hit its `maxWaitS` cap (default 240s, always well
|
|
1493
|
+
* under a harness tool-timeout SIGKILL) with no match yet. A clean, non-error return the caller
|
|
1494
|
+
* re-arms on (calls `awaitReply` again) — NOT the same as giving up.
|
|
1495
|
+
* - `timed-out` — the caller's overall `timeoutMs` (default 600_000ms / 600s; `0` = wait forever)
|
|
1496
|
+
* elapsed across re-arms with no match. The caller should exit non-zero.
|
|
1497
|
+
*/
|
|
1498
|
+
async function awaitReply(ctx, input) {
|
|
1499
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1500
|
+
const maxWaitMs = (input.maxWaitS ?? DEFAULT_MAX_WAIT_S) * 1e3;
|
|
1501
|
+
const now = input.now ?? (() => Date.now());
|
|
1502
|
+
const sleep = input.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1503
|
+
const start = now();
|
|
1504
|
+
const cycleDeadline = start + maxWaitMs;
|
|
1505
|
+
for (;;) {
|
|
1506
|
+
const items = inbox(ctx, {
|
|
1507
|
+
meId: input.meId,
|
|
1508
|
+
unread: true,
|
|
1509
|
+
thread: input.thread,
|
|
1510
|
+
from: input.from
|
|
1511
|
+
});
|
|
1512
|
+
if (items.length > 0) {
|
|
1513
|
+
const message = items[0];
|
|
1514
|
+
ack(ctx, input.meId, message.id);
|
|
1515
|
+
return {
|
|
1516
|
+
kind: "matched",
|
|
1517
|
+
message
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
const elapsed = now() - start;
|
|
1521
|
+
if (timeoutMs !== 0 && elapsed >= timeoutMs) return { kind: "timed-out" };
|
|
1522
|
+
if (now() >= cycleDeadline) return { kind: "waiting" };
|
|
1523
|
+
await sleep(POLL_MS$1);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
//#endregion
|
|
1576
1527
|
//#region src/wake/watch.ts
|
|
1577
1528
|
const POLL_MS = 1e3;
|
|
1578
1529
|
/**
|
|
@@ -1606,4 +1557,4 @@ async function watchMail(ctx, input, onMessage, opts = {}) {
|
|
|
1606
1557
|
}
|
|
1607
1558
|
}
|
|
1608
1559
|
//#endregion
|
|
1609
|
-
export {
|
|
1560
|
+
export { loadAgent as A, resolveSelfId as B, spawn as C, clearPresence as D, claimPresence as E, register as F, projectRoot as G, touch as H, registerStanding as I, normalizeMuxEnv as J, resolveProjectLocalRoot as K, resolveAgent as L, prune as M, realExec as N, detectHarness as O, reconcile as P, resolvePresence as R, clearUnit as S, bumpLastSeen as T, ensureMarker as U, resolveStandingOwner as V, paths as W, install as _, emit as a, resolveAgentDef as b, toonList as c, deleteMessage as d, inbox as f, send as g, resolveBody as h, injectInbox as i, presenceOf as j, listAgents as k, toonObject as l, readAck as m, awaitReply as n, fail as o, peek as p, resolveRoot as q, FileStore as r, nextStep as s, watchMail as t, ack as u, decommission as v, selectSessionAdapter as w, realizeLaunch as x, listAgentDefs as y, resolveRecipient as z };
|