cyberlegion 0.1.0 → 0.3.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 bumpLastSeen, B as registerStanding, C as nudgeUnit, D as selectSessionAdapter, E as spawnAndWake, F as loadAgent, G as resolveStandingOwner, H as resolvePresence, I as prune, J as listAgentDefs, K as touch, L as realExec, M as clearPresence, N as detectHarness, O as DELIVERY_DOORBELL, P as listAgents, R as reconcile, S as focusUnit, V as resolveAgent, W as resolveSelfId, Y as resolveAgentDef, _ as install, a as emit, b as resolveSpawnLaunch, c as toonList, d as deleteMessage, et as resolveRoot, f as inbox, g as send, h as resolveBody, i as injectInbox, j as claimPresence, k as wakeRecipient, l as toonObject, m as readAck, n as awaitReply, o as fail, p as peek, q as normalizeMuxEnv, r as FileStore, s as nextStep, t as watchMail, u as ack, v as decommission, w as readUnit, x as clearUnit, z as register } from "./watch-BZmEAqUo.mjs";
3
3
  import { resolve } from "node:path";
4
4
  import { Command, Option } from "commander";
5
+ import { currentPane, 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,41 @@ function migrateStore(from, to) {
37
38
  };
38
39
  }
39
40
  //#endregion
41
+ //#region src/cli-input.ts
42
+ /**
43
+ * Translate `unit spawn`'s options into the spawn input and its wake decision — resolving an
44
+ * `--agent`/`--agent-file` def into the harness and composed launch command, with an explicit
45
+ * `--harness` overriding the def's own.
46
+ *
47
+ * Throws when no harness can be resolved, so the CLI's own `fail()` still renders it.
48
+ */
49
+ function spawnCommandInput(opts) {
50
+ const { harness, command } = resolveSpawnLaunch({
51
+ agent: opts.agent,
52
+ agentFile: opts.agentFile,
53
+ harness: opts.harness
54
+ });
55
+ if (!harness) throw new Error("unit spawn needs --harness, or --agent/--agent-file resolving one");
56
+ return {
57
+ input: {
58
+ harness,
59
+ command,
60
+ task: opts.task,
61
+ briefFile: opts.briefFile,
62
+ handle: opts.handle,
63
+ branch: opts.branch,
64
+ worktreePath: opts.worktreePath,
65
+ cwd: opts.cwd,
66
+ at: opts.at
67
+ },
68
+ noWake: opts.wake === false
69
+ };
70
+ }
71
+ /** What `unit read` prints: the raw scrape, or the JSON envelope under `--format json`. */
72
+ function readCommandOutput(format, result) {
73
+ return format === "json" ? JSON.stringify(result, null, 2) : result.output;
74
+ }
75
+ //#endregion
40
76
  //#region src/cli.ts
41
77
  const VERSION = "0.0.0";
42
78
  function ctxOf(opts) {
@@ -50,55 +86,18 @@ function formatOf(opts) {
50
86
  }
51
87
  function requireSelf(ctx) {
52
88
  const id = resolveSelfId(ctx);
53
- if (!id) fail("no identity in this session — run `cyberlegion identity register` first");
89
+ if (!id) fail("no identity in this session — run `cyberlegion unit register` first");
54
90
  bumpLastSeen(ctx, id);
55
91
  return id;
56
92
  }
57
- function resolveTarget(ctx, ref) {
58
- const agent = resolveAgent(ctx.store, ref);
59
- const pane = agent.tmux?.pane ?? ctx.store.findPaneByAgentId(agent.id);
60
- if (!pane) fail(`unit "${ref}" has no known session pane`);
61
- return { id: pane };
62
- }
63
93
  function withGlobals(cmd) {
64
94
  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
95
  }
66
96
  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);
97
+ program.name("cyberlegion").description("Harness-agnostic agent session spawning and messaging over the filesystem").version(VERSION).enablePositionalOptions();
98
+ const unit = program.command("unit").description("legion units — register, discover, spawn, and reap");
99
+ /** The standing-owner branch of `unit register --standing` (folds the old `identity owner`). */
100
+ function runStanding(ctx, opts) {
102
101
  if (!opts.handle) {
103
102
  const standing = listAgents(ctx.store).filter((a) => a.kind === "standing");
104
103
  emit(formatOf(opts), {
@@ -119,7 +118,7 @@ withGlobals(identity.command("owner")).description("mint (or refresh) a standing
119
118
  key: "status",
120
119
  get: (a) => a.status
121
120
  }
122
- ], `${standing.length} agents`),
121
+ ], `${standing.length} standing`),
123
122
  json: standing
124
123
  });
125
124
  return;
@@ -136,13 +135,94 @@ withGlobals(identity.command("owner")).description("mint (or refresh) a standing
136
135
  }),
137
136
  json: rec
138
137
  });
138
+ }
139
+ 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) => {
140
+ const ctx = ctxOf(opts);
141
+ if (opts.standing) {
142
+ runStanding(ctx, opts);
143
+ return;
144
+ }
145
+ const rec = register(ctx, {
146
+ handle: opts.handle,
147
+ harness: opts.harness
148
+ });
149
+ emit(formatOf(opts), {
150
+ toon: toonObject({
151
+ id: rec.id,
152
+ handle: rec.handle,
153
+ harness: rec.harness ?? "-",
154
+ status: rec.status
155
+ }),
156
+ json: rec
157
+ });
158
+ });
159
+ 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) => {
160
+ const ctx = ctxOf(opts);
161
+ if (opts.show) {
162
+ let presence;
163
+ try {
164
+ presence = resolvePresence(ctx.store, handle);
165
+ } catch (err) {
166
+ fail(err instanceof Error ? err.message : String(err));
167
+ }
168
+ emit(formatOf(opts), {
169
+ toon: toonObject({ presence: presence?.id ?? "none" }),
170
+ json: { presence: presence?.id ?? null }
171
+ });
172
+ return;
173
+ }
174
+ if (opts.clear) {
175
+ try {
176
+ clearPresence(ctx, handle);
177
+ } catch (err) {
178
+ fail(err instanceof Error ? err.message : String(err));
179
+ }
180
+ emit(formatOf(opts), {
181
+ toon: toonObject({ presence: "none" }),
182
+ json: { presence: null }
183
+ });
184
+ return;
185
+ }
186
+ let rec;
187
+ try {
188
+ rec = claimPresence(ctx, handle);
189
+ } catch (err) {
190
+ fail(err instanceof Error ? err.message : String(err));
191
+ }
192
+ emit(formatOf(opts), {
193
+ toon: toonObject({
194
+ owner: rec.handle,
195
+ presence: rec.presence
196
+ }),
197
+ json: {
198
+ owner: rec.handle,
199
+ presence: rec.presence
200
+ }
201
+ });
202
+ });
203
+ withGlobals(unit.command("whoami")).description("print this session own identity").action((opts) => {
204
+ const ctx = ctxOf(opts);
205
+ const id = resolveSelfId(ctx);
206
+ if (!id) fail("no identity in this session — run `cyberlegion unit register` first");
207
+ const rec = loadAgent(ctx.store, id);
208
+ if (!rec) fail(`registered self id "${id}" has no agent record`);
209
+ emit(formatOf(opts), {
210
+ toon: toonObject({
211
+ id: rec.id,
212
+ handle: rec.handle,
213
+ harness: rec.harness ?? "-",
214
+ status: rec.status
215
+ }),
216
+ json: rec
217
+ });
139
218
  });
140
219
  function runWho(opts) {
141
220
  const ctx = ctxOf(opts);
142
221
  touch(ctx);
222
+ if (opts.reconcile) reconcile(ctx, { adopt: true });
143
223
  const agents = listAgents(ctx.store).filter((a) => opts.all || a.status !== "exited");
144
224
  emit(formatOf(opts), {
145
- toon: toonList("agents", agents, [
225
+ toon: toonList("units", agents, [
146
226
  {
147
227
  key: "id",
148
228
  get: (a) => a.id
@@ -158,14 +238,18 @@ function runWho(opts) {
158
238
  {
159
239
  key: "status",
160
240
  get: (a) => a.status
241
+ },
242
+ {
243
+ key: "pane",
244
+ get: (a) => a.pane?.id ?? "-"
161
245
  }
162
- ], `${agents.length} agents`),
246
+ ], `${agents.length} units`),
163
247
  json: agents
164
248
  });
165
- if (agents.length === 0) nextStep("cyberlegion identity register to join");
249
+ if (agents.length === 0) nextStep("cyberlegion unit register to join");
166
250
  }
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) => {
251
+ 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);
252
+ withGlobals(unit.command("prune")).description("mark dead units exited and sweep").action((opts) => {
169
253
  const ctx = ctxOf(opts);
170
254
  touch(ctx);
171
255
  const changed = prune(ctx);
@@ -180,60 +264,44 @@ withGlobals(identity.command("prune")).description("mark dead agents exited and
180
264
  json: changed
181
265
  });
182
266
  });
183
- const session = program.command("session").description("warm peer session lifecycle over a multiplexer");
184
267
  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([
268
+ 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
269
  "pane:right",
187
270
  "pane:down",
188
271
  "tab",
189
- "window",
190
272
  "workspace"
191
- ]).default("pane:right")).action((opts) => {
273
+ ])).option("--no-wake", "suppress the first-turn doorbell (spawn idle; the caller drives the first turn itself)").action(async (opts) => {
192
274
  const ctx = ctxOf(opts);
193
275
  touch(ctx);
194
- let harness = opts.harness;
195
- let command;
196
- if (opts.agent || opts.agentFile) {
197
- let def;
198
- try {
199
- def = resolveAgentDef({
200
- name: opts.agent,
201
- file: opts.agentFile
202
- });
203
- } catch (err) {
204
- fail(err instanceof Error ? err.message : String(err));
205
- }
206
- const realized = realizeLaunch(def, { harness: opts.harness });
207
- harness = realized.harness;
208
- command = realized.command;
276
+ let spawnInput;
277
+ try {
278
+ spawnInput = spawnCommandInput(opts);
279
+ } catch (err) {
280
+ fail(err instanceof Error ? err.message : String(err));
209
281
  }
210
- if (!harness) fail("session spawn needs --harness, or --agent/--agent-file resolving one");
211
- const res = spawn(ctx, {
212
- harness,
213
- command,
214
- task: opts.task,
215
- briefFile: opts.briefFile,
216
- handle: opts.handle,
217
- branch: opts.branch,
218
- worktreePath: opts.worktreePath,
219
- cwd: opts.cwd,
220
- at: opts.at
221
- });
282
+ const res = await spawnAndWake(ctx, spawnInput.input, { noWake: spawnInput.noWake });
283
+ if (res.warning) console.error(`first-turn doorbell not confirmed (peer still spawned; nudge it manually): ${res.warning}`);
222
284
  emit(formatOf(opts), {
223
285
  toon: toonObject({
224
286
  spawned: res.agent.id,
225
287
  handle: res.agent.handle,
226
288
  harness: res.agent.harness,
227
289
  worktree: res.agent.worktree?.root,
228
- pane: res.pane
290
+ pane: res.pane,
291
+ rung: res.rung
229
292
  }),
230
- json: res
293
+ json: {
294
+ agent: res.agent,
295
+ pane: res.pane,
296
+ launch: res.launch,
297
+ rung: res.rung
298
+ }
231
299
  });
232
- nextStep(`cyberlegion session read ${res.agent.id}`);
300
+ nextStep(`cyberlegion unit read ${res.agent.id}`);
233
301
  });
234
302
  }
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) => {
303
+ defineSpawn(unit.command("spawn"));
304
+ 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
305
  const ctx = ctxOf(opts);
238
306
  touch(ctx);
239
307
  const agent = resolveAgent(ctx.store, ref);
@@ -250,79 +318,73 @@ withGlobals(session.command("close")).description("tear down a unit's worktree +
250
318
  json: res
251
319
  });
252
320
  });
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) => {
321
+ 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
322
  const ctx = ctxOf(opts);
281
323
  touch(ctx);
282
- const target = resolveTarget(ctx, ref);
283
- selectSessionAdapter(ctx.env ?? process.env).focus(realExec, target);
324
+ const { pane } = focusUnit(ctx, ref);
284
325
  emit(formatOf(opts), {
285
326
  toon: toonObject({
286
327
  focused: ref,
287
- pane: target.id
328
+ pane
288
329
  }),
289
330
  json: {
290
331
  ref,
291
- pane: target.id
332
+ pane
292
333
  }
293
334
  });
294
335
  });
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) => {
336
+ 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
337
  const ctx = ctxOf(opts);
297
338
  touch(ctx);
298
- const target = resolveTarget(ctx, ref);
299
- selectSessionAdapter(ctx.env ?? process.env).send(realExec, target, "");
339
+ const { pane, message, resubmits } = await nudgeUnit(ctx, ref, { message: opts.message });
300
340
  emit(formatOf(opts), {
301
341
  toon: toonObject({
302
342
  nudged: ref,
303
- pane: target.id
343
+ pane
304
344
  }),
305
345
  json: {
306
346
  ref,
307
- pane: target.id
347
+ pane,
348
+ message,
349
+ resubmits
308
350
  }
309
351
  });
310
352
  });
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) => {
353
+ 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
354
  const ctx = ctxOf(opts);
313
355
  touch(ctx);
314
- const target = resolveTarget(ctx, ref);
315
- const text = selectSessionAdapter(ctx.env ?? process.env).read(realExec, target, { lines: opts.lines });
316
- if (formatOf(opts) === "json") console.log(JSON.stringify({
356
+ const { pane, output } = readUnit(ctx, ref, { lines: opts.lines });
357
+ console.log(readCommandOutput(formatOf(opts), {
317
358
  ref,
318
- pane: target.id,
319
- output: text
320
- }, null, 2));
321
- else console.log(text);
359
+ pane,
360
+ output
361
+ }));
362
+ });
363
+ 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) => {
364
+ const ctx = ctxOf(opts);
365
+ touch(ctx);
366
+ let res;
367
+ try {
368
+ res = clearUnit(ctx, ref);
369
+ } catch (err) {
370
+ fail(err instanceof Error ? err.message : String(err));
371
+ }
372
+ emit(formatOf(opts), {
373
+ toon: toonObject({
374
+ cleared: ref,
375
+ pane: res.pane,
376
+ command: res.command
377
+ }),
378
+ json: {
379
+ cleared: ref,
380
+ pane: res.pane,
381
+ command: res.command
382
+ }
383
+ });
322
384
  });
323
385
  const mail = program.command("mail").description("durable inter-agent messaging");
324
386
  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) => {
387
+ 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
388
  const ctx = ctxOf(opts);
327
389
  const fromId = opts.from ?? requireSelf(ctx);
328
390
  const body = resolveBody(opts.body, opts.bodyFile);
@@ -334,21 +396,30 @@ function defineSend(cmd) {
334
396
  thread: opts.thread,
335
397
  replyTo: opts.replyTo
336
398
  });
399
+ const wake = await wakeRecipient(ctx.store, () => selectSessionAdapter(ctx.env ?? process.env), realExec, {
400
+ toId: msg.to,
401
+ fromId,
402
+ noNudge: opts.nudge === false
403
+ });
404
+ if (wake.warning) console.error(`delivery doorbell not confirmed (message still delivered): ${wake.warning}`);
337
405
  emit(formatOf(opts), {
338
406
  toon: toonObject({
339
407
  sent: msg.id,
340
408
  to: opts.to,
341
- subject: msg.subject
409
+ subject: msg.subject,
410
+ rung: wake.rung
342
411
  }),
343
- json: msg
412
+ json: {
413
+ ...msg,
414
+ rung: wake.rung
415
+ }
344
416
  });
345
417
  });
346
418
  }
347
419
  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);
420
+ /** List a resolved inbox (the caller's own or an --owner mailbox) as TOON with an aggregate;
421
+ * `readCmd` is the follow-up read command prefix surfaced as the next step. */
422
+ function emitInbox(ctx, opts, meId, readCmd) {
352
423
  const items = inbox({ store: ctx.store }, {
353
424
  meId,
354
425
  unread: opts.unread,
@@ -378,12 +449,34 @@ function runInbox(opts) {
378
449
  json: items
379
450
  });
380
451
  const firstUnread = items.find((m) => !m.read);
381
- if (firstUnread) nextStep(`cyberlegion mail read ${firstUnread.id}`);
452
+ if (firstUnread) nextStep(`${readCmd} ${firstUnread.id}`);
453
+ }
454
+ function runInbox(opts) {
455
+ const ctx = ctxOf(opts);
456
+ touch(ctx);
457
+ emitInbox(ctx, opts, opts.owner ? resolveStandingOwner(ctx.store, opts.owner) : requireSelf(ctx), "cyberlegion mail read");
382
458
  }
383
459
  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) => {
460
+ 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
461
  const ctx = ctxOf(opts);
386
462
  const meId = opts.owner ? resolveStandingOwner(ctx.store, opts.owner) : requireSelf(ctx);
463
+ if (opts.ack) {
464
+ const { msg, acked } = readAck({ store: ctx.store }, meId, msgId);
465
+ emit(formatOf(opts), {
466
+ toon: toonObject({
467
+ id: msg.id,
468
+ from: msg.fromHandle,
469
+ subject: msg.subject,
470
+ body: msg.body,
471
+ acked
472
+ }),
473
+ json: {
474
+ ...msg,
475
+ acked
476
+ }
477
+ });
478
+ return;
479
+ }
387
480
  const msg = peek({ store: ctx.store }, meId, msgId);
388
481
  if (!msg) fail(`"${msgId}" is not a message in this inbox`);
389
482
  emit(formatOf(opts), {
@@ -473,96 +566,6 @@ withGlobals(mail.command("hook")).description("emit the harness hook injection p
473
566
  const payload = injectInbox(ctx, opts.event);
474
567
  if (payload) console.log(JSON.stringify(payload));
475
568
  });
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
569
  const agent = program.command("agent").description("resolve reusable agent definitions");
567
570
  const INSTRUCTIONS_PREVIEW_LEN = 200;
568
571
  function truncated(text, full) {
@@ -654,31 +657,11 @@ withGlobals(agent.command("path")).description("print the resolved def file path
654
657
  json: { path: def.path }
655
658
  });
656
659
  });
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) => {
660
+ const mux = program.command("mux").description("the unit-agnostic pane layer — multiplexer detection and diagnostics");
661
+ withGlobals(mux.command("doctor")).description("probe harness, multiplexer (ancestry-discovered), hub root, and self-id").action((opts) => {
679
662
  const ctx = ctxOf(opts);
680
663
  const harness = detectHarness(void 0, ctx) ?? "unknown";
681
- const probe = probeMultiplexer(ctx.exec ?? realExec, ctx.env ?? process.env);
664
+ const probe = probeMultiplexer(ctx.exec ?? realExec, normalizeMuxEnv(ctx.env ?? process.env));
682
665
  const selfId = resolveSelfId(ctx) ?? "-";
683
666
  emit(formatOf(opts), {
684
667
  toon: toonObject({
@@ -698,19 +681,46 @@ withGlobals(admin.command("doctor")).description("probe harness, multiplexer (an
698
681
  selfId
699
682
  }
700
683
  });
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`);
684
+ 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
685
  });
703
- withGlobals(admin.command("mode")).description("report the detected session-backend mode").action((opts) => {
704
- let mux = "none";
686
+ withGlobals(mux.command("mode")).description("report the detected session-backend mode").action((opts) => {
687
+ const ctx = ctxOf(opts);
688
+ let mode = "none";
705
689
  try {
706
- mux = selectSessionAdapter(process.env).name;
690
+ mode = selectSessionAdapter(ctx.env ?? process.env).name;
707
691
  } catch {}
708
692
  emit(formatOf(opts), {
709
- toon: toonObject({ mode: mux }),
710
- json: { mode: mux }
693
+ toon: toonObject({ mode }),
694
+ json: { mode }
711
695
  });
712
696
  });
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) => {
697
+ 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) => {
698
+ const ctx = ctxOf(opts);
699
+ if (opts.show) {
700
+ const pane = ctx.store.getMainPane();
701
+ emit(formatOf(opts), {
702
+ toon: toonObject({ mainPane: pane ?? "none" }),
703
+ json: { mainPane: pane ?? null }
704
+ });
705
+ return;
706
+ }
707
+ if (opts.clear) {
708
+ ctx.store.setMainPane(null);
709
+ emit(formatOf(opts), {
710
+ toon: toonObject({ mainPane: "none" }),
711
+ json: { mainPane: null }
712
+ });
713
+ return;
714
+ }
715
+ const cur = currentPane(normalizeMuxEnv(ctx.env ?? process.env));
716
+ if (!cur) fail("no multiplexer pane to bind — run this from inside a tmux or herdr pane");
717
+ ctx.store.setMainPane(cur.pane);
718
+ emit(formatOf(opts), {
719
+ toon: toonObject({ mainPane: cur.pane }),
720
+ json: { mainPane: cur.pane }
721
+ });
722
+ });
723
+ 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
724
  const res = migrateStore(new FileStore(resolve(opts.from)), new FileStore(resolve(opts.to)));
715
725
  emit(formatOf(opts), {
716
726
  toon: toonObject({
@@ -721,10 +731,51 @@ withGlobals(admin.command("migrate")).description("merge one hub root state into
721
731
  json: res
722
732
  });
723
733
  });
734
+ 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) => {
735
+ const ctx = ctxOf(opts);
736
+ let harness;
737
+ try {
738
+ const detected = detectHarness(opts.agent, ctx);
739
+ if (!detected) fail("could not detect harness — pass --agent claude|cursor|codex");
740
+ harness = detected;
741
+ } catch (err) {
742
+ fail(err instanceof Error ? err.message : String(err));
743
+ }
744
+ let results;
745
+ try {
746
+ results = install(harness, opts.dir, opts.pin);
747
+ } catch (err) {
748
+ fail(err instanceof Error ? err.message : String(err));
749
+ }
750
+ emit(formatOf(opts), {
751
+ toon: toonList("hooks", results, [
752
+ {
753
+ key: "event",
754
+ get: (r) => r.event
755
+ },
756
+ {
757
+ key: "status",
758
+ get: (r) => r.status
759
+ },
760
+ {
761
+ key: "file",
762
+ get: (r) => r.file
763
+ }
764
+ ], `harness ${harness}, ${results.length} hooks`),
765
+ json: {
766
+ harness,
767
+ hooks: results
768
+ }
769
+ });
770
+ if (!listAgents(ctx.store).some((a) => a.kind === "standing")) {
771
+ nextStep("cyberlegion unit register --standing --handle <name> to mint the durable owner inbox");
772
+ nextStep("cyberlegion attach to bind this pane as the owner live presence");
773
+ }
774
+ });
724
775
  defineSpawn(program.command("spawn"));
725
776
  defineSend(program.command("send"));
726
777
  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);
778
+ 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
779
  withGlobals(program).action((opts) => {
729
780
  const ctx = ctxOf(opts);
730
781
  touch(ctx);
@@ -752,12 +803,24 @@ withGlobals(program).action((opts) => {
752
803
  units
753
804
  }
754
805
  });
755
- if (!id) nextStep("cyberlegion identity register to join");
806
+ if (!id) nextStep("cyberlegion unit register to join");
756
807
  else if (unread > 0) nextStep("cyberlegion mail inbox --unread");
757
808
  });
758
- program.parseAsync(process.argv).catch((err) => {
759
- console.error(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
760
- process.exit(1);
761
- });
809
+ /**
810
+ * Run the CLI. Exported rather than executed at module scope so the command bodies are reachable
811
+ * from a test: with a bare `program.parseAsync(process.argv)` here, importing this module ran the
812
+ * whole CLI, so every `.action()` body — and every option wire inside it — was unreachable, and a
813
+ * flag that stopped being forwarded changed real behavior with the suite green.
814
+ *
815
+ * `bin/cyberlegion.mjs` calls this; nothing else should.
816
+ */
817
+ async function runCli(argv = process.argv) {
818
+ try {
819
+ await program.parseAsync(argv);
820
+ } catch (err) {
821
+ console.error(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
822
+ process.exit(1);
823
+ }
824
+ }
762
825
  //#endregion
763
- export {};
826
+ export { runCli };