cyberlegion 0.0.0 → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 unional
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { B as loadAgent, C as resolveAgentDef, E as spawn, F as selectSessionAdapter, H as realExec, I as probeMultiplexer, K as resolveSelfId, L as bumpLastSeen, R as detectHarness, S as listAgentDefs, U as register, V as prune, W as resolveAgent, _ 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 resolveRoot, l as install, m as awaitReply, n as FileStore, o as nextStep, p as prep, q as touch, 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 listAgents } from "./watch-DA8I44dv.mjs";
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: 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
- type SessionPlacement = 'pane:right' | 'pane:down' | 'tab' | 'window';
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 — where spawned unit worktrees
479
- * live (never the global hub), keyed off `git worktree`'s common-dir so it resolves the same from
480
- * the primary checkout or from any of its linked worktrees.
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 under `<primary>/.agents/cyberlegion/worktrees/<id>`. */
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 project-local (`<primary>/.agents/cyberlegion/worktrees/<id>`), even
532
- * though the registry/mailbox itself lives in the global hub — the hub addresses units across
533
- * project and worktree boundaries, but a unit's checkout is necessarily project-local.
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 resolveProjectLocalRoot, B as loadAgent, C as resolveAgentDef, D as ensureMarker, E as spawn, F as selectSessionAdapter, G as resolveRecipient, H as realExec, I as probeMultiplexer, K as resolveSelfId, L as bumpLastSeen, M as assertDistinctFromPrimary, N as gitWorktreeAdapter, O as paths, P as resolvePrimaryRoot, R as detectHarness, S as listAgentDefs, T as realizeSubagentInstruction, U as register, V as prune, W as resolveAgent, _ 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 resolveRoot, k as projectRoot, l as install, m as awaitReply, n as FileStore, o as nextStep, p as prep, q as touch, 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 listAgents } from "./watch-DA8I44dv.mjs";
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).find((a) => a.handle === to);
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.find((a) => a.handle === ref);
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
- * herdr is optional/experimental and is not installed in this environment; this follows herdr's
289
- * documented CLI reference (split/run/read/close/focus) but is unverified against a live binary.
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
- const id = exec("herdr", [
295
- "pane",
296
- "split",
297
- "--current",
298
- "--direction",
299
- opts.at === "pane:down" ? "down" : "right",
300
- "--cwd",
301
- opts.cwd
302
- ]);
303
- if (!id) throw new Error("herdr pane split failed");
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 pane = exec("tmux", opts.at === "pane:down" ? [
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
- if (!pane) throw new Error("tmux split-window failed");
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 project-local (`<primary>/.agents/cyberlegion/worktrees/<id>`), even
580
- * though the registry/mailbox itself lives in the global hub — the hub addresses units across
581
- * project and worktree boundaries, but a unit's checkout is necessarily project-local.
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 target = sessionAdapter.open(exec, {
605
- cwd: worktree.root,
606
- launch: `${muxEnvPrefix(sessionAdapter.name)}${launch}`,
607
- at: input.at
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: worktree.root,
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 { resolveProjectLocalRoot as A, loadAgent as B, resolveAgentDef as C, ensureMarker as D, spawn as E, selectSessionAdapter as F, resolveRecipient as G, realExec as H, probeMultiplexer as I, resolveSelfId as K, bumpLastSeen as L, assertDistinctFromPrimary as M, gitWorktreeAdapter as N, paths as O, resolvePrimaryRoot as P, detectHarness as R, listAgentDefs as S, realizeSubagentInstruction as T, register as U, prune as V, resolveAgent as W, inbox as _, fail as a, send as b, toonObject as c, DispatchWaitingError as d, channel as f, deleteMessage as g, ack as h, emit as i, resolveRoot as j, projectRoot as k, install as l, awaitReply as m, FileStore as n, nextStep as o, prep as p, touch as q, injectInbox as r, toonList as s, watchMail as t, collect as u, peek as v, realizeLaunch as w, decommission as x, resolveBody as y, listAgents as z };
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 };
package/package.json CHANGED
@@ -1,61 +1,60 @@
1
1
  {
2
- "name": "cyberlegion",
3
- "version": "0.0.0",
4
- "description": "Harness-agnostic agent session spawning, messaging, and dispatch over the filesystem",
5
- "keywords": [
6
- "agent",
7
- "claude-code",
8
- "cursor",
9
- "codex",
10
- "inter-agent",
11
- "dispatch",
12
- "tmux"
13
- ],
14
- "repository": {
15
- "type": "git",
16
- "url": "git+https://github.com/cyberuni/cyberplace.git",
17
- "directory": "packages/cyberlegion"
18
- },
19
- "license": "MIT",
20
- "author": "unional <homawong@gmail.com>",
21
- "type": "module",
22
- "bin": {
23
- "cyberlegion": "bin/cyberlegion.mjs"
24
- },
25
- "exports": {
26
- ".": {
27
- "types": "./dist/index.d.mts",
28
- "import": "./dist/index.mjs"
29
- },
30
- "./package.json": "./package.json"
31
- },
32
- "files": [
33
- "bin",
34
- "dist"
35
- ],
36
- "scripts": {
37
- "build": "tsdown",
38
- "dev": "tsx src/cli.ts",
39
- "prepack": "pnpm build",
40
- "test": "pnpm build && vitest run src",
41
- "test:watch": "vitest",
42
- "typecheck": "tsc --noEmit"
43
- },
44
- "dependencies": {
45
- "commander": "^14.0.3"
46
- },
47
- "devDependencies": {
48
- "@types/node": "^24.10.1",
49
- "tsdown": "^0.22.0",
50
- "tsx": "^4.22.3",
51
- "typescript": "^6.0.0",
52
- "vitest": "^4.1.7"
53
- },
54
- "engines": {
55
- "node": ">=22"
56
- },
57
- "publishConfig": {
58
- "access": "public",
59
- "provenance": true
60
- }
61
- }
2
+ "name": "cyberlegion",
3
+ "version": "0.1.0",
4
+ "description": "Harness-agnostic agent session spawning, messaging, and dispatch over the filesystem",
5
+ "keywords": [
6
+ "agent",
7
+ "claude-code",
8
+ "cursor",
9
+ "codex",
10
+ "inter-agent",
11
+ "dispatch",
12
+ "tmux"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/cyberuni/cyberplace.git",
17
+ "directory": "packages/cyberlegion"
18
+ },
19
+ "license": "MIT",
20
+ "author": "unional <homawong@gmail.com>",
21
+ "type": "module",
22
+ "bin": {
23
+ "cyberlegion": "bin/cyberlegion.mjs"
24
+ },
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.mts",
28
+ "import": "./dist/index.mjs"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "bin",
34
+ "dist"
35
+ ],
36
+ "dependencies": {
37
+ "commander": "^14.0.3"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^24.10.1",
41
+ "tsdown": "^0.22.0",
42
+ "tsx": "^4.22.3",
43
+ "typescript": "^6.0.0",
44
+ "vitest": "^4.1.7"
45
+ },
46
+ "engines": {
47
+ "node": ">=22"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public",
51
+ "provenance": true
52
+ },
53
+ "scripts": {
54
+ "build": "tsdown",
55
+ "dev": "tsx src/cli.ts",
56
+ "test": "pnpm build && vitest run src",
57
+ "test:watch": "vitest",
58
+ "typecheck": "tsc --noEmit"
59
+ }
60
+ }
package/readme.md ADDED
@@ -0,0 +1,98 @@
1
+ # cyberlegion
2
+
3
+ [![npm version](https://img.shields.io/npm/v/cyberlegion.svg)](https://www.npmjs.com/package/cyberlegion)
4
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ Harness-agnostic, MCP-free agent session spawning, messaging, and dispatch over the filesystem (Claude Code, Cursor, Codex).
7
+
8
+ The CLI is pure mechanism — dumb hands a routing layer composes. It never decides *how* to reach a peer (warm-peer session vs cold subagent vs run-inline); it just spawns, messages, and dispatches. State lives under a shared hub root (`$CYBERLEGION_ROOT`, else the global hub).
9
+
10
+ ## Usage
11
+
12
+ No install required — run with `npx`:
13
+
14
+ ```sh
15
+ npx cyberlegion <command>
16
+ ```
17
+
18
+ Or pin to an exact version for reproducible hooks:
19
+
20
+ ```sh
21
+ npx cyberlegion@0.1.0 <command>
22
+ ```
23
+
24
+ ## Commands
25
+
26
+ ### identity — self-identify and discover peers
27
+
28
+ ```sh
29
+ npx cyberlegion identity register --handle scout # register/refresh this session
30
+ npx cyberlegion identity whoami # print your own identity
31
+ npx cyberlegion identity who # list addressable peers
32
+ npx cyberlegion identity prune # mark dead agents exited and sweep
33
+ ```
34
+
35
+ ### session — warm peer session lifecycle over a multiplexer
36
+
37
+ ```sh
38
+ npx cyberlegion session spawn --agent reviewer # launch a peer in its own git worktree (tmux/herdr)
39
+ npx cyberlegion session list # list live peer sessions
40
+ npx cyberlegion session focus <ref> # move input focus to a peer's session
41
+ npx cyberlegion session nudge <ref> # ring a peer's session (doorbell)
42
+ npx cyberlegion session read <ref> # scrape a peer's session screen
43
+ npx cyberlegion session close <id> # tear down worktree + session, reap state
44
+ ```
45
+
46
+ ### mail — durable inter-agent messaging
47
+
48
+ ```sh
49
+ npx cyberlegion mail send --to scout --body "ready" # send a message (by handle or id)
50
+ npx cyberlegion mail inbox # list your mail
51
+ npx cyberlegion mail read <msg-id> # peek without acknowledging
52
+ npx cyberlegion mail ack <msg-id> # acknowledge (move out of unread)
53
+ npx cyberlegion mail delete <msg-id> # permanently remove a message
54
+ npx cyberlegion mail await # block until a thread reply arrives, print + ack
55
+ npx cyberlegion mail watch # stream new matching mail (observer only)
56
+ npx cyberlegion mail hook # emit the harness hook injection payload (JSON)
57
+ ```
58
+
59
+ ### dispatch — delegate work and await a result
60
+
61
+ ```sh
62
+ npx cyberlegion dispatch prep # allocate id + brief + result slot (spawns nothing)
63
+ npx cyberlegion dispatch channel --agent worker --wait # prep + spawn + await the reply
64
+ npx cyberlegion dispatch collect <id> # read + validate a subagent's result file
65
+ ```
66
+
67
+ ### agent — resolve reusable agent definitions
68
+
69
+ Reads definitions under `.agents/agents/`.
70
+
71
+ ```sh
72
+ npx cyberlegion agent list # list resolvable agent definitions
73
+ npx cyberlegion agent show <name> # show a resolved def (model/effort/harness/…)
74
+ npx cyberlegion agent resolve <name> # emit the full machine payload for a routing caller
75
+ npx cyberlegion agent path <name> # print the resolved def file path
76
+ ```
77
+
78
+ ### admin — setup and diagnostics
79
+
80
+ ```sh
81
+ npx cyberlegion admin install # wire the mail-surfacing hook into a harness config
82
+ npx cyberlegion admin doctor # probe harness, multiplexer, hub root, self-id
83
+ npx cyberlegion admin mode # report the detected session-backend mode
84
+ npx cyberlegion admin migrate # merge one hub root state into another
85
+ ```
86
+
87
+ Top-level shortcuts are provided for the common verbs: `spawn`, `send`, `inbox`, `who`.
88
+
89
+ ## Global options
90
+
91
+ | Option | Description |
92
+ |---------------------|-------------------------------------------------------------------|
93
+ | `--space <path>` | Isolate the hub root (overrides the global hub / `$CYBERLEGION_ROOT`) |
94
+ | `--format <format>` | Output format: `toon` (default) or `json` |
95
+
96
+ ## License
97
+
98
+ MIT