cyberlegion 0.3.2 → 0.4.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/.claude-plugin/plugin.json +20 -0
- package/.codex-plugin/plugin.json +20 -0
- package/.plugin/pins.json +3 -0
- package/agents/headless-legate.md +59 -0
- package/dist/cli.mjs +7699 -69
- package/dist/index.d.mts +243 -7
- package/dist/index.mjs +2214 -4
- package/package.json +8 -2
- package/plugin.json +24 -0
- package/readme.md +60 -0
- package/skills/dispatch-governance/README.md +29 -0
- package/skills/dispatch-governance/SKILL.md +138 -0
- package/skills/init-cyberlegion/README.md +14 -0
- package/skills/init-cyberlegion/SKILL.md +110 -0
- package/skills/legate/README.md +22 -0
- package/skills/legate/SKILL.md +62 -0
- package/skills/manage-inbox/README.md +12 -0
- package/skills/manage-inbox/SKILL.md +77 -0
- package/skills/relay-governance/README.md +36 -0
- package/skills/relay-governance/SKILL.md +199 -0
- package/skills/session-adapter-governance/README.md +17 -0
- package/skills/session-adapter-governance/SKILL.md +74 -0
- package/skills/subagent-backend-governance/README.md +35 -0
- package/skills/subagent-backend-governance/SKILL.md +94 -0
- package/dist/cli.d.mts +0 -12
- package/dist/watch-D33fQcUI.mjs +0 -2044
package/dist/watch-D33fQcUI.mjs
DELETED
|
@@ -1,2044 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
-
import { callerPane, currentPane, herdrMuxAdapter, nodeExec, nudge, probeMultiplexer, tmuxMuxAdapter } from "cyber-mux";
|
|
4
|
-
import { execFileSync } from "node:child_process";
|
|
5
|
-
import { homedir } from "node:os";
|
|
6
|
-
import { assertDistinctFromPrimary, gitWorktreeAdapter, resolvePrimaryRoot } from "cyber-mux/worktree";
|
|
7
|
-
import { randomBytes } from "node:crypto";
|
|
8
|
-
//#region src/paths.ts
|
|
9
|
-
/** The tracked marker file that makes a hub root initialized (see ensureMarker). */
|
|
10
|
-
const MARKER_FILE = "config.json";
|
|
11
|
-
/** Walk up from `cwd` to the nearest git repo root; fall back to `cwd`. */
|
|
12
|
-
function projectRoot(cwd = process.cwd()) {
|
|
13
|
-
let dir = resolve(cwd);
|
|
14
|
-
for (;;) {
|
|
15
|
-
if (existsSync(join(dir, ".git"))) return dir;
|
|
16
|
-
const parent = dirname(dir);
|
|
17
|
-
if (parent === dir) return resolve(cwd);
|
|
18
|
-
dir = parent;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
function makeDefaultExec(cwd) {
|
|
22
|
-
return (cmd, args) => {
|
|
23
|
-
try {
|
|
24
|
-
return execFileSync(cmd, args, {
|
|
25
|
-
encoding: "utf8",
|
|
26
|
-
stdio: [
|
|
27
|
-
"ignore",
|
|
28
|
-
"pipe",
|
|
29
|
-
"ignore"
|
|
30
|
-
],
|
|
31
|
-
cwd
|
|
32
|
-
}).trim();
|
|
33
|
-
} catch {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
/**
|
|
39
|
-
* Resolve the cyberlegion hub root. Precedence: explicit --root/--space, then `$CYBERLEGION_ROOT`,
|
|
40
|
-
* then the GLOBAL hub `~/.agents/cyberlegion` (addressable across every project and worktree
|
|
41
|
-
* boundary — identity/mail/registry state lives here by default; `--space` isolates it), falling
|
|
42
|
-
* back to a project-local `.agents/cyberlegion` only when no home directory is resolvable.
|
|
43
|
-
*/
|
|
44
|
-
function resolveRoot(opts = {}) {
|
|
45
|
-
const env = opts.env ?? process.env;
|
|
46
|
-
const explicit = opts.root ?? opts.space ?? env.CYBERLEGION_ROOT;
|
|
47
|
-
if (explicit) return resolve(explicit);
|
|
48
|
-
const home = homedir();
|
|
49
|
-
if (home) return join(home, ".agents", "cyberlegion");
|
|
50
|
-
return join(projectRoot(opts.cwd), ".agents", "cyberlegion");
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Resolve the primary checkout's project-local cyberlegion dir — the tracked marker for a
|
|
54
|
-
* legion-enabled project (never the global hub, and never where spawned unit worktrees themselves
|
|
55
|
-
* are checked out — see `resolveUnitWorktreePath`), keyed off `git worktree`'s common-dir so it
|
|
56
|
-
* resolves the same from the primary checkout or from any of its linked worktrees.
|
|
57
|
-
*/
|
|
58
|
-
function resolveProjectLocalRoot(opts = {}) {
|
|
59
|
-
const exec = opts.exec ?? makeDefaultExec(opts.cwd);
|
|
60
|
-
let primary;
|
|
61
|
-
try {
|
|
62
|
-
primary = resolvePrimaryRoot(exec);
|
|
63
|
-
} catch {
|
|
64
|
-
primary = void 0;
|
|
65
|
-
}
|
|
66
|
-
return join(primary ?? projectRoot(opts.cwd), ".agents", "cyberlegion");
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Create the tracked `config.json` marker at `root` (a hub root) if it does not already exist.
|
|
70
|
-
* Callers pass the root under which the marker should be created/ensured; this mkdir's it as
|
|
71
|
-
* needed. Idempotent — never overwrites an existing marker.
|
|
72
|
-
*/
|
|
73
|
-
function ensureMarker(root) {
|
|
74
|
-
mkdirSync(root, { recursive: true });
|
|
75
|
-
const marker = join(root, MARKER_FILE);
|
|
76
|
-
if (!existsSync(marker)) writeFileSync(marker, `${JSON.stringify({ version: 1 }, null, 2)}\n`);
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Thrown when an agent/message id destined to become a filename PATH SEGMENT fails
|
|
80
|
-
* `assertSafeId` — see that function's doc for why this rejects rather than encodes.
|
|
81
|
-
*/
|
|
82
|
-
var InvalidIdError = class extends Error {
|
|
83
|
-
kind;
|
|
84
|
-
value;
|
|
85
|
-
constructor(kind, value) {
|
|
86
|
-
super(`invalid ${kind} ${JSON.stringify(value)} — must not be empty, ".", "..", or contain a path separator`);
|
|
87
|
-
this.kind = kind;
|
|
88
|
-
this.value = value;
|
|
89
|
-
this.name = "InvalidIdError";
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
const UNSAFE_ID = /[\\/\0]/;
|
|
93
|
-
/**
|
|
94
|
-
* Filename-safety guard for any id that becomes a bare PATH SEGMENT under the hub root — agent ids
|
|
95
|
-
* (`agentFile`/`inboxDir`/`dataDir`/`briefFile`) and message ids (`messageFile`). Both are
|
|
96
|
-
* effectively user/peer-controlled: an agent id round-trips a caller-supplied `--handle`-adjacent
|
|
97
|
-
* value in some flows, and nothing upstream of the store validates it before it reaches a `join()`.
|
|
98
|
-
*
|
|
99
|
-
* REJECT, don't encode: unlike `sanitizePane` (which deliberately encodes, because a tmux/herdr pane
|
|
100
|
-
* locator is an opaque, internally-produced token that only needs to survive as a *lookup key*, not
|
|
101
|
-
* round-trip as an identity), an agent/message id is a PRIMARY KEY — `getAgent(id)` must return
|
|
102
|
-
* exactly what `putAgent` stored under that same `id`. Silently encoding two different malformed ids
|
|
103
|
-
* onto the same sanitized filename would let one caller's write silently clobber or read another's
|
|
104
|
-
* record — a worse failure than a loud, immediate refusal. So: empty, `.`, `..`, and any embedded
|
|
105
|
-
* path separator (POSIX `/`, Windows `\`) or NUL byte are rejected outright, which also rejects an
|
|
106
|
-
* absolute path outright (an absolute path always contains a separator).
|
|
107
|
-
*/
|
|
108
|
-
function assertSafeId(id, kind) {
|
|
109
|
-
if (!id || id === "." || id === ".." || UNSAFE_ID.test(id)) throw new InvalidIdError(kind, id);
|
|
110
|
-
return id;
|
|
111
|
-
}
|
|
112
|
-
const paths = {
|
|
113
|
-
agentsDir: (root) => join(root, "agents"),
|
|
114
|
-
agentFile: (root, id) => join(root, "agents", `${assertSafeId(id, "agent id")}.json`),
|
|
115
|
-
panesDir: (root) => join(root, "panes"),
|
|
116
|
-
paneFile: (root, pane) => join(root, "panes", `${sanitizePane(pane)}.id`),
|
|
117
|
-
inboxDir: (root, id) => join(root, "inbox", assertSafeId(id, "agent id")),
|
|
118
|
-
inboxReadDir: (root, id) => join(root, "inbox", assertSafeId(id, "agent id"), "read"),
|
|
119
|
-
dataDir: (root, id) => join(root, "data", assertSafeId(id, "agent id")),
|
|
120
|
-
briefFile: (root, id) => join(root, "data", assertSafeId(id, "agent id"), "brief.md"),
|
|
121
|
-
mainPaneFile: (root) => join(root, "main-pane.id"),
|
|
122
|
-
/** A message's file path within `toId`'s unread/read inbox dir, keyed by its own collision-free
|
|
123
|
-
* id — validated the same as an agent id (it's the same class of risk: a peer- or CLI-controlled
|
|
124
|
-
* string becoming a filename). */
|
|
125
|
-
messageFile: (root, toId, msgId) => join(paths.inboxDir(root, toId), `${assertSafeId(msgId, "message id")}.json`),
|
|
126
|
-
messageReadFile: (root, toId, msgId) => join(paths.inboxReadDir(root, toId), `${assertSafeId(msgId, "message id")}.json`)
|
|
127
|
-
};
|
|
128
|
-
/**
|
|
129
|
-
* Where a spawned unit's own git worktree is checked out by default — a sibling of the primary
|
|
130
|
-
* checkout (`<parent>/<repo>.worktrees/legion-<id>`), never nested inside the primary's own working
|
|
131
|
-
* tree. A linked worktree living inside the primary's tree is untracked-but-present: it pollutes
|
|
132
|
-
* `git status` in the primary checkout, confuses tooling that walks the tree recursively (test
|
|
133
|
-
* runners, watchers, `find`/`rm -rf`), and risks a tree-wide op in the primary crossing into the
|
|
134
|
-
* nested worktree's own checkout. `<repo>.worktrees/` matches the sibling convention already in use
|
|
135
|
-
* for other tools' worktrees in this environment (herdr's `worktree-<word>-<word>-<hash>`, cursor's
|
|
136
|
-
* `<proj><count>`); the `legion-` prefix self-identifies this tool's own units the same way, without
|
|
137
|
-
* needing a subfolder.
|
|
138
|
-
*/
|
|
139
|
-
function resolveUnitWorktreePath(primaryRoot, id) {
|
|
140
|
-
return join(dirname(primaryRoot), `${basename(primaryRoot)}.worktrees`, `legion-${id}`);
|
|
141
|
-
}
|
|
142
|
-
/** tmux pane ids look like "%3"; make them filesystem-safe. */
|
|
143
|
-
function sanitizePane(pane) {
|
|
144
|
-
return pane.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
145
|
-
}
|
|
146
|
-
//#endregion
|
|
147
|
-
//#region src/agentdef/resolve.ts
|
|
148
|
-
function parseAgentDefFile(text) {
|
|
149
|
-
const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n)?([\s\S]*)$/.exec(text);
|
|
150
|
-
if (!m) return {
|
|
151
|
-
fm: {},
|
|
152
|
-
instructions: text.trim()
|
|
153
|
-
};
|
|
154
|
-
const [, block, rest] = m;
|
|
155
|
-
const fm = {};
|
|
156
|
-
const lines = block.split("\n");
|
|
157
|
-
let i = 0;
|
|
158
|
-
while (i < lines.length) {
|
|
159
|
-
const line = lines[i].replace(/\r$/, "");
|
|
160
|
-
if (line.trim() === "" || line.trim().startsWith("#")) {
|
|
161
|
-
i++;
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
if (line.length - line.trimStart().length > 0) {
|
|
165
|
-
i++;
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
const idx = line.indexOf(":");
|
|
169
|
-
if (idx === -1) {
|
|
170
|
-
i++;
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
const key = line.slice(0, idx).trim();
|
|
174
|
-
let value = line.slice(idx + 1).trim();
|
|
175
|
-
i++;
|
|
176
|
-
if (value === ">" || value === ">-" || value === "|" || value === "|-") {
|
|
177
|
-
const parts = [];
|
|
178
|
-
while (i < lines.length) {
|
|
179
|
-
const l = lines[i].replace(/\r$/, "");
|
|
180
|
-
if (l.trim() === "") {
|
|
181
|
-
i++;
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
if (l.length - l.trimStart().length === 0) break;
|
|
185
|
-
parts.push(l.trim());
|
|
186
|
-
i++;
|
|
187
|
-
}
|
|
188
|
-
value = parts.join(value.startsWith("|") ? "\n" : " ");
|
|
189
|
-
}
|
|
190
|
-
fm[key] = unquote(value);
|
|
191
|
-
}
|
|
192
|
-
return {
|
|
193
|
-
fm,
|
|
194
|
-
instructions: rest.trim()
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
function unquote(v) {
|
|
198
|
-
if (v.length >= 2 && (v.startsWith("\"") && v.endsWith("\"") || v.startsWith("'") && v.endsWith("'"))) return v.slice(1, -1);
|
|
199
|
-
return v;
|
|
200
|
-
}
|
|
201
|
-
function toBool(v) {
|
|
202
|
-
if (v === void 0) return void 0;
|
|
203
|
-
if (v === "true") return true;
|
|
204
|
-
if (v === "false") return false;
|
|
205
|
-
}
|
|
206
|
-
function toAgentDef(path, text, fallbackName) {
|
|
207
|
-
const { fm, instructions } = parseAgentDefFile(text);
|
|
208
|
-
return {
|
|
209
|
-
name: fm.name ?? fallbackName,
|
|
210
|
-
description: fm.description,
|
|
211
|
-
model: fm.model,
|
|
212
|
-
effort: fm.effort,
|
|
213
|
-
harness: fm.harness,
|
|
214
|
-
warm: toBool(fm.warm),
|
|
215
|
-
interactive: toBool(fm.interactive),
|
|
216
|
-
instructions,
|
|
217
|
-
path
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
/** The project's own `.agents/agents/` dir — the convention `agent list`/name-search scans. */
|
|
221
|
-
function projectAgentsDir(cwd) {
|
|
222
|
-
return join(projectRoot(cwd), ".agents", "agents");
|
|
223
|
-
}
|
|
224
|
-
/** Resolve one agent definition, by explicit file or by name search. Throws a clear error when
|
|
225
|
-
* neither locates a def — never returns a partial/empty result. */
|
|
226
|
-
function resolveAgentDef(input) {
|
|
227
|
-
if (input.file) {
|
|
228
|
-
const path = resolve(input.file);
|
|
229
|
-
if (!existsSync(path)) throw new Error(`agent def file "${input.file}" does not exist`);
|
|
230
|
-
return toAgentDef(path, readFileSync(path, "utf8"), basename(path).replace(/\.md$/, ""));
|
|
231
|
-
}
|
|
232
|
-
if (!input.name) throw new Error("resolveAgentDef needs a --agent <name> or --agent-file <path>");
|
|
233
|
-
const roots = [...input.searchRoots ?? [], projectAgentsDir(input.cwd)];
|
|
234
|
-
for (const root of roots) {
|
|
235
|
-
const candidate = join(root, `${input.name}.md`);
|
|
236
|
-
if (existsSync(candidate)) return toAgentDef(candidate, readFileSync(candidate, "utf8"), input.name);
|
|
237
|
-
}
|
|
238
|
-
throw new Error(`no agent definition named "${input.name}" found under .agents/agents/`);
|
|
239
|
-
}
|
|
240
|
-
/** List every resolvable agent def under `.agents/agents/` (+ any extra search roots). Definitive
|
|
241
|
-
* empty array when the dir is absent or empty — never throws for "none found" here (unlike a
|
|
242
|
-
* named resolve, listing is inherently zero-or-more). */
|
|
243
|
-
function listAgentDefs(input = {}) {
|
|
244
|
-
const roots = [...input.searchRoots ?? [], projectAgentsDir(input.cwd)];
|
|
245
|
-
const seen = /* @__PURE__ */ new Set();
|
|
246
|
-
const defs = [];
|
|
247
|
-
for (const root of roots) {
|
|
248
|
-
if (!existsSync(root)) continue;
|
|
249
|
-
for (const file of readdirSync(root)) {
|
|
250
|
-
if (!file.endsWith(".md")) continue;
|
|
251
|
-
const path = join(root, file);
|
|
252
|
-
if (seen.has(path)) continue;
|
|
253
|
-
seen.add(path);
|
|
254
|
-
defs.push(toAgentDef(path, readFileSync(path, "utf8"), file.replace(/\.md$/, "")));
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
return defs;
|
|
258
|
-
}
|
|
259
|
-
//#endregion
|
|
260
|
-
//#region src/mux-env.ts
|
|
261
|
-
/**
|
|
262
|
-
* Transitional env-normalization seam (mux.feature: "a pane carrying only the legacy fast-path vars
|
|
263
|
-
* is still honored"). cyber-mux's own fast-path reads `$CYBER_MUX`/`$CYBER_MUX_PANE` — its
|
|
264
|
-
* `currentPane` hardcodes those names (no `envPrefix` override), and `probeMultiplexer`'s `envPrefix`
|
|
265
|
-
* option renames the WHOLE pair together, so neither can be steered to fall back onto a
|
|
266
|
-
* differently-named legacy pair. Every call into cyber-mux that reads the fast-path
|
|
267
|
-
* (`probeMultiplexer`, `currentPane`, `callerPane`, `resolveMuxAdapter`) must be handed this seam's
|
|
268
|
-
* output instead of the raw env.
|
|
269
|
-
*
|
|
270
|
-
* When `$CYBER_MUX`/`$CYBER_MUX_PANE` are both absent and either legacy `$CYBERLEGION_MUX`/
|
|
271
|
-
* `$CYBERLEGION_MUX_PANE` var is present, copies the legacy pair onto the current names. Never
|
|
272
|
-
* overwrites an already-set current var — the current pair always wins outright the moment either
|
|
273
|
-
* half of it is set, exactly matching the frozen precedence chain (mux.feature: "the current
|
|
274
|
-
* fast-path vars win over the legacy pair when both are set").
|
|
275
|
-
*
|
|
276
|
-
* Transitional — deleted once no pre-migration pane (one that only ever exported the legacy pair) is
|
|
277
|
-
* still alive.
|
|
278
|
-
*/
|
|
279
|
-
function normalizeMuxEnv(env) {
|
|
280
|
-
const hasCurrent = env.CYBER_MUX !== void 0 || env.CYBER_MUX_PANE !== void 0;
|
|
281
|
-
const hasLegacy = env.CYBERLEGION_MUX !== void 0 || env.CYBERLEGION_MUX_PANE !== void 0;
|
|
282
|
-
if (hasCurrent || !hasLegacy) return env;
|
|
283
|
-
return {
|
|
284
|
-
...env,
|
|
285
|
-
CYBER_MUX: env.CYBERLEGION_MUX,
|
|
286
|
-
CYBER_MUX_PANE: env.CYBERLEGION_MUX_PANE
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
//#endregion
|
|
290
|
-
//#region src/identity.ts
|
|
291
|
-
const realExec = nodeExec;
|
|
292
|
-
/** `currentPane`, narrowed to a backend the registry can actually store a locator under. A caller
|
|
293
|
-
* inside a pane-carrying-but-unstorable multiplexer (wezterm/zellij) is simply unpaned here — it can
|
|
294
|
-
* still resolve an identity via the `$CYBERLEGION_AGENT_ID` env fallback, it just cannot bind a pane
|
|
295
|
-
* locator to it. */
|
|
296
|
-
function storablePane(env) {
|
|
297
|
-
const cur = currentPane(normalizeMuxEnv(env));
|
|
298
|
-
return cur && (cur.mux === "tmux" || cur.mux === "herdr") ? {
|
|
299
|
-
mux: cur.mux,
|
|
300
|
-
pane: cur.pane
|
|
301
|
-
} : void 0;
|
|
302
|
-
}
|
|
303
|
-
const nowIso = (ctx) => new Date(ctx.now?.() ?? Date.now()).toISOString();
|
|
304
|
-
function randomId() {
|
|
305
|
-
return randomBytes(8).toString("hex");
|
|
306
|
-
}
|
|
307
|
-
function loadAgent(store, id) {
|
|
308
|
-
return store.getAgent(id);
|
|
309
|
-
}
|
|
310
|
-
function saveAgent(store, rec) {
|
|
311
|
-
store.putAgent(rec);
|
|
312
|
-
}
|
|
313
|
-
function listAgents(store) {
|
|
314
|
-
return store.listAgents();
|
|
315
|
-
}
|
|
316
|
-
/** Detect the harness: explicit wins, then env probes, then the tmux pane command. */
|
|
317
|
-
function detectHarness(explicit, ctx) {
|
|
318
|
-
if (explicit) {
|
|
319
|
-
if (explicit === "claude" || explicit === "cursor" || explicit === "codex") return explicit;
|
|
320
|
-
throw new Error(`unknown --harness "${explicit}" (expected claude | cursor | codex)`);
|
|
321
|
-
}
|
|
322
|
-
const env = ctx.env ?? process.env;
|
|
323
|
-
if (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT) return "claude";
|
|
324
|
-
if (Object.keys(env).some((k) => k.startsWith("CURSOR"))) return "cursor";
|
|
325
|
-
if (Object.keys(env).some((k) => k.startsWith("CODEX"))) return "codex";
|
|
326
|
-
const pane = env.TMUX_PANE;
|
|
327
|
-
if (pane) {
|
|
328
|
-
const cmd = (ctx.exec ?? realExec)("tmux", [
|
|
329
|
-
"display-message",
|
|
330
|
-
"-p",
|
|
331
|
-
"-t",
|
|
332
|
-
pane,
|
|
333
|
-
"#{pane_current_command}"
|
|
334
|
-
]);
|
|
335
|
-
if (cmd?.includes("cursor")) return "cursor";
|
|
336
|
-
if (cmd?.includes("codex")) return "codex";
|
|
337
|
-
if (cmd?.includes("claude")) return "claude";
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
/**
|
|
341
|
-
* Recover the calling agent's own id, mux-agnostically. Inside any multiplexer pane (tmux or herdr)
|
|
342
|
-
* the pane index is authoritative — a pane with no pane entry is simply unregistered and must NOT
|
|
343
|
-
* adopt `$CYBERLEGION_AGENT_ID` (that env fallback applies ONLY when the session is in no
|
|
344
|
-
* multiplexer pane at all). There is no shared bare "self" file — self-id is always pane-keyed or
|
|
345
|
-
* explicit via the env var.
|
|
346
|
-
*/
|
|
347
|
-
function resolveSelfId(ctx) {
|
|
348
|
-
const env = ctx.env ?? process.env;
|
|
349
|
-
const cur = currentPane(normalizeMuxEnv(env));
|
|
350
|
-
if (cur) return ctx.store.resolvePaneId(cur.pane);
|
|
351
|
-
return env.CYBERLEGION_AGENT_ID || void 0;
|
|
352
|
-
}
|
|
353
|
-
/** Register (or idempotently refresh) this session's identity. */
|
|
354
|
-
function register(ctx, input) {
|
|
355
|
-
ctx.store.ensureMarker();
|
|
356
|
-
const env = ctx.env ?? process.env;
|
|
357
|
-
const harness = detectHarness(input.harness, ctx);
|
|
358
|
-
if (!harness) throw new Error("could not detect harness — pass --harness claude|cursor|codex");
|
|
359
|
-
const existingId = resolveSelfId(ctx);
|
|
360
|
-
const existing = existingId ? loadAgent(ctx.store, existingId) : void 0;
|
|
361
|
-
const id = existing?.id ?? existingId ?? randomId();
|
|
362
|
-
const ts = nowIso(ctx);
|
|
363
|
-
const cur = storablePane(env);
|
|
364
|
-
const exec = ctx.exec ?? realExec;
|
|
365
|
-
const rec = {
|
|
366
|
-
id,
|
|
367
|
-
handle: input.handle ?? existing?.handle ?? id.slice(0, 6),
|
|
368
|
-
harness,
|
|
369
|
-
cwd: process.cwd(),
|
|
370
|
-
worktree: existing?.worktree ?? gitWorktree(exec),
|
|
371
|
-
pane: cur ? paneLocator(cur, exec, env) : null,
|
|
372
|
-
status: "active",
|
|
373
|
-
createdAt: existing?.createdAt ?? ts,
|
|
374
|
-
lastSeen: ts,
|
|
375
|
-
...existing?.brief ? { brief: existing.brief } : {},
|
|
376
|
-
...existing?.spawnedBy ? { spawnedBy: existing.spawnedBy } : {}
|
|
377
|
-
};
|
|
378
|
-
saveAgent(ctx.store, rec);
|
|
379
|
-
if (cur) ctx.store.putPaneIndex(cur.pane, id);
|
|
380
|
-
return rec;
|
|
381
|
-
}
|
|
382
|
-
/** Build the record's pane locator. `window`/`session` are tmux-only (herdr's pane id is
|
|
383
|
-
* self-contained and its CLI needs nothing more to address a pane). */
|
|
384
|
-
function paneLocator(cur, exec, env) {
|
|
385
|
-
if (cur.mux === "tmux") return {
|
|
386
|
-
mux: "tmux",
|
|
387
|
-
id: cur.pane,
|
|
388
|
-
window: exec("tmux", [
|
|
389
|
-
"display-message",
|
|
390
|
-
"-p",
|
|
391
|
-
"-t",
|
|
392
|
-
cur.pane,
|
|
393
|
-
"#{window_id}"
|
|
394
|
-
]) ?? void 0,
|
|
395
|
-
session: env.TMUX?.split(",")[0]
|
|
396
|
-
};
|
|
397
|
-
return {
|
|
398
|
-
mux: cur.mux,
|
|
399
|
-
id: cur.pane
|
|
400
|
-
};
|
|
401
|
-
}
|
|
402
|
-
/** Derive a standing record's stable id from its handle — same slug rule as `sanitizePane`, prefixed
|
|
403
|
-
* so it reads distinct from a random 16-hex session id or a pane pointer. */
|
|
404
|
-
function standingId(handle) {
|
|
405
|
-
return `standing-${sanitizePane(handle)}`;
|
|
406
|
-
}
|
|
407
|
-
/**
|
|
408
|
-
* Mint (or idempotently refresh) a standing identity: a session-independent, prune-exempt owner
|
|
409
|
-
* inbox keyed by handle, with no pane/tmux/harness. A SIBLING of `register`, not an overload — it
|
|
410
|
-
* skips all pane/tmux machinery and harness auto-detection entirely.
|
|
411
|
-
*/
|
|
412
|
-
function registerStanding(ctx, input) {
|
|
413
|
-
ctx.store.ensureMarker();
|
|
414
|
-
const id = standingId(input.handle);
|
|
415
|
-
const existing = loadAgent(ctx.store, id);
|
|
416
|
-
const ts = nowIso(ctx);
|
|
417
|
-
const rec = {
|
|
418
|
-
id,
|
|
419
|
-
handle: input.handle,
|
|
420
|
-
kind: "standing",
|
|
421
|
-
pane: null,
|
|
422
|
-
harness: void 0,
|
|
423
|
-
cwd: process.cwd(),
|
|
424
|
-
status: "active",
|
|
425
|
-
createdAt: existing?.createdAt ?? ts,
|
|
426
|
-
lastSeen: ts
|
|
427
|
-
};
|
|
428
|
-
saveAgent(ctx.store, rec);
|
|
429
|
-
return rec;
|
|
430
|
-
}
|
|
431
|
-
function gitWorktree(exec) {
|
|
432
|
-
const root = exec("git", ["rev-parse", "--show-toplevel"]);
|
|
433
|
-
if (!root) return null;
|
|
434
|
-
return {
|
|
435
|
-
root,
|
|
436
|
-
branch: exec("git", [
|
|
437
|
-
"rev-parse",
|
|
438
|
-
"--abbrev-ref",
|
|
439
|
-
"HEAD"
|
|
440
|
-
]) ?? void 0
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
/** Prefer a standing record over a plain session record when both match a handle — an owner
|
|
444
|
-
* report must land in the durable standing inbox, not a dying session's. */
|
|
445
|
-
function preferStanding(matches) {
|
|
446
|
-
return matches.find((a) => a.kind === "standing") ?? matches[0];
|
|
447
|
-
}
|
|
448
|
-
/** Split a handle's matches into live and exited. An exited unit's pane is gone and its inbox has
|
|
449
|
-
* no reader, so a *name* must never resolve to one — a handle is reusable across units, and the
|
|
450
|
-
* dead holders of it outnumber the live one over time. Standing records never exit. An explicit id
|
|
451
|
-
* still resolves either way: naming a unit outright is a deliberate choice, unlike reaching for a
|
|
452
|
-
* handle and silently landing on a corpse. */
|
|
453
|
-
function matchHandle(agents, handle) {
|
|
454
|
-
const matched = agents.filter((a) => a.handle === handle);
|
|
455
|
-
return {
|
|
456
|
-
live: matched.filter((a) => a.status !== "exited"),
|
|
457
|
-
exited: matched.filter((a) => a.status === "exited")
|
|
458
|
-
};
|
|
459
|
-
}
|
|
460
|
-
/** Fail loudly when a handle names only the dead — never fall through to a corpse. */
|
|
461
|
-
function unaddressable(ref, exited, tried) {
|
|
462
|
-
if (exited.length > 0) {
|
|
463
|
-
const dead = exited.map((a) => `${a.id.slice(0, 6)}${a.pane ? ` (${a.pane.id})` : ""}`).join(", ");
|
|
464
|
-
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.`);
|
|
465
|
-
}
|
|
466
|
-
return /* @__PURE__ */ new Error(`no agent addressable as "${ref}" (tried ${tried})`);
|
|
467
|
-
}
|
|
468
|
-
/** Resolve a handle to its standing owner record's id — never falls back to a live session agent
|
|
469
|
-
* sharing that handle, so `--owner` can never be pointed at a session's inbox by mistake. */
|
|
470
|
-
function resolveStandingOwner(store, handle) {
|
|
471
|
-
const match = listAgents(store).find((a) => a.handle === handle && a.kind === "standing");
|
|
472
|
-
if (!match) throw new Error(`no standing owner "${handle}" — run 'cyberlegion unit register --standing --handle ${handle}'`);
|
|
473
|
-
return match.id;
|
|
474
|
-
}
|
|
475
|
-
/**
|
|
476
|
-
* Bind the caller's own unit as a standing owner's presence — the live unit standing in for a
|
|
477
|
-
* durable record that has no session of its own. Order matters: resolve the standing record first
|
|
478
|
-
* (an unknown handle throws via `resolveStandingOwner` — fail-loud, never auto-mints), THEN gate on
|
|
479
|
-
* spawn capability, THEN resolve the caller's own id — so an unknown handle or a caller with no
|
|
480
|
-
* multiplexer never touches the pointer. Last claim wins: a plain overwrite, no merge.
|
|
481
|
-
*/
|
|
482
|
-
function claimPresence(ctx, handle) {
|
|
483
|
-
const standingId = resolveStandingOwner(ctx.store, handle);
|
|
484
|
-
if (!loadAgent(ctx.store, standingId)) throw new Error(`no standing owner "${handle}" — run 'cyberlegion unit register --standing --handle ${handle}'`);
|
|
485
|
-
if (probeMultiplexer(ctx.exec ?? realExec, normalizeMuxEnv(ctx.env ?? process.env)).mux === "none") throw new Error("claiming a presence needs a multiplexer to open panes");
|
|
486
|
-
const selfId = resolveSelfId(ctx);
|
|
487
|
-
if (!selfId) throw new Error("no identity in this session — run `cyberlegion unit register` first");
|
|
488
|
-
return ctx.store.withLock(`presence:${standingId}`, () => {
|
|
489
|
-
const rec = loadAgent(ctx.store, standingId);
|
|
490
|
-
if (!rec) throw new Error(`no standing owner "${handle}" — run 'cyberlegion unit register --standing --handle ${handle}'`);
|
|
491
|
-
rec.presence = selfId;
|
|
492
|
-
saveAgent(ctx.store, rec);
|
|
493
|
-
return rec;
|
|
494
|
-
});
|
|
495
|
-
}
|
|
496
|
-
/**
|
|
497
|
-
* Unbind a standing owner's presence. The unknown-handle throw (via `resolveStandingOwner`) wins
|
|
498
|
-
* over this call's own tolerance: `--clear` is forgiving about *nothing being bound* (a no-op, never
|
|
499
|
-
* an error), never about *the owner not existing* — a typo'd handle fails loudly instead of silently
|
|
500
|
-
* reporting a clear it never performed.
|
|
501
|
-
*/
|
|
502
|
-
function clearPresence(ctx, handle) {
|
|
503
|
-
const standingId = resolveStandingOwner(ctx.store, handle);
|
|
504
|
-
if (!loadAgent(ctx.store, standingId)) throw new Error(`no standing owner "${handle}" — run 'cyberlegion unit register --standing --handle ${handle}'`);
|
|
505
|
-
return ctx.store.withLock(`presence:${standingId}`, () => {
|
|
506
|
-
const rec = loadAgent(ctx.store, standingId);
|
|
507
|
-
if (!rec) throw new Error(`no standing owner "${handle}" — run 'cyberlegion unit register --standing --handle ${handle}'`);
|
|
508
|
-
if (rec.presence !== void 0) {
|
|
509
|
-
rec.presence = void 0;
|
|
510
|
-
saveAgent(ctx.store, rec);
|
|
511
|
-
}
|
|
512
|
-
return rec;
|
|
513
|
-
});
|
|
514
|
-
}
|
|
515
|
-
/**
|
|
516
|
-
* The live-only presence rule, keyed off a standing record the caller ALREADY HOLDS — the pointer
|
|
517
|
-
* records a unit id, and that unit can exit while the standing record it stands in for never does,
|
|
518
|
-
* so a presence whose unit is missing or `status: exited` reads as no presence bound, exactly as if
|
|
519
|
-
* none were ever claimed. Nothing here self-heals the stale pointer; it stays inert until re-claimed
|
|
520
|
-
* via `claimPresence`.
|
|
521
|
-
*
|
|
522
|
-
* Separate from `resolvePresence` because this one is INCAPABLE of throwing: the delivery doorbell
|
|
523
|
-
* must never fail a send that already landed durably, so it cannot resolve the presence through a
|
|
524
|
-
* handle-resolving path that throws when the standing record races away underneath it (a concurrent
|
|
525
|
-
* `unit close`/`decommission` between loading the recipient and reading its presence). It is also
|
|
526
|
-
* strictly cheaper — no O(n) registry scan to re-find a record the caller is holding.
|
|
527
|
-
*/
|
|
528
|
-
function presenceOf(store, rec) {
|
|
529
|
-
if (!rec.presence) return void 0;
|
|
530
|
-
const presenceUnit = loadAgent(store, rec.presence);
|
|
531
|
-
if (!presenceUnit || presenceUnit.status === "exited") return void 0;
|
|
532
|
-
return presenceUnit;
|
|
533
|
-
}
|
|
534
|
-
/**
|
|
535
|
-
* Resolve a standing owner's presence by handle, live-only (`presenceOf`). The handle-keyed front
|
|
536
|
-
* door for the CLI read path: it keeps the fail-loud unknown-handle throw (via `resolveStandingOwner`)
|
|
537
|
-
* so `unit claim <handle> --show` on a typo'd handle errors rather than reporting a definitive `none`
|
|
538
|
-
* for an owner that does not exist.
|
|
539
|
-
*/
|
|
540
|
-
function resolvePresence(store, handle) {
|
|
541
|
-
const rec = loadAgent(store, resolveStandingOwner(store, handle));
|
|
542
|
-
if (!rec) return void 0;
|
|
543
|
-
return presenceOf(store, rec);
|
|
544
|
-
}
|
|
545
|
-
/** Resolve a recipient argument (id or handle) to an agent id. A handle resolves to live units
|
|
546
|
-
* only — mail addressed to an exited unit lands in an inbox nobody reads. */
|
|
547
|
-
function resolveRecipient(store, to) {
|
|
548
|
-
if (loadAgent(store, to)) return to;
|
|
549
|
-
const { live, exited } = matchHandle(listAgents(store), to);
|
|
550
|
-
const match = preferStanding(live);
|
|
551
|
-
if (!match) throw unaddressable(to, exited, "id and handle");
|
|
552
|
-
return match.id;
|
|
553
|
-
}
|
|
554
|
-
/**
|
|
555
|
-
* Resolve a unit reference by id, handle, or its worktree branch (the unit↔CR join key: an
|
|
556
|
-
* `AgentRecord.worktree.branch` equals the SDD `<cr-ref>` it maps to, when spawned for one). Used
|
|
557
|
-
* by verbs that address "the unit working on CR X" as well as "the unit named X".
|
|
558
|
-
*/
|
|
559
|
-
function resolveAgent(store, ref) {
|
|
560
|
-
const byId = loadAgent(store, ref);
|
|
561
|
-
if (byId) return byId;
|
|
562
|
-
const agents = listAgents(store);
|
|
563
|
-
const { live, exited } = matchHandle(agents, ref);
|
|
564
|
-
const byHandle = preferStanding(live);
|
|
565
|
-
if (byHandle) return byHandle;
|
|
566
|
-
const byBranchAll = agents.filter((a) => a.worktree?.branch === ref);
|
|
567
|
-
const byBranch = byBranchAll.find((a) => a.status !== "exited");
|
|
568
|
-
if (byBranch) return byBranch;
|
|
569
|
-
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");
|
|
570
|
-
}
|
|
571
|
-
function bumpLastSeen(ctx, id) {
|
|
572
|
-
const rec = loadAgent(ctx.store, id);
|
|
573
|
-
if (!rec) return;
|
|
574
|
-
rec.lastSeen = nowIso(ctx);
|
|
575
|
-
saveAgent(ctx.store, rec);
|
|
576
|
-
}
|
|
577
|
-
/** Refresh this session's own last-seen if it is registered — best-effort, never throws. */
|
|
578
|
-
function touch(ctx) {
|
|
579
|
-
const id = resolveSelfId(ctx);
|
|
580
|
-
if (id) bumpLastSeen(ctx, id);
|
|
581
|
-
}
|
|
582
|
-
const STALE_MS = 9e5;
|
|
583
|
-
/** The per-mux session adapters `prune` consults for pane liveness — each answers with its own
|
|
584
|
-
* backend primitive so a herdr pane is never probed with a tmux query, and vice versa. */
|
|
585
|
-
const PANE_ADAPTERS = {
|
|
586
|
-
tmux: tmuxMuxAdapter,
|
|
587
|
-
herdr: herdrMuxAdapter
|
|
588
|
-
};
|
|
589
|
-
/** Map a backend-reported agent string to a known harness — substring-matched like the tmux
|
|
590
|
-
* pane-command probe in `detectHarness`; anything else is unclassifiable. */
|
|
591
|
-
function harnessFromAgent(agent) {
|
|
592
|
-
if (!agent) return void 0;
|
|
593
|
-
if (agent.includes("cursor")) return "cursor";
|
|
594
|
-
if (agent.includes("codex")) return "codex";
|
|
595
|
-
if (agent.includes("claude")) return "claude";
|
|
596
|
-
}
|
|
597
|
-
/**
|
|
598
|
-
* Adopt half of reconcile-against-mux: mint a record for each live pane with a detectable harness
|
|
599
|
-
* and no matching record — bind pane→id, handle from the pane's reported cwd basename (sanitized;
|
|
600
|
-
* `id.slice(0, 6)` when the backend reports no cwd), status active, lastSeen now. A pane is bound —
|
|
601
|
-
* and never adopted — when its pane index resolves to an existing record or any record (any status,
|
|
602
|
-
* exited included) bears it; resurrecting an exited record is the in-pane session's own `register`
|
|
603
|
-
* via the pane pointer, never reconcile's. tmux panes carry no harness signal, so only herdr panes
|
|
604
|
-
* are adoptable today.
|
|
605
|
-
*/
|
|
606
|
-
function adopt(ctx, panes) {
|
|
607
|
-
const agents = listAgents(ctx.store);
|
|
608
|
-
const adopted = [];
|
|
609
|
-
for (const pane of panes) {
|
|
610
|
-
if (pane.mux !== "tmux" && pane.mux !== "herdr") continue;
|
|
611
|
-
const harness = harnessFromAgent(pane.harness);
|
|
612
|
-
if (!harness) continue;
|
|
613
|
-
const boundId = ctx.store.resolvePaneId(pane.id);
|
|
614
|
-
if (boundId && loadAgent(ctx.store, boundId)) continue;
|
|
615
|
-
if (agents.some((a) => a.pane?.mux === pane.mux && a.pane.id === pane.id)) continue;
|
|
616
|
-
const id = randomId();
|
|
617
|
-
const ts = nowIso(ctx);
|
|
618
|
-
const rec = {
|
|
619
|
-
id,
|
|
620
|
-
handle: pane.cwd ? sanitizePane(basename(pane.cwd)) : id.slice(0, 6),
|
|
621
|
-
harness,
|
|
622
|
-
cwd: pane.cwd ?? "",
|
|
623
|
-
pane: {
|
|
624
|
-
mux: pane.mux,
|
|
625
|
-
id: pane.id
|
|
626
|
-
},
|
|
627
|
-
status: "active",
|
|
628
|
-
createdAt: ts,
|
|
629
|
-
lastSeen: ts
|
|
630
|
-
};
|
|
631
|
-
saveAgent(ctx.store, rec);
|
|
632
|
-
ctx.store.putPaneIndex(pane.id, id);
|
|
633
|
-
adopted.push(rec);
|
|
634
|
-
}
|
|
635
|
-
return adopted;
|
|
636
|
-
}
|
|
637
|
-
/**
|
|
638
|
-
* Cull dead records against the current mux's live pane set (the mux the caller is actually inside,
|
|
639
|
-
* per `currentPane`) — mux-scoped: it never declares the *other* mux's records dead, since it can't
|
|
640
|
-
* enumerate them. Standing records are exempt; a `pane: null` record can't be pane-culled by
|
|
641
|
-
* enumeration (left to `prune`'s staleness timer). Outside any multiplexer pane there is nothing to
|
|
642
|
-
* enumerate, so it culls nothing. With `adopt` set (the `who --reconcile` path — `prune` stays
|
|
643
|
-
* cull-only), the same live set also feeds adoption of unbound harness-bearing panes.
|
|
644
|
-
*/
|
|
645
|
-
function reconcile(ctx, opts) {
|
|
646
|
-
const exec = ctx.exec ?? realExec;
|
|
647
|
-
const cur = storablePane(ctx.env ?? process.env);
|
|
648
|
-
if (!cur) return [];
|
|
649
|
-
const panes = PANE_ADAPTERS[cur.mux].listPanes(exec);
|
|
650
|
-
const live = new Set(panes.map((p) => p.id));
|
|
651
|
-
const changed = [];
|
|
652
|
-
for (const rec of listAgents(ctx.store)) {
|
|
653
|
-
if (rec.kind === "standing") continue;
|
|
654
|
-
if (rec.status === "exited") continue;
|
|
655
|
-
if (!rec.pane) continue;
|
|
656
|
-
if (rec.pane.mux !== cur.mux) continue;
|
|
657
|
-
if (!live.has(rec.pane.id)) {
|
|
658
|
-
rec.status = "exited";
|
|
659
|
-
saveAgent(ctx.store, rec);
|
|
660
|
-
changed.push(rec);
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
if (opts?.adopt) changed.push(...adopt(ctx, panes));
|
|
664
|
-
return changed;
|
|
665
|
-
}
|
|
666
|
-
/** Mark agents whose pane is gone or whose last-seen is stale as exited. Reconcile-culls against the
|
|
667
|
-
* current mux's live set first, then falls through to the per-record paneExists + staleness check
|
|
668
|
-
* (covers the other mux and sessions outside any multiplexer pane). */
|
|
669
|
-
function prune(ctx) {
|
|
670
|
-
const exec = ctx.exec ?? realExec;
|
|
671
|
-
const now = ctx.now?.() ?? Date.now();
|
|
672
|
-
const changed = reconcile(ctx);
|
|
673
|
-
for (const rec of listAgents(ctx.store)) {
|
|
674
|
-
if (rec.kind === "standing") continue;
|
|
675
|
-
if (rec.status === "exited") continue;
|
|
676
|
-
const paneGone = rec.pane ? !PANE_ADAPTERS[rec.pane.mux].paneExists(exec, { id: rec.pane.id }) : false;
|
|
677
|
-
const stale = now - new Date(rec.lastSeen).getTime() > STALE_MS;
|
|
678
|
-
if (paneGone || stale) {
|
|
679
|
-
rec.status = "exited";
|
|
680
|
-
saveAgent(ctx.store, rec);
|
|
681
|
-
changed.push(rec);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
return changed;
|
|
685
|
-
}
|
|
686
|
-
//#endregion
|
|
687
|
-
//#region src/console/doorbell.ts
|
|
688
|
-
/** The doorbell text delivered to a woken recipient; also the standalone `unit nudge` default. */
|
|
689
|
-
const DELIVERY_DOORBELL = "You have unread mail — check your inbox.";
|
|
690
|
-
/**
|
|
691
|
-
* The first-turn doorbell `unit spawn` delivers to a freshly-opened paned peer — the instruction
|
|
692
|
-
* itself, not a notification that context is already populated. It names the brief's **file path**
|
|
693
|
-
* and tells the peer to read it and begin, so pickup does not depend on a SessionStart hook firing
|
|
694
|
-
* in the child (superseding ADR-0027, which split payload-delivery from turn-delivery).
|
|
695
|
-
*
|
|
696
|
-
* The path is named, never the brief's body: the brief is still written to its file and still never
|
|
697
|
-
* typed into the pane, so a long brief costs one line here however large it is, and a re-submit on
|
|
698
|
-
* the boot race re-types this instruction rather than the payload.
|
|
699
|
-
*/
|
|
700
|
-
function spawnDoorbell(briefPath) {
|
|
701
|
-
return `Read your brief at ${briefPath}, then begin work.`;
|
|
702
|
-
}
|
|
703
|
-
/**
|
|
704
|
-
* A freshly-launched harness cold-boots slower than an already-running peer, so the spawn first-turn
|
|
705
|
-
* ring gets a wider retry budget than a plain `mail send` doorbell (nudge's own 10 × 400ms): flush the
|
|
706
|
-
* staged buffer for up to ~8s (20 × 400ms) before giving up. The common case still returns after one
|
|
707
|
-
* settle cycle once the peer takes the turn — the budget only bounds a slow or stuck boot. Still
|
|
708
|
-
* bounded — a harness that never reaches its prompt resolves to a best-effort warning, never a hang.
|
|
709
|
-
*/
|
|
710
|
-
const SPAWN_NUDGE_OPTS = {
|
|
711
|
-
attempts: 20,
|
|
712
|
-
settleMs: 400
|
|
713
|
-
};
|
|
714
|
-
/** Resolve an agent's live session pane: its recorded pane, else a pane pointer keyed by its id. */
|
|
715
|
-
function paneOf(store, id) {
|
|
716
|
-
return loadAgent(store, id)?.pane?.id ?? store.findPaneByAgentId(id);
|
|
717
|
-
}
|
|
718
|
-
/**
|
|
719
|
-
* Best-effort wake the recipient of a just-delivered message so it checks its inbox. A peer's live
|
|
720
|
-
* session pane, a standing owner's bound presence (`unit claim` — the live unit standing in for it,
|
|
721
|
-
* an exited one falling back below), or — with neither — the hub's bound main pane, is rung via the
|
|
722
|
-
* nudge submit-verify path (a taken turn, not fire-and-forget). Durable delivery already happened;
|
|
723
|
-
* the ring is opportunistic on top, so a legitimate no-op (`--no-nudge`, a headless/absent recipient
|
|
724
|
-
* with no live pane, a standing-owner send with no presence and no main pane bound, or a
|
|
725
|
-
* self-addressed send) rings nothing, and a ring that never completes within nudge's retry cap is
|
|
726
|
-
* swallowed into a warning. This never throws — it can never fail the send.
|
|
727
|
-
*
|
|
728
|
-
* The adapter is resolved lazily via `getAdapter`, invoked only once a pane to ring is confirmed and
|
|
729
|
-
* inside the same swallowing try — so a session with no mux backend (where `selectSessionAdapter`
|
|
730
|
-
* throws) is just a no-op wake, never a failed send.
|
|
731
|
-
*/
|
|
732
|
-
async function wakeRecipient(store, getAdapter, exec, input, nudgeOpts) {
|
|
733
|
-
if (input.noNudge) return { rung: false };
|
|
734
|
-
const recipient = loadAgent(store, input.toId);
|
|
735
|
-
if (!recipient) return { rung: false };
|
|
736
|
-
let pane;
|
|
737
|
-
let focusGated = false;
|
|
738
|
-
if (recipient.kind === "standing") {
|
|
739
|
-
const presenceUnit = presenceOf(store, recipient);
|
|
740
|
-
if (presenceUnit) pane = paneOf(store, presenceUnit.id);
|
|
741
|
-
else {
|
|
742
|
-
pane = store.getMainPane();
|
|
743
|
-
focusGated = true;
|
|
744
|
-
}
|
|
745
|
-
} else pane = paneOf(store, recipient.id);
|
|
746
|
-
if (!pane) return { rung: false };
|
|
747
|
-
if (pane === paneOf(store, input.fromId)) return { rung: false };
|
|
748
|
-
if (focusGated) {
|
|
749
|
-
let focused;
|
|
750
|
-
try {
|
|
751
|
-
focused = getAdapter().isPaneFocused(exec, { id: pane });
|
|
752
|
-
} catch {
|
|
753
|
-
focused = void 0;
|
|
754
|
-
}
|
|
755
|
-
if (focused === false) return {
|
|
756
|
-
rung: false,
|
|
757
|
-
pane
|
|
758
|
-
};
|
|
759
|
-
}
|
|
760
|
-
try {
|
|
761
|
-
await nudge(getAdapter(), exec, { id: pane }, DELIVERY_DOORBELL, nudgeOpts);
|
|
762
|
-
return {
|
|
763
|
-
rung: true,
|
|
764
|
-
pane
|
|
765
|
-
};
|
|
766
|
-
} catch (err) {
|
|
767
|
-
return {
|
|
768
|
-
rung: false,
|
|
769
|
-
pane,
|
|
770
|
-
warning: err instanceof Error ? err.message : String(err)
|
|
771
|
-
};
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
/**
|
|
775
|
-
* Best-effort deliver a freshly-spawned paned peer's first turn so it acts on its brief with no
|
|
776
|
-
* human nudge. A paned agent boots to an idle prompt — the brief sits unread on disk and the model
|
|
777
|
-
* takes no turn on its own, unlike a subagent (where the caller's Task call IS the turn). So `unit
|
|
778
|
-
* spawn` rings the instruction (`spawnDoorbell`, naming the brief's file path) over the
|
|
779
|
-
* boot-race-aware `nudge` submit-verify path (a taken turn, never typing the brief itself), the same
|
|
780
|
-
* best-effort ring `wakeRecipient` gives `mail send`: the spawn (worktree, session, registry record)
|
|
781
|
-
* is the guaranteed effect and the ring is opportunistic on top, so `--no-wake` rings nothing and a
|
|
782
|
-
* ring that never completes within the retry budget is swallowed into a warning. This never throws —
|
|
783
|
-
* it can never fail the spawn.
|
|
784
|
-
*
|
|
785
|
-
* The adapter is resolved lazily via `getAdapter` inside the same swallowing try, so even a session
|
|
786
|
-
* whose backend has since gone away (where `selectSessionAdapter` would throw) degrades to a warned
|
|
787
|
-
* no-op rather than a failed spawn.
|
|
788
|
-
*/
|
|
789
|
-
async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS) {
|
|
790
|
-
if (input.noWake) return { rung: false };
|
|
791
|
-
try {
|
|
792
|
-
await nudge(getAdapter(), exec, input.target, spawnDoorbell(input.briefPath), nudgeOpts);
|
|
793
|
-
return {
|
|
794
|
-
rung: true,
|
|
795
|
-
pane: input.target.id
|
|
796
|
-
};
|
|
797
|
-
} catch (err) {
|
|
798
|
-
return {
|
|
799
|
-
rung: false,
|
|
800
|
-
pane: input.target.id,
|
|
801
|
-
warning: err instanceof Error ? err.message : String(err)
|
|
802
|
-
};
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
//#endregion
|
|
806
|
-
//#region src/mux-select.ts
|
|
807
|
-
/**
|
|
808
|
-
* Backend selection via cyber-mux's two-mode mux probe, normalized through the transitional
|
|
809
|
-
* `$CYBERLEGION_MUX*` → `$CYBER_MUX*` env seam (`mux-env.ts`) — tmux/herdr map to their existing
|
|
810
|
-
* cyber-mux adapters.
|
|
811
|
-
*
|
|
812
|
-
* cyber-mux detects MORE backends than `unit/registry`'s `AgentRecord.pane` can carry a locator
|
|
813
|
-
* under (`'tmux' | 'herdr'` only). A DETECTED wezterm/zellij is refused HERE, before anything opens,
|
|
814
|
-
* naming the backend it found (mux.feature: "a detected backend a unit record cannot carry is
|
|
815
|
-
* refused before opening anything") — driving it would open a real pane no record could name,
|
|
816
|
-
* stranding a live session `prune` can never reap and no caller can nudge. Anything else (`none`,
|
|
817
|
-
* `screen`) falls through to the plain "no backend" refusal, unchanged from before the migration.
|
|
818
|
-
*/
|
|
819
|
-
function selectSessionAdapter(env, exec = realExec) {
|
|
820
|
-
const probe = probeMultiplexer(exec, normalizeMuxEnv(env));
|
|
821
|
-
if (probe.mux === "tmux") return tmuxMuxAdapter;
|
|
822
|
-
if (probe.mux === "herdr") return herdrMuxAdapter;
|
|
823
|
-
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`);
|
|
824
|
-
throw new Error("spawn requires a session backend — run inside tmux ($TMUX) or herdr ($HERDR_ENV=1)");
|
|
825
|
-
}
|
|
826
|
-
//#endregion
|
|
827
|
-
//#region src/workspace-label.ts
|
|
828
|
-
/** Leading actions that tear down or revert — the `A2-` class, checked first. */
|
|
829
|
-
const TEARDOWN_ACTIONS = /* @__PURE__ */ new Set([
|
|
830
|
-
"clean",
|
|
831
|
-
"cleanup",
|
|
832
|
-
"decommission",
|
|
833
|
-
"delete",
|
|
834
|
-
"deprecate",
|
|
835
|
-
"disable",
|
|
836
|
-
"drop",
|
|
837
|
-
"prune",
|
|
838
|
-
"purge",
|
|
839
|
-
"remove",
|
|
840
|
-
"retire",
|
|
841
|
-
"revert",
|
|
842
|
-
"revoke",
|
|
843
|
-
"rollback",
|
|
844
|
-
"teardown",
|
|
845
|
-
"undo",
|
|
846
|
-
"uninstall",
|
|
847
|
-
"unregister"
|
|
848
|
-
]);
|
|
849
|
-
/** Leading actions that only look and report — the `9S-` class, checked after teardown. */
|
|
850
|
-
const RECON_ACTIONS = /* @__PURE__ */ new Set([
|
|
851
|
-
"analyze",
|
|
852
|
-
"assess",
|
|
853
|
-
"audit",
|
|
854
|
-
"check",
|
|
855
|
-
"compare",
|
|
856
|
-
"debug",
|
|
857
|
-
"diagnose",
|
|
858
|
-
"evaluate",
|
|
859
|
-
"examine",
|
|
860
|
-
"explain",
|
|
861
|
-
"explore",
|
|
862
|
-
"find",
|
|
863
|
-
"identify",
|
|
864
|
-
"inspect",
|
|
865
|
-
"investigate",
|
|
866
|
-
"locate",
|
|
867
|
-
"measure",
|
|
868
|
-
"profile",
|
|
869
|
-
"read",
|
|
870
|
-
"reproduce",
|
|
871
|
-
"research",
|
|
872
|
-
"review",
|
|
873
|
-
"scan",
|
|
874
|
-
"survey",
|
|
875
|
-
"trace",
|
|
876
|
-
"triage",
|
|
877
|
-
"verify"
|
|
878
|
-
]);
|
|
879
|
-
/**
|
|
880
|
-
* Leading actions that build or change — the `2B-` class. `2B-` is also the code for a brief whose
|
|
881
|
-
* lead matches nothing at all, but the two cases differ in the SUBJECT: a recognized action is
|
|
882
|
-
* dropped from it (the code already says "build"), an unrecognized leading word is kept (it is the
|
|
883
|
-
* subject's own first noun, and dropping it would strip real signal).
|
|
884
|
-
*/
|
|
885
|
-
const BUILD_ACTIONS = /* @__PURE__ */ new Set([
|
|
886
|
-
"add",
|
|
887
|
-
"backfill",
|
|
888
|
-
"build",
|
|
889
|
-
"create",
|
|
890
|
-
"document",
|
|
891
|
-
"enable",
|
|
892
|
-
"extend",
|
|
893
|
-
"fix",
|
|
894
|
-
"generalize",
|
|
895
|
-
"harden",
|
|
896
|
-
"implement",
|
|
897
|
-
"introduce",
|
|
898
|
-
"make",
|
|
899
|
-
"migrate",
|
|
900
|
-
"move",
|
|
901
|
-
"port",
|
|
902
|
-
"refactor",
|
|
903
|
-
"rename",
|
|
904
|
-
"replace",
|
|
905
|
-
"rework",
|
|
906
|
-
"ship",
|
|
907
|
-
"split",
|
|
908
|
-
"support",
|
|
909
|
-
"swap",
|
|
910
|
-
"update",
|
|
911
|
-
"wire",
|
|
912
|
-
"write"
|
|
913
|
-
]);
|
|
914
|
-
/** Dropped when it leads the subject — an article carries no identifying signal. */
|
|
915
|
-
const LEADING_ARTICLES = /* @__PURE__ */ new Set([
|
|
916
|
-
"a",
|
|
917
|
-
"an",
|
|
918
|
-
"the"
|
|
919
|
-
]);
|
|
920
|
-
/**
|
|
921
|
-
* Resolve the label a `workspace` placement opens under. Total width never exceeds `LABEL_CAP`, and
|
|
922
|
-
* the result always carries a code — a brief that yields no usable subject falls back to the unit's
|
|
923
|
-
* own short id rather than to a bare code.
|
|
924
|
-
*/
|
|
925
|
-
function deriveWorkspaceLabel(input) {
|
|
926
|
-
const briefWords = tokenize(firstNonEmptyLine(input.brief));
|
|
927
|
-
const lead = briefWords[0];
|
|
928
|
-
let code = "2B-";
|
|
929
|
-
let leadIsAction = false;
|
|
930
|
-
if (lead && TEARDOWN_ACTIONS.has(lead)) {
|
|
931
|
-
code = "A2-";
|
|
932
|
-
leadIsAction = true;
|
|
933
|
-
} else if (lead && RECON_ACTIONS.has(lead)) {
|
|
934
|
-
code = "9S-";
|
|
935
|
-
leadIsAction = true;
|
|
936
|
-
} else if (lead && BUILD_ACTIONS.has(lead)) leadIsAction = true;
|
|
937
|
-
let subjectWords;
|
|
938
|
-
if (input.handle) subjectWords = tokenize(input.handle);
|
|
939
|
-
else {
|
|
940
|
-
subjectWords = leadIsAction ? briefWords.slice(1) : briefWords.slice();
|
|
941
|
-
if (subjectWords[0] && LEADING_ARTICLES.has(subjectWords[0])) subjectWords.shift();
|
|
942
|
-
}
|
|
943
|
-
const subject = fitWords(subjectWords, 27) || input.id.slice(0, 6);
|
|
944
|
-
return `${code}${subject}`;
|
|
945
|
-
}
|
|
946
|
-
/** The brief's first line with any content — a leading blank line is not the subject. */
|
|
947
|
-
function firstNonEmptyLine(brief) {
|
|
948
|
-
for (const line of brief.split("\n")) if (line.trim() !== "") return line;
|
|
949
|
-
return "";
|
|
950
|
-
}
|
|
951
|
-
/** Lowercase alphanumeric runs; every other character is a separator, never part of a word. */
|
|
952
|
-
function tokenize(text) {
|
|
953
|
-
return text.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w !== "");
|
|
954
|
-
}
|
|
955
|
-
/**
|
|
956
|
-
* Join whole words with `-` while they fit `budget`, so the label ends on a complete word rather
|
|
957
|
-
* than mid-word. A single first word wider than the whole budget is hard-truncated — that is the
|
|
958
|
-
* only case where a label can end mid-word, and the alternative is no subject at all.
|
|
959
|
-
*/
|
|
960
|
-
function fitWords(words, budget) {
|
|
961
|
-
if (words.length === 0) return "";
|
|
962
|
-
let out = words[0].slice(0, budget);
|
|
963
|
-
for (const word of words.slice(1)) {
|
|
964
|
-
const next = `${out}-${word}`;
|
|
965
|
-
if (next.length > budget) break;
|
|
966
|
-
out = next;
|
|
967
|
-
}
|
|
968
|
-
return out;
|
|
969
|
-
}
|
|
970
|
-
//#endregion
|
|
971
|
-
//#region src/session.ts
|
|
972
|
-
/** How each harness's own CLI is launched in the new pane. */
|
|
973
|
-
const LAUNCH_MAP = {
|
|
974
|
-
claude: "claude",
|
|
975
|
-
cursor: "cursor-agent",
|
|
976
|
-
codex: "codex"
|
|
977
|
-
};
|
|
978
|
-
/**
|
|
979
|
-
* Per-harness fresh-context ("reset") command `clear` injects into a warm peer's pane to return
|
|
980
|
-
* its conversation to a cold context without tearing the pane/session down — keyed on genuine
|
|
981
|
-
* fresh-context semantics, never on the literal word "clear".
|
|
982
|
-
*/
|
|
983
|
-
const RESET_MAP = {
|
|
984
|
-
claude: "/clear",
|
|
985
|
-
codex: "/clear",
|
|
986
|
-
copilot: "/clear",
|
|
987
|
-
cursor: "/new-chat"
|
|
988
|
-
};
|
|
989
|
-
/**
|
|
990
|
-
* Harnesses whose apparent "clear" command does NOT truly empty the model context — e.g. gemini's
|
|
991
|
-
* `/clear` wipes only the terminal screen, leaving the model's own context stale. There is no
|
|
992
|
-
* honest fresh-context command for these, so they are refused explicitly rather than lumped in
|
|
993
|
-
* with a harness that is merely unmapped.
|
|
994
|
-
*/
|
|
995
|
-
const FALSE_FRIEND_HARNESSES = /* @__PURE__ */ new Set(["gemini"]);
|
|
996
|
-
/**
|
|
997
|
-
* Resolve `harness`'s own fresh-context command from the reset map. String-keyed (not `Harness`)
|
|
998
|
-
* so it also guards a harness present in `LAUNCH_MAP`/`identity.ts`'s `Harness` union but not yet
|
|
999
|
-
* given an honest reset mapping here. Throws rather than guessing:
|
|
1000
|
-
* - a known false-friend harness (its "clear" only clears the screen, not the context)
|
|
1001
|
-
* - any harness absent from the map entirely
|
|
1002
|
-
*/
|
|
1003
|
-
function resetCommandFor(harness) {
|
|
1004
|
-
const mapped = RESET_MAP[harness];
|
|
1005
|
-
if (mapped) return mapped;
|
|
1006
|
-
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`);
|
|
1007
|
-
throw new Error(`"${harness}" is not in the reset map (${Object.keys(RESET_MAP).join(" | ")}) — unit clear refuses to guess a command`);
|
|
1008
|
-
}
|
|
1009
|
-
/**
|
|
1010
|
-
* Launch a new peer session as a genuine sibling unit: create a real git worktree distinct from
|
|
1011
|
-
* the primary checkout (refuse the primary checkout), open a session backend (tmux or herdr) with
|
|
1012
|
-
* its cwd set to that worktree, pre-register the peer, and drop its brief as a file — never typed
|
|
1013
|
-
* into its prompt. Nothing loads that file for the peer: it is the spawn wake that tells the peer to
|
|
1014
|
-
* go read it, naming its path (`spawnAndWake` below, and ADR-0032). `spawn` alone therefore leaves a
|
|
1015
|
-
* peer sitting idle with its brief unread, which is why callers want `spawnAndWake`.
|
|
1016
|
-
*
|
|
1017
|
-
* Spawned unit worktrees live sibling to the primary checkout (`<parent>/<repo>.worktrees/legion-<id6>`),
|
|
1018
|
-
* never nested inside it, even though the registry/mailbox itself lives in the global hub — the hub
|
|
1019
|
-
* addresses units across project and worktree boundaries, but a unit's checkout is necessarily
|
|
1020
|
-
* scoped to this one project's git remote.
|
|
1021
|
-
*/
|
|
1022
|
-
function spawn(ctx, input) {
|
|
1023
|
-
ctx.store.ensureMarker();
|
|
1024
|
-
const env = ctx.env ?? process.env;
|
|
1025
|
-
const exec = ctx.exec ?? realExec;
|
|
1026
|
-
const sessionAdapter = selectSessionAdapter(env, exec);
|
|
1027
|
-
const normalizedEnv = normalizeMuxEnv(env);
|
|
1028
|
-
const harness = input.harness;
|
|
1029
|
-
if (!harness || !(harness in LAUNCH_MAP)) throw new Error(`spawn needs a --harness in the launch map (${Object.keys(LAUNCH_MAP).join(" | ")})`);
|
|
1030
|
-
const brief = resolveBrief(input);
|
|
1031
|
-
if (brief == null) throw new Error("spawn needs a brief — pass --task <text>, --task - (stdin), or --brief-file <path>");
|
|
1032
|
-
if (input.cwd && (input.branch || input.worktreePath)) throw new Error("--cwd cannot combine with the worktree-creating flags --branch/--worktree-path");
|
|
1033
|
-
const id = randomId();
|
|
1034
|
-
const primaryRoot = resolvePrimaryRoot(exec);
|
|
1035
|
-
const launch = input.command ?? LAUNCH_MAP[harness];
|
|
1036
|
-
const fullLaunch = `${muxEnvPrefix(sessionAdapter.name)}${launch}`;
|
|
1037
|
-
const from = callerPane(sessionAdapter, normalizedEnv);
|
|
1038
|
-
let cwd;
|
|
1039
|
-
let worktree;
|
|
1040
|
-
let target;
|
|
1041
|
-
if (input.cwd) {
|
|
1042
|
-
if (!existsSync(input.cwd)) throw new Error(`--cwd directory must already exist: ${input.cwd}`);
|
|
1043
|
-
cwd = resolve(input.cwd);
|
|
1044
|
-
assertDistinctFromPrimary(cwd, primaryRoot);
|
|
1045
|
-
worktree = null;
|
|
1046
|
-
const at = input.at ?? "tab";
|
|
1047
|
-
target = sessionAdapter.open(exec, {
|
|
1048
|
-
cwd,
|
|
1049
|
-
launch: fullLaunch,
|
|
1050
|
-
at,
|
|
1051
|
-
from,
|
|
1052
|
-
...labelFor(at, input, brief, id)
|
|
1053
|
-
});
|
|
1054
|
-
} else {
|
|
1055
|
-
const branch = input.branch ?? `cyberlegion/unit-${id}`;
|
|
1056
|
-
const at = input.at ?? "workspace";
|
|
1057
|
-
const worktreePath = input.worktreePath ?? resolveUnitWorktreePath(primaryRoot, id.slice(0, 6));
|
|
1058
|
-
assertDistinctFromPrimary(resolve(worktreePath), primaryRoot);
|
|
1059
|
-
if (at === "workspace" && sessionAdapter.worktree) {
|
|
1060
|
-
const opened = sessionAdapter.worktree.createInWorkspace(exec, {
|
|
1061
|
-
primaryRoot,
|
|
1062
|
-
branch,
|
|
1063
|
-
path: worktreePath,
|
|
1064
|
-
launch: fullLaunch,
|
|
1065
|
-
...labelFor(at, input, brief, id)
|
|
1066
|
-
});
|
|
1067
|
-
assertDistinctFromPrimary(opened.worktree.root, primaryRoot);
|
|
1068
|
-
ensureMarker(join(opened.worktree.root, ".agents", "cyberlegion"));
|
|
1069
|
-
cwd = opened.worktree.root;
|
|
1070
|
-
worktree = opened.worktree;
|
|
1071
|
-
target = opened.target;
|
|
1072
|
-
} else {
|
|
1073
|
-
const added = gitWorktreeAdapter.add(exec, {
|
|
1074
|
-
primaryRoot,
|
|
1075
|
-
path: worktreePath,
|
|
1076
|
-
branch
|
|
1077
|
-
});
|
|
1078
|
-
assertDistinctFromPrimary(added.root, primaryRoot);
|
|
1079
|
-
ensureMarker(join(added.root, ".agents", "cyberlegion"));
|
|
1080
|
-
cwd = added.root;
|
|
1081
|
-
worktree = added;
|
|
1082
|
-
target = sessionAdapter.open(exec, {
|
|
1083
|
-
cwd,
|
|
1084
|
-
launch: fullLaunch,
|
|
1085
|
-
at,
|
|
1086
|
-
from,
|
|
1087
|
-
...labelFor(at, input, brief, id)
|
|
1088
|
-
});
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
const ts = new Date(ctx.now?.() ?? Date.now()).toISOString();
|
|
1092
|
-
const muxName = sessionAdapter.name;
|
|
1093
|
-
const rec = {
|
|
1094
|
-
id,
|
|
1095
|
-
handle: input.handle ?? id.slice(0, 6),
|
|
1096
|
-
harness,
|
|
1097
|
-
cwd,
|
|
1098
|
-
worktree,
|
|
1099
|
-
pane: muxName === "tmux" || muxName === "herdr" ? {
|
|
1100
|
-
mux: muxName,
|
|
1101
|
-
id: target.id
|
|
1102
|
-
} : null,
|
|
1103
|
-
status: "active",
|
|
1104
|
-
createdAt: ts,
|
|
1105
|
-
lastSeen: ts,
|
|
1106
|
-
brief: paths.briefFile(ctx.store.root, id),
|
|
1107
|
-
...resolveSelfId(ctx) ? { spawnedBy: resolveSelfId(ctx) } : {}
|
|
1108
|
-
};
|
|
1109
|
-
saveAgent(ctx.store, rec);
|
|
1110
|
-
ctx.store.putPaneIndex(target.id, id);
|
|
1111
|
-
ctx.store.writeBrief(id, brief);
|
|
1112
|
-
return {
|
|
1113
|
-
agent: rec,
|
|
1114
|
-
pane: target.id,
|
|
1115
|
-
launch
|
|
1116
|
-
};
|
|
1117
|
-
}
|
|
1118
|
-
/**
|
|
1119
|
-
* `unit spawn` end to end: open the peer, then deliver its first turn.
|
|
1120
|
-
*
|
|
1121
|
-
* The two acts live together here rather than at the CLI call site so the **brief path the doorbell
|
|
1122
|
-
* names is derived from the record `spawn` just wrote**, not handed in by a caller. That is the
|
|
1123
|
-
* point of the seam: a caller cannot ring with the wrong path because it supplies no path — an
|
|
1124
|
-
* empty string, a stale path, or the brief's whole body are all unrepresentable here, rather than
|
|
1125
|
-
* merely untested.
|
|
1126
|
-
*
|
|
1127
|
-
* The ring is best-effort on top of the guaranteed spawn effect (worktree + session + registry
|
|
1128
|
-
* record): `--no-wake` rings nothing, and a ring that never completes is reported as a warning on
|
|
1129
|
-
* the result rather than thrown, so it can never fail a spawn that already landed.
|
|
1130
|
-
*/
|
|
1131
|
-
async function spawnAndWake(ctx, input, options = {}) {
|
|
1132
|
-
const res = spawn(ctx, input);
|
|
1133
|
-
const env = ctx.env ?? process.env;
|
|
1134
|
-
const exec = ctx.exec ?? realExec;
|
|
1135
|
-
const briefPath = res.agent.brief;
|
|
1136
|
-
const wake = await wakeSpawn(() => selectSessionAdapter(env, exec), exec, {
|
|
1137
|
-
target: { id: res.pane },
|
|
1138
|
-
briefPath: briefPath ?? "",
|
|
1139
|
-
noWake: options.noWake || !briefPath
|
|
1140
|
-
}, options.nudgeOpts);
|
|
1141
|
-
return {
|
|
1142
|
-
...res,
|
|
1143
|
-
rung: wake.rung,
|
|
1144
|
-
...wake.warning ? { warning: wake.warning } : {}
|
|
1145
|
-
};
|
|
1146
|
-
}
|
|
1147
|
-
/**
|
|
1148
|
-
* The label a `workspace` placement opens under — the short human-scannable name that makes a unit's
|
|
1149
|
-
* own visible space findable by eye (`console/workspace-label.ts` owns the naming rule). Only the
|
|
1150
|
-
* `workspace` placement carries one: a pane or tab lives inside a space the caller is already
|
|
1151
|
-
* looking at, so it has nothing of its own to name. Spread into the adapter options, so a
|
|
1152
|
-
* non-workspace placement passes no `label` key at all rather than an explicit `undefined`.
|
|
1153
|
-
*
|
|
1154
|
-
* The gate is load-bearing, not defensive: cyber-mux names whatever tier `at` opens — a workspace,
|
|
1155
|
-
* a tab, or a pane — so passing a label on a `tab` placement would rename the caller's own tab.
|
|
1156
|
-
* Exported so that gate is tested directly rather than only through a backend's arguments.
|
|
1157
|
-
*/
|
|
1158
|
-
function labelFor(at, input, brief, id) {
|
|
1159
|
-
if (at !== "workspace") return {};
|
|
1160
|
-
return { label: deriveWorkspaceLabel({
|
|
1161
|
-
brief,
|
|
1162
|
-
handle: input.handle,
|
|
1163
|
-
id
|
|
1164
|
-
}) };
|
|
1165
|
-
}
|
|
1166
|
-
/**
|
|
1167
|
-
* The env prefix typed ahead of the launch command so the spawned peer inherits the caller's
|
|
1168
|
-
* multiplexer fast-path and never has to run its own ancestry discovery (`$CYBER_MUX` /
|
|
1169
|
-
* `$CYBER_MUX_PANE`). `VAR=val cmd` scopes the vars to that one process (and its children)
|
|
1170
|
-
* without needing `export`. tmux natively sets `$TMUX_PANE` for a pane's own processes, so the
|
|
1171
|
-
* pane var is expanded by the child's own shell rather than baked in here.
|
|
1172
|
-
*/
|
|
1173
|
-
function muxEnvPrefix(muxName) {
|
|
1174
|
-
if (muxName === "tmux") return "CYBER_MUX=tmux CYBER_MUX_PANE=$TMUX_PANE ";
|
|
1175
|
-
if (muxName === "herdr") return "CYBER_MUX=herdr ";
|
|
1176
|
-
return "";
|
|
1177
|
-
}
|
|
1178
|
-
/**
|
|
1179
|
-
* Resolve a unit's live session pane from a ref, or throw naming the ref. The one place
|
|
1180
|
-
* focus/nudge/read agree on what "addressable" means — a record's own pane, else a pane pointer
|
|
1181
|
-
* keyed by its id (a herdr peer stores its pane only in that index).
|
|
1182
|
-
*/
|
|
1183
|
-
function paneTargetOf(ctx, ref) {
|
|
1184
|
-
const agent = resolveAgent(ctx.store, ref);
|
|
1185
|
-
const pane = agent.pane?.id ?? ctx.store.findPaneByAgentId(agent.id);
|
|
1186
|
-
if (!pane) throw new Error(`unit "${ref}" has no known session pane`);
|
|
1187
|
-
return {
|
|
1188
|
-
agent,
|
|
1189
|
-
target: { id: pane }
|
|
1190
|
-
};
|
|
1191
|
-
}
|
|
1192
|
-
/**
|
|
1193
|
-
* Move input focus to a peer's session. The adapter beams the attached view all the way there —
|
|
1194
|
-
* across workspace and tab — rather than no-opping when the peer sits outside the caller's current
|
|
1195
|
-
* workspace, and surfaces a backend failure instead of reporting a false success.
|
|
1196
|
-
*
|
|
1197
|
-
* Lives here rather than inline in the CLI for the same reason `spawnAndWake` does: composed at the
|
|
1198
|
-
* call site it took a hardcoded `realExec`, so the operation could not be driven with an injected
|
|
1199
|
-
* exec and its success path was unreachable from a test.
|
|
1200
|
-
*/
|
|
1201
|
-
function focusUnit(ctx, ref) {
|
|
1202
|
-
const { agent, target } = paneTargetOf(ctx, ref);
|
|
1203
|
-
const exec = ctx.exec ?? realExec;
|
|
1204
|
-
selectSessionAdapter(ctx.env ?? process.env, exec).focus(exec, target);
|
|
1205
|
-
return {
|
|
1206
|
-
agent,
|
|
1207
|
-
pane: target.id
|
|
1208
|
-
};
|
|
1209
|
-
}
|
|
1210
|
-
/**
|
|
1211
|
-
* Ring a peer's session as a **taken turn**, not fire-and-forget: `nudge` submits, reads the pane
|
|
1212
|
-
* back to confirm the text is no longer staged, and flushes the staged buffer (never re-typing) up
|
|
1213
|
-
* to a bounded cap — then throws if the peer never took the turn. A doorbell must carry text; an
|
|
1214
|
-
* empty ring is a no-op, so the default points the peer at its inbox.
|
|
1215
|
-
*/
|
|
1216
|
-
async function nudgeUnit(ctx, ref, options = {}) {
|
|
1217
|
-
const { agent, target } = paneTargetOf(ctx, ref);
|
|
1218
|
-
const exec = ctx.exec ?? realExec;
|
|
1219
|
-
const message = options.message || "You have unread mail — check your inbox.";
|
|
1220
|
-
const result = await nudge(selectSessionAdapter(ctx.env ?? process.env, exec), exec, target, message, options.nudgeOpts);
|
|
1221
|
-
return {
|
|
1222
|
-
agent,
|
|
1223
|
-
pane: target.id,
|
|
1224
|
-
message,
|
|
1225
|
-
resubmits: result.resubmits
|
|
1226
|
-
};
|
|
1227
|
-
}
|
|
1228
|
-
/** Scrape the trailing output of a peer's session screen. */
|
|
1229
|
-
function readUnit(ctx, ref, options = {}) {
|
|
1230
|
-
const { agent, target } = paneTargetOf(ctx, ref);
|
|
1231
|
-
const exec = ctx.exec ?? realExec;
|
|
1232
|
-
const output = selectSessionAdapter(ctx.env ?? process.env, exec).read(exec, target, { lines: options.lines }).text;
|
|
1233
|
-
return {
|
|
1234
|
-
agent,
|
|
1235
|
-
pane: target.id,
|
|
1236
|
-
output
|
|
1237
|
-
};
|
|
1238
|
-
}
|
|
1239
|
-
/**
|
|
1240
|
-
* Reset a warm peer's context to cold WITHOUT tearing anything down — injects the peer's own
|
|
1241
|
-
* harness fresh-context command (`resetCommandFor`) into its pane through the session adapter.
|
|
1242
|
-
* Warmth is the unit (pane/process stays warm — no cold-start), coldness is the context. The
|
|
1243
|
-
* command is resolved (and any false-friend/unmapped harness throws) BEFORE anything is sent, so
|
|
1244
|
-
* a fail-loud harness never has anything typed into its pane. Touches neither the registry record
|
|
1245
|
-
* nor the worktree — `close` (`decommission`) owns teardown.
|
|
1246
|
-
*/
|
|
1247
|
-
function clearUnit(ctx, ref) {
|
|
1248
|
-
const { agent, target } = paneTargetOf(ctx, ref);
|
|
1249
|
-
const pane = target.id;
|
|
1250
|
-
if (!agent.harness) throw new Error(`unit "${ref}" has no harness on record — cannot resolve its reset command`);
|
|
1251
|
-
const command = resetCommandFor(agent.harness);
|
|
1252
|
-
const env = ctx.env ?? process.env;
|
|
1253
|
-
const exec = ctx.exec ?? realExec;
|
|
1254
|
-
selectSessionAdapter(env, exec).submit(exec, { id: pane }, command);
|
|
1255
|
-
return {
|
|
1256
|
-
agent,
|
|
1257
|
-
pane,
|
|
1258
|
-
command
|
|
1259
|
-
};
|
|
1260
|
-
}
|
|
1261
|
-
/** Resolve a spawn brief from --brief-file, --task -, or --task <text>; null if no source given. */
|
|
1262
|
-
function resolveBrief(input, readStdin = () => readFileSync(0, "utf8")) {
|
|
1263
|
-
if (input.briefFile) return readFileSync(input.briefFile, "utf8");
|
|
1264
|
-
if (input.task === "-") return readStdin();
|
|
1265
|
-
if (input.task != null && input.task !== "") return input.task;
|
|
1266
|
-
return null;
|
|
1267
|
-
}
|
|
1268
|
-
//#endregion
|
|
1269
|
-
//#region src/agentdef/realize.ts
|
|
1270
|
-
const DEFAULT_HARNESS = "claude";
|
|
1271
|
-
/** POSIX single-quote a value for safe inclusion in a shell command line. */
|
|
1272
|
-
function shellQuote(value) {
|
|
1273
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
1274
|
-
}
|
|
1275
|
-
/** Build the harness launch invocation for a def — explicit `model`/`harness` win over the def's
|
|
1276
|
-
* own tags, which win over the harness default. Every harness gets the same shape (bin,
|
|
1277
|
-
* `--model` when known, `--append-system-prompt` carrying the def's instructions body) since the
|
|
1278
|
-
* exact per-harness flag surface is realized later by the launching caller, not by cyberlegion. */
|
|
1279
|
-
function realizeLaunch(def, opts = {}) {
|
|
1280
|
-
const harness = opts.harness ?? def.harness ?? DEFAULT_HARNESS;
|
|
1281
|
-
const model = opts.model ?? def.model;
|
|
1282
|
-
const parts = [LAUNCH_MAP[harness]];
|
|
1283
|
-
if (model) parts.push("--model", shellQuote(model));
|
|
1284
|
-
if (def.instructions) parts.push("--append-system-prompt", shellQuote(def.instructions));
|
|
1285
|
-
return {
|
|
1286
|
-
harness,
|
|
1287
|
-
command: parts.join(" ")
|
|
1288
|
-
};
|
|
1289
|
-
}
|
|
1290
|
-
/**
|
|
1291
|
-
* Resolve what `unit spawn` should launch, from either an explicit `--harness` or an agent def
|
|
1292
|
-
* (`--agent` / `--agent-file`) whose harness, model and instructions compose the launch command.
|
|
1293
|
-
* An explicit `--harness` overrides the def's own.
|
|
1294
|
-
*
|
|
1295
|
-
* Extracted from the CLI action so the def→launch wiring is reachable from a test: composed inline
|
|
1296
|
-
* it sat between two well-covered halves (`resolveAgentDef`, `realizeLaunch`) with nothing
|
|
1297
|
-
* exercising the join between them.
|
|
1298
|
-
*/
|
|
1299
|
-
function resolveSpawnLaunch(input) {
|
|
1300
|
-
if (!input.agent && !input.agentFile) return { harness: input.harness };
|
|
1301
|
-
const realized = realizeLaunch(resolveAgentDef({
|
|
1302
|
-
name: input.agent,
|
|
1303
|
-
file: input.agentFile,
|
|
1304
|
-
...input.cwd ? { cwd: input.cwd } : {},
|
|
1305
|
-
...input.searchRoots ? { searchRoots: input.searchRoots } : {}
|
|
1306
|
-
}), { harness: input.harness });
|
|
1307
|
-
return {
|
|
1308
|
-
harness: realized.harness,
|
|
1309
|
-
command: realized.command
|
|
1310
|
-
};
|
|
1311
|
-
}
|
|
1312
|
-
//#endregion
|
|
1313
|
-
//#region src/decommission.ts
|
|
1314
|
-
/**
|
|
1315
|
-
* Tear a unit down and reap its registry record — the deterministic inverse of `spawn`. Refuses
|
|
1316
|
-
* the primary checkout (absolute — `--force` never overrides it) and a dirty worktree unless
|
|
1317
|
-
* `--force`. Teardown always precedes reap: an already-gone worktree or pane is tolerated, but a
|
|
1318
|
-
* genuine worktree-removal failure aborts and leaves the record intact so the operation is
|
|
1319
|
-
* retryable.
|
|
1320
|
-
*/
|
|
1321
|
-
function decommission(ctx, input) {
|
|
1322
|
-
const rec = loadAgent(ctx.store, input.id);
|
|
1323
|
-
if (!rec) throw new Error(`no unit registered as agents/${input.id}.json — nothing to decommission`);
|
|
1324
|
-
const exec = ctx.exec ?? realExec;
|
|
1325
|
-
const env = ctx.env ?? process.env;
|
|
1326
|
-
const worktreeRoot = rec.worktree?.root;
|
|
1327
|
-
const primaryRoot = worktreeRoot ? resolvePrimaryRoot(exec) : void 0;
|
|
1328
|
-
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`);
|
|
1329
|
-
const worktreeExists = worktreeRoot != null && existsSync(worktreeRoot);
|
|
1330
|
-
if (worktreeExists && !input.force && isDirty(exec, worktreeRoot)) throw new Error(`unit "${input.id}" has uncommitted changes in its worktree — pass --force to discard them`);
|
|
1331
|
-
const pane = rec.pane?.id ?? ctx.store.findPaneByAgentId(input.id);
|
|
1332
|
-
if (worktreeExists) try {
|
|
1333
|
-
gitWorktreeAdapter.remove(exec, worktreeRoot, { primaryRoot });
|
|
1334
|
-
} catch (err) {
|
|
1335
|
-
throw new Error(`decommission "${input.id}" aborted — worktree removal failed, record left intact for retry: ${err instanceof Error ? err.message : String(err)}`);
|
|
1336
|
-
}
|
|
1337
|
-
if (pane) try {
|
|
1338
|
-
selectSessionAdapter(env, exec).teardown(exec, { id: pane });
|
|
1339
|
-
} catch {}
|
|
1340
|
-
ctx.store.removeAgent(input.id);
|
|
1341
|
-
if (pane) ctx.store.removePaneIndex(pane);
|
|
1342
|
-
ctx.store.removeAgentData(input.id);
|
|
1343
|
-
return {
|
|
1344
|
-
agent: rec,
|
|
1345
|
-
worktreeRoot,
|
|
1346
|
-
pane
|
|
1347
|
-
};
|
|
1348
|
-
}
|
|
1349
|
-
function isDirty(exec, worktreeRoot) {
|
|
1350
|
-
return !!exec("git", [
|
|
1351
|
-
"-C",
|
|
1352
|
-
worktreeRoot,
|
|
1353
|
-
"status",
|
|
1354
|
-
"--porcelain"
|
|
1355
|
-
]);
|
|
1356
|
-
}
|
|
1357
|
-
//#endregion
|
|
1358
|
-
//#region src/install.ts
|
|
1359
|
-
const PIN_TOKEN = /^[0-9A-Za-z][0-9A-Za-z._+-]*$/;
|
|
1360
|
-
function validatePin(pin) {
|
|
1361
|
-
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)`);
|
|
1362
|
-
}
|
|
1363
|
-
const hookCommand = (event, pin) => pin ? `npx cyberlegion@${pin} mail hook --event ${event}` : `npx cyberlegion mail hook --event ${event}`;
|
|
1364
|
-
function hookTarget(command) {
|
|
1365
|
-
return command.match(/^(?:npx cyberlegion(?:@[^\s]+)?|cyberlegion) (mail hook --event \S+)$/)?.[1];
|
|
1366
|
-
}
|
|
1367
|
-
const VENDORS = {
|
|
1368
|
-
claude: {
|
|
1369
|
-
file: ".claude/settings.json",
|
|
1370
|
-
shape: "claude",
|
|
1371
|
-
events: {
|
|
1372
|
-
SessionStart: "SessionStart",
|
|
1373
|
-
PostToolUse: "PostToolUse"
|
|
1374
|
-
}
|
|
1375
|
-
},
|
|
1376
|
-
cursor: {
|
|
1377
|
-
file: ".cursor/hooks.json",
|
|
1378
|
-
shape: "cursor",
|
|
1379
|
-
events: { SessionStart: "sessionStart" }
|
|
1380
|
-
},
|
|
1381
|
-
codex: {
|
|
1382
|
-
file: ".codex/hooks.json",
|
|
1383
|
-
shape: "cursor",
|
|
1384
|
-
events: {
|
|
1385
|
-
SessionStart: "SessionStart",
|
|
1386
|
-
PostToolUse: "PostToolUse"
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
1389
|
-
};
|
|
1390
|
-
function readJson(file) {
|
|
1391
|
-
if (!existsSync(file)) return {};
|
|
1392
|
-
try {
|
|
1393
|
-
return JSON.parse(readFileSync(file, "utf8"));
|
|
1394
|
-
} catch {
|
|
1395
|
-
return {};
|
|
1396
|
-
}
|
|
1397
|
-
}
|
|
1398
|
-
function writeJson$1(file, data) {
|
|
1399
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
1400
|
-
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
1401
|
-
}
|
|
1402
|
-
/** Register the surfacing hook into one harness's config, idempotently. */
|
|
1403
|
-
function install(harness, projectDir = process.cwd(), pin) {
|
|
1404
|
-
const spec = VENDORS[harness];
|
|
1405
|
-
if (!spec) throw new Error(`unknown harness "${harness}" (expected claude | cursor | codex)`);
|
|
1406
|
-
if (pin !== void 0) validatePin(pin);
|
|
1407
|
-
const file = join(projectDir, spec.file);
|
|
1408
|
-
const settings = readJson(file);
|
|
1409
|
-
const results = [];
|
|
1410
|
-
for (const [canonical, vendorEvent] of Object.entries(spec.events)) {
|
|
1411
|
-
const command = hookCommand(canonical, pin);
|
|
1412
|
-
const status = spec.shape === "claude" ? upsertClaude(settings, vendorEvent, command) : upsertCursor(settings, vendorEvent, command);
|
|
1413
|
-
results.push({
|
|
1414
|
-
harness,
|
|
1415
|
-
event: canonical,
|
|
1416
|
-
vendorEvent,
|
|
1417
|
-
file,
|
|
1418
|
-
status
|
|
1419
|
-
});
|
|
1420
|
-
}
|
|
1421
|
-
writeJson$1(file, settings);
|
|
1422
|
-
return results;
|
|
1423
|
-
}
|
|
1424
|
-
function upsertClaude(settings, event, command) {
|
|
1425
|
-
const hooks = settings.hooks ??= {};
|
|
1426
|
-
const groups = hooks[event] ??= [];
|
|
1427
|
-
const target = hookTarget(command);
|
|
1428
|
-
for (const g of groups) for (const h of g.hooks ?? []) {
|
|
1429
|
-
if (h.command === command) return "already present";
|
|
1430
|
-
if (target && hookTarget(h.command) === target) {
|
|
1431
|
-
h.command = command;
|
|
1432
|
-
return "already present";
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
const group = { hooks: [{
|
|
1436
|
-
type: "command",
|
|
1437
|
-
command
|
|
1438
|
-
}] };
|
|
1439
|
-
if (event === "PostToolUse") group.matcher = "Write|Edit";
|
|
1440
|
-
groups.push(group);
|
|
1441
|
-
return "registered";
|
|
1442
|
-
}
|
|
1443
|
-
function upsertCursor(settings, event, command) {
|
|
1444
|
-
if (settings.version == null) settings.version = 1;
|
|
1445
|
-
const hooks = settings.hooks ??= {};
|
|
1446
|
-
const list = hooks[event] ??= [];
|
|
1447
|
-
const target = hookTarget(command);
|
|
1448
|
-
for (const h of list) {
|
|
1449
|
-
if (h.command === command) return "already present";
|
|
1450
|
-
if (target && hookTarget(h.command) === target) {
|
|
1451
|
-
h.command = command;
|
|
1452
|
-
return "already present";
|
|
1453
|
-
}
|
|
1454
|
-
}
|
|
1455
|
-
list.push({ command });
|
|
1456
|
-
return "registered";
|
|
1457
|
-
}
|
|
1458
|
-
//#endregion
|
|
1459
|
-
//#region src/message.ts
|
|
1460
|
-
/** Write one message into the recipient's inbox. Collision-free by <epochMs>-<hex>. */
|
|
1461
|
-
function send(ctx, input) {
|
|
1462
|
-
const toId = resolveRecipient(ctx.store, input.to);
|
|
1463
|
-
const from = loadAgent(ctx.store, input.fromId);
|
|
1464
|
-
const ts = ctx.now?.() ?? Date.now();
|
|
1465
|
-
const msg = {
|
|
1466
|
-
id: `${ts}-${randomBytes(3).toString("hex")}`,
|
|
1467
|
-
from: input.fromId,
|
|
1468
|
-
fromHandle: from?.handle ?? input.fromId,
|
|
1469
|
-
to: toId,
|
|
1470
|
-
...input.subject ? { subject: input.subject } : {},
|
|
1471
|
-
body: input.body,
|
|
1472
|
-
...input.thread ? { thread: input.thread } : {},
|
|
1473
|
-
...input.replyTo ? { replyTo: input.replyTo } : {},
|
|
1474
|
-
ts,
|
|
1475
|
-
sentAt: new Date(ts).toISOString()
|
|
1476
|
-
};
|
|
1477
|
-
ctx.store.putMessage(toId, msg);
|
|
1478
|
-
return msg;
|
|
1479
|
-
}
|
|
1480
|
-
/** Resolve a message body from the --body flag, a --body-file path, or stdin (--body-file -). */
|
|
1481
|
-
function resolveBody(body, bodyFile, readStdin = () => readFileSync(0, "utf8")) {
|
|
1482
|
-
if (body != null) return body;
|
|
1483
|
-
if (bodyFile) return bodyFile === "-" ? readStdin() : readFileSync(bodyFile, "utf8");
|
|
1484
|
-
throw new Error("provide --body <text> or --body-file <path|->");
|
|
1485
|
-
}
|
|
1486
|
-
/** List the caller's mail, chronological (lexical id sort == time order). */
|
|
1487
|
-
function inbox(ctx, q) {
|
|
1488
|
-
const snap = ctx.store.listInbox(q.meId);
|
|
1489
|
-
const unread = snap.unread.map((m) => ({
|
|
1490
|
-
...m,
|
|
1491
|
-
read: false
|
|
1492
|
-
}));
|
|
1493
|
-
const acked = q.unread ? [] : snap.read.map((m) => ({
|
|
1494
|
-
...m,
|
|
1495
|
-
read: true
|
|
1496
|
-
}));
|
|
1497
|
-
let items = [...unread, ...acked];
|
|
1498
|
-
if (q.from) items = items.filter((m) => m.from === q.from || m.fromHandle === q.from);
|
|
1499
|
-
if (q.thread) items = items.filter((m) => m.thread === q.thread);
|
|
1500
|
-
return items.sort((a, b) => a.id.localeCompare(b.id));
|
|
1501
|
-
}
|
|
1502
|
-
/** Peek at a message (unread or already-acked) without changing its state. */
|
|
1503
|
-
function peek(ctx, meId, msgId) {
|
|
1504
|
-
const snap = ctx.store.listInbox(meId);
|
|
1505
|
-
return [...snap.unread, ...snap.read].find((m) => m.id === msgId);
|
|
1506
|
-
}
|
|
1507
|
-
/** Acknowledge a message by moving it out of the unread set. Errors if not currently unread. */
|
|
1508
|
-
function ack(ctx, meId, msgId) {
|
|
1509
|
-
return ctx.store.ackMessage(meId, msgId);
|
|
1510
|
-
}
|
|
1511
|
-
/** Read and consume a message in one atomic step: returns the body and acks it if still unread.
|
|
1512
|
-
* Idempotent — an already-acked message is returned with `acked: false` rather than erroring (unlike
|
|
1513
|
-
* bare `ack`); an unknown message id throws. Backs `mail read --ack`. */
|
|
1514
|
-
function readAck(ctx, meId, msgId) {
|
|
1515
|
-
const snap = ctx.store.listInbox(meId);
|
|
1516
|
-
if (snap.unread.some((m) => m.id === msgId)) return {
|
|
1517
|
-
msg: ctx.store.ackMessage(meId, msgId),
|
|
1518
|
-
acked: true
|
|
1519
|
-
};
|
|
1520
|
-
const already = snap.read.find((m) => m.id === msgId);
|
|
1521
|
-
if (already) return {
|
|
1522
|
-
msg: already,
|
|
1523
|
-
acked: false
|
|
1524
|
-
};
|
|
1525
|
-
throw new Error(`"${msgId}" is not a message in this inbox`);
|
|
1526
|
-
}
|
|
1527
|
-
/** Permanently remove a message (unread or already-acked) from the caller's inbox. */
|
|
1528
|
-
function deleteMessage(ctx, meId, msgId) {
|
|
1529
|
-
ctx.store.removeMessage(meId, msgId);
|
|
1530
|
-
}
|
|
1531
|
-
//#endregion
|
|
1532
|
-
//#region src/output.ts
|
|
1533
|
-
function stringifyCell(v) {
|
|
1534
|
-
if (v == null) return "";
|
|
1535
|
-
const s = String(v);
|
|
1536
|
-
return /[,\n"]/.test(s) ? `"${s.replace(/"/g, "\"\"")}"` : s;
|
|
1537
|
-
}
|
|
1538
|
-
/** One TOON-encoded object: `key: value` lines, blank/undefined fields dropped. */
|
|
1539
|
-
function toonObject(fields) {
|
|
1540
|
-
return Object.entries(fields).filter(([, v]) => v != null).map(([k, v]) => `${k}: ${v}`).join("\n");
|
|
1541
|
-
}
|
|
1542
|
-
/** One TOON-encoded list: a `name[N]{field,...}:` header plus one row per item, then an aggregate
|
|
1543
|
-
* summary line. Definitive on empty — still emits `name[0]{...}:` plus the summary line. */
|
|
1544
|
-
function toonList(name, items, fields, summary) {
|
|
1545
|
-
return [
|
|
1546
|
-
`${name}[${items.length}]{${fields.map((f) => f.key).join(",")}}:`,
|
|
1547
|
-
...items.map((item) => fields.map((f) => stringifyCell(f.get(item))).join(",")).map((r) => ` ${r}`),
|
|
1548
|
-
summary
|
|
1549
|
-
].join("\n");
|
|
1550
|
-
}
|
|
1551
|
-
/** Print a command's result to stdout in the requested format. */
|
|
1552
|
-
function emit(format, payload) {
|
|
1553
|
-
if (format === "json") console.log(JSON.stringify(payload.json, null, 2));
|
|
1554
|
-
else console.log(payload.toon);
|
|
1555
|
-
}
|
|
1556
|
-
/** A stderr-only next-step suggestion — never part of the machine-readable stdout result. */
|
|
1557
|
-
function nextStep(msg) {
|
|
1558
|
-
console.error(`→ ${msg}`);
|
|
1559
|
-
}
|
|
1560
|
-
/** A structured, fail-loud error: stderr + exit 1. Never prompts, never partially writes. */
|
|
1561
|
-
function fail(msg) {
|
|
1562
|
-
console.error(JSON.stringify({ error: msg }));
|
|
1563
|
-
process.exit(1);
|
|
1564
|
-
}
|
|
1565
|
-
//#endregion
|
|
1566
|
-
//#region src/runtime/inject-inbox.ts
|
|
1567
|
-
const EVENTS = ["SessionStart", "PostToolUse"];
|
|
1568
|
-
/**
|
|
1569
|
-
* Resolve the calling agent, gather its unread mail (and a standing owner's, when this session is
|
|
1570
|
-
* the hub's main pane), and return the SessionStart-style injection payload — or null when there is
|
|
1571
|
-
* nothing to inject (an unregistered caller or an empty inbox never fails the harness hook).
|
|
1572
|
-
*
|
|
1573
|
-
* This never injects a brief. Brief delivery is the spawn wake's job: the first-turn doorbell
|
|
1574
|
-
* carries the instruction and names the brief's file path, so pickup does not depend on this hook
|
|
1575
|
-
* firing in the child (`unit/lifecycle`, superseding ADR-0027).
|
|
1576
|
-
*/
|
|
1577
|
-
function injectInbox(ctx, event) {
|
|
1578
|
-
if (!EVENTS.includes(event)) throw new Error(`unsupported --event "${event}" (expected ${EVENTS.join(" | ")})`);
|
|
1579
|
-
let meId = resolveSelfId(ctx);
|
|
1580
|
-
if (!meId) {
|
|
1581
|
-
if (currentPane(normalizeMuxEnv(ctx.env ?? process.env))) try {
|
|
1582
|
-
register(ctx, {});
|
|
1583
|
-
meId = resolveSelfId(ctx);
|
|
1584
|
-
} catch {
|
|
1585
|
-
return null;
|
|
1586
|
-
}
|
|
1587
|
-
if (!meId) return null;
|
|
1588
|
-
}
|
|
1589
|
-
const parts = [];
|
|
1590
|
-
const rec = loadAgent(ctx.store, meId);
|
|
1591
|
-
const unread = inbox({ store: ctx.store }, {
|
|
1592
|
-
meId,
|
|
1593
|
-
unread: true
|
|
1594
|
-
});
|
|
1595
|
-
if (unread.length > 0) {
|
|
1596
|
-
const lines = unread.map((m) => `- **${m.fromHandle}**${m.subject ? ` — ${m.subject}` : ""}: ${m.body} \`(${m.id})\``);
|
|
1597
|
-
parts.push(`## Unread mail (${unread.length})\n\n${lines.join("\n")}`);
|
|
1598
|
-
}
|
|
1599
|
-
const cur = currentPane(normalizeMuxEnv(ctx.env ?? process.env));
|
|
1600
|
-
if (rec && !rec.spawnedBy) try {
|
|
1601
|
-
const bound = ctx.store.getMainPane();
|
|
1602
|
-
if (!bound || cur?.pane === bound) {
|
|
1603
|
-
const standing = listAgents(ctx.store).filter((a) => a.kind === "standing");
|
|
1604
|
-
for (const owner of standing) {
|
|
1605
|
-
const ownerUnread = inbox({ store: ctx.store }, {
|
|
1606
|
-
meId: owner.id,
|
|
1607
|
-
unread: true
|
|
1608
|
-
});
|
|
1609
|
-
if (ownerUnread.length === 0) continue;
|
|
1610
|
-
const lines = ownerUnread.map((m) => `- **${m.fromHandle}**${m.subject ? ` — ${m.subject}` : ""}: ${m.body} \`(${m.id})\``);
|
|
1611
|
-
parts.push(`## Owner mail — ${owner.handle} (${ownerUnread.length})\n\n${lines.join("\n")}`);
|
|
1612
|
-
}
|
|
1613
|
-
}
|
|
1614
|
-
} catch {}
|
|
1615
|
-
if (rec && !rec.spawnedBy) try {
|
|
1616
|
-
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.");
|
|
1617
|
-
} catch {}
|
|
1618
|
-
if (parts.length === 0) return null;
|
|
1619
|
-
return { hookSpecificOutput: {
|
|
1620
|
-
hookEventName: event,
|
|
1621
|
-
additionalContext: parts.join("\n\n")
|
|
1622
|
-
} };
|
|
1623
|
-
}
|
|
1624
|
-
//#endregion
|
|
1625
|
-
//#region src/store/errors.ts
|
|
1626
|
-
/** A record file exists but its content didn't parse as JSON — a torn write (crash mid-`writeFileSync`,
|
|
1627
|
-
* pre-atomic-write code path) or on-disk tampering. Carries the file path and the original parse
|
|
1628
|
-
* error so a caller can report exactly what's broken and where, rather than "Unexpected token" with
|
|
1629
|
-
* no location. */
|
|
1630
|
-
var CorruptRecordError = class extends Error {
|
|
1631
|
-
file;
|
|
1632
|
-
constructor(file, cause) {
|
|
1633
|
-
super(`corrupt record at "${file}": ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
1634
|
-
this.file = file;
|
|
1635
|
-
this.name = "CorruptRecordError";
|
|
1636
|
-
}
|
|
1637
|
-
};
|
|
1638
|
-
//#endregion
|
|
1639
|
-
//#region src/store/process-liveness.ts
|
|
1640
|
-
/**
|
|
1641
|
-
* Probe whether `pid` is alive using `process.kill(pid, 0)` — the POSIX "does this process exist"
|
|
1642
|
-
* idiom; it sends no signal, it only tests deliverability. `ESRCH` (no such process) is the only
|
|
1643
|
-
* errno that means "definitely dead". Every other errno — `EPERM` above all, common under sandboxes
|
|
1644
|
-
* and containers where a genuinely live process can be unsignalable by this one, or any process
|
|
1645
|
-
* owned by a different uid — reads as `'unknown'`, never `'dead'`: collapsing `EPERM` into "dead" is
|
|
1646
|
-
* exactly the defect this three-state return exists to make structurally impossible to reintroduce
|
|
1647
|
-
* at a new call site. Same-pid is trivially `'alive'` (a process checking its own prior identity,
|
|
1648
|
-
* e.g. after a crash-and-restart that reused nothing).
|
|
1649
|
-
*/
|
|
1650
|
-
function probeProcess(pid) {
|
|
1651
|
-
if (pid === process.pid) return "alive";
|
|
1652
|
-
try {
|
|
1653
|
-
process.kill(pid, 0);
|
|
1654
|
-
return "alive";
|
|
1655
|
-
} catch (err) {
|
|
1656
|
-
return err.code === "ESRCH" ? "dead" : "unknown";
|
|
1657
|
-
}
|
|
1658
|
-
}
|
|
1659
|
-
//#endregion
|
|
1660
|
-
//#region src/store/lock.ts
|
|
1661
|
-
var LockTimeoutError = class extends Error {
|
|
1662
|
-
name;
|
|
1663
|
-
constructor(name) {
|
|
1664
|
-
super(`timed out waiting for lock "${name}"`);
|
|
1665
|
-
this.name = name;
|
|
1666
|
-
this.name = "LockTimeoutError";
|
|
1667
|
-
}
|
|
1668
|
-
};
|
|
1669
|
-
function locksDir(root) {
|
|
1670
|
-
return join(root, "locks");
|
|
1671
|
-
}
|
|
1672
|
-
function lockDirFor(root, name) {
|
|
1673
|
-
return join(locksDir(root), `${name}.lock`);
|
|
1674
|
-
}
|
|
1675
|
-
/** Read a lock dir's recorded holder. Absent/corrupt/mid-acquire (holder.json not written yet) all
|
|
1676
|
-
* read as "unknown" — never grounds to steal. An ambiguous read must never look like a green light. */
|
|
1677
|
-
function readHolder(dir) {
|
|
1678
|
-
try {
|
|
1679
|
-
return JSON.parse(readFileSync(join(dir, "holder.json"), "utf8"));
|
|
1680
|
-
} catch {
|
|
1681
|
-
return;
|
|
1682
|
-
}
|
|
1683
|
-
}
|
|
1684
|
-
/** Locking's policy on `probeProcess`'s `'unknown'` state: treat it exactly like `'alive'` — i.e.
|
|
1685
|
-
* NOT `'dead'`. The one invariant that must never break here is stealing a lock a live holder still
|
|
1686
|
-
* holds, and `'unknown'` means "cannot rule out alive", so it can only ever be safe to fold it into
|
|
1687
|
-
* the "do not steal" side. This is a local policy decision, not `probeProcess`'s — a different call
|
|
1688
|
-
* site (a staleness reaper, say) folding `'unknown'` into "not provably alive" instead would be
|
|
1689
|
-
* reading the SAME three states toward the opposite default, which is exactly the bug class this
|
|
1690
|
-
* three-state type exists to force each call site to decide explicitly (see process-liveness.ts). */
|
|
1691
|
-
function heldByLiveOrUnknownPid(pid) {
|
|
1692
|
-
return probeProcess(pid) !== "dead";
|
|
1693
|
-
}
|
|
1694
|
-
function sleepSync(ms) {
|
|
1695
|
-
if (ms <= 0) return;
|
|
1696
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1697
|
-
}
|
|
1698
|
-
/**
|
|
1699
|
-
* Attempt to reclaim an abandoned lock dir without ever discarding a LIVE one out from under its
|
|
1700
|
-
* holder — the exact defect agmsg/firstmate's lock suites exist to catch (evidence.md items 3-4).
|
|
1701
|
-
*
|
|
1702
|
-
* `mkdirSync` on the lock path itself already arbitrates ordinary contention (EEXIST = someone else
|
|
1703
|
-
* got there first); the harder race is a STEAL: two processes independently deciding the same lock
|
|
1704
|
-
* looks dead. `renameSync` gives the same exclusivity `mkdirSync` gives for a fresh path, but for an
|
|
1705
|
-
* EXISTING one — of any number of processes racing to rename the SAME source path away, exactly one
|
|
1706
|
-
* succeeds and the rest get ENOENT. So the rename-away is the sole arbiter of "who gets to attempt
|
|
1707
|
-
* the steal", not the earlier staleness read.
|
|
1708
|
-
*
|
|
1709
|
-
* That still leaves one race: the content we read as stale might not be the content we actually
|
|
1710
|
-
* capture, if the true holder released and a NEW, live holder re-acquired at this same path between
|
|
1711
|
-
* our staleness read and our rename. So after winning the rename, re-check the pid we ACTUALLY
|
|
1712
|
-
* captured (not the one from the earlier read) — if it's alive, this was a live lock we grabbed by
|
|
1713
|
-
* accident; put it back immediately (best-effort — if a third party has since re-mkdir'd the path,
|
|
1714
|
-
* the invariant we're protecting already holds, so just drop our capture) and refuse to finalize.
|
|
1715
|
-
*/
|
|
1716
|
-
function tryReclaimStale(dir) {
|
|
1717
|
-
const grave = `${dir}.stale-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1718
|
-
try {
|
|
1719
|
-
renameSync(dir, grave);
|
|
1720
|
-
} catch (err) {
|
|
1721
|
-
if (err.code === "ENOENT") return false;
|
|
1722
|
-
throw err;
|
|
1723
|
-
}
|
|
1724
|
-
const captured = readHolder(grave);
|
|
1725
|
-
if (captured && heldByLiveOrUnknownPid(captured.pid)) {
|
|
1726
|
-
try {
|
|
1727
|
-
renameSync(grave, dir);
|
|
1728
|
-
} catch {
|
|
1729
|
-
rmSync(grave, {
|
|
1730
|
-
recursive: true,
|
|
1731
|
-
force: true
|
|
1732
|
-
});
|
|
1733
|
-
}
|
|
1734
|
-
return false;
|
|
1735
|
-
}
|
|
1736
|
-
rmSync(grave, {
|
|
1737
|
-
recursive: true,
|
|
1738
|
-
force: true
|
|
1739
|
-
});
|
|
1740
|
-
return true;
|
|
1741
|
-
}
|
|
1742
|
-
/**
|
|
1743
|
-
* Acquire an advisory, mkdir-based lock at `root/locks/<name>.lock`. mkdir (not O_EXCL on a file) is
|
|
1744
|
-
* the primitive here because a lock also needs to carry its holder metadata (pid + timestamp) for
|
|
1745
|
-
* staleness detection, and a directory gives that a natural home (`holder.json` inside it) without a
|
|
1746
|
-
* second file to keep in sync; `mkdirSync` on a non-existent path is exactly as atomic as an
|
|
1747
|
-
* exclusive file create on every filesystem this tool targets (POSIX; NTFS via Node's Win32 mkdir).
|
|
1748
|
-
*
|
|
1749
|
-
* Blocks (busy-retries) until acquired or `timeoutMs` elapses, reclaiming a dead holder's lock along
|
|
1750
|
-
* the way (see `tryReclaimStale`) but NEVER a live one — contention with a live holder always waits
|
|
1751
|
-
* it out or times out, never steals.
|
|
1752
|
-
*/
|
|
1753
|
-
function acquireLock(root, name, opts = {}) {
|
|
1754
|
-
const retryDelayMs = opts.retryDelayMs ?? 20;
|
|
1755
|
-
const timeoutMs = opts.timeoutMs ?? 5e3;
|
|
1756
|
-
const dir = lockDirFor(root, name);
|
|
1757
|
-
mkdirSync(locksDir(root), { recursive: true });
|
|
1758
|
-
const deadline = Date.now() + timeoutMs;
|
|
1759
|
-
for (;;) try {
|
|
1760
|
-
mkdirSync(dir);
|
|
1761
|
-
writeFileSync(join(dir, "holder.json"), JSON.stringify({
|
|
1762
|
-
pid: process.pid,
|
|
1763
|
-
acquiredAt: Date.now()
|
|
1764
|
-
}));
|
|
1765
|
-
return { release: () => rmSync(dir, {
|
|
1766
|
-
recursive: true,
|
|
1767
|
-
force: true
|
|
1768
|
-
}) };
|
|
1769
|
-
} catch (err) {
|
|
1770
|
-
if (err.code !== "EEXIST") throw err;
|
|
1771
|
-
const holder = readHolder(dir);
|
|
1772
|
-
if (holder && !heldByLiveOrUnknownPid(holder.pid) && tryReclaimStale(dir)) continue;
|
|
1773
|
-
if (Date.now() > deadline) throw new LockTimeoutError(name);
|
|
1774
|
-
sleepSync(retryDelayMs);
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
/** Acquire `name`, run `fn`, and always release — the shape every genuine read-modify-write in the
|
|
1778
|
-
* store should use (`setMainPane`, `identity.ts`'s `claimPresence`/`clearPresence` today). */
|
|
1779
|
-
function withLock(root, name, fn, opts) {
|
|
1780
|
-
const handle = acquireLock(root, name, opts);
|
|
1781
|
-
try {
|
|
1782
|
-
return fn();
|
|
1783
|
-
} finally {
|
|
1784
|
-
handle.release();
|
|
1785
|
-
}
|
|
1786
|
-
}
|
|
1787
|
-
//#endregion
|
|
1788
|
-
//#region src/store/file-store.ts
|
|
1789
|
-
/** Parse a record file's content, wrapping a `JSON.parse` failure in a typed, file-named
|
|
1790
|
-
* `CorruptRecordError` instead of letting a bare `SyntaxError` bubble up from deep inside
|
|
1791
|
-
* `listInbox`/`listAgents`/`getAgent` with no indication of WHICH file broke. A file that doesn't
|
|
1792
|
-
* exist is a caller error — callers check existence first, so this only ever sees files known to
|
|
1793
|
-
* be present but possibly torn (a crash mid-write predating the atomic-write fix, or on-disk
|
|
1794
|
-
* tampering) — see evidence.md item 5, "no silent success on a broken store". */
|
|
1795
|
-
function readJsonRecord(file) {
|
|
1796
|
-
const raw = readFileSync(file, "utf8");
|
|
1797
|
-
try {
|
|
1798
|
-
return JSON.parse(raw);
|
|
1799
|
-
} catch (err) {
|
|
1800
|
-
throw new CorruptRecordError(file, err);
|
|
1801
|
-
}
|
|
1802
|
-
}
|
|
1803
|
-
function readMessages(dir) {
|
|
1804
|
-
if (!existsSync(dir)) return [];
|
|
1805
|
-
return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => readJsonRecord(join(dir, f)));
|
|
1806
|
-
}
|
|
1807
|
-
/** Write `data` to `file` crash-safely: write to a sibling temp file first, then `renameSync` into
|
|
1808
|
-
* place. `rename` is atomic within a filesystem (POSIX guarantees this; Node's Win32 rename is too
|
|
1809
|
-
* for same-volume paths, which every path here is — always under the same store root), so any
|
|
1810
|
-
* reader either sees the OLD complete content or the NEW complete content, never a truncated
|
|
1811
|
-
* in-between. Before this fix, `putMessage`/`putAgent`/`putPaneIndex`/`setMainPane` called
|
|
1812
|
-
* `writeFileSync` directly on the final path — only `ackMessage`'s move (a rename of an already-
|
|
1813
|
-
* complete file) was atomic. A process crashing mid-`writeFileSync`, or a reader racing a writer on
|
|
1814
|
-
* a large record, could hand `JSON.parse` a truncated file and throw an uncaught `SyntaxError` deep
|
|
1815
|
-
* inside `listInbox`/`listAgents`/`getAgent`.
|
|
1816
|
-
*
|
|
1817
|
-
* Deliberately no `fsync` before the rename: this tool defends against a process CRASHING mid-write
|
|
1818
|
-
* (the actual, observed risk in a daemonless multi-process CLI), where the page cache alone is
|
|
1819
|
-
* sufficient, not against a power-loss/OS-crash losing unflushed pages, which cyberlegion doesn't
|
|
1820
|
-
* claim to survive today (no writer here holds data the user can't just re-send). Add fsync if that
|
|
1821
|
-
* durability bar ever changes.
|
|
1822
|
-
*
|
|
1823
|
-
* The temp name embeds pid + a counter so two writers to the SAME final path never collide on their
|
|
1824
|
-
* own temp files mid-write (each writer's temp file is unique to it). */
|
|
1825
|
-
let tmpCounter = 0;
|
|
1826
|
-
function writeFileAtomic(file, data) {
|
|
1827
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
1828
|
-
const tmp = `${file}.${process.pid}.${tmpCounter++}.tmp`;
|
|
1829
|
-
writeFileSync(tmp, data);
|
|
1830
|
-
renameSync(tmp, file);
|
|
1831
|
-
}
|
|
1832
|
-
function writeJson(file, data) {
|
|
1833
|
-
writeFileAtomic(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
1834
|
-
}
|
|
1835
|
-
function writeText(file, text) {
|
|
1836
|
-
writeFileAtomic(file, text);
|
|
1837
|
-
}
|
|
1838
|
-
/** Build a path via `build()` for a READ-only lookup, treating a syntactically-invalid id
|
|
1839
|
-
* (`InvalidIdError`) the same as "not found" rather than throwing. Reads are the wrong place to
|
|
1840
|
-
* enforce id-shape: `resolveAgent`/`resolveRecipient` (identity.ts) speculatively probe an arbitrary
|
|
1841
|
-
* ref — which may legitimately be a worktree BRANCH NAME containing `/` — as a candidate id before
|
|
1842
|
-
* falling back to handle/branch lookup, so `getAgent('cyberlegion/unit-abc')` must fail soft, not
|
|
1843
|
-
* throw, or that fallback chain breaks. The traversal risk this guards against (an id escaping the
|
|
1844
|
-
* store root) only bites on a WRITE — nothing is ever created or overwritten by a probe that finds
|
|
1845
|
-
* nothing — so a hard reject stays reserved for the write paths below (`putAgent`, `putMessage`,
|
|
1846
|
-
* `putPaneIndex`, `writeBrief`), where a malformed id would otherwise put a stray file wherever it
|
|
1847
|
-
* pointed. */
|
|
1848
|
-
function readPathOrUndefined(build) {
|
|
1849
|
-
try {
|
|
1850
|
-
return build();
|
|
1851
|
-
} catch (err) {
|
|
1852
|
-
if (err instanceof InvalidIdError) return void 0;
|
|
1853
|
-
throw err;
|
|
1854
|
-
}
|
|
1855
|
-
}
|
|
1856
|
-
/** The on-disk `Store` implementation — current per-writer sharded `.json` layout (ADR-0020):
|
|
1857
|
-
* one file per message/agent, collision-free filenames, ack = atomic rename into `read/`. */
|
|
1858
|
-
var FileStore = class {
|
|
1859
|
-
root;
|
|
1860
|
-
constructor(root) {
|
|
1861
|
-
this.root = root;
|
|
1862
|
-
}
|
|
1863
|
-
ensureMarker() {
|
|
1864
|
-
ensureMarker(this.root);
|
|
1865
|
-
}
|
|
1866
|
-
putMessage(toId, msg) {
|
|
1867
|
-
writeFileAtomic(paths.messageFile(this.root, toId, msg.id), `${JSON.stringify(msg, null, 2)}\n`);
|
|
1868
|
-
}
|
|
1869
|
-
listInbox(id) {
|
|
1870
|
-
const inbox = readPathOrUndefined(() => paths.inboxDir(this.root, id));
|
|
1871
|
-
const read = readPathOrUndefined(() => paths.inboxReadDir(this.root, id));
|
|
1872
|
-
return {
|
|
1873
|
-
unread: inbox ? readMessages(inbox) : [],
|
|
1874
|
-
read: read ? readMessages(read) : []
|
|
1875
|
-
};
|
|
1876
|
-
}
|
|
1877
|
-
ackMessage(id, msgId) {
|
|
1878
|
-
const src = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
|
|
1879
|
-
if (!src || !existsSync(src)) throw new Error(`"${msgId}" is not an unread message in this inbox`);
|
|
1880
|
-
const msg = readJsonRecord(src);
|
|
1881
|
-
const dest = paths.messageReadFile(this.root, id, msgId);
|
|
1882
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
1883
|
-
renameSync(src, dest);
|
|
1884
|
-
return msg;
|
|
1885
|
-
}
|
|
1886
|
-
removeMessage(id, msgId) {
|
|
1887
|
-
const unreadFile = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
|
|
1888
|
-
if (unreadFile && existsSync(unreadFile)) {
|
|
1889
|
-
rmSync(unreadFile);
|
|
1890
|
-
return;
|
|
1891
|
-
}
|
|
1892
|
-
const readFile = readPathOrUndefined(() => paths.messageReadFile(this.root, id, msgId));
|
|
1893
|
-
if (readFile && existsSync(readFile)) {
|
|
1894
|
-
rmSync(readFile);
|
|
1895
|
-
return;
|
|
1896
|
-
}
|
|
1897
|
-
throw new Error(`"${msgId}" is not a message in this inbox`);
|
|
1898
|
-
}
|
|
1899
|
-
putAgent(rec) {
|
|
1900
|
-
writeJson(paths.agentFile(this.root, rec.id), rec);
|
|
1901
|
-
}
|
|
1902
|
-
getAgent(id) {
|
|
1903
|
-
const file = readPathOrUndefined(() => paths.agentFile(this.root, id));
|
|
1904
|
-
if (!file || !existsSync(file)) return void 0;
|
|
1905
|
-
return readJsonRecord(file);
|
|
1906
|
-
}
|
|
1907
|
-
listAgents() {
|
|
1908
|
-
const dir = paths.agentsDir(this.root);
|
|
1909
|
-
if (!existsSync(dir)) return [];
|
|
1910
|
-
return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => readJsonRecord(join(dir, f))).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
1911
|
-
}
|
|
1912
|
-
removeAgent(id) {
|
|
1913
|
-
rmSync(paths.agentFile(this.root, id), { force: true });
|
|
1914
|
-
}
|
|
1915
|
-
removeAgentData(id) {
|
|
1916
|
-
rmSync(paths.dataDir(this.root, id), {
|
|
1917
|
-
recursive: true,
|
|
1918
|
-
force: true
|
|
1919
|
-
});
|
|
1920
|
-
}
|
|
1921
|
-
putPaneIndex(pane, agentId) {
|
|
1922
|
-
writeText(paths.paneFile(this.root, pane), agentId);
|
|
1923
|
-
}
|
|
1924
|
-
resolvePaneId(pane) {
|
|
1925
|
-
const file = paths.paneFile(this.root, pane);
|
|
1926
|
-
return existsSync(file) ? readFileSync(file, "utf8").trim() : void 0;
|
|
1927
|
-
}
|
|
1928
|
-
findPaneByAgentId(agentId) {
|
|
1929
|
-
const dir = paths.panesDir(this.root);
|
|
1930
|
-
if (!existsSync(dir)) return void 0;
|
|
1931
|
-
for (const f of readdirSync(dir)) {
|
|
1932
|
-
if (!f.endsWith(".id")) continue;
|
|
1933
|
-
if (readFileSync(join(dir, f), "utf8").trim() === agentId) return f.slice(0, -3);
|
|
1934
|
-
}
|
|
1935
|
-
}
|
|
1936
|
-
removePaneIndex(pane) {
|
|
1937
|
-
rmSync(paths.paneFile(this.root, pane), { force: true });
|
|
1938
|
-
}
|
|
1939
|
-
writeBrief(agentId, text) {
|
|
1940
|
-
writeText(paths.briefFile(this.root, agentId), text);
|
|
1941
|
-
}
|
|
1942
|
-
readBrief(agentId) {
|
|
1943
|
-
const file = readPathOrUndefined(() => paths.briefFile(this.root, agentId));
|
|
1944
|
-
return file && existsSync(file) ? readFileSync(file, "utf8") : void 0;
|
|
1945
|
-
}
|
|
1946
|
-
setMainPane(pane) {
|
|
1947
|
-
withLock(this.root, "main-pane", () => {
|
|
1948
|
-
const file = paths.mainPaneFile(this.root);
|
|
1949
|
-
if (pane) {
|
|
1950
|
-
writeText(file, pane);
|
|
1951
|
-
return;
|
|
1952
|
-
}
|
|
1953
|
-
rmSync(file, { force: true });
|
|
1954
|
-
});
|
|
1955
|
-
}
|
|
1956
|
-
getMainPane() {
|
|
1957
|
-
const file = paths.mainPaneFile(this.root);
|
|
1958
|
-
return existsSync(file) ? readFileSync(file, "utf8").trim() : void 0;
|
|
1959
|
-
}
|
|
1960
|
-
withLock(name, fn) {
|
|
1961
|
-
return withLock(this.root, name, fn);
|
|
1962
|
-
}
|
|
1963
|
-
};
|
|
1964
|
-
//#endregion
|
|
1965
|
-
//#region src/wake/await.ts
|
|
1966
|
-
/** Internal poll interval — not user-facing (the round-trip cost of one `mail await` cycle). */
|
|
1967
|
-
const POLL_MS$1 = 1e3;
|
|
1968
|
-
const DEFAULT_TIMEOUT_MS = 6e5;
|
|
1969
|
-
const DEFAULT_MAX_WAIT_S = 240;
|
|
1970
|
-
/**
|
|
1971
|
-
* Block on the caller's UNREAD inbox until a message matching `thread` (and optional `from`)
|
|
1972
|
-
* arrives, polling on a fixed ~1s internal interval. Three unambiguous outcomes:
|
|
1973
|
-
*
|
|
1974
|
-
* - `matched` — a message arrived; it is printed by the caller AND acked here (moved to read/), so
|
|
1975
|
-
* it leaves the unread set. Block-then-read: each await consumes exactly its round.
|
|
1976
|
-
* - `waiting` — this call's internal poll cycle hit its `maxWaitS` cap (default 240s, always well
|
|
1977
|
-
* under a harness tool-timeout SIGKILL) with no match yet. A clean, non-error return the caller
|
|
1978
|
-
* re-arms on (calls `awaitReply` again) — NOT the same as giving up.
|
|
1979
|
-
* - `timed-out` — the caller's overall `timeoutMs` (default 600_000ms / 600s; `0` = wait forever)
|
|
1980
|
-
* elapsed across re-arms with no match. The caller should exit non-zero.
|
|
1981
|
-
*/
|
|
1982
|
-
async function awaitReply(ctx, input) {
|
|
1983
|
-
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1984
|
-
const maxWaitMs = (input.maxWaitS ?? DEFAULT_MAX_WAIT_S) * 1e3;
|
|
1985
|
-
const now = input.now ?? (() => Date.now());
|
|
1986
|
-
const sleep = input.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1987
|
-
const start = now();
|
|
1988
|
-
const cycleDeadline = start + maxWaitMs;
|
|
1989
|
-
for (;;) {
|
|
1990
|
-
const items = inbox(ctx, {
|
|
1991
|
-
meId: input.meId,
|
|
1992
|
-
unread: true,
|
|
1993
|
-
thread: input.thread,
|
|
1994
|
-
from: input.from
|
|
1995
|
-
});
|
|
1996
|
-
if (items.length > 0) {
|
|
1997
|
-
const message = items[0];
|
|
1998
|
-
ack(ctx, input.meId, message.id);
|
|
1999
|
-
return {
|
|
2000
|
-
kind: "matched",
|
|
2001
|
-
message
|
|
2002
|
-
};
|
|
2003
|
-
}
|
|
2004
|
-
const elapsed = now() - start;
|
|
2005
|
-
if (timeoutMs !== 0 && elapsed >= timeoutMs) return { kind: "timed-out" };
|
|
2006
|
-
if (now() >= cycleDeadline) return { kind: "waiting" };
|
|
2007
|
-
await sleep(POLL_MS$1);
|
|
2008
|
-
}
|
|
2009
|
-
}
|
|
2010
|
-
//#endregion
|
|
2011
|
-
//#region src/wake/watch.ts
|
|
2012
|
-
const POLL_MS = 1e3;
|
|
2013
|
-
/**
|
|
2014
|
-
* A continuous foreground observer: prints each NEW matching message as it arrives, polling on a
|
|
2015
|
-
* fixed ~1s interval, until interrupted (or `maxIterations` in tests). Never acks — a message
|
|
2016
|
-
* `watch` prints stays unread and still surfaces later in `mail inbox`/`mail await`.
|
|
2017
|
-
*/
|
|
2018
|
-
async function watchMail(ctx, input, onMessage, opts = {}) {
|
|
2019
|
-
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
2020
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2021
|
-
for (const m of inbox(ctx, {
|
|
2022
|
-
meId: input.meId,
|
|
2023
|
-
thread: input.thread,
|
|
2024
|
-
from: input.from
|
|
2025
|
-
})) seen.add(m.id);
|
|
2026
|
-
let iterations = 0;
|
|
2027
|
-
for (;;) {
|
|
2028
|
-
const items = inbox(ctx, {
|
|
2029
|
-
meId: input.meId,
|
|
2030
|
-
thread: input.thread,
|
|
2031
|
-
from: input.from
|
|
2032
|
-
});
|
|
2033
|
-
for (const m of items) {
|
|
2034
|
-
if (seen.has(m.id)) continue;
|
|
2035
|
-
seen.add(m.id);
|
|
2036
|
-
onMessage(m);
|
|
2037
|
-
}
|
|
2038
|
-
iterations++;
|
|
2039
|
-
if (opts.maxIterations != null && iterations >= opts.maxIterations) return;
|
|
2040
|
-
await sleep(POLL_MS);
|
|
2041
|
-
}
|
|
2042
|
-
}
|
|
2043
|
-
//#endregion
|
|
2044
|
-
export { resolveProjectLocalRoot as $, bumpLastSeen as A, registerStanding as B, nudgeUnit as C, selectSessionAdapter as D, spawnAndWake as E, loadAgent as F, resolveStandingOwner as G, resolvePresence as H, prune as I, listAgentDefs as J, touch as K, realExec as L, clearPresence as M, detectHarness as N, DELIVERY_DOORBELL as O, listAgents as P, projectRoot as Q, reconcile as R, focusUnit as S, spawn as T, resolveRecipient as U, resolveAgent as V, resolveSelfId as W, ensureMarker as X, resolveAgentDef as Y, paths as Z, install as _, emit as a, resolveSpawnLaunch as b, toonList as c, deleteMessage as d, resolveRoot as et, inbox as f, send as g, resolveBody as h, injectInbox as i, claimPresence as j, wakeRecipient as k, toonObject as l, readAck as m, awaitReply as n, fail as o, peek as p, normalizeMuxEnv as q, FileStore as r, nextStep as s, watchMail as t, ack as u, decommission as v, readUnit as w, clearUnit as x, realizeLaunch as y, register as z };
|