cyberlegion 0.1.0 → 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 { 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";
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,41 +178,10 @@ 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), {
71
- handle: opts.handle,
72
- harness: opts.harness
73
- });
74
- emit(formatOf(opts), {
75
- toon: toonObject({
76
- id: rec.id,
77
- handle: rec.handle,
78
- harness: rec.harness ?? "-",
79
- status: rec.status
80
- }),
81
- json: rec
82
- });
83
- });
84
- withGlobals(identity.command("whoami")).description("print this session own identity").action((opts) => {
85
- const ctx = ctxOf(opts);
86
- const id = resolveSelfId(ctx);
87
- if (!id) fail("no identity in this session — run `cyberlegion identity register` first");
88
- const rec = loadAgent(ctx.store, id);
89
- if (!rec) fail(`registered self id "${id}" has no agent record`);
90
- emit(formatOf(opts), {
91
- toon: toonObject({
92
- id: rec.id,
93
- handle: rec.handle,
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);
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) {
102
185
  if (!opts.handle) {
103
186
  const standing = listAgents(ctx.store).filter((a) => a.kind === "standing");
104
187
  emit(formatOf(opts), {
@@ -119,7 +202,7 @@ withGlobals(identity.command("owner")).description("mint (or refresh) a standing
119
202
  key: "status",
120
203
  get: (a) => a.status
121
204
  }
122
- ], `${standing.length} agents`),
205
+ ], `${standing.length} standing`),
123
206
  json: standing
124
207
  });
125
208
  return;
@@ -136,13 +219,94 @@ withGlobals(identity.command("owner")).description("mint (or refresh) a standing
136
219
  }),
137
220
  json: rec
138
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, {
230
+ handle: opts.handle,
231
+ harness: opts.harness
232
+ });
233
+ emit(formatOf(opts), {
234
+ toon: toonObject({
235
+ id: rec.id,
236
+ handle: rec.handle,
237
+ harness: rec.harness ?? "-",
238
+ status: rec.status
239
+ }),
240
+ json: rec
241
+ });
242
+ });
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) => {
288
+ const ctx = ctxOf(opts);
289
+ const id = resolveSelfId(ctx);
290
+ if (!id) fail("no identity in this session — run `cyberlegion unit register` first");
291
+ const rec = loadAgent(ctx.store, id);
292
+ if (!rec) fail(`registered self id "${id}" has no agent record`);
293
+ emit(formatOf(opts), {
294
+ toon: toonObject({
295
+ id: rec.id,
296
+ handle: rec.handle,
297
+ harness: rec.harness ?? "-",
298
+ status: rec.status
299
+ }),
300
+ json: rec
301
+ });
139
302
  });
140
303
  function runWho(opts) {
141
304
  const ctx = ctxOf(opts);
142
305
  touch(ctx);
306
+ if (opts.reconcile) reconcile(ctx, { adopt: true });
143
307
  const agents = listAgents(ctx.store).filter((a) => opts.all || a.status !== "exited");
144
308
  emit(formatOf(opts), {
145
- toon: toonList("agents", agents, [
309
+ toon: toonList("units", agents, [
146
310
  {
147
311
  key: "id",
148
312
  get: (a) => a.id
@@ -158,14 +322,18 @@ function runWho(opts) {
158
322
  {
159
323
  key: "status",
160
324
  get: (a) => a.status
325
+ },
326
+ {
327
+ key: "pane",
328
+ get: (a) => a.pane?.id ?? "-"
161
329
  }
162
- ], `${agents.length} agents`),
330
+ ], `${agents.length} units`),
163
331
  json: agents
164
332
  });
165
- if (agents.length === 0) nextStep("cyberlegion identity register to join");
333
+ if (agents.length === 0) nextStep("cyberlegion unit register to join");
166
334
  }
167
- withGlobals(identity.command("who")).description("list the addressable peers").option("--all", "include exited agents").action(runWho);
168
- 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) => {
169
337
  const ctx = ctxOf(opts);
170
338
  touch(ctx);
171
339
  const changed = prune(ctx);
@@ -180,15 +348,13 @@ withGlobals(identity.command("prune")).description("mark dead agents exited and
180
348
  json: changed
181
349
  });
182
350
  });
183
- const session = program.command("session").description("warm peer session lifecycle over a multiplexer");
184
351
  function defineSpawn(cmd) {
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([
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([
186
353
  "pane:right",
187
354
  "pane:down",
188
355
  "tab",
189
- "window",
190
356
  "workspace"
191
- ]).default("pane:right")).action((opts) => {
357
+ ])).option("--no-wake", "suppress the first-turn doorbell (spawn idle; the caller drives the first turn itself)").action(async (opts) => {
192
358
  const ctx = ctxOf(opts);
193
359
  touch(ctx);
194
360
  let harness = opts.harness;
@@ -207,7 +373,7 @@ function defineSpawn(cmd) {
207
373
  harness = realized.harness;
208
374
  command = realized.command;
209
375
  }
210
- 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");
211
377
  const res = spawn(ctx, {
212
378
  harness,
213
379
  command,
@@ -219,21 +385,30 @@ function defineSpawn(cmd) {
219
385
  cwd: opts.cwd,
220
386
  at: opts.at
221
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}`);
222
393
  emit(formatOf(opts), {
223
394
  toon: toonObject({
224
395
  spawned: res.agent.id,
225
396
  handle: res.agent.handle,
226
397
  harness: res.agent.harness,
227
398
  worktree: res.agent.worktree?.root,
228
- pane: res.pane
399
+ pane: res.pane,
400
+ rung: wake.rung
229
401
  }),
230
- json: res
402
+ json: {
403
+ ...res,
404
+ rung: wake.rung
405
+ }
231
406
  });
232
- nextStep(`cyberlegion session read ${res.agent.id}`);
407
+ nextStep(`cyberlegion unit read ${res.agent.id}`);
233
408
  });
234
409
  }
235
- defineSpawn(session.command("spawn"));
236
- 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) => {
237
412
  const ctx = ctxOf(opts);
238
413
  touch(ctx);
239
414
  const agent = resolveAgent(ctx.store, ref);
@@ -250,33 +425,7 @@ withGlobals(session.command("close")).description("tear down a unit's worktree +
250
425
  json: res
251
426
  });
252
427
  });
253
- withGlobals(session.command("list")).description("list live peer sessions").action((opts) => {
254
- const ctx = ctxOf(opts);
255
- touch(ctx);
256
- const agents = listAgents(ctx.store).filter((a) => a.status !== "exited");
257
- emit(formatOf(opts), {
258
- toon: toonList("sessions", agents, [
259
- {
260
- key: "id",
261
- get: (a) => a.id
262
- },
263
- {
264
- key: "handle",
265
- get: (a) => a.handle
266
- },
267
- {
268
- key: "status",
269
- get: (a) => a.status
270
- },
271
- {
272
- key: "pane",
273
- get: (a) => a.tmux?.pane ?? "-"
274
- }
275
- ], `${agents.length} sessions`),
276
- json: agents
277
- });
278
- });
279
- 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) => {
280
429
  const ctx = ctxOf(opts);
281
430
  touch(ctx);
282
431
  const target = resolveTarget(ctx, ref);
@@ -292,11 +441,12 @@ withGlobals(session.command("focus")).description("move input focus to a peer's
292
441
  }
293
442
  });
294
443
  });
295
- 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) => {
296
445
  const ctx = ctxOf(opts);
297
446
  touch(ctx);
298
447
  const target = resolveTarget(ctx, ref);
299
- 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);
300
450
  emit(formatOf(opts), {
301
451
  toon: toonObject({
302
452
  nudged: ref,
@@ -304,11 +454,13 @@ withGlobals(session.command("nudge")).description("ring a peer's session (a dumb
304
454
  }),
305
455
  json: {
306
456
  ref,
307
- pane: target.id
457
+ pane: target.id,
458
+ message,
459
+ resubmits: result.resubmits
308
460
  }
309
461
  });
310
462
  });
311
- 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) => {
312
464
  const ctx = ctxOf(opts);
313
465
  touch(ctx);
314
466
  const target = resolveTarget(ctx, ref);
@@ -320,9 +472,31 @@ withGlobals(session.command("read")).description("scrape a peer's session screen
320
472
  }, null, 2));
321
473
  else console.log(text);
322
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
+ });
323
497
  const mail = program.command("mail").description("durable inter-agent messaging");
324
498
  function defineSend(cmd) {
325
- 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) => {
326
500
  const ctx = ctxOf(opts);
327
501
  const fromId = opts.from ?? requireSelf(ctx);
328
502
  const body = resolveBody(opts.body, opts.bodyFile);
@@ -334,21 +508,30 @@ function defineSend(cmd) {
334
508
  thread: opts.thread,
335
509
  replyTo: opts.replyTo
336
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}`);
337
517
  emit(formatOf(opts), {
338
518
  toon: toonObject({
339
519
  sent: msg.id,
340
520
  to: opts.to,
341
- subject: msg.subject
521
+ subject: msg.subject,
522
+ rung: wake.rung
342
523
  }),
343
- json: msg
524
+ json: {
525
+ ...msg,
526
+ rung: wake.rung
527
+ }
344
528
  });
345
529
  });
346
530
  }
347
531
  defineSend(mail.command("send"));
348
- function runInbox(opts) {
349
- const ctx = ctxOf(opts);
350
- touch(ctx);
351
- const meId = opts.owner ? resolveStandingOwner(ctx.store, opts.owner) : 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) {
352
535
  const items = inbox({ store: ctx.store }, {
353
536
  meId,
354
537
  unread: opts.unread,
@@ -378,12 +561,34 @@ function runInbox(opts) {
378
561
  json: items
379
562
  });
380
563
  const firstUnread = items.find((m) => !m.read);
381
- if (firstUnread) nextStep(`cyberlegion mail read ${firstUnread.id}`);
564
+ if (firstUnread) nextStep(`${readCmd} ${firstUnread.id}`);
565
+ }
566
+ function runInbox(opts) {
567
+ const ctx = ctxOf(opts);
568
+ touch(ctx);
569
+ emitInbox(ctx, opts, opts.owner ? resolveStandingOwner(ctx.store, opts.owner) : requireSelf(ctx), "cyberlegion mail read");
382
570
  }
383
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);
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) => {
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) => {
385
573
  const ctx = ctxOf(opts);
386
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
+ }
387
592
  const msg = peek({ store: ctx.store }, meId, msgId);
388
593
  if (!msg) fail(`"${msgId}" is not a message in this inbox`);
389
594
  emit(formatOf(opts), {
@@ -473,96 +678,6 @@ withGlobals(mail.command("hook")).description("emit the harness hook injection p
473
678
  const payload = injectInbox(ctx, opts.event);
474
679
  if (payload) console.log(JSON.stringify(payload));
475
680
  });
476
- const dispatch = program.command("dispatch").description("delegate work and await a result (result-slot primitives)");
477
- function definePrepOptions(cmd) {
478
- 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)");
479
- }
480
- function dispatchResultFields(r) {
481
- return {
482
- id: r.id,
483
- verdict: r.verdict != null ? JSON.stringify(r.verdict) : void 0,
484
- body: r.body,
485
- ts: r.ts
486
- };
487
- }
488
- definePrepOptions(dispatch.command("prep")).description("allocate an id + brief + result slot and return the envelope — spawns nothing, never invokes Task").action((opts) => {
489
- const ctx = ctxOf(opts);
490
- let envelope;
491
- try {
492
- envelope = prep({ store: ctx.store }, {
493
- agent: opts.agent,
494
- agentFile: opts.agentFile,
495
- role: opts.role,
496
- briefText: opts.briefText,
497
- briefFile: opts.briefFile,
498
- thread: opts.thread
499
- });
500
- } catch (err) {
501
- fail(err instanceof Error ? err.message : String(err));
502
- }
503
- emit(formatOf(opts), {
504
- toon: toonObject({ ...envelope }),
505
- json: envelope
506
- });
507
- nextStep(`spawn a subagent with the instruction, then \`cyberlegion dispatch collect ${envelope.id}\` — or on the channel path, \`cyberlegion mail await --thread ${envelope.thread}\``);
508
- });
509
- 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([
510
- "pane:right",
511
- "pane:down",
512
- "tab",
513
- "window",
514
- "workspace"
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) => {
516
- const ctx = ctxOf(opts);
517
- touch(ctx);
518
- let result;
519
- try {
520
- result = await channel(ctx, {
521
- agent: opts.agent,
522
- agentFile: opts.agentFile,
523
- role: opts.role,
524
- briefText: opts.briefText,
525
- briefFile: opts.briefFile,
526
- verdictSchema: opts.verdictSchema,
527
- thread: opts.thread,
528
- at: opts.at,
529
- wait: opts.wait,
530
- timeoutMs: opts.timeout,
531
- maxWaitS: opts.maxWait
532
- });
533
- } catch (err) {
534
- if (err instanceof DispatchWaitingError) {
535
- console.error(err.message);
536
- return;
537
- }
538
- fail(err instanceof Error ? err.message : String(err));
539
- }
540
- if ("ts" in result) {
541
- emit(formatOf(opts), {
542
- toon: toonObject(dispatchResultFields(result)),
543
- json: result
544
- });
545
- return;
546
- }
547
- emit(formatOf(opts), {
548
- toon: toonObject({ ...result }),
549
- json: result
550
- });
551
- nextStep(`cyberlegion mail await --thread ${result.thread} to collect the reply later`);
552
- });
553
- 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) => {
554
- const ctx = ctxOf(opts);
555
- let result;
556
- try {
557
- result = collect({ store: ctx.store }, id, opts.verdictSchema);
558
- } catch (err) {
559
- fail(err instanceof Error ? err.message : String(err));
560
- }
561
- emit(formatOf(opts), {
562
- toon: toonObject(dispatchResultFields(result)),
563
- json: result
564
- });
565
- });
566
681
  const agent = program.command("agent").description("resolve reusable agent definitions");
567
682
  const INSTRUCTIONS_PREVIEW_LEN = 200;
568
683
  function truncated(text, full) {
@@ -654,31 +769,11 @@ withGlobals(agent.command("path")).description("print the resolved def file path
654
769
  json: { path: def.path }
655
770
  });
656
771
  });
657
- const admin = program.command("admin").description("setup and diagnostics");
658
- 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) => {
659
- const results = install(opts.agent, opts.dir);
660
- emit(formatOf(opts), {
661
- toon: toonList("hooks", results, [
662
- {
663
- key: "event",
664
- get: (r) => r.event
665
- },
666
- {
667
- key: "status",
668
- get: (r) => r.status
669
- },
670
- {
671
- key: "file",
672
- get: (r) => r.file
673
- }
674
- ], `${results.length} hooks`),
675
- json: results
676
- });
677
- });
678
- 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) => {
679
774
  const ctx = ctxOf(opts);
680
775
  const harness = detectHarness(void 0, ctx) ?? "unknown";
681
- const probe = probeMultiplexer(ctx.exec ?? realExec, ctx.env ?? process.env);
776
+ const probe = probeMultiplexer(ctx.exec ?? realExec, normalizeMuxEnv(ctx.env ?? process.env));
682
777
  const selfId = resolveSelfId(ctx) ?? "-";
683
778
  emit(formatOf(opts), {
684
779
  toon: toonObject({
@@ -698,19 +793,46 @@ withGlobals(admin.command("doctor")).description("probe harness, multiplexer (an
698
793
  selfId
699
794
  }
700
795
  });
701
- 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`);
702
797
  });
703
- withGlobals(admin.command("mode")).description("report the detected session-backend mode").action((opts) => {
704
- 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";
705
801
  try {
706
- mux = selectSessionAdapter(process.env).name;
802
+ mode = selectSessionAdapter(ctx.env ?? process.env).name;
707
803
  } catch {}
708
804
  emit(formatOf(opts), {
709
- toon: toonObject({ mode: mux }),
710
- json: { mode: mux }
805
+ toon: toonObject({ mode }),
806
+ json: { mode }
711
807
  });
712
808
  });
713
- 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) => {
714
836
  const res = migrateStore(new FileStore(resolve(opts.from)), new FileStore(resolve(opts.to)));
715
837
  emit(formatOf(opts), {
716
838
  toon: toonObject({
@@ -721,10 +843,51 @@ withGlobals(admin.command("migrate")).description("merge one hub root state into
721
843
  json: res
722
844
  });
723
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
+ });
724
887
  defineSpawn(program.command("spawn"));
725
888
  defineSend(program.command("send"));
726
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);
727
- 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);
728
891
  withGlobals(program).action((opts) => {
729
892
  const ctx = ctxOf(opts);
730
893
  touch(ctx);
@@ -752,7 +915,7 @@ withGlobals(program).action((opts) => {
752
915
  units
753
916
  }
754
917
  });
755
- if (!id) nextStep("cyberlegion identity register to join");
918
+ if (!id) nextStep("cyberlegion unit register to join");
756
919
  else if (unread > 0) nextStep("cyberlegion mail inbox --unread");
757
920
  });
758
921
  program.parseAsync(process.argv).catch((err) => {