cyberlegion 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,7 +1,8 @@
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 loadAgent, B as resolveSelfId, C as spawn, D as clearPresence, E as claimPresence, F as register, H as touch, I as registerStanding, J as normalizeMuxEnv, L as resolveAgent, M as prune, N as realExec, O as detectHarness, P as reconcile, R as resolvePresence, S as clearUnit, T as bumpLastSeen, V as resolveStandingOwner, _ as install, a as emit, b as resolveAgentDef, c as toonList, d as deleteMessage, f as inbox, g as send, h as resolveBody, i as injectInbox, j as presenceOf, k as listAgents, l as toonObject, m as readAck, n as awaitReply, o as fail, p as peek, q as resolveRoot, r as FileStore, s as nextStep, t as watchMail, u as ack, v as decommission, w as selectSessionAdapter, x as realizeLaunch, y as listAgentDefs } from "./watch-nkm6F1_f.mjs";
3
3
  import { resolve } from "node:path";
4
4
  import { Command, Option } from "commander";
5
+ import { currentPane, nudge, probeMultiplexer } from "cyber-mux";
5
6
  //#region src/admin.ts
6
7
  /**
7
8
  * Merge one store's registry + mailboxes + briefs into another (skip agent ids already present at
@@ -37,6 +38,119 @@ function migrateStore(from, to) {
37
38
  };
38
39
  }
39
40
  //#endregion
41
+ //#region src/console/doorbell.ts
42
+ /** The doorbell text delivered to a woken recipient; also the standalone `unit nudge` default. */
43
+ const DELIVERY_DOORBELL = "You have unread mail — check your inbox.";
44
+ /**
45
+ * The first-turn doorbell `unit spawn` delivers to a freshly-opened paned peer. The peer's brief is
46
+ * already injected into its context by its own SessionStart hook (never re-typed here — the ring
47
+ * only makes the peer take a turn); this text points it at that loaded brief so it starts on its own
48
+ * without a human nudge.
49
+ */
50
+ const SPAWN_DOORBELL = "Your brief is loaded in context — read it and begin work.";
51
+ /**
52
+ * A freshly-launched harness cold-boots slower than an already-running peer, so the spawn first-turn
53
+ * ring gets a wider retry budget than a plain `mail send` doorbell (nudge's own 10 × 400ms): flush the
54
+ * staged buffer for up to ~8s (20 × 400ms) before giving up. The common case still returns after one
55
+ * settle cycle once the peer takes the turn — the budget only bounds a slow or stuck boot. Still
56
+ * bounded — a harness that never reaches its prompt resolves to a best-effort warning, never a hang.
57
+ */
58
+ const SPAWN_NUDGE_OPTS = {
59
+ attempts: 20,
60
+ settleMs: 400
61
+ };
62
+ /** Resolve an agent's live session pane: its recorded pane, else a pane pointer keyed by its id. */
63
+ function paneOf(store, id) {
64
+ return loadAgent(store, id)?.pane?.id ?? store.findPaneByAgentId(id);
65
+ }
66
+ /**
67
+ * Best-effort wake the recipient of a just-delivered message so it checks its inbox. A peer's live
68
+ * session pane, a standing owner's bound presence (`unit claim` — the live unit standing in for it,
69
+ * an exited one falling back below), or — with neither — the hub's bound main pane, is rung via the
70
+ * nudge submit-verify path (a taken turn, not fire-and-forget). Durable delivery already happened;
71
+ * the ring is opportunistic on top, so a legitimate no-op (`--no-nudge`, a headless/absent recipient
72
+ * with no live pane, a standing-owner send with no presence and no main pane bound, or a
73
+ * self-addressed send) rings nothing, and a ring that never completes within nudge's retry cap is
74
+ * swallowed into a warning. This never throws — it can never fail the send.
75
+ *
76
+ * The adapter is resolved lazily via `getAdapter`, invoked only once a pane to ring is confirmed and
77
+ * inside the same swallowing try — so a session with no mux backend (where `selectSessionAdapter`
78
+ * throws) is just a no-op wake, never a failed send.
79
+ */
80
+ async function wakeRecipient(store, getAdapter, exec, input, nudgeOpts) {
81
+ if (input.noNudge) return { rung: false };
82
+ const recipient = loadAgent(store, input.toId);
83
+ if (!recipient) return { rung: false };
84
+ let pane;
85
+ let focusGated = false;
86
+ if (recipient.kind === "standing") {
87
+ const presenceUnit = presenceOf(store, recipient);
88
+ if (presenceUnit) pane = paneOf(store, presenceUnit.id);
89
+ else {
90
+ pane = store.getMainPane();
91
+ focusGated = true;
92
+ }
93
+ } else pane = paneOf(store, recipient.id);
94
+ if (!pane) return { rung: false };
95
+ if (pane === paneOf(store, input.fromId)) return { rung: false };
96
+ if (focusGated) {
97
+ let focused;
98
+ try {
99
+ focused = getAdapter().isPaneFocused(exec, { id: pane });
100
+ } catch {
101
+ focused = void 0;
102
+ }
103
+ if (focused === false) return {
104
+ rung: false,
105
+ pane
106
+ };
107
+ }
108
+ try {
109
+ await nudge(getAdapter(), exec, { id: pane }, DELIVERY_DOORBELL, nudgeOpts);
110
+ return {
111
+ rung: true,
112
+ pane
113
+ };
114
+ } catch (err) {
115
+ return {
116
+ rung: false,
117
+ pane,
118
+ warning: err instanceof Error ? err.message : String(err)
119
+ };
120
+ }
121
+ }
122
+ /**
123
+ * Best-effort deliver a freshly-spawned paned peer's first turn so it acts on its already-loaded
124
+ * brief with no human nudge. A paned agent boots to an idle prompt — its brief is injected into
125
+ * context by its own SessionStart hook, but the model takes no turn on its own, unlike a subagent
126
+ * (where the caller's Task call IS the turn). So `unit spawn` rings the `SPAWN_DOORBELL` over the
127
+ * boot-race-aware `nudge` submit-verify path (a taken turn, never re-typing the brief), the same
128
+ * best-effort ring `wakeRecipient` gives `mail send`: the spawn (worktree, session, registry record)
129
+ * is the guaranteed effect and the ring is opportunistic on top, so `--no-wake` rings nothing and a
130
+ * ring that never completes within the retry budget is swallowed into a warning. This never throws —
131
+ * it can never fail the spawn.
132
+ *
133
+ * The adapter is resolved lazily via `getAdapter` inside the same swallowing try, so even a session
134
+ * whose backend has since gone away (where `selectSessionAdapter` would throw) degrades to a warned
135
+ * no-op rather than a failed spawn.
136
+ */
137
+ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS) {
138
+ if (input.noWake) return { rung: false };
139
+ try {
140
+ await nudge(getAdapter(), exec, input.target, SPAWN_DOORBELL, nudgeOpts);
141
+ return {
142
+ rung: true,
143
+ pane: input.target.id
144
+ };
145
+ } catch (err) {
146
+ return {
147
+ rung: false,
148
+ pane: input.target.id,
149
+ warning: err instanceof Error ? err.message : String(err)
150
+ };
151
+ }
152
+ }
153
+ //#endregion
40
154
  //#region src/cli.ts
41
155
  const VERSION = "0.0.0";
42
156
  function ctxOf(opts) {
@@ -50,13 +164,13 @@ function formatOf(opts) {
50
164
  }
51
165
  function requireSelf(ctx) {
52
166
  const id = resolveSelfId(ctx);
53
- if (!id) fail("no identity in this session — run `cyberlegion identity register` first");
167
+ if (!id) fail("no identity in this session — run `cyberlegion unit register` first");
54
168
  bumpLastSeen(ctx, id);
55
169
  return id;
56
170
  }
57
171
  function resolveTarget(ctx, ref) {
58
172
  const agent = resolveAgent(ctx.store, ref);
59
- const pane = agent.tmux?.pane ?? ctx.store.findPaneByAgentId(agent.id);
173
+ const pane = agent.pane?.id ?? ctx.store.findPaneByAgentId(agent.id);
60
174
  if (!pane) fail(`unit "${ref}" has no known session pane`);
61
175
  return { id: pane };
62
176
  }
@@ -64,10 +178,55 @@ function withGlobals(cmd) {
64
178
  return cmd.option("--space <path>", "isolate the hub root (overrides the global hub / $CYBERLEGION_ROOT)").addOption(new Option("--format <format>", "output format").choices(["toon", "json"]).default("toon"));
65
179
  }
66
180
  const program = new Command();
67
- program.name("cyberlegion").description("Harness-agnostic agent session spawning, messaging, and dispatch over the filesystem").version(VERSION).enablePositionalOptions();
68
- const identity = program.command("identity").description("self-identify and discover peers");
69
- withGlobals(identity.command("register")).description("register or refresh this session identity").option("--handle <name>", "human handle for this agent").option("--harness <h>", "claude | cursor | codex (else auto-detected)").action((opts) => {
70
- const rec = register(ctxOf(opts), {
181
+ program.name("cyberlegion").description("Harness-agnostic agent session spawning and messaging over the filesystem").version(VERSION).enablePositionalOptions();
182
+ const unit = program.command("unit").description("legion units — register, discover, spawn, and reap");
183
+ /** The standing-owner branch of `unit register --standing` (folds the old `identity owner`). */
184
+ function runStanding(ctx, opts) {
185
+ if (!opts.handle) {
186
+ const standing = listAgents(ctx.store).filter((a) => a.kind === "standing");
187
+ emit(formatOf(opts), {
188
+ toon: toonList("agents", standing, [
189
+ {
190
+ key: "id",
191
+ get: (a) => a.id
192
+ },
193
+ {
194
+ key: "handle",
195
+ get: (a) => a.handle
196
+ },
197
+ {
198
+ key: "harness",
199
+ get: (a) => a.harness ?? "-"
200
+ },
201
+ {
202
+ key: "status",
203
+ get: (a) => a.status
204
+ }
205
+ ], `${standing.length} standing`),
206
+ json: standing
207
+ });
208
+ return;
209
+ }
210
+ const liveClaim = listAgents(ctx.store).find((a) => a.handle === opts.handle && a.kind !== "standing" && a.status !== "exited");
211
+ const rec = registerStanding(ctx, { handle: opts.handle });
212
+ if (liveClaim) console.error(`a live session already claims handle "${opts.handle}"`);
213
+ emit(formatOf(opts), {
214
+ toon: toonObject({
215
+ id: rec.id,
216
+ handle: rec.handle,
217
+ kind: rec.kind,
218
+ status: rec.status
219
+ }),
220
+ json: rec
221
+ });
222
+ }
223
+ withGlobals(unit.command("register")).description("register or refresh this session identity (or --standing: a session-independent owner inbox)").option("--handle <name>", "human handle for this agent").option("--harness <h>", "claude | cursor | codex (else auto-detected)").option("--standing", "mint a standing, session-independent owner inbox (bare, with no --handle: list them)").action((opts) => {
224
+ const ctx = ctxOf(opts);
225
+ if (opts.standing) {
226
+ runStanding(ctx, opts);
227
+ return;
228
+ }
229
+ const rec = register(ctx, {
71
230
  handle: opts.handle,
72
231
  harness: opts.harness
73
232
  });
@@ -75,23 +234,67 @@ withGlobals(identity.command("register")).description("register or refresh this
75
234
  toon: toonObject({
76
235
  id: rec.id,
77
236
  handle: rec.handle,
78
- harness: rec.harness,
237
+ harness: rec.harness ?? "-",
79
238
  status: rec.status
80
239
  }),
81
240
  json: rec
82
241
  });
83
242
  });
84
- withGlobals(identity.command("whoami")).description("print this session own identity").action((opts) => {
243
+ withGlobals(unit.command("claim")).description("bind the caller's unit as a standing owner's presence; --show reads it, --clear unbinds").argument("<handle>", "standing owner handle").option("--clear", "unbind the presence (a no-op when nothing is bound)").option("--show", "print the bound presence instead of claiming").action((handle, opts) => {
244
+ const ctx = ctxOf(opts);
245
+ if (opts.show) {
246
+ let presence;
247
+ try {
248
+ presence = resolvePresence(ctx.store, handle);
249
+ } catch (err) {
250
+ fail(err instanceof Error ? err.message : String(err));
251
+ }
252
+ emit(formatOf(opts), {
253
+ toon: toonObject({ presence: presence?.id ?? "none" }),
254
+ json: { presence: presence?.id ?? null }
255
+ });
256
+ return;
257
+ }
258
+ if (opts.clear) {
259
+ try {
260
+ clearPresence(ctx, handle);
261
+ } catch (err) {
262
+ fail(err instanceof Error ? err.message : String(err));
263
+ }
264
+ emit(formatOf(opts), {
265
+ toon: toonObject({ presence: "none" }),
266
+ json: { presence: null }
267
+ });
268
+ return;
269
+ }
270
+ let rec;
271
+ try {
272
+ rec = claimPresence(ctx, handle);
273
+ } catch (err) {
274
+ fail(err instanceof Error ? err.message : String(err));
275
+ }
276
+ emit(formatOf(opts), {
277
+ toon: toonObject({
278
+ owner: rec.handle,
279
+ presence: rec.presence
280
+ }),
281
+ json: {
282
+ owner: rec.handle,
283
+ presence: rec.presence
284
+ }
285
+ });
286
+ });
287
+ withGlobals(unit.command("whoami")).description("print this session own identity").action((opts) => {
85
288
  const ctx = ctxOf(opts);
86
289
  const id = resolveSelfId(ctx);
87
- if (!id) fail("no identity in this session — run `cyberlegion identity register` first");
290
+ if (!id) fail("no identity in this session — run `cyberlegion unit register` first");
88
291
  const rec = loadAgent(ctx.store, id);
89
292
  if (!rec) fail(`registered self id "${id}" has no agent record`);
90
293
  emit(formatOf(opts), {
91
294
  toon: toonObject({
92
295
  id: rec.id,
93
296
  handle: rec.handle,
94
- harness: rec.harness,
297
+ harness: rec.harness ?? "-",
95
298
  status: rec.status
96
299
  }),
97
300
  json: rec
@@ -100,9 +303,10 @@ withGlobals(identity.command("whoami")).description("print this session own iden
100
303
  function runWho(opts) {
101
304
  const ctx = ctxOf(opts);
102
305
  touch(ctx);
306
+ if (opts.reconcile) reconcile(ctx, { adopt: true });
103
307
  const agents = listAgents(ctx.store).filter((a) => opts.all || a.status !== "exited");
104
308
  emit(formatOf(opts), {
105
- toon: toonList("agents", agents, [
309
+ toon: toonList("units", agents, [
106
310
  {
107
311
  key: "id",
108
312
  get: (a) => a.id
@@ -113,19 +317,23 @@ function runWho(opts) {
113
317
  },
114
318
  {
115
319
  key: "harness",
116
- get: (a) => a.harness
320
+ get: (a) => a.harness ?? "-"
117
321
  },
118
322
  {
119
323
  key: "status",
120
324
  get: (a) => a.status
325
+ },
326
+ {
327
+ key: "pane",
328
+ get: (a) => a.pane?.id ?? "-"
121
329
  }
122
- ], `${agents.length} agents`),
330
+ ], `${agents.length} units`),
123
331
  json: agents
124
332
  });
125
- if (agents.length === 0) nextStep("cyberlegion identity register to join");
333
+ if (agents.length === 0) nextStep("cyberlegion unit register to join");
126
334
  }
127
- withGlobals(identity.command("who")).description("list the addressable peers").option("--all", "include exited agents").action(runWho);
128
- withGlobals(identity.command("prune")).description("mark dead agents exited and sweep").action((opts) => {
335
+ withGlobals(unit.command("who")).description("list the addressable units").option("--all", "include exited units").option("--reconcile", "live-probe the current mux: cull dead-pane records and adopt unbound harness-bearing panes before listing").action(runWho);
336
+ withGlobals(unit.command("prune")).description("mark dead units exited and sweep").action((opts) => {
129
337
  const ctx = ctxOf(opts);
130
338
  touch(ctx);
131
339
  const changed = prune(ctx);
@@ -140,14 +348,13 @@ withGlobals(identity.command("prune")).description("mark dead agents exited and
140
348
  json: changed
141
349
  });
142
350
  });
143
- const session = program.command("session").description("warm peer session lifecycle over a multiplexer");
144
351
  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([
352
+ 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 (default: new-worktree → workspace, --cwd → tab)").choices([
146
353
  "pane:right",
147
354
  "pane:down",
148
355
  "tab",
149
- "window"
150
- ]).default("pane:right")).action((opts) => {
356
+ "workspace"
357
+ ])).option("--no-wake", "suppress the first-turn doorbell (spawn idle; the caller drives the first turn itself)").action(async (opts) => {
151
358
  const ctx = ctxOf(opts);
152
359
  touch(ctx);
153
360
  let harness = opts.harness;
@@ -166,7 +373,7 @@ function defineSpawn(cmd) {
166
373
  harness = realized.harness;
167
374
  command = realized.command;
168
375
  }
169
- if (!harness) fail("session spawn needs --harness, or --agent/--agent-file resolving one");
376
+ if (!harness) fail("unit spawn needs --harness, or --agent/--agent-file resolving one");
170
377
  const res = spawn(ctx, {
171
378
  harness,
172
379
  command,
@@ -175,23 +382,33 @@ function defineSpawn(cmd) {
175
382
  handle: opts.handle,
176
383
  branch: opts.branch,
177
384
  worktreePath: opts.worktreePath,
385
+ cwd: opts.cwd,
178
386
  at: opts.at
179
387
  });
388
+ const wake = await wakeSpawn(() => selectSessionAdapter(ctx.env ?? process.env), realExec, {
389
+ target: { id: res.pane },
390
+ noWake: opts.wake === false
391
+ });
392
+ if (wake.warning) console.error(`first-turn doorbell not confirmed (peer still spawned; nudge it manually): ${wake.warning}`);
180
393
  emit(formatOf(opts), {
181
394
  toon: toonObject({
182
395
  spawned: res.agent.id,
183
396
  handle: res.agent.handle,
184
397
  harness: res.agent.harness,
185
398
  worktree: res.agent.worktree?.root,
186
- pane: res.pane
399
+ pane: res.pane,
400
+ rung: wake.rung
187
401
  }),
188
- json: res
402
+ json: {
403
+ ...res,
404
+ rung: wake.rung
405
+ }
189
406
  });
190
- nextStep(`cyberlegion session read ${res.agent.id}`);
407
+ nextStep(`cyberlegion unit read ${res.agent.id}`);
191
408
  });
192
409
  }
193
- defineSpawn(session.command("spawn"));
194
- withGlobals(session.command("close")).description("tear down a unit's worktree + session and reap its state (the inverse of spawn)").argument("<id>", "unit id, handle, or worktree branch/CR ref").option("--force", "discard uncommitted changes in the worktree (never overrides refusing the primary checkout)").action((ref, opts) => {
410
+ defineSpawn(unit.command("spawn"));
411
+ withGlobals(unit.command("close")).description("tear down a unit's worktree + session and reap its state (the inverse of spawn)").argument("<id>", "unit id, handle, or worktree branch/CR ref").option("--force", "discard uncommitted changes in the worktree (never overrides refusing the primary checkout)").action((ref, opts) => {
195
412
  const ctx = ctxOf(opts);
196
413
  touch(ctx);
197
414
  const agent = resolveAgent(ctx.store, ref);
@@ -208,33 +425,7 @@ withGlobals(session.command("close")).description("tear down a unit's worktree +
208
425
  json: res
209
426
  });
210
427
  });
211
- withGlobals(session.command("list")).description("list live peer sessions").action((opts) => {
212
- const ctx = ctxOf(opts);
213
- touch(ctx);
214
- const agents = listAgents(ctx.store).filter((a) => a.status !== "exited");
215
- emit(formatOf(opts), {
216
- toon: toonList("sessions", agents, [
217
- {
218
- key: "id",
219
- get: (a) => a.id
220
- },
221
- {
222
- key: "handle",
223
- get: (a) => a.handle
224
- },
225
- {
226
- key: "status",
227
- get: (a) => a.status
228
- },
229
- {
230
- key: "pane",
231
- get: (a) => a.tmux?.pane ?? "-"
232
- }
233
- ], `${agents.length} sessions`),
234
- json: agents
235
- });
236
- });
237
- withGlobals(session.command("focus")).description("move input focus to a peer's session").argument("<ref>", "unit id, handle, or worktree branch/CR ref").action((ref, opts) => {
428
+ withGlobals(unit.command("focus")).description("move input focus to a peer's session").argument("<ref>", "unit id, handle, or worktree branch/CR ref").action((ref, opts) => {
238
429
  const ctx = ctxOf(opts);
239
430
  touch(ctx);
240
431
  const target = resolveTarget(ctx, ref);
@@ -250,11 +441,12 @@ withGlobals(session.command("focus")).description("move input focus to a peer's
250
441
  }
251
442
  });
252
443
  });
253
- withGlobals(session.command("nudge")).description("ring a peer's session (a dumb doorbell — the mail is the payload)").argument("<ref>", "unit id, handle, or worktree branch/CR ref").action((ref, opts) => {
444
+ withGlobals(unit.command("nudge")).description("ring a peer's session (a doorbell that tells them to check their mail)").argument("<ref>", "unit id, handle, or worktree branch/CR ref").option("--message <text>", "the doorbell text delivered to the peer session", DELIVERY_DOORBELL).action(async (ref, opts) => {
254
445
  const ctx = ctxOf(opts);
255
446
  touch(ctx);
256
447
  const target = resolveTarget(ctx, ref);
257
- selectSessionAdapter(ctx.env ?? process.env).send(realExec, target, "");
448
+ const message = opts.message || "You have unread mail — check your inbox.";
449
+ const result = await nudge(selectSessionAdapter(ctx.env ?? process.env), realExec, target, message);
258
450
  emit(formatOf(opts), {
259
451
  toon: toonObject({
260
452
  nudged: ref,
@@ -262,11 +454,13 @@ withGlobals(session.command("nudge")).description("ring a peer's session (a dumb
262
454
  }),
263
455
  json: {
264
456
  ref,
265
- pane: target.id
457
+ pane: target.id,
458
+ message,
459
+ resubmits: result.resubmits
266
460
  }
267
461
  });
268
462
  });
269
- withGlobals(session.command("read")).description("scrape a peer's session screen").argument("<ref>", "unit id, handle, or worktree branch/CR ref").option("--lines <n>", "trailing lines to capture", (v) => Number.parseInt(v, 10)).action((ref, opts) => {
463
+ withGlobals(unit.command("read")).description("scrape a peer's session screen").argument("<ref>", "unit id, handle, or worktree branch/CR ref").option("--lines <n>", "trailing lines to capture", (v) => Number.parseInt(v, 10)).action((ref, opts) => {
270
464
  const ctx = ctxOf(opts);
271
465
  touch(ctx);
272
466
  const target = resolveTarget(ctx, ref);
@@ -278,9 +472,31 @@ withGlobals(session.command("read")).description("scrape a peer's session screen
278
472
  }, null, 2));
279
473
  else console.log(text);
280
474
  });
475
+ withGlobals(unit.command("clear")).description("reset a warm peer's context to cold by injecting its own harness fresh-context command — keeps the pane/session warm; tears down nothing").argument("<ref>", "unit id, handle, or worktree branch/CR ref").action((ref, opts) => {
476
+ const ctx = ctxOf(opts);
477
+ touch(ctx);
478
+ let res;
479
+ try {
480
+ res = clearUnit(ctx, ref);
481
+ } catch (err) {
482
+ fail(err instanceof Error ? err.message : String(err));
483
+ }
484
+ emit(formatOf(opts), {
485
+ toon: toonObject({
486
+ cleared: ref,
487
+ pane: res.pane,
488
+ command: res.command
489
+ }),
490
+ json: {
491
+ cleared: ref,
492
+ pane: res.pane,
493
+ command: res.command
494
+ }
495
+ });
496
+ });
281
497
  const mail = program.command("mail").description("durable inter-agent messaging");
282
498
  function defineSend(cmd) {
283
- return withGlobals(cmd).description("send a message to a peer (by handle or id)").requiredOption("--to <peer>", "recipient handle or id").option("--from <id>", "sender id (else this session own identity)").option("--subject <s>", "subject").option("--body <text>", "message body").option("--body-file <path>", "read body from a file, or - for stdin").option("--thread <id>", "thread id").option("--reply-to <msg>", "message id this replies to").action((opts) => {
499
+ return withGlobals(cmd).description("send a message to a peer (by handle or id)").requiredOption("--to <peer>", "recipient handle or id").option("--from <id>", "sender id (else this session own identity)").option("--subject <s>", "subject").option("--body <text>", "message body").option("--body-file <path>", "read body from a file, or - for stdin").option("--thread <id>", "thread id").option("--reply-to <msg>", "message id this replies to").option("--no-nudge", "suppress the delivery doorbell (do not wake the recipient's pane)").action(async (opts) => {
284
500
  const ctx = ctxOf(opts);
285
501
  const fromId = opts.from ?? requireSelf(ctx);
286
502
  const body = resolveBody(opts.body, opts.bodyFile);
@@ -292,21 +508,30 @@ function defineSend(cmd) {
292
508
  thread: opts.thread,
293
509
  replyTo: opts.replyTo
294
510
  });
511
+ const wake = await wakeRecipient(ctx.store, () => selectSessionAdapter(ctx.env ?? process.env), realExec, {
512
+ toId: msg.to,
513
+ fromId,
514
+ noNudge: opts.nudge === false
515
+ });
516
+ if (wake.warning) console.error(`delivery doorbell not confirmed (message still delivered): ${wake.warning}`);
295
517
  emit(formatOf(opts), {
296
518
  toon: toonObject({
297
519
  sent: msg.id,
298
520
  to: opts.to,
299
- subject: msg.subject
521
+ subject: msg.subject,
522
+ rung: wake.rung
300
523
  }),
301
- json: msg
524
+ json: {
525
+ ...msg,
526
+ rung: wake.rung
527
+ }
302
528
  });
303
529
  });
304
530
  }
305
531
  defineSend(mail.command("send"));
306
- function runInbox(opts) {
307
- const ctx = ctxOf(opts);
308
- touch(ctx);
309
- const meId = requireSelf(ctx);
532
+ /** List a resolved inbox (the caller's own or an --owner mailbox) as TOON with an aggregate;
533
+ * `readCmd` is the follow-up read command prefix surfaced as the next step. */
534
+ function emitInbox(ctx, opts, meId, readCmd) {
310
535
  const items = inbox({ store: ctx.store }, {
311
536
  meId,
312
537
  unread: opts.unread,
@@ -336,12 +561,34 @@ function runInbox(opts) {
336
561
  json: items
337
562
  });
338
563
  const firstUnread = items.find((m) => !m.read);
339
- if (firstUnread) nextStep(`cyberlegion mail read ${firstUnread.id}`);
564
+ if (firstUnread) nextStep(`${readCmd} ${firstUnread.id}`);
340
565
  }
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) => {
566
+ function runInbox(opts) {
343
567
  const ctx = ctxOf(opts);
344
- const meId = requireSelf(ctx);
568
+ touch(ctx);
569
+ emitInbox(ctx, opts, opts.owner ? resolveStandingOwner(ctx.store, opts.owner) : requireSelf(ctx), "cyberlegion mail read");
570
+ }
571
+ 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);
572
+ withGlobals(mail.command("read")).description("read a message; --ack consumes it in the same step, otherwise it only peeks").argument("<msg-id>", "message id").option("--ack", "acknowledge the message in the same step (idempotent — no error if already acked)").option("--owner <handle>", "read a standing owner's mailbox instead of this session's own").action((msgId, opts) => {
573
+ const ctx = ctxOf(opts);
574
+ const meId = opts.owner ? resolveStandingOwner(ctx.store, opts.owner) : requireSelf(ctx);
575
+ if (opts.ack) {
576
+ const { msg, acked } = readAck({ store: ctx.store }, meId, msgId);
577
+ emit(formatOf(opts), {
578
+ toon: toonObject({
579
+ id: msg.id,
580
+ from: msg.fromHandle,
581
+ subject: msg.subject,
582
+ body: msg.body,
583
+ acked
584
+ }),
585
+ json: {
586
+ ...msg,
587
+ acked
588
+ }
589
+ });
590
+ return;
591
+ }
345
592
  const msg = peek({ store: ctx.store }, meId, msgId);
346
593
  if (!msg) fail(`"${msgId}" is not a message in this inbox`);
347
594
  emit(formatOf(opts), {
@@ -353,11 +600,11 @@ withGlobals(mail.command("read")).description("peek at a message without acknowl
353
600
  }),
354
601
  json: msg
355
602
  });
356
- nextStep(`cyberlegion mail ack ${msg.id}`);
603
+ nextStep(`cyberlegion mail ack ${msg.id}${opts.owner ? ` --owner ${opts.owner}` : ""}`);
357
604
  });
358
- withGlobals(mail.command("ack")).description("acknowledge a message (moves it out of the unread set)").argument("<msg-id>", "message id").action((msgId, opts) => {
605
+ 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
606
  const ctx = ctxOf(opts);
360
- const meId = requireSelf(ctx);
607
+ const meId = opts.owner ? resolveStandingOwner(ctx.store, opts.owner) : requireSelf(ctx);
361
608
  const msg = ack({ store: ctx.store }, meId, msgId);
362
609
  emit(formatOf(opts), {
363
610
  toon: toonObject({
@@ -431,95 +678,6 @@ withGlobals(mail.command("hook")).description("emit the harness hook injection p
431
678
  const payload = injectInbox(ctx, opts.event);
432
679
  if (payload) console.log(JSON.stringify(payload));
433
680
  });
434
- const dispatch = program.command("dispatch").description("delegate work and await a result (result-slot primitives)");
435
- function definePrepOptions(cmd) {
436
- return withGlobals(cmd).option("--agent <name>", "resolve an agent def to build the instruction/launch from").option("--agent-file <path>", "read an exact agent def file instead of resolving by name").option("--role <name>", "role label folded into the generic instruction when no agent def is given").option("--brief-text <text>", "brief body text").option("--brief-file <path>", "read the brief from a file, or - for stdin").option("--verdict-schema <path>", "JSON schema file (required keys + primitive types) the result must satisfy").option("--thread <id>", "thread id (defaults to the minted dispatch id)");
437
- }
438
- function dispatchResultFields(r) {
439
- return {
440
- id: r.id,
441
- verdict: r.verdict != null ? JSON.stringify(r.verdict) : void 0,
442
- body: r.body,
443
- ts: r.ts
444
- };
445
- }
446
- definePrepOptions(dispatch.command("prep")).description("allocate an id + brief + result slot and return the envelope — spawns nothing, never invokes Task").action((opts) => {
447
- const ctx = ctxOf(opts);
448
- let envelope;
449
- try {
450
- envelope = prep({ store: ctx.store }, {
451
- agent: opts.agent,
452
- agentFile: opts.agentFile,
453
- role: opts.role,
454
- briefText: opts.briefText,
455
- briefFile: opts.briefFile,
456
- thread: opts.thread
457
- });
458
- } catch (err) {
459
- fail(err instanceof Error ? err.message : String(err));
460
- }
461
- emit(formatOf(opts), {
462
- toon: toonObject({ ...envelope }),
463
- json: envelope
464
- });
465
- nextStep(`spawn a subagent with the instruction, then \`cyberlegion dispatch collect ${envelope.id}\` — or on the channel path, \`cyberlegion mail await --thread ${envelope.thread}\``);
466
- });
467
- definePrepOptions(dispatch.command("channel")).description("prep + spawn a peer session + (--wait) await the mail-thread reply — the one CLI-driven convenience (needs --agent or --agent-file to realize the peer launch)").addOption(new Option("--at <placement>", "where to open the new session").choices([
468
- "pane:right",
469
- "pane:down",
470
- "tab",
471
- "window"
472
- ]).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
- const ctx = ctxOf(opts);
474
- touch(ctx);
475
- let result;
476
- try {
477
- result = await channel(ctx, {
478
- agent: opts.agent,
479
- agentFile: opts.agentFile,
480
- role: opts.role,
481
- briefText: opts.briefText,
482
- briefFile: opts.briefFile,
483
- verdictSchema: opts.verdictSchema,
484
- thread: opts.thread,
485
- at: opts.at,
486
- wait: opts.wait,
487
- timeoutMs: opts.timeout,
488
- maxWaitS: opts.maxWait
489
- });
490
- } catch (err) {
491
- if (err instanceof DispatchWaitingError) {
492
- console.error(err.message);
493
- return;
494
- }
495
- fail(err instanceof Error ? err.message : String(err));
496
- }
497
- if ("ts" in result) {
498
- emit(formatOf(opts), {
499
- toon: toonObject(dispatchResultFields(result)),
500
- json: result
501
- });
502
- return;
503
- }
504
- emit(formatOf(opts), {
505
- toon: toonObject({ ...result }),
506
- json: result
507
- });
508
- nextStep(`cyberlegion mail await --thread ${result.thread} to collect the reply later`);
509
- });
510
- withGlobals(dispatch.command("collect")).description("read + validate a subagent's result file (the subagent path's counterpart to mail await)").argument("<id>", "dispatch id").option("--verdict-schema <path>", "JSON schema file the result must satisfy").action((id, opts) => {
511
- const ctx = ctxOf(opts);
512
- let result;
513
- try {
514
- result = collect({ store: ctx.store }, id, opts.verdictSchema);
515
- } catch (err) {
516
- fail(err instanceof Error ? err.message : String(err));
517
- }
518
- emit(formatOf(opts), {
519
- toon: toonObject(dispatchResultFields(result)),
520
- json: result
521
- });
522
- });
523
681
  const agent = program.command("agent").description("resolve reusable agent definitions");
524
682
  const INSTRUCTIONS_PREVIEW_LEN = 200;
525
683
  function truncated(text, full) {
@@ -611,31 +769,11 @@ withGlobals(agent.command("path")).description("print the resolved def file path
611
769
  json: { path: def.path }
612
770
  });
613
771
  });
614
- const admin = program.command("admin").description("setup and diagnostics");
615
- withGlobals(admin.command("install")).description("wire the mail-surfacing hook into a harness config").requiredOption("--agent <harness>", "claude | cursor | codex").option("--dir <path>", "project dir to write config into", process.cwd()).action((opts) => {
616
- const results = install(opts.agent, opts.dir);
617
- emit(formatOf(opts), {
618
- toon: toonList("hooks", results, [
619
- {
620
- key: "event",
621
- get: (r) => r.event
622
- },
623
- {
624
- key: "status",
625
- get: (r) => r.status
626
- },
627
- {
628
- key: "file",
629
- get: (r) => r.file
630
- }
631
- ], `${results.length} hooks`),
632
- json: results
633
- });
634
- });
635
- withGlobals(admin.command("doctor")).description("probe harness, multiplexer (ancestry-discovered), hub root, and self-id").action((opts) => {
772
+ const mux = program.command("mux").description("the unit-agnostic pane layer — multiplexer detection and diagnostics");
773
+ withGlobals(mux.command("doctor")).description("probe harness, multiplexer (ancestry-discovered), hub root, and self-id").action((opts) => {
636
774
  const ctx = ctxOf(opts);
637
775
  const harness = detectHarness(void 0, ctx) ?? "unknown";
638
- const probe = probeMultiplexer(ctx.exec ?? realExec, ctx.env ?? process.env);
776
+ const probe = probeMultiplexer(ctx.exec ?? realExec, normalizeMuxEnv(ctx.env ?? process.env));
639
777
  const selfId = resolveSelfId(ctx) ?? "-";
640
778
  emit(formatOf(opts), {
641
779
  toon: toonObject({
@@ -655,19 +793,46 @@ withGlobals(admin.command("doctor")).description("probe harness, multiplexer (an
655
793
  selfId
656
794
  }
657
795
  });
658
- if (probe.mux !== "none") nextStep(`export CYBERLEGION_MUX=${probe.mux}${probe.pane ? ` CYBERLEGION_MUX_PANE=${probe.pane}` : ""} — pin the fast-path, skip ancestry discovery on later calls`);
796
+ if (probe.mux !== "none") nextStep(`export CYBER_MUX=${probe.mux}${probe.pane ? ` CYBER_MUX_PANE=${probe.pane}` : ""} — pin the fast-path, skip ancestry discovery on later calls`);
659
797
  });
660
- withGlobals(admin.command("mode")).description("report the detected session-backend mode").action((opts) => {
661
- let mux = "none";
798
+ withGlobals(mux.command("mode")).description("report the detected session-backend mode").action((opts) => {
799
+ const ctx = ctxOf(opts);
800
+ let mode = "none";
662
801
  try {
663
- mux = selectSessionAdapter(process.env).name;
802
+ mode = selectSessionAdapter(ctx.env ?? process.env).name;
664
803
  } catch {}
665
804
  emit(formatOf(opts), {
666
- toon: toonObject({ mode: mux }),
667
- json: { mode: mux }
805
+ toon: toonObject({ mode }),
806
+ json: { mode }
668
807
  });
669
808
  });
670
- withGlobals(admin.command("migrate")).description("merge one hub root state into another (e.g. an old project-local root into the global hub)").requiredOption("--from <path>", "source hub root").requiredOption("--to <path>", "destination hub root").action((opts) => {
809
+ withGlobals(program.command("attach")).description("bind this pane as the hub's main pane (the owner's live presence); --show reads it, --clear unbinds").option("--clear", "unbind the main pane (a no-op when nothing is bound)").option("--show", "print the bound main pane instead of binding").action((opts) => {
810
+ const ctx = ctxOf(opts);
811
+ if (opts.show) {
812
+ const pane = ctx.store.getMainPane();
813
+ emit(formatOf(opts), {
814
+ toon: toonObject({ mainPane: pane ?? "none" }),
815
+ json: { mainPane: pane ?? null }
816
+ });
817
+ return;
818
+ }
819
+ if (opts.clear) {
820
+ ctx.store.setMainPane(null);
821
+ emit(formatOf(opts), {
822
+ toon: toonObject({ mainPane: "none" }),
823
+ json: { mainPane: null }
824
+ });
825
+ return;
826
+ }
827
+ const cur = currentPane(normalizeMuxEnv(ctx.env ?? process.env));
828
+ if (!cur) fail("no multiplexer pane to bind — run this from inside a tmux or herdr pane");
829
+ ctx.store.setMainPane(cur.pane);
830
+ emit(formatOf(opts), {
831
+ toon: toonObject({ mainPane: cur.pane }),
832
+ json: { mainPane: cur.pane }
833
+ });
834
+ });
835
+ withGlobals(program.command("admin").description("hub-state maintenance").command("migrate")).description("merge one hub root state into another (e.g. an old project-local root into the global hub)").requiredOption("--from <path>", "source hub root").requiredOption("--to <path>", "destination hub root").action((opts) => {
671
836
  const res = migrateStore(new FileStore(resolve(opts.from)), new FileStore(resolve(opts.to)));
672
837
  emit(formatOf(opts), {
673
838
  toon: toonObject({
@@ -678,10 +843,51 @@ withGlobals(admin.command("migrate")).description("merge one hub root state into
678
843
  json: res
679
844
  });
680
845
  });
846
+ withGlobals(program.command("init")).description("resolve this session harness, register the Legion surfacing hook, and advise owner binding").option("--agent <h>", "claude | cursor | codex (else auto-detected)").option("--dir <path>", "project dir to write config into", process.cwd()).option("--pin <version>", "version to pin the registered npx hook command to (e.g. the bundled plugin version)").action((opts) => {
847
+ const ctx = ctxOf(opts);
848
+ let harness;
849
+ try {
850
+ const detected = detectHarness(opts.agent, ctx);
851
+ if (!detected) fail("could not detect harness — pass --agent claude|cursor|codex");
852
+ harness = detected;
853
+ } catch (err) {
854
+ fail(err instanceof Error ? err.message : String(err));
855
+ }
856
+ let results;
857
+ try {
858
+ results = install(harness, opts.dir, opts.pin);
859
+ } catch (err) {
860
+ fail(err instanceof Error ? err.message : String(err));
861
+ }
862
+ emit(formatOf(opts), {
863
+ toon: toonList("hooks", results, [
864
+ {
865
+ key: "event",
866
+ get: (r) => r.event
867
+ },
868
+ {
869
+ key: "status",
870
+ get: (r) => r.status
871
+ },
872
+ {
873
+ key: "file",
874
+ get: (r) => r.file
875
+ }
876
+ ], `harness ${harness}, ${results.length} hooks`),
877
+ json: {
878
+ harness,
879
+ hooks: results
880
+ }
881
+ });
882
+ if (!listAgents(ctx.store).some((a) => a.kind === "standing")) {
883
+ nextStep("cyberlegion unit register --standing --handle <name> to mint the durable owner inbox");
884
+ nextStep("cyberlegion attach to bind this pane as the owner live presence");
885
+ }
886
+ });
681
887
  defineSpawn(program.command("spawn"));
682
888
  defineSend(program.command("send"));
683
889
  withGlobals(program.command("inbox")).description("list your mail (alias of `mail inbox`)").option("--unread", "only un-acked mail").option("--from <id>", "filter by sender").option("--thread <id>", "filter to messages carrying this thread id").action(runInbox);
684
- withGlobals(program.command("who")).description("list the addressable peers (alias of `identity who`)").option("--all", "include exited agents").action(runWho);
890
+ withGlobals(program.command("who")).description("list the addressable units (alias of `unit who`)").option("--all", "include exited units").option("--reconcile", "live-probe the current mux: cull dead-pane records and adopt unbound harness-bearing panes before listing").action(runWho);
685
891
  withGlobals(program).action((opts) => {
686
892
  const ctx = ctxOf(opts);
687
893
  touch(ctx);
@@ -709,7 +915,7 @@ withGlobals(program).action((opts) => {
709
915
  units
710
916
  }
711
917
  });
712
- if (!id) nextStep("cyberlegion identity register to join");
918
+ if (!id) nextStep("cyberlegion unit register to join");
713
919
  else if (unread > 0) nextStep("cyberlegion mail inbox --unread");
714
920
  });
715
921
  program.parseAsync(process.argv).catch((err) => {