cyberlegion 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +57 -14
- package/dist/index.d.mts +80 -45
- package/dist/index.mjs +1 -1
- package/dist/{watch-DA8I44dv.mjs → watch-BuYsdwhx.mjs} +379 -173
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { B as
|
|
2
|
+
import { A as detectHarness, B as resolveStandingOwner, C as resolveAgentDef, D as selectSessionAdapter, E as spawn, F as register, I as registerStanding, K as resolveRoot, L as resolveAgent, M as loadAgent, N as prune, O as probeMultiplexer, P as realExec, S as listAgentDefs, V as touch, _ as inbox, a as fail, b as send, c as toonObject, d as DispatchWaitingError, f as channel, g as deleteMessage, h as ack, i as emit, j as listAgents, k as bumpLastSeen, l as install, m as awaitReply, n as FileStore, o as nextStep, p as prep, r as injectInbox, s as toonList, t as watchMail, u as collect, v as peek, w as realizeLaunch, x as decommission, y as resolveBody, z as resolveSelfId } from "./watch-BuYsdwhx.mjs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { Command, Option } from "commander";
|
|
5
5
|
//#region src/admin.ts
|
|
@@ -75,7 +75,7 @@ withGlobals(identity.command("register")).description("register or refresh this
|
|
|
75
75
|
toon: toonObject({
|
|
76
76
|
id: rec.id,
|
|
77
77
|
handle: rec.handle,
|
|
78
|
-
harness: rec.harness,
|
|
78
|
+
harness: rec.harness ?? "-",
|
|
79
79
|
status: rec.status
|
|
80
80
|
}),
|
|
81
81
|
json: rec
|
|
@@ -91,7 +91,47 @@ withGlobals(identity.command("whoami")).description("print this session own iden
|
|
|
91
91
|
toon: toonObject({
|
|
92
92
|
id: rec.id,
|
|
93
93
|
handle: rec.handle,
|
|
94
|
-
harness: rec.harness,
|
|
94
|
+
harness: rec.harness ?? "-",
|
|
95
|
+
status: rec.status
|
|
96
|
+
}),
|
|
97
|
+
json: rec
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
withGlobals(identity.command("owner")).description("mint (or refresh) a standing, session-independent owner inbox").option("--handle <name>", "the owner handle to claim").action((opts) => {
|
|
101
|
+
const ctx = ctxOf(opts);
|
|
102
|
+
if (!opts.handle) {
|
|
103
|
+
const standing = listAgents(ctx.store).filter((a) => a.kind === "standing");
|
|
104
|
+
emit(formatOf(opts), {
|
|
105
|
+
toon: toonList("agents", standing, [
|
|
106
|
+
{
|
|
107
|
+
key: "id",
|
|
108
|
+
get: (a) => a.id
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
key: "handle",
|
|
112
|
+
get: (a) => a.handle
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
key: "harness",
|
|
116
|
+
get: (a) => a.harness ?? "-"
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
key: "status",
|
|
120
|
+
get: (a) => a.status
|
|
121
|
+
}
|
|
122
|
+
], `${standing.length} agents`),
|
|
123
|
+
json: standing
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const liveClaim = listAgents(ctx.store).find((a) => a.handle === opts.handle && a.kind !== "standing" && a.status !== "exited");
|
|
128
|
+
const rec = registerStanding(ctx, { handle: opts.handle });
|
|
129
|
+
if (liveClaim) console.error(`a live session already claims handle "${opts.handle}"`);
|
|
130
|
+
emit(formatOf(opts), {
|
|
131
|
+
toon: toonObject({
|
|
132
|
+
id: rec.id,
|
|
133
|
+
handle: rec.handle,
|
|
134
|
+
kind: rec.kind,
|
|
95
135
|
status: rec.status
|
|
96
136
|
}),
|
|
97
137
|
json: rec
|
|
@@ -113,7 +153,7 @@ function runWho(opts) {
|
|
|
113
153
|
},
|
|
114
154
|
{
|
|
115
155
|
key: "harness",
|
|
116
|
-
get: (a) => a.harness
|
|
156
|
+
get: (a) => a.harness ?? "-"
|
|
117
157
|
},
|
|
118
158
|
{
|
|
119
159
|
key: "status",
|
|
@@ -142,11 +182,12 @@ withGlobals(identity.command("prune")).description("mark dead agents exited and
|
|
|
142
182
|
});
|
|
143
183
|
const session = program.command("session").description("warm peer session lifecycle over a multiplexer");
|
|
144
184
|
function defineSpawn(cmd) {
|
|
145
|
-
return withGlobals(cmd).description("launch a new peer session in its own git worktree (tmux or herdr)").option("--harness <h>", "claude | cursor | codex (required unless --agent/--agent-file resolves one)").option("--agent <name>", "resolve an agent def (.agents/agents/<name>.md) for harness/model/instructions").option("--agent-file <path>", "read an exact agent def file instead of resolving by name").option("--task <text>", "brief text, or - for stdin").option("--brief-file <path>", "read the brief from a file").option("--handle <name>", "handle for the new peer").option("--branch <name>", "branch for the new worktree (default cyberlegion/unit-<id>)").option("--worktree-path <path>", "where to check out the new worktree").addOption(new Option("--at <placement>", "where to open the new session").choices([
|
|
185
|
+
return withGlobals(cmd).description("launch a new peer session in its own git worktree (tmux or herdr)").option("--harness <h>", "claude | cursor | codex (required unless --agent/--agent-file resolves one)").option("--agent <name>", "resolve an agent def (.agents/agents/<name>.md) for harness/model/instructions").option("--agent-file <path>", "read an exact agent def file instead of resolving by name").option("--task <text>", "brief text, or - for stdin").option("--brief-file <path>", "read the brief from a file").option("--handle <name>", "handle for the new peer").option("--branch <name>", "branch for the new worktree (default cyberlegion/unit-<id>)").option("--worktree-path <path>", "where to check out the new worktree").option("--cwd <path>", "spawn the session in an existing directory; create no worktree (mutually exclusive with --branch/--worktree-path)").addOption(new Option("--at <placement>", "where to open the new session").choices([
|
|
146
186
|
"pane:right",
|
|
147
187
|
"pane:down",
|
|
148
188
|
"tab",
|
|
149
|
-
"window"
|
|
189
|
+
"window",
|
|
190
|
+
"workspace"
|
|
150
191
|
]).default("pane:right")).action((opts) => {
|
|
151
192
|
const ctx = ctxOf(opts);
|
|
152
193
|
touch(ctx);
|
|
@@ -175,6 +216,7 @@ function defineSpawn(cmd) {
|
|
|
175
216
|
handle: opts.handle,
|
|
176
217
|
branch: opts.branch,
|
|
177
218
|
worktreePath: opts.worktreePath,
|
|
219
|
+
cwd: opts.cwd,
|
|
178
220
|
at: opts.at
|
|
179
221
|
});
|
|
180
222
|
emit(formatOf(opts), {
|
|
@@ -306,7 +348,7 @@ defineSend(mail.command("send"));
|
|
|
306
348
|
function runInbox(opts) {
|
|
307
349
|
const ctx = ctxOf(opts);
|
|
308
350
|
touch(ctx);
|
|
309
|
-
const meId = requireSelf(ctx);
|
|
351
|
+
const meId = opts.owner ? resolveStandingOwner(ctx.store, opts.owner) : requireSelf(ctx);
|
|
310
352
|
const items = inbox({ store: ctx.store }, {
|
|
311
353
|
meId,
|
|
312
354
|
unread: opts.unread,
|
|
@@ -338,10 +380,10 @@ function runInbox(opts) {
|
|
|
338
380
|
const firstUnread = items.find((m) => !m.read);
|
|
339
381
|
if (firstUnread) nextStep(`cyberlegion mail read ${firstUnread.id}`);
|
|
340
382
|
}
|
|
341
|
-
withGlobals(mail.command("inbox")).description("list your mail").option("--unread", "only un-acked mail").option("--from <id>", "filter by sender").option("--thread <id>", "filter to messages carrying this thread id").action(runInbox);
|
|
342
|
-
withGlobals(mail.command("read")).description("peek at a message without acknowledging it").argument("<msg-id>", "message id").action((msgId, opts) => {
|
|
383
|
+
withGlobals(mail.command("inbox")).description("list your mail").option("--unread", "only un-acked mail").option("--from <id>", "filter by sender").option("--thread <id>", "filter to messages carrying this thread id").option("--owner <handle>", "target a standing owner's mailbox instead of this session's own").action(runInbox);
|
|
384
|
+
withGlobals(mail.command("read")).description("peek at a message without acknowledging it").argument("<msg-id>", "message id").option("--owner <handle>", "peek a standing owner's mailbox instead of this session's own").action((msgId, opts) => {
|
|
343
385
|
const ctx = ctxOf(opts);
|
|
344
|
-
const meId = requireSelf(ctx);
|
|
386
|
+
const meId = opts.owner ? resolveStandingOwner(ctx.store, opts.owner) : requireSelf(ctx);
|
|
345
387
|
const msg = peek({ store: ctx.store }, meId, msgId);
|
|
346
388
|
if (!msg) fail(`"${msgId}" is not a message in this inbox`);
|
|
347
389
|
emit(formatOf(opts), {
|
|
@@ -353,11 +395,11 @@ withGlobals(mail.command("read")).description("peek at a message without acknowl
|
|
|
353
395
|
}),
|
|
354
396
|
json: msg
|
|
355
397
|
});
|
|
356
|
-
nextStep(`cyberlegion mail ack ${msg.id}`);
|
|
398
|
+
nextStep(`cyberlegion mail ack ${msg.id}${opts.owner ? ` --owner ${opts.owner}` : ""}`);
|
|
357
399
|
});
|
|
358
|
-
withGlobals(mail.command("ack")).description("acknowledge a message (moves it out of the unread set)").argument("<msg-id>", "message id").action((msgId, opts) => {
|
|
400
|
+
withGlobals(mail.command("ack")).description("acknowledge a message (moves it out of the unread set)").argument("<msg-id>", "message id").option("--owner <handle>", "ack a standing owner's mailbox instead of this session's own").action((msgId, opts) => {
|
|
359
401
|
const ctx = ctxOf(opts);
|
|
360
|
-
const meId = requireSelf(ctx);
|
|
402
|
+
const meId = opts.owner ? resolveStandingOwner(ctx.store, opts.owner) : requireSelf(ctx);
|
|
361
403
|
const msg = ack({ store: ctx.store }, meId, msgId);
|
|
362
404
|
emit(formatOf(opts), {
|
|
363
405
|
toon: toonObject({
|
|
@@ -468,7 +510,8 @@ definePrepOptions(dispatch.command("channel")).description("prep + spawn a peer
|
|
|
468
510
|
"pane:right",
|
|
469
511
|
"pane:down",
|
|
470
512
|
"tab",
|
|
471
|
-
"window"
|
|
513
|
+
"window",
|
|
514
|
+
"workspace"
|
|
472
515
|
]).default("pane:right")).option("--wait", "block for the mail-thread reply and print the validated result").option("--timeout <ms>", "give up after this many ms with no reply (0 = wait forever)", (v) => Number.parseInt(v, 10), 6e5).option("--max-wait <s>", "self-cap for one internal poll cycle, in seconds (re-arm by re-running with --wait)", (v) => Number.parseInt(v, 10), 240).action(async (opts) => {
|
|
473
516
|
const ctx = ctxOf(opts);
|
|
474
517
|
touch(ctx);
|
package/dist/index.d.mts
CHANGED
|
@@ -16,7 +16,8 @@ type AgentStatus = 'spawning' | 'active' | 'idle' | 'stale' | 'exited' | 'paused
|
|
|
16
16
|
interface AgentRecord {
|
|
17
17
|
id: string;
|
|
18
18
|
handle: string;
|
|
19
|
-
harness
|
|
19
|
+
/** Absent for a standing record (a human/owner principal — no claude/cursor/codex harness). */
|
|
20
|
+
harness?: Harness;
|
|
20
21
|
cwd: string;
|
|
21
22
|
worktree?: {
|
|
22
23
|
root: string;
|
|
@@ -33,6 +34,9 @@ interface AgentRecord {
|
|
|
33
34
|
lastSeen: string;
|
|
34
35
|
brief?: string;
|
|
35
36
|
spawnedBy?: string;
|
|
37
|
+
/** Absent ⇒ session (backward compat, no migration). 'standing' = a session-independent,
|
|
38
|
+
* prune-exempt owner inbox minted by `identity owner`. */
|
|
39
|
+
kind?: 'session' | 'standing';
|
|
36
40
|
}
|
|
37
41
|
interface InboxSnapshot {
|
|
38
42
|
unread: Message[];
|
|
@@ -176,10 +180,47 @@ interface RealizeSubagentOptions {
|
|
|
176
180
|
* instruction string is correct even when the harness has no such named subagent type. */
|
|
177
181
|
declare function realizeSubagentInstruction(def: AgentDef, opts: RealizeSubagentOptions): string;
|
|
178
182
|
//#endregion
|
|
183
|
+
//#region src/console/worktree.d.ts
|
|
184
|
+
/** Generic multiplexer/worktree seam — no legion-specific concepts. */
|
|
185
|
+
interface WorktreeAddOptions {
|
|
186
|
+
/** The primary checkout's root — the repo `git worktree add` runs against. */
|
|
187
|
+
primaryRoot: string;
|
|
188
|
+
/** Where the new worktree should be checked out. */
|
|
189
|
+
path: string;
|
|
190
|
+
/** Branch to create the worktree on. */
|
|
191
|
+
branch: string;
|
|
192
|
+
}
|
|
193
|
+
interface Worktree {
|
|
194
|
+
root: string;
|
|
195
|
+
branch: string;
|
|
196
|
+
}
|
|
197
|
+
interface WorktreeRemoveOptions {
|
|
198
|
+
primaryRoot: string;
|
|
199
|
+
}
|
|
200
|
+
interface WorktreeAdapter {
|
|
201
|
+
add(exec: Exec, opts: WorktreeAddOptions): Worktree;
|
|
202
|
+
remove(exec: Exec, path: string, opts: WorktreeRemoveOptions): void;
|
|
203
|
+
}
|
|
204
|
+
/** The only worktree backend at MVP — plain `git worktree`. */
|
|
205
|
+
declare const gitWorktreeAdapter: WorktreeAdapter;
|
|
206
|
+
/**
|
|
207
|
+
* Resolve the primary checkout's root regardless of whether the caller's cwd is the primary
|
|
208
|
+
* checkout or a linked worktree — `--git-common-dir` always points at the main repo's `.git`.
|
|
209
|
+
*/
|
|
210
|
+
declare function resolvePrimaryRoot(exec: Exec): string;
|
|
211
|
+
/**
|
|
212
|
+
* Refuse the primary checkout: a spawned unit's resolved worktree root must never be the primary
|
|
213
|
+
* checkout itself. A unit is never run in the primary checkout.
|
|
214
|
+
*/
|
|
215
|
+
declare function assertDistinctFromPrimary(worktreeRoot: string, primaryRoot: string): void;
|
|
216
|
+
//#endregion
|
|
179
217
|
//#region src/console/session.d.ts
|
|
180
218
|
/** Generic multiplexer seam — no legion-specific concepts, so this composes with any host. */
|
|
181
|
-
/** Where a new pane/window/session should be placed relative to the caller's current one.
|
|
182
|
-
|
|
219
|
+
/** Where a new pane/window/session should be placed relative to the caller's current one.
|
|
220
|
+
* `'workspace'` opens a genuinely separate workspace/session (herdr: `workspace create`; tmux: a
|
|
221
|
+
* new detached session) — the caller's current workspace/session is left untouched, unlike every
|
|
222
|
+
* other placement, which adds a pane/window inside it. */
|
|
223
|
+
type SessionPlacement = 'pane:right' | 'pane:down' | 'tab' | 'window' | 'workspace';
|
|
183
224
|
interface SessionOpenOptions {
|
|
184
225
|
/** Working directory the new pane/window/session should start in. */
|
|
185
226
|
cwd: string;
|
|
@@ -196,11 +237,33 @@ interface SessionReadOptions {
|
|
|
196
237
|
/** How many trailing lines of output to capture; omit for the backend's default. */
|
|
197
238
|
lines?: number;
|
|
198
239
|
}
|
|
240
|
+
interface OpenInNewWorktreeOptions {
|
|
241
|
+
/** The primary checkout's root — the repo the new worktree branches from. */
|
|
242
|
+
primaryRoot: string;
|
|
243
|
+
/** Branch to create the worktree on. */
|
|
244
|
+
branch: string;
|
|
245
|
+
/** Where the new worktree should be checked out. */
|
|
246
|
+
path: string;
|
|
247
|
+
/** Command line to launch inside the new session once it is open. */
|
|
248
|
+
launch: string;
|
|
249
|
+
}
|
|
199
250
|
interface SessionAdapter {
|
|
200
251
|
/** Backend name, e.g. "tmux" or "herdr". */
|
|
201
252
|
readonly name: string;
|
|
202
253
|
/** Create a new pane/window running `opts.launch` in `opts.cwd`; returns its target handle. */
|
|
203
254
|
open(exec: Exec, opts: SessionOpenOptions): SessionTarget;
|
|
255
|
+
/**
|
|
256
|
+
* Atomically create a new worktree AND open it in a genuinely new workspace/session, when the
|
|
257
|
+
* backend has a native primitive for that combination (herdr's `worktree create` both creates
|
|
258
|
+
* the worktree and nests it under its source workspace in one call — a real organizational
|
|
259
|
+
* improvement over a separate worktree-add followed by a plain `open()`). Backends without such
|
|
260
|
+
* a primitive (tmux has none) omit this; callers fall back to creating the worktree themselves
|
|
261
|
+
* and then calling `open()` with `at: 'workspace'`.
|
|
262
|
+
*/
|
|
263
|
+
openInNewWorktree?(exec: Exec, opts: OpenInNewWorktreeOptions): {
|
|
264
|
+
target: SessionTarget;
|
|
265
|
+
worktree: Worktree;
|
|
266
|
+
};
|
|
204
267
|
/** Type text into the target session (submitted, not queued). */
|
|
205
268
|
send(exec: Exec, target: SessionTarget, text: string): void;
|
|
206
269
|
/** Capture the target session's current output. */
|
|
@@ -243,40 +306,6 @@ declare function probeMultiplexer(exec: Exec, env: NodeJS.ProcessEnv, opts?: {
|
|
|
243
306
|
discover?: boolean;
|
|
244
307
|
}): MuxProbe;
|
|
245
308
|
//#endregion
|
|
246
|
-
//#region src/console/worktree.d.ts
|
|
247
|
-
/** Generic multiplexer/worktree seam — no legion-specific concepts. */
|
|
248
|
-
interface WorktreeAddOptions {
|
|
249
|
-
/** The primary checkout's root — the repo `git worktree add` runs against. */
|
|
250
|
-
primaryRoot: string;
|
|
251
|
-
/** Where the new worktree should be checked out. */
|
|
252
|
-
path: string;
|
|
253
|
-
/** Branch to create the worktree on. */
|
|
254
|
-
branch: string;
|
|
255
|
-
}
|
|
256
|
-
interface Worktree {
|
|
257
|
-
root: string;
|
|
258
|
-
branch: string;
|
|
259
|
-
}
|
|
260
|
-
interface WorktreeRemoveOptions {
|
|
261
|
-
primaryRoot: string;
|
|
262
|
-
}
|
|
263
|
-
interface WorktreeAdapter {
|
|
264
|
-
add(exec: Exec, opts: WorktreeAddOptions): Worktree;
|
|
265
|
-
remove(exec: Exec, path: string, opts: WorktreeRemoveOptions): void;
|
|
266
|
-
}
|
|
267
|
-
/** The only worktree backend at MVP — plain `git worktree`. */
|
|
268
|
-
declare const gitWorktreeAdapter: WorktreeAdapter;
|
|
269
|
-
/**
|
|
270
|
-
* Resolve the primary checkout's root regardless of whether the caller's cwd is the primary
|
|
271
|
-
* checkout or a linked worktree — `--git-common-dir` always points at the main repo's `.git`.
|
|
272
|
-
*/
|
|
273
|
-
declare function resolvePrimaryRoot(exec: Exec): string;
|
|
274
|
-
/**
|
|
275
|
-
* Refuse the primary checkout: a spawned unit's resolved worktree root must never be the primary
|
|
276
|
-
* checkout itself. A unit is never run in the primary checkout.
|
|
277
|
-
*/
|
|
278
|
-
declare function assertDistinctFromPrimary(worktreeRoot: string, primaryRoot: string): void;
|
|
279
|
-
//#endregion
|
|
280
309
|
//#region src/decommission.d.ts
|
|
281
310
|
interface DecommissionInput {
|
|
282
311
|
id: string;
|
|
@@ -475,9 +504,10 @@ interface RootOptions {
|
|
|
475
504
|
*/
|
|
476
505
|
declare function resolveRoot(opts?: RootOptions): string;
|
|
477
506
|
/**
|
|
478
|
-
* Resolve the primary checkout's project-local cyberlegion dir —
|
|
479
|
-
*
|
|
480
|
-
*
|
|
507
|
+
* Resolve the primary checkout's project-local cyberlegion dir — the tracked marker for a
|
|
508
|
+
* legion-enabled project (never the global hub, and never where spawned unit worktrees themselves
|
|
509
|
+
* are checked out — see `resolveUnitWorktreePath`), keyed off `git worktree`'s common-dir so it
|
|
510
|
+
* resolves the same from the primary checkout or from any of its linked worktrees.
|
|
481
511
|
*/
|
|
482
512
|
declare function resolveProjectLocalRoot(opts?: RootOptions): string;
|
|
483
513
|
/**
|
|
@@ -496,7 +526,6 @@ declare const paths: {
|
|
|
496
526
|
dataDir: (root: string, id: string) => string;
|
|
497
527
|
briefFile: (root: string, id: string) => string;
|
|
498
528
|
resultFile: (root: string, id: string) => string;
|
|
499
|
-
worktreeDir: (root: string, id: string) => string;
|
|
500
529
|
};
|
|
501
530
|
//#endregion
|
|
502
531
|
//#region src/session.d.ts
|
|
@@ -512,8 +541,13 @@ interface SpawnInput {
|
|
|
512
541
|
command?: string;
|
|
513
542
|
/** Branch to create the unit's worktree on; defaults to `cyberlegion/unit-<id>`. */
|
|
514
543
|
branch?: string;
|
|
515
|
-
/** Where to check out the unit's worktree; defaults
|
|
544
|
+
/** Where to check out the unit's worktree; defaults to a sibling of the primary checkout
|
|
545
|
+
* (`<parent>/<repo>.worktrees/legion-<id6>`, `id6` the same 6-char slice `handle` defaults to)
|
|
546
|
+
* — never nested inside the primary's own tree. */
|
|
516
547
|
worktreePath?: string;
|
|
548
|
+
/** Spawn into this existing directory instead — creates no worktree. Mutually exclusive with
|
|
549
|
+
* `branch`/`worktreePath` (those create a worktree; this reuses one). */
|
|
550
|
+
cwd?: string;
|
|
517
551
|
/** Placement relative to the caller; defaults to 'pane:right'. */
|
|
518
552
|
at?: SessionPlacement;
|
|
519
553
|
}
|
|
@@ -528,9 +562,10 @@ interface SpawnResult {
|
|
|
528
562
|
* its cwd set to that worktree, pre-register the peer, and drop its brief as a file the peer's own
|
|
529
563
|
* SessionStart hook reads — never typed into its prompt.
|
|
530
564
|
*
|
|
531
|
-
* Spawned unit worktrees live
|
|
532
|
-
* though the registry/mailbox itself lives in the global hub — the hub
|
|
533
|
-
* project and worktree boundaries, but a unit's checkout is necessarily
|
|
565
|
+
* Spawned unit worktrees live sibling to the primary checkout (`<parent>/<repo>.worktrees/legion-<id6>`),
|
|
566
|
+
* never nested inside it, even though the registry/mailbox itself lives in the global hub — the hub
|
|
567
|
+
* addresses units across project and worktree boundaries, but a unit's checkout is necessarily
|
|
568
|
+
* scoped to this one project's git remote.
|
|
534
569
|
*/
|
|
535
570
|
declare function spawn(ctx: IdContext, input: SpawnInput): SpawnResult;
|
|
536
571
|
//#endregion
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as detectHarness, C as resolveAgentDef, D as selectSessionAdapter, E as spawn, F as register, G as resolveProjectLocalRoot, H as ensureMarker, J as gitWorktreeAdapter, K as resolveRoot, L as resolveAgent, M as loadAgent, N as prune, O as probeMultiplexer, P as realExec, R as resolveRecipient, S as listAgentDefs, T as realizeSubagentInstruction, U as paths, V as touch, W as projectRoot, Y as resolvePrimaryRoot, _ as inbox, a as fail, b as send, c as toonObject, d as DispatchWaitingError, f as channel, g as deleteMessage, h as ack, i as emit, j as listAgents, k as bumpLastSeen, l as install, m as awaitReply, n as FileStore, o as nextStep, p as prep, q as assertDistinctFromPrimary, r as injectInbox, s as toonList, t as watchMail, u as collect, v as peek, w as realizeLaunch, x as decommission, y as resolveBody, z as resolveSelfId } from "./watch-BuYsdwhx.mjs";
|
|
2
2
|
//#region src/wake/wake-path.ts
|
|
3
3
|
/**
|
|
4
4
|
* Select a wake path from the shipped matrix. Rule order: a `mux.mux === 'none'` caller can never
|
|
@@ -3,6 +3,156 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, w
|
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
|
+
//#region src/console/worktree.ts
|
|
7
|
+
/** The only worktree backend at MVP — plain `git worktree`. */
|
|
8
|
+
const gitWorktreeAdapter = {
|
|
9
|
+
add(exec, opts) {
|
|
10
|
+
if (exec("git", [
|
|
11
|
+
"-C",
|
|
12
|
+
opts.primaryRoot,
|
|
13
|
+
"worktree",
|
|
14
|
+
"add",
|
|
15
|
+
"-b",
|
|
16
|
+
opts.branch,
|
|
17
|
+
opts.path
|
|
18
|
+
]) === null) throw new Error(`git worktree add failed for ${opts.path}`);
|
|
19
|
+
return {
|
|
20
|
+
root: resolve(opts.path),
|
|
21
|
+
branch: opts.branch
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
remove(exec, path, opts) {
|
|
25
|
+
if (exec("git", [
|
|
26
|
+
"-C",
|
|
27
|
+
opts.primaryRoot,
|
|
28
|
+
"worktree",
|
|
29
|
+
"remove",
|
|
30
|
+
path,
|
|
31
|
+
"--force"
|
|
32
|
+
]) === null) throw new Error(`git worktree remove failed for ${path}`);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the primary checkout's root regardless of whether the caller's cwd is the primary
|
|
37
|
+
* checkout or a linked worktree — `--git-common-dir` always points at the main repo's `.git`.
|
|
38
|
+
*/
|
|
39
|
+
function resolvePrimaryRoot(exec) {
|
|
40
|
+
const commonDir = exec("git", [
|
|
41
|
+
"rev-parse",
|
|
42
|
+
"--path-format=absolute",
|
|
43
|
+
"--git-common-dir"
|
|
44
|
+
]);
|
|
45
|
+
if (!commonDir) throw new Error("cannot resolve the primary checkout — not inside a git repository");
|
|
46
|
+
return dirname(commonDir);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Refuse the primary checkout: a spawned unit's resolved worktree root must never be the primary
|
|
50
|
+
* checkout itself. A unit is never run in the primary checkout.
|
|
51
|
+
*/
|
|
52
|
+
function assertDistinctFromPrimary(worktreeRoot, primaryRoot) {
|
|
53
|
+
if (resolve(worktreeRoot) === resolve(primaryRoot)) throw new Error("refusing to run a unit in the primary checkout — spawn a worktree distinct from the primary checkout");
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/paths.ts
|
|
57
|
+
/** The tracked marker file that makes a hub root initialized (see ensureMarker). */
|
|
58
|
+
const MARKER_FILE = "config.json";
|
|
59
|
+
/** Walk up from `cwd` to the nearest git repo root; fall back to `cwd`. */
|
|
60
|
+
function projectRoot(cwd = process.cwd()) {
|
|
61
|
+
let dir = resolve(cwd);
|
|
62
|
+
for (;;) {
|
|
63
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
64
|
+
const parent = dirname(dir);
|
|
65
|
+
if (parent === dir) return resolve(cwd);
|
|
66
|
+
dir = parent;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function makeDefaultExec(cwd) {
|
|
70
|
+
return (cmd, args) => {
|
|
71
|
+
try {
|
|
72
|
+
return execFileSync(cmd, args, {
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
stdio: [
|
|
75
|
+
"ignore",
|
|
76
|
+
"pipe",
|
|
77
|
+
"ignore"
|
|
78
|
+
],
|
|
79
|
+
cwd
|
|
80
|
+
}).trim();
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the cyberlegion hub root. Precedence: explicit --root/--space, then `$CYBERLEGION_ROOT`,
|
|
88
|
+
* then the GLOBAL hub `~/.agents/cyberlegion` (addressable across every project and worktree
|
|
89
|
+
* boundary — identity/mail/registry state lives here by default; `--space` isolates it), falling
|
|
90
|
+
* back to a project-local `.agents/cyberlegion` only when no home directory is resolvable.
|
|
91
|
+
*/
|
|
92
|
+
function resolveRoot(opts = {}) {
|
|
93
|
+
const env = opts.env ?? process.env;
|
|
94
|
+
const explicit = opts.root ?? opts.space ?? env.CYBERLEGION_ROOT;
|
|
95
|
+
if (explicit) return resolve(explicit);
|
|
96
|
+
const home = homedir();
|
|
97
|
+
if (home) return join(home, ".agents", "cyberlegion");
|
|
98
|
+
return join(projectRoot(opts.cwd), ".agents", "cyberlegion");
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the primary checkout's project-local cyberlegion dir — the tracked marker for a
|
|
102
|
+
* legion-enabled project (never the global hub, and never where spawned unit worktrees themselves
|
|
103
|
+
* are checked out — see `resolveUnitWorktreePath`), keyed off `git worktree`'s common-dir so it
|
|
104
|
+
* resolves the same from the primary checkout or from any of its linked worktrees.
|
|
105
|
+
*/
|
|
106
|
+
function resolveProjectLocalRoot(opts = {}) {
|
|
107
|
+
const exec = opts.exec ?? makeDefaultExec(opts.cwd);
|
|
108
|
+
let primary;
|
|
109
|
+
try {
|
|
110
|
+
primary = resolvePrimaryRoot(exec);
|
|
111
|
+
} catch {
|
|
112
|
+
primary = void 0;
|
|
113
|
+
}
|
|
114
|
+
return join(primary ?? projectRoot(opts.cwd), ".agents", "cyberlegion");
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Create the tracked `config.json` marker at `root` (a hub root) if it does not already exist.
|
|
118
|
+
* Callers pass the root under which the marker should be created/ensured; this mkdir's it as
|
|
119
|
+
* needed. Idempotent — never overwrites an existing marker.
|
|
120
|
+
*/
|
|
121
|
+
function ensureMarker(root) {
|
|
122
|
+
mkdirSync(root, { recursive: true });
|
|
123
|
+
const marker = join(root, MARKER_FILE);
|
|
124
|
+
if (!existsSync(marker)) writeFileSync(marker, `${JSON.stringify({ version: 1 }, null, 2)}\n`);
|
|
125
|
+
}
|
|
126
|
+
const paths = {
|
|
127
|
+
agentsDir: (root) => join(root, "agents"),
|
|
128
|
+
agentFile: (root, id) => join(root, "agents", `${id}.json`),
|
|
129
|
+
panesDir: (root) => join(root, "panes"),
|
|
130
|
+
paneFile: (root, pane) => join(root, "panes", `${sanitizePane(pane)}.id`),
|
|
131
|
+
inboxDir: (root, id) => join(root, "inbox", id),
|
|
132
|
+
inboxReadDir: (root, id) => join(root, "inbox", id, "read"),
|
|
133
|
+
dataDir: (root, id) => join(root, "data", id),
|
|
134
|
+
briefFile: (root, id) => join(root, "data", id, "brief.md"),
|
|
135
|
+
resultFile: (root, id) => join(root, "data", id, "result.json")
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Where a spawned unit's own git worktree is checked out by default — a sibling of the primary
|
|
139
|
+
* checkout (`<parent>/<repo>.worktrees/legion-<id>`), never nested inside the primary's own working
|
|
140
|
+
* tree. A linked worktree living inside the primary's tree is untracked-but-present: it pollutes
|
|
141
|
+
* `git status` in the primary checkout, confuses tooling that walks the tree recursively (test
|
|
142
|
+
* runners, watchers, `find`/`rm -rf`), and risks a tree-wide op in the primary crossing into the
|
|
143
|
+
* nested worktree's own checkout. `<repo>.worktrees/` matches the sibling convention already in use
|
|
144
|
+
* for other tools' worktrees in this environment (herdr's `worktree-<word>-<word>-<hash>`, cursor's
|
|
145
|
+
* `<proj><count>`); the `legion-` prefix self-identifies this tool's own units the same way, without
|
|
146
|
+
* needing a subfolder.
|
|
147
|
+
*/
|
|
148
|
+
function resolveUnitWorktreePath(primaryRoot, id) {
|
|
149
|
+
return join(dirname(primaryRoot), `${basename(primaryRoot)}.worktrees`, `legion-${id}`);
|
|
150
|
+
}
|
|
151
|
+
/** tmux pane ids look like "%3"; make them filesystem-safe. */
|
|
152
|
+
function sanitizePane(pane) {
|
|
153
|
+
return pane.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
154
|
+
}
|
|
155
|
+
//#endregion
|
|
6
156
|
//#region src/identity.ts
|
|
7
157
|
const realExec = (cmd, args) => {
|
|
8
158
|
try {
|
|
@@ -106,6 +256,35 @@ function register(ctx, input) {
|
|
|
106
256
|
if (pane) ctx.store.putPaneIndex(pane, id);
|
|
107
257
|
return rec;
|
|
108
258
|
}
|
|
259
|
+
/** Derive a standing record's stable id from its handle — same slug rule as `sanitizePane`, prefixed
|
|
260
|
+
* so it reads distinct from a random 16-hex session id or a pane pointer. */
|
|
261
|
+
function standingId(handle) {
|
|
262
|
+
return `standing-${sanitizePane(handle)}`;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Mint (or idempotently refresh) a standing identity: a session-independent, prune-exempt owner
|
|
266
|
+
* inbox keyed by handle, with no pane/tmux/harness. A SIBLING of `register`, not an overload — it
|
|
267
|
+
* skips all pane/tmux machinery and harness auto-detection entirely.
|
|
268
|
+
*/
|
|
269
|
+
function registerStanding(ctx, input) {
|
|
270
|
+
ctx.store.ensureMarker();
|
|
271
|
+
const id = standingId(input.handle);
|
|
272
|
+
const existing = loadAgent(ctx.store, id);
|
|
273
|
+
const ts = nowIso(ctx);
|
|
274
|
+
const rec = {
|
|
275
|
+
id,
|
|
276
|
+
handle: input.handle,
|
|
277
|
+
kind: "standing",
|
|
278
|
+
tmux: null,
|
|
279
|
+
harness: void 0,
|
|
280
|
+
cwd: process.cwd(),
|
|
281
|
+
status: "active",
|
|
282
|
+
createdAt: existing?.createdAt ?? ts,
|
|
283
|
+
lastSeen: ts
|
|
284
|
+
};
|
|
285
|
+
saveAgent(ctx.store, rec);
|
|
286
|
+
return rec;
|
|
287
|
+
}
|
|
109
288
|
function gitWorktree(exec) {
|
|
110
289
|
const root = exec("git", ["rev-parse", "--show-toplevel"]);
|
|
111
290
|
if (!root) return null;
|
|
@@ -118,11 +297,23 @@ function gitWorktree(exec) {
|
|
|
118
297
|
]) ?? void 0
|
|
119
298
|
};
|
|
120
299
|
}
|
|
300
|
+
/** Prefer a standing record over a plain session record when both match a handle — an owner
|
|
301
|
+
* report must land in the durable standing inbox, not a dying session's. */
|
|
302
|
+
function preferStanding(matches) {
|
|
303
|
+
return matches.find((a) => a.kind === "standing") ?? matches[0];
|
|
304
|
+
}
|
|
305
|
+
/** Resolve a handle to its standing owner record's id — never falls back to a live session agent
|
|
306
|
+
* sharing that handle, so `--owner` can never be pointed at a session's inbox by mistake. */
|
|
307
|
+
function resolveStandingOwner(store, handle) {
|
|
308
|
+
const match = listAgents(store).find((a) => a.handle === handle && a.kind === "standing");
|
|
309
|
+
if (!match) throw new Error(`no standing owner "${handle}" — run 'cyberlegion identity owner --handle ${handle}'`);
|
|
310
|
+
return match.id;
|
|
311
|
+
}
|
|
121
312
|
/** Resolve a recipient argument (id or handle) to an agent id. */
|
|
122
313
|
function resolveRecipient(store, to) {
|
|
123
314
|
if (loadAgent(store, to)) return to;
|
|
124
|
-
const match = listAgents(store).
|
|
125
|
-
if (!match) throw new Error(`no agent addressable as "${to}"`);
|
|
315
|
+
const match = preferStanding(listAgents(store).filter((a) => a.handle === to));
|
|
316
|
+
if (!match) throw new Error(`no agent addressable as "${to}" — run 'cyberlegion identity owner --handle ${to}' to create a standing inbox`);
|
|
126
317
|
return match.id;
|
|
127
318
|
}
|
|
128
319
|
/**
|
|
@@ -134,7 +325,7 @@ function resolveAgent(store, ref) {
|
|
|
134
325
|
const byId = loadAgent(store, ref);
|
|
135
326
|
if (byId) return byId;
|
|
136
327
|
const agents = listAgents(store);
|
|
137
|
-
const byHandle = agents.
|
|
328
|
+
const byHandle = preferStanding(agents.filter((a) => a.handle === ref));
|
|
138
329
|
if (byHandle) return byHandle;
|
|
139
330
|
const byBranch = agents.find((a) => a.worktree?.branch === ref);
|
|
140
331
|
if (byBranch) return byBranch;
|
|
@@ -158,6 +349,7 @@ function prune(ctx) {
|
|
|
158
349
|
const now = ctx.now?.() ?? Date.now();
|
|
159
350
|
const changed = [];
|
|
160
351
|
for (const rec of listAgents(ctx.store)) {
|
|
352
|
+
if (rec.kind === "standing") continue;
|
|
161
353
|
if (rec.status === "exited") continue;
|
|
162
354
|
const paneGone = rec.tmux?.pane ? exec("tmux", [
|
|
163
355
|
"has-session",
|
|
@@ -285,22 +477,36 @@ function discoverByAncestry(exec, env) {
|
|
|
285
477
|
* rather than its Unix-socket API, so it composes with this codebase's synchronous `Exec`
|
|
286
478
|
* convention exactly like the tmux adapter — no new client/transport needed.
|
|
287
479
|
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
480
|
+
* The pane lifecycle (split/run/read/close) is verified against a live herdr binary; `pane split`
|
|
481
|
+
* returns a JSON `pane_info` envelope whose id is extracted in `parsePaneId`.
|
|
290
482
|
*/
|
|
291
483
|
const herdrSessionAdapter = {
|
|
292
484
|
name: "herdr",
|
|
293
485
|
open(exec, opts) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
"
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
486
|
+
let id;
|
|
487
|
+
if (opts.at === "workspace") {
|
|
488
|
+
const out = exec("herdr", [
|
|
489
|
+
"workspace",
|
|
490
|
+
"create",
|
|
491
|
+
"--cwd",
|
|
492
|
+
opts.cwd,
|
|
493
|
+
"--no-focus"
|
|
494
|
+
]);
|
|
495
|
+
if (!out) throw new Error("herdr workspace create failed");
|
|
496
|
+
id = parseWorkspaceRootPaneId(out);
|
|
497
|
+
} else {
|
|
498
|
+
const out = exec("herdr", [
|
|
499
|
+
"pane",
|
|
500
|
+
"split",
|
|
501
|
+
"--current",
|
|
502
|
+
"--direction",
|
|
503
|
+
opts.at === "pane:down" ? "down" : "right",
|
|
504
|
+
"--cwd",
|
|
505
|
+
opts.cwd
|
|
506
|
+
]);
|
|
507
|
+
if (!out) throw new Error("herdr pane split failed");
|
|
508
|
+
id = parsePaneId(out);
|
|
509
|
+
}
|
|
304
510
|
const target = { id };
|
|
305
511
|
exec("herdr", [
|
|
306
512
|
"pane",
|
|
@@ -310,6 +516,32 @@ const herdrSessionAdapter = {
|
|
|
310
516
|
]);
|
|
311
517
|
return target;
|
|
312
518
|
},
|
|
519
|
+
openInNewWorktree(exec, opts) {
|
|
520
|
+
const out = exec("herdr", [
|
|
521
|
+
"worktree",
|
|
522
|
+
"create",
|
|
523
|
+
"--cwd",
|
|
524
|
+
opts.primaryRoot,
|
|
525
|
+
"--branch",
|
|
526
|
+
opts.branch,
|
|
527
|
+
"--path",
|
|
528
|
+
opts.path,
|
|
529
|
+
"--no-focus"
|
|
530
|
+
]);
|
|
531
|
+
if (!out) throw new Error("herdr worktree create failed");
|
|
532
|
+
const { paneId, worktree } = parseWorktreeCreate(out);
|
|
533
|
+
const target = { id: paneId };
|
|
534
|
+
exec("herdr", [
|
|
535
|
+
"pane",
|
|
536
|
+
"run",
|
|
537
|
+
paneId,
|
|
538
|
+
opts.launch
|
|
539
|
+
]);
|
|
540
|
+
return {
|
|
541
|
+
target,
|
|
542
|
+
worktree
|
|
543
|
+
};
|
|
544
|
+
},
|
|
313
545
|
send(exec, target, text) {
|
|
314
546
|
exec("herdr", [
|
|
315
547
|
"pane",
|
|
@@ -344,13 +576,76 @@ const herdrSessionAdapter = {
|
|
|
344
576
|
]);
|
|
345
577
|
}
|
|
346
578
|
};
|
|
579
|
+
/**
|
|
580
|
+
* `herdr pane split` emits a JSON envelope, not a bare id:
|
|
581
|
+
* `{"id":"cli:pane:split","result":{"pane":{"pane_id":"w3:pB", ...},"type":"pane_info"}}`.
|
|
582
|
+
* The pane id herdr's other `pane` subcommands accept lives at `.result.pane.pane_id`. Extract it —
|
|
583
|
+
* passing the whole blob downstream lands it in a filename and blows the path length limit.
|
|
584
|
+
*/
|
|
585
|
+
function parsePaneId(out) {
|
|
586
|
+
let paneId;
|
|
587
|
+
try {
|
|
588
|
+
paneId = JSON.parse(out)?.result?.pane?.pane_id;
|
|
589
|
+
} catch {
|
|
590
|
+
throw new Error(`herdr pane split returned unparseable output: ${out.slice(0, 200)}`);
|
|
591
|
+
}
|
|
592
|
+
if (typeof paneId !== "string" || paneId === "") throw new Error(`herdr pane split output had no result.pane.pane_id: ${out.slice(0, 200)}`);
|
|
593
|
+
return paneId;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* `herdr workspace create` emits its new workspace's initial pane at `.result.root_pane.pane_id`
|
|
597
|
+
* (a different path than `pane split`'s `.result.pane.pane_id`).
|
|
598
|
+
*/
|
|
599
|
+
function parseWorkspaceRootPaneId(out) {
|
|
600
|
+
let paneId;
|
|
601
|
+
try {
|
|
602
|
+
paneId = JSON.parse(out)?.result?.root_pane?.pane_id;
|
|
603
|
+
} catch {
|
|
604
|
+
throw new Error(`herdr workspace create returned unparseable output: ${out.slice(0, 200)}`);
|
|
605
|
+
}
|
|
606
|
+
if (typeof paneId !== "string" || paneId === "") throw new Error(`herdr workspace create output had no result.root_pane.pane_id: ${out.slice(0, 200)}`);
|
|
607
|
+
return paneId;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* `herdr worktree create` emits the same `.result.root_pane.pane_id` shape as `workspace create`,
|
|
611
|
+
* plus the created worktree's own checkout at `.result.worktree.{path,branch}`.
|
|
612
|
+
*/
|
|
613
|
+
function parseWorktreeCreate(out) {
|
|
614
|
+
let parsed;
|
|
615
|
+
try {
|
|
616
|
+
parsed = JSON.parse(out);
|
|
617
|
+
} catch {
|
|
618
|
+
throw new Error(`herdr worktree create returned unparseable output: ${out.slice(0, 200)}`);
|
|
619
|
+
}
|
|
620
|
+
const result = parsed?.result;
|
|
621
|
+
const paneId = result?.root_pane?.pane_id;
|
|
622
|
+
const path = result?.worktree?.path;
|
|
623
|
+
const branch = result?.worktree?.branch;
|
|
624
|
+
if (typeof paneId !== "string" || paneId === "") throw new Error(`herdr worktree create output had no result.root_pane.pane_id: ${out.slice(0, 200)}`);
|
|
625
|
+
if (typeof path !== "string" || path === "" || typeof branch !== "string" || branch === "") throw new Error(`herdr worktree create output had no result.worktree.{path,branch}: ${out.slice(0, 200)}`);
|
|
626
|
+
return {
|
|
627
|
+
paneId,
|
|
628
|
+
worktree: {
|
|
629
|
+
root: resolve(path),
|
|
630
|
+
branch
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
}
|
|
347
634
|
//#endregion
|
|
348
635
|
//#region src/console/session.tmux.ts
|
|
349
636
|
/** tmux backend — detected via `$TMUX`. */
|
|
350
637
|
const tmuxSessionAdapter = {
|
|
351
638
|
name: "tmux",
|
|
352
639
|
open(exec, opts) {
|
|
353
|
-
const
|
|
640
|
+
const args = opts.at === "workspace" ? [
|
|
641
|
+
"new-session",
|
|
642
|
+
"-d",
|
|
643
|
+
"-c",
|
|
644
|
+
opts.cwd,
|
|
645
|
+
"-P",
|
|
646
|
+
"-F",
|
|
647
|
+
"#{pane_id}"
|
|
648
|
+
] : opts.at === "pane:down" ? [
|
|
354
649
|
"split-window",
|
|
355
650
|
"-v",
|
|
356
651
|
"-c",
|
|
@@ -373,8 +668,9 @@ const tmuxSessionAdapter = {
|
|
|
373
668
|
"-P",
|
|
374
669
|
"-F",
|
|
375
670
|
"#{pane_id}"
|
|
376
|
-
]
|
|
377
|
-
|
|
671
|
+
];
|
|
672
|
+
const pane = exec("tmux", args);
|
|
673
|
+
if (!pane) throw new Error(`tmux ${args[0]} failed`);
|
|
378
674
|
const target = { id: pane };
|
|
379
675
|
tmuxSessionAdapter.send(exec, target, opts.launch);
|
|
380
676
|
return target;
|
|
@@ -427,142 +723,6 @@ function selectSessionAdapter(env, exec = realExec) {
|
|
|
427
723
|
throw new Error("spawn requires a session backend — run inside tmux ($TMUX) or herdr ($HERDR_ENV=1)");
|
|
428
724
|
}
|
|
429
725
|
//#endregion
|
|
430
|
-
//#region src/console/worktree.ts
|
|
431
|
-
/** The only worktree backend at MVP — plain `git worktree`. */
|
|
432
|
-
const gitWorktreeAdapter = {
|
|
433
|
-
add(exec, opts) {
|
|
434
|
-
if (exec("git", [
|
|
435
|
-
"-C",
|
|
436
|
-
opts.primaryRoot,
|
|
437
|
-
"worktree",
|
|
438
|
-
"add",
|
|
439
|
-
"-b",
|
|
440
|
-
opts.branch,
|
|
441
|
-
opts.path
|
|
442
|
-
]) === null) throw new Error(`git worktree add failed for ${opts.path}`);
|
|
443
|
-
return {
|
|
444
|
-
root: resolve(opts.path),
|
|
445
|
-
branch: opts.branch
|
|
446
|
-
};
|
|
447
|
-
},
|
|
448
|
-
remove(exec, path, opts) {
|
|
449
|
-
if (exec("git", [
|
|
450
|
-
"-C",
|
|
451
|
-
opts.primaryRoot,
|
|
452
|
-
"worktree",
|
|
453
|
-
"remove",
|
|
454
|
-
path,
|
|
455
|
-
"--force"
|
|
456
|
-
]) === null) throw new Error(`git worktree remove failed for ${path}`);
|
|
457
|
-
}
|
|
458
|
-
};
|
|
459
|
-
/**
|
|
460
|
-
* Resolve the primary checkout's root regardless of whether the caller's cwd is the primary
|
|
461
|
-
* checkout or a linked worktree — `--git-common-dir` always points at the main repo's `.git`.
|
|
462
|
-
*/
|
|
463
|
-
function resolvePrimaryRoot(exec) {
|
|
464
|
-
const commonDir = exec("git", [
|
|
465
|
-
"rev-parse",
|
|
466
|
-
"--path-format=absolute",
|
|
467
|
-
"--git-common-dir"
|
|
468
|
-
]);
|
|
469
|
-
if (!commonDir) throw new Error("cannot resolve the primary checkout — not inside a git repository");
|
|
470
|
-
return dirname(commonDir);
|
|
471
|
-
}
|
|
472
|
-
/**
|
|
473
|
-
* Refuse the primary checkout: a spawned unit's resolved worktree root must never be the primary
|
|
474
|
-
* checkout itself. A unit is never run in the primary checkout.
|
|
475
|
-
*/
|
|
476
|
-
function assertDistinctFromPrimary(worktreeRoot, primaryRoot) {
|
|
477
|
-
if (resolve(worktreeRoot) === resolve(primaryRoot)) throw new Error("refusing to run a unit in the primary checkout — spawn a worktree distinct from the primary checkout");
|
|
478
|
-
}
|
|
479
|
-
//#endregion
|
|
480
|
-
//#region src/paths.ts
|
|
481
|
-
/** The tracked marker file that makes a hub root initialized (see ensureMarker). */
|
|
482
|
-
const MARKER_FILE = "config.json";
|
|
483
|
-
/** Walk up from `cwd` to the nearest git repo root; fall back to `cwd`. */
|
|
484
|
-
function projectRoot(cwd = process.cwd()) {
|
|
485
|
-
let dir = resolve(cwd);
|
|
486
|
-
for (;;) {
|
|
487
|
-
if (existsSync(join(dir, ".git"))) return dir;
|
|
488
|
-
const parent = dirname(dir);
|
|
489
|
-
if (parent === dir) return resolve(cwd);
|
|
490
|
-
dir = parent;
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
function makeDefaultExec(cwd) {
|
|
494
|
-
return (cmd, args) => {
|
|
495
|
-
try {
|
|
496
|
-
return execFileSync(cmd, args, {
|
|
497
|
-
encoding: "utf8",
|
|
498
|
-
stdio: [
|
|
499
|
-
"ignore",
|
|
500
|
-
"pipe",
|
|
501
|
-
"ignore"
|
|
502
|
-
],
|
|
503
|
-
cwd
|
|
504
|
-
}).trim();
|
|
505
|
-
} catch {
|
|
506
|
-
return null;
|
|
507
|
-
}
|
|
508
|
-
};
|
|
509
|
-
}
|
|
510
|
-
/**
|
|
511
|
-
* Resolve the cyberlegion hub root. Precedence: explicit --root/--space, then `$CYBERLEGION_ROOT`,
|
|
512
|
-
* then the GLOBAL hub `~/.agents/cyberlegion` (addressable across every project and worktree
|
|
513
|
-
* boundary — identity/mail/registry state lives here by default; `--space` isolates it), falling
|
|
514
|
-
* back to a project-local `.agents/cyberlegion` only when no home directory is resolvable.
|
|
515
|
-
*/
|
|
516
|
-
function resolveRoot(opts = {}) {
|
|
517
|
-
const env = opts.env ?? process.env;
|
|
518
|
-
const explicit = opts.root ?? opts.space ?? env.CYBERLEGION_ROOT;
|
|
519
|
-
if (explicit) return resolve(explicit);
|
|
520
|
-
const home = homedir();
|
|
521
|
-
if (home) return join(home, ".agents", "cyberlegion");
|
|
522
|
-
return join(projectRoot(opts.cwd), ".agents", "cyberlegion");
|
|
523
|
-
}
|
|
524
|
-
/**
|
|
525
|
-
* Resolve the primary checkout's project-local cyberlegion dir — where spawned unit worktrees
|
|
526
|
-
* live (never the global hub), keyed off `git worktree`'s common-dir so it resolves the same from
|
|
527
|
-
* the primary checkout or from any of its linked worktrees.
|
|
528
|
-
*/
|
|
529
|
-
function resolveProjectLocalRoot(opts = {}) {
|
|
530
|
-
const exec = opts.exec ?? makeDefaultExec(opts.cwd);
|
|
531
|
-
let primary;
|
|
532
|
-
try {
|
|
533
|
-
primary = resolvePrimaryRoot(exec);
|
|
534
|
-
} catch {
|
|
535
|
-
primary = void 0;
|
|
536
|
-
}
|
|
537
|
-
return join(primary ?? projectRoot(opts.cwd), ".agents", "cyberlegion");
|
|
538
|
-
}
|
|
539
|
-
/**
|
|
540
|
-
* Create the tracked `config.json` marker at `root` (a hub root) if it does not already exist.
|
|
541
|
-
* Callers pass the root under which the marker should be created/ensured; this mkdir's it as
|
|
542
|
-
* needed. Idempotent — never overwrites an existing marker.
|
|
543
|
-
*/
|
|
544
|
-
function ensureMarker(root) {
|
|
545
|
-
mkdirSync(root, { recursive: true });
|
|
546
|
-
const marker = join(root, MARKER_FILE);
|
|
547
|
-
if (!existsSync(marker)) writeFileSync(marker, `${JSON.stringify({ version: 1 }, null, 2)}\n`);
|
|
548
|
-
}
|
|
549
|
-
const paths = {
|
|
550
|
-
agentsDir: (root) => join(root, "agents"),
|
|
551
|
-
agentFile: (root, id) => join(root, "agents", `${id}.json`),
|
|
552
|
-
panesDir: (root) => join(root, "panes"),
|
|
553
|
-
paneFile: (root, pane) => join(root, "panes", `${sanitizePane(pane)}.id`),
|
|
554
|
-
inboxDir: (root, id) => join(root, "inbox", id),
|
|
555
|
-
inboxReadDir: (root, id) => join(root, "inbox", id, "read"),
|
|
556
|
-
dataDir: (root, id) => join(root, "data", id),
|
|
557
|
-
briefFile: (root, id) => join(root, "data", id, "brief.md"),
|
|
558
|
-
resultFile: (root, id) => join(root, "data", id, "result.json"),
|
|
559
|
-
worktreeDir: (root, id) => join(root, "worktrees", id)
|
|
560
|
-
};
|
|
561
|
-
/** tmux pane ids look like "%3"; make them filesystem-safe. */
|
|
562
|
-
function sanitizePane(pane) {
|
|
563
|
-
return pane.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
564
|
-
}
|
|
565
|
-
//#endregion
|
|
566
726
|
//#region src/session.ts
|
|
567
727
|
/** How each harness's own CLI is launched in the new pane. */
|
|
568
728
|
const LAUNCH_MAP = {
|
|
@@ -576,9 +736,10 @@ const LAUNCH_MAP = {
|
|
|
576
736
|
* its cwd set to that worktree, pre-register the peer, and drop its brief as a file the peer's own
|
|
577
737
|
* SessionStart hook reads — never typed into its prompt.
|
|
578
738
|
*
|
|
579
|
-
* Spawned unit worktrees live
|
|
580
|
-
* though the registry/mailbox itself lives in the global hub — the hub
|
|
581
|
-
* project and worktree boundaries, but a unit's checkout is necessarily
|
|
739
|
+
* Spawned unit worktrees live sibling to the primary checkout (`<parent>/<repo>.worktrees/legion-<id6>`),
|
|
740
|
+
* never nested inside it, even though the registry/mailbox itself lives in the global hub — the hub
|
|
741
|
+
* addresses units across project and worktree boundaries, but a unit's checkout is necessarily
|
|
742
|
+
* scoped to this one project's git remote.
|
|
582
743
|
*/
|
|
583
744
|
function spawn(ctx, input) {
|
|
584
745
|
ctx.store.ensureMarker();
|
|
@@ -589,29 +750,62 @@ function spawn(ctx, input) {
|
|
|
589
750
|
if (!harness || !(harness in LAUNCH_MAP)) throw new Error(`spawn needs a --harness in the launch map (${Object.keys(LAUNCH_MAP).join(" | ")})`);
|
|
590
751
|
const brief = resolveBrief(input);
|
|
591
752
|
if (brief == null) throw new Error("spawn needs a brief — pass --task <text>, --task - (stdin), or --brief-file <path>");
|
|
753
|
+
if (input.cwd && (input.branch || input.worktreePath)) throw new Error("--cwd cannot combine with the worktree-creating flags --branch/--worktree-path");
|
|
592
754
|
const id = randomId();
|
|
593
755
|
const primaryRoot = resolvePrimaryRoot(exec);
|
|
594
|
-
const branch = input.branch ?? `cyberlegion/unit-${id}`;
|
|
595
|
-
const worktreePath = input.worktreePath ?? paths.worktreeDir(join(primaryRoot, ".agents", "cyberlegion"), id);
|
|
596
|
-
const worktree = gitWorktreeAdapter.add(exec, {
|
|
597
|
-
primaryRoot,
|
|
598
|
-
path: worktreePath,
|
|
599
|
-
branch
|
|
600
|
-
});
|
|
601
|
-
assertDistinctFromPrimary(worktree.root, primaryRoot);
|
|
602
|
-
ensureMarker(join(worktree.root, ".agents", "cyberlegion"));
|
|
603
756
|
const launch = input.command ?? LAUNCH_MAP[harness];
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
757
|
+
const fullLaunch = `${muxEnvPrefix(sessionAdapter.name)}${launch}`;
|
|
758
|
+
let cwd;
|
|
759
|
+
let worktree;
|
|
760
|
+
let target;
|
|
761
|
+
if (input.cwd) {
|
|
762
|
+
if (!existsSync(input.cwd)) throw new Error(`--cwd directory must already exist: ${input.cwd}`);
|
|
763
|
+
cwd = resolve(input.cwd);
|
|
764
|
+
assertDistinctFromPrimary(cwd, primaryRoot);
|
|
765
|
+
worktree = null;
|
|
766
|
+
target = sessionAdapter.open(exec, {
|
|
767
|
+
cwd,
|
|
768
|
+
launch: fullLaunch,
|
|
769
|
+
at: input.at
|
|
770
|
+
});
|
|
771
|
+
} else {
|
|
772
|
+
const branch = input.branch ?? `cyberlegion/unit-${id}`;
|
|
773
|
+
const worktreePath = input.worktreePath ?? resolveUnitWorktreePath(primaryRoot, id.slice(0, 6));
|
|
774
|
+
if (input.at === "workspace" && sessionAdapter.openInNewWorktree) {
|
|
775
|
+
const opened = sessionAdapter.openInNewWorktree(exec, {
|
|
776
|
+
primaryRoot,
|
|
777
|
+
branch,
|
|
778
|
+
path: worktreePath,
|
|
779
|
+
launch: fullLaunch
|
|
780
|
+
});
|
|
781
|
+
assertDistinctFromPrimary(opened.worktree.root, primaryRoot);
|
|
782
|
+
ensureMarker(join(opened.worktree.root, ".agents", "cyberlegion"));
|
|
783
|
+
cwd = opened.worktree.root;
|
|
784
|
+
worktree = opened.worktree;
|
|
785
|
+
target = opened.target;
|
|
786
|
+
} else {
|
|
787
|
+
const added = gitWorktreeAdapter.add(exec, {
|
|
788
|
+
primaryRoot,
|
|
789
|
+
path: worktreePath,
|
|
790
|
+
branch
|
|
791
|
+
});
|
|
792
|
+
assertDistinctFromPrimary(added.root, primaryRoot);
|
|
793
|
+
ensureMarker(join(added.root, ".agents", "cyberlegion"));
|
|
794
|
+
cwd = added.root;
|
|
795
|
+
worktree = added;
|
|
796
|
+
target = sessionAdapter.open(exec, {
|
|
797
|
+
cwd,
|
|
798
|
+
launch: fullLaunch,
|
|
799
|
+
at: input.at
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
}
|
|
609
803
|
const ts = new Date(ctx.now?.() ?? Date.now()).toISOString();
|
|
610
804
|
const rec = {
|
|
611
805
|
id,
|
|
612
806
|
handle: input.handle ?? id.slice(0, 6),
|
|
613
807
|
harness,
|
|
614
|
-
cwd
|
|
808
|
+
cwd,
|
|
615
809
|
worktree,
|
|
616
810
|
tmux: sessionAdapter.name === "tmux" ? { pane: target.id } : null,
|
|
617
811
|
status: "spawning",
|
|
@@ -1245,6 +1439,18 @@ function injectInbox(ctx, event) {
|
|
|
1245
1439
|
const lines = unread.map((m) => `- **${m.fromHandle}**${m.subject ? ` — ${m.subject}` : ""}: ${m.body} \`(${m.id})\``);
|
|
1246
1440
|
parts.push(`## Unread mail (${unread.length})\n\n${lines.join("\n")}`);
|
|
1247
1441
|
}
|
|
1442
|
+
if (rec && !rec.spawnedBy) try {
|
|
1443
|
+
const standing = listAgents(ctx.store).filter((a) => a.kind === "standing");
|
|
1444
|
+
for (const owner of standing) {
|
|
1445
|
+
const ownerUnread = inbox({ store: ctx.store }, {
|
|
1446
|
+
meId: owner.id,
|
|
1447
|
+
unread: true
|
|
1448
|
+
});
|
|
1449
|
+
if (ownerUnread.length === 0) continue;
|
|
1450
|
+
const lines = ownerUnread.map((m) => `- **${m.fromHandle}**${m.subject ? ` — ${m.subject}` : ""}: ${m.body} \`(${m.id})\``);
|
|
1451
|
+
parts.push(`## Owner mail — ${owner.handle} (${ownerUnread.length})\n\n${lines.join("\n")}`);
|
|
1452
|
+
}
|
|
1453
|
+
} catch {}
|
|
1248
1454
|
if (parts.length === 0) return null;
|
|
1249
1455
|
return { hookSpecificOutput: {
|
|
1250
1456
|
hookEventName: event,
|
|
@@ -1400,4 +1606,4 @@ async function watchMail(ctx, input, onMessage, opts = {}) {
|
|
|
1400
1606
|
}
|
|
1401
1607
|
}
|
|
1402
1608
|
//#endregion
|
|
1403
|
-
export {
|
|
1609
|
+
export { detectHarness as A, resolveStandingOwner as B, resolveAgentDef as C, selectSessionAdapter as D, spawn as E, register as F, resolveProjectLocalRoot as G, ensureMarker as H, registerStanding as I, gitWorktreeAdapter as J, resolveRoot as K, resolveAgent as L, loadAgent as M, prune as N, probeMultiplexer as O, realExec as P, resolveRecipient as R, listAgentDefs as S, realizeSubagentInstruction as T, paths as U, touch as V, projectRoot as W, resolvePrimaryRoot as Y, inbox as _, fail as a, send as b, toonObject as c, DispatchWaitingError as d, channel as f, deleteMessage as g, ack as h, emit as i, listAgents as j, bumpLastSeen as k, install as l, awaitReply as m, FileStore as n, nextStep as o, prep as p, assertDistinctFromPrimary as q, injectInbox as r, toonList as s, watchMail as t, collect as u, peek as v, realizeLaunch as w, decommission as x, resolveBody as y, resolveSelfId as z };
|