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