cyberlegion 1.7.0 → 1.9.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cyberlegion",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Agent session spawning, messaging, and dispatch — the Legion and its Legate.",
5
5
  "author": {
6
6
  "name": "unional"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cyberlegion",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Agent session spawning, messaging, and dispatch — the Legion and its Legate.",
5
5
  "author": {
6
6
  "name": "unional"
package/.plugin/pins.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "cyberlegion": "1.7.0"
2
+ "cyberlegion": "1.9.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # cyberlegion
2
2
 
3
+ ## 1.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - deed34d: `mail inbox --unit <handle|id>` lists another unit's mail without acking it and without the caller taking that unit's identity. Exited units resolve too, so a cleanup sweep can check whether a dead unit left unread mail without reading the hub's files itself. The output carries a `mailbox` state of `none` (no mail was ever delivered), `present`, or `unreadable`. An unreadable mailbox exits 1, so a caller never reads it as empty.
8
+
9
+ ### Patch Changes
10
+
11
+ - a7a2d57: `unit spawn` no longer leaves several unsent "Read your brief" lines in a new session's input box. The first-turn ring now waits for the harness to draw its input box before typing; keys typed while a harness boots are taken as one paste, so each Enter became a newline and re-typed copies piled up. Copies of the doorbell sitting in the input box are also no longer counted as a posted turn, so a staged doorbell is flushed with Enter instead of reported rung.
12
+
13
+ ## 1.8.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 991f54c: `unit close` now closes a unit whose worktree is its repository's primary checkout instead of refusing it. It ends the session and deletes the record, pane pointer, brief and mailbox, and never removes, resets or cleans the checkout, so no `--force` is needed and uncommitted changes there are not refused. `--force` is still refused for such a unit, since it asks to discard changes in the checkout. This lets a live seat on a main checkout, or an exited record pointing at one, be removed for good.
18
+ - 999b959: Update runtime dependencies.
19
+
3
20
  ## 1.7.0
4
21
 
5
22
  ### Minor Changes
package/dist/cli.mjs CHANGED
@@ -2963,7 +2963,7 @@ function useColor() {
2963
2963
  }
2964
2964
  new Command();
2965
2965
  //#endregion
2966
- //#region ../../node_modules/.pnpm/cyber-mux@0.11.0/node_modules/cyber-mux/dist/exec-Bmdcg_T7.mjs
2966
+ //#region ../../node_modules/.pnpm/cyber-mux@0.12.0/node_modules/cyber-mux/dist/exec-Bmdcg_T7.mjs
2967
2967
  const nodeExec$1 = (cmd, args) => {
2968
2968
  try {
2969
2969
  const out = execFileSync(cmd, args, {
@@ -2991,7 +2991,7 @@ function withReason(exec, message) {
2991
2991
  return exec.lastError ? `${message} — ${exec.lastError}` : message;
2992
2992
  }
2993
2993
  //#endregion
2994
- //#region ../../node_modules/.pnpm/cyber-mux@0.11.0/node_modules/cyber-mux/dist/backend-D3VSsev4.mjs
2994
+ //#region ../../node_modules/.pnpm/cyber-mux@0.12.0/node_modules/cyber-mux/dist/backend-D3VSsev4.mjs
2995
2995
  const KNOWN_MUX = [
2996
2996
  "tmux",
2997
2997
  "rmux",
@@ -10248,7 +10248,7 @@ async function exists(path) {
10248
10248
  }
10249
10249
  }
10250
10250
  //#endregion
10251
- //#region ../../node_modules/.pnpm/cyber-mux@0.11.0/node_modules/cyber-mux/dist/worktree.mjs
10251
+ //#region ../../node_modules/.pnpm/cyber-mux@0.12.0/node_modules/cyber-mux/dist/worktree.mjs
10252
10252
  /**
10253
10253
  * This module's own refusals and failures — plain cyber-mux prose, never a dependency's raw words.
10254
10254
  * `reportWorktreeFailure` (`cli.ts`) forwards a `WorktreeGitError`'s message onto stdout verbatim
@@ -10671,6 +10671,20 @@ function resolveOwnerMailbox(store, ref) {
10671
10671
  if (services.length > 1) throw new Error(`"${ref}" names ${services.length} service endpoints — pass an endpoint id (${services.map((a) => a.id).join(", ")})`);
10672
10672
  return resolveStandingOwner(store, ref);
10673
10673
  }
10674
+ /** Resolve the unit whose mailbox `mail inbox --unit` reads, by id or handle. Unlike a recipient, an
10675
+ * exited unit resolves: the caller is deciding about that unit (a sweep asking whether a dead unit left
10676
+ * unread mail), not writing to it. A live holder of the handle wins; several exited holders and no live
10677
+ * one fail rather than picking one. */
10678
+ function resolveUnitMailbox(store, ref) {
10679
+ const byId = loadAgent(store, ref);
10680
+ if (byId) return byId;
10681
+ const { live, exited } = matchHandle(listAgents(store), ref);
10682
+ const byHandle = pickLive(ref, live);
10683
+ if (byHandle) return byHandle;
10684
+ if (exited.length > 1) throw new Error(`"${ref}" names ${exited.length} exited units — pass an id (${exited.map((a) => a.id).join(", ")})`);
10685
+ if (exited[0]) return exited[0];
10686
+ throw new Error(`no unit addressable as "${ref}" (tried id and handle)`);
10687
+ }
10674
10688
  /**
10675
10689
  * Bind the caller's own unit as a standing owner's presence — the live unit standing in for a
10676
10690
  * durable record that has no session of its own. Order matters: resolve the standing record first
@@ -11016,6 +11030,10 @@ function readShape(rows, shape) {
11016
11030
  }
11017
11031
  return { kind: "unknown" };
11018
11032
  }
11033
+ /** Whether this reader knows `harness`'s input box, so a missing box on screen means it is not drawn yet. */
11034
+ function knowsPromptOf(harness) {
11035
+ return harness !== void 0 && SHAPES[harness] !== void 0;
11036
+ }
11019
11037
  /**
11020
11038
  * Read the input box off `screen` — a plain-text scrape of the pane — with the shape of `harness`,
11021
11039
  * or, when the harness is not known, with each known shape in turn until one recognizes the box.
@@ -11116,6 +11134,27 @@ function copiesOn(screen, message) {
11116
11134
  return screen.split("\n").filter((row) => row.replace(/\s+/g, " ").includes(needle)).length;
11117
11135
  }
11118
11136
  /**
11137
+ * Read `screen` for the ring: whether `message` sits in the input box, and how many copies of it
11138
+ * are on screen outside the box. With the box read by its harness's shape, copies inside the box are
11139
+ * left out of the count: a booting harness can take several typed copies as one paste, and those
11140
+ * copies are staged text, not posted turns. A screen with no input box the reader knows falls back to
11141
+ * cyber-mux's `isStaged`, and the box's copy is counted with the rest.
11142
+ */
11143
+ function readRing(screen, message, harness) {
11144
+ const box = screen ? readPrompt(screen, harness) : { kind: "unknown" };
11145
+ if (box.kind === "unknown") return {
11146
+ boxKnown: false,
11147
+ staged: isStaged(screen, message),
11148
+ outside: copiesOn(screen, message)
11149
+ };
11150
+ const inBox = box.kind === "draft" ? copiesOn(box.text, message) : 0;
11151
+ return {
11152
+ boxKnown: true,
11153
+ staged: inBox > 0,
11154
+ outside: copiesOn(screen, message) - inBox
11155
+ };
11156
+ }
11157
+ /**
11119
11158
  * Submit `message` to `target` as a taken turn: cyber-mux's `nudge`, with one more way to see that the
11120
11159
  * turn was taken.
11121
11160
  *
@@ -11135,18 +11174,32 @@ function copiesOn(screen, message) {
11135
11174
  * queue. Text that is neither staged nor posted is lost: after one more settle for a slow redraw, it
11136
11175
  * is typed again. It is opt-in because it needs the posted copy to stay on screen: a busy peer's
11137
11176
  * output can scroll earlier copies away, so a ring to it could read as lost and be typed twice.
11177
+ *
11178
+ * With `requirePosted` and a harness whose input box this reader knows, nothing is typed until the
11179
+ * harness has drawn that box, for up to the
11180
+ * whole retry budget. Keys typed while a harness boots are buffered, and the harness takes them as one
11181
+ * paste once it draws the box: each Enter becomes a newline, so re-typed copies pile up in the box
11182
+ * unsent. A box that never shows within the budget is typed into anyway, as before.
11183
+ *
11184
+ * Where the input box can be read, copies of the text inside it are not counted as posted: only a
11185
+ * copy outside the box means the harness took the turn.
11138
11186
  */
11139
11187
  async function ringTurn(adapter, exec, target, message, opts = {}) {
11140
11188
  const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
11141
11189
  const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
11142
11190
  const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
11143
11191
  if (!adapter.paneExists(exec, target)) throw new Error(`nudge failed: pane ${target.id} no longer exists — the peer's session is gone, not busy.`);
11144
- const before = copiesOn(adapter.read(exec, target).text, message);
11192
+ let first = adapter.read(exec, target).text;
11193
+ if (opts.requirePosted && knowsPromptOf(opts.harness)) for (let wait = 0; wait < attempts && !readRing(first, message, opts.harness).boxKnown; wait++) {
11194
+ await sleep(settleMs);
11195
+ first = adapter.read(exec, target).text;
11196
+ }
11197
+ const before = readRing(first, message, opts.harness).outside;
11145
11198
  const check = () => {
11146
- const screen = adapter.read(exec, target).text;
11147
- const copies = copiesOn(screen, message);
11148
- if (isStaged(screen, message)) return copies >= before + 2 ? "taken" : "staged";
11149
- return copies > before || !opts.requirePosted ? "taken" : "lost";
11199
+ const seen = readRing(adapter.read(exec, target).text, message, opts.harness);
11200
+ const posted = seen.outside >= before + (seen.boxKnown || !seen.staged ? 1 : 2);
11201
+ if (seen.staged) return posted ? "taken" : "staged";
11202
+ return posted || !opts.requirePosted ? "taken" : "lost";
11150
11203
  };
11151
11204
  adapter.submit(exec, target, message);
11152
11205
  await sleep(settleMs);
@@ -11306,7 +11359,10 @@ async function wakeRecipient(store, getAdapter, exec, input, nudgeOpts, guardOpt
11306
11359
  try {
11307
11360
  const adapter = getAdapter();
11308
11361
  const target = { id: pane };
11309
- await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, DELIVERY_DOORBELL, nudgeOpts), {
11362
+ await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, DELIVERY_DOORBELL, {
11363
+ ...nudgeOpts,
11364
+ harness
11365
+ }), {
11310
11366
  ...guardOpts,
11311
11367
  harness,
11312
11368
  ownText: (text) => isRingText(text)
@@ -11351,7 +11407,8 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
11351
11407
  const doorbell = (input.resumed ? resumeDoorbell : spawnDoorbell)(input.briefPath);
11352
11408
  await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, doorbell, {
11353
11409
  ...nudgeOpts,
11354
- requirePosted: true
11410
+ requirePosted: true,
11411
+ harness: input.harness
11355
11412
  }), {
11356
11413
  ...guardOpts,
11357
11414
  harness: input.harness,
@@ -12575,6 +12632,26 @@ function inbox(ctx, q) {
12575
12632
  if (q.thread) items = items.filter((m) => m.thread === q.thread);
12576
12633
  return items.sort((a, b) => a.id.localeCompare(b.id));
12577
12634
  }
12635
+ /** List another unit's mail read-only, for a caller deciding about that unit (a cleanup sweep): never
12636
+ * acks, and reports a missing or unreadable mailbox as its own state rather than as an empty one. */
12637
+ function unitInbox(ctx, q) {
12638
+ if (!ctx.store.hasMailbox(q.meId)) return {
12639
+ mailbox: "none",
12640
+ messages: []
12641
+ };
12642
+ try {
12643
+ return {
12644
+ mailbox: "present",
12645
+ messages: inbox(ctx, q)
12646
+ };
12647
+ } catch (err) {
12648
+ return {
12649
+ mailbox: "unreadable",
12650
+ messages: [],
12651
+ error: err instanceof Error ? err.message : String(err)
12652
+ };
12653
+ }
12654
+ }
12578
12655
  /** Peek at a message (unread or already-acked) without changing its state. */
12579
12656
  function peek(ctx, meId, msgId) {
12580
12657
  const snap = ctx.store.listInbox(meId);
@@ -12612,8 +12689,7 @@ function deleteMailbox(store, id) {
12612
12689
  //#endregion
12613
12690
  //#region src/decommission.ts
12614
12691
  /**
12615
- * Tear a unit down and reap its registry record — the deterministic inverse of `spawn`. Refuses
12616
- * the primary checkout (absolute — neither `--force` nor `--keep-worktree` overrides it) and a
12692
+ * Tear a unit down and reap its registry record — the deterministic inverse of `spawn`. Refuses a
12617
12693
  * dirty worktree unless `--force`. Teardown always precedes reap: an already-gone worktree or pane
12618
12694
  * is tolerated, but a genuine worktree-removal failure aborts and leaves the record intact so the
12619
12695
  * operation is retryable.
@@ -12630,6 +12706,12 @@ function deleteMailbox(store, id) {
12630
12706
  * discarded, so neither the dirty refusal nor `--force` applies — a dirty worktree is skipped by the
12631
12707
  * next `acquire` rather than lost. Release precedes the pane teardown, so a release that throws leaves
12632
12708
  * everything for a retry, as a failed removal does.
12709
+ *
12710
+ * A unit whose worktree is its repository's primary checkout — a seat registered on a main checkout —
12711
+ * is closed without touching the checkout: its session ends and its record, brief and mailbox are
12712
+ * reaped, but nothing is removed, reset or cleaned, so no dirty check applies and no `--force` is
12713
+ * needed. `--force` is refused there, since it asks to discard changes in the checkout. The checkout
12714
+ * is not reported as retained: it is no pool path a next unit could be spawned into.
12633
12715
  */
12634
12716
  async function decommission(ctx, input) {
12635
12717
  const rec = loadAgent(ctx.store, input.id);
@@ -12641,8 +12723,9 @@ async function decommission(ctx, input) {
12641
12723
  const worktreeExists = worktreeRoot != null && existsSync(worktreeRoot);
12642
12724
  const primaryRoot = worktreeExists ? await primaryRootOf(exec, worktreeRoot) : rec.worktree?.primaryRoot;
12643
12725
  const held = rec.worktree?.lease;
12644
- if (worktreeRoot && primaryRoot && resolve(worktreeRoot) === resolve(primaryRoot)) throw new Error(`refusing to decommission "${input.id}" — its worktree is the primary checkout; neither --force nor --keep-worktree overrides this`);
12645
- const removesWorktree = worktreeExists && !input.keepWorktree && !held;
12726
+ const onPrimary = worktreeRoot != null && primaryRoot != null && resolve(worktreeRoot) === resolve(primaryRoot);
12727
+ if (onPrimary && input.force) throw new Error(`refusing to close "${input.id}" with --force — its worktree is the primary checkout, which close never alters; close it without --force to end its session and reap its record`);
12728
+ const removesWorktree = worktreeExists && !input.keepWorktree && !held && !onPrimary;
12646
12729
  if (removesWorktree && !input.force && isDirty(exec, worktreeRoot)) throw new Error(`unit "${input.id}" has uncommitted changes in its worktree — pass --force to discard them`);
12647
12730
  const pane = rec.pane?.id ?? ctx.store.findPaneByAgentId(input.id);
12648
12731
  if (worktreeRoot && !worktreeExists && primaryRoot && existsSync(primaryRoot)) exec("git", [
@@ -12671,7 +12754,7 @@ async function decommission(ctx, input) {
12671
12754
  return {
12672
12755
  agent: rec,
12673
12756
  worktreeRoot,
12674
- retainedWorktree: worktreeExists && (input.keepWorktree || held) ? worktreeRoot : void 0,
12757
+ retainedWorktree: worktreeExists && !onPrimary && (input.keepWorktree || held) ? worktreeRoot : void 0,
12675
12758
  ...lease ? { lease } : {},
12676
12759
  pane
12677
12760
  };
@@ -14002,6 +14085,10 @@ var FileStore = class {
14002
14085
  read: read ? readMessages(read) : []
14003
14086
  };
14004
14087
  }
14088
+ hasMailbox(id) {
14089
+ const dir = readPathOrUndefined(() => paths.inboxDir(this.root, id));
14090
+ return dir !== void 0 && existsSync(dir);
14091
+ }
14005
14092
  ackMessage(id, msgId) {
14006
14093
  const src = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
14007
14094
  if (!src || !existsSync(src)) throw new Error(`"${msgId}" is not an unread message in this inbox`);
@@ -14785,7 +14872,7 @@ function defineSpawn(cmd) {
14785
14872
  });
14786
14873
  }
14787
14874
  defineSpawn(unit.command("spawn"));
14788
- syncsUnits(withGlobals(unit.command("close"))).description("release the unit's worktree lease (or remove a worktree it holds no lease on), tear down its 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 a worktree close removes (never overrides refusing the primary checkout)").option("--keep-worktree", "leave the worktree on disk for reuse and reap everything else (skips the dirty check; never overrides refusing the primary checkout)").action(async (ref, opts) => {
14875
+ syncsUnits(withGlobals(unit.command("close"))).description("release the unit's worktree lease (or remove a worktree it holds no lease on), tear down its 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 a worktree close removes (refused for a unit on the primary checkout)").option("--keep-worktree", "leave the worktree on disk for reuse and reap everything else (skips the dirty check)").action(async (ref, opts) => {
14789
14876
  const ctx = ctxOf(opts);
14790
14877
  touch(ctx);
14791
14878
  const agent = resolveAgent(ctx.store, ref);
@@ -15232,12 +15319,67 @@ function emitInbox(ctx, opts, meId, readCmd) {
15232
15319
  const firstUnread = items.find((m) => !m.read);
15233
15320
  if (firstUnread) nextStep(`${readCmd} ${firstUnread.id}`);
15234
15321
  }
15322
+ /** List another unit's mailbox read-only (`--unit`). Its `mailbox` state tells a missing mailbox
15323
+ * from an empty one; an unreadable one is reported on stdout and exits 1, never read as empty. */
15324
+ function emitUnitInbox(ctx, opts, ref) {
15325
+ let unit;
15326
+ try {
15327
+ unit = resolveUnitMailbox(ctx.store, ref);
15328
+ } catch (err) {
15329
+ fail(err instanceof Error ? err.message : String(err));
15330
+ }
15331
+ const result = unitInbox({ store: ctx.store }, {
15332
+ meId: unit.id,
15333
+ unread: opts.unread,
15334
+ from: opts.from,
15335
+ thread: opts.thread
15336
+ });
15337
+ const unreadCount = result.messages.filter((m) => !m.read).length;
15338
+ const summary = result.mailbox === "none" ? "no mailbox" : result.mailbox === "unreadable" ? `mailbox unreadable: ${result.error}` : `${result.messages.length} messages (${unreadCount} unread)`;
15339
+ emit(formatOf(opts), {
15340
+ toon: [toonObject({
15341
+ unit: unit.id,
15342
+ handle: unit.handle,
15343
+ mailbox: result.mailbox
15344
+ }), toonList("messages", result.messages, [
15345
+ {
15346
+ key: "id",
15347
+ get: (m) => m.id
15348
+ },
15349
+ {
15350
+ key: "from",
15351
+ get: (m) => m.fromHandle
15352
+ },
15353
+ {
15354
+ key: "subject",
15355
+ get: (m) => m.subject ?? ""
15356
+ },
15357
+ {
15358
+ key: "read",
15359
+ get: (m) => m.read
15360
+ }
15361
+ ], summary)].join("\n"),
15362
+ json: {
15363
+ unit: unit.id,
15364
+ handle: unit.handle,
15365
+ ...result
15366
+ }
15367
+ });
15368
+ if (result.mailbox === "unreadable") {
15369
+ console.error(JSON.stringify({ error: `mailbox of ${unit.id} is unreadable: ${result.error}` }));
15370
+ process.exitCode = 1;
15371
+ }
15372
+ }
15235
15373
  function runInbox(opts) {
15236
15374
  const ctx = ctxOf(opts);
15237
15375
  touch(ctx);
15376
+ if (opts.unit) {
15377
+ emitUnitInbox(ctx, opts, opts.unit);
15378
+ return;
15379
+ }
15238
15380
  emitInbox(ctx, opts, opts.owner ? resolveOwnerMailbox(ctx.store, opts.owner) : requireSelf(ctx), "cyberlegion mail read");
15239
15381
  }
15240
- 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);
15382
+ 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").addOption(new Option("--unit <handle|id>", "list another unit's mailbox read-only (exited units too); never acks, reports mailbox: none|present|unreadable").conflicts("owner")).action(runInbox);
15241
15383
  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) => {
15242
15384
  const ctx = ctxOf(opts);
15243
15385
  const meId = opts.owner ? resolveOwnerMailbox(ctx.store, opts.owner) : requireSelf(ctx);
package/dist/index.d.mts CHANGED
@@ -162,6 +162,8 @@ interface MailboxStore {
162
162
  putMessage(toId: string, msg: Message): void;
163
163
  /** The full unread/read split for one agent's inbox. */
164
164
  listInbox(id: string): InboxSnapshot;
165
+ /** Whether `id` has a mailbox at all — one exists once mail was first delivered to it. */
166
+ hasMailbox(id: string): boolean;
165
167
  /** Ack a message by moving it out of the unread set; throws if it is not currently unread. */
166
168
  ackMessage(id: string, msgId: string): Message;
167
169
  /** Permanently remove a message (unread or already-acked) from `id`'s inbox; throws if absent. */
@@ -332,12 +334,12 @@ export declare function realizeLaunch(def: AgentDef, opts?: RealizeLaunchOptions
332
334
  //#region src/decommission.d.ts
333
335
  interface DecommissionInput {
334
336
  id: string;
335
- /** Discard uncommitted changes in the worktree. Never overrides refusing the primary checkout. */
337
+ /** Discard uncommitted changes in the worktree. Refused for a unit on the primary checkout, which close never alters. */
336
338
  force?: boolean;
337
339
  /**
338
340
  * Reap the unit but leave its worktree on disk, so a pool can detach it and hand it to the next
339
- * unit — far cheaper than a fresh checkout per unit. Never overrides refusing the primary
340
- * checkout: that guard is about identity, not destructiveness.
341
+ * unit — far cheaper than a fresh checkout per unit. A unit on the primary checkout is closed this
342
+ * way with or without the flag.
341
343
  */
342
344
  keepWorktree?: boolean;
343
345
  }
@@ -356,8 +358,7 @@ interface DecommissionResult {
356
358
  pane?: string;
357
359
  }
358
360
  /**
359
- * Tear a unit down and reap its registry record — the deterministic inverse of `spawn`. Refuses
360
- * the primary checkout (absolute — neither `--force` nor `--keep-worktree` overrides it) and a
361
+ * Tear a unit down and reap its registry record — the deterministic inverse of `spawn`. Refuses a
361
362
  * dirty worktree unless `--force`. Teardown always precedes reap: an already-gone worktree or pane
362
363
  * is tolerated, but a genuine worktree-removal failure aborts and leaves the record intact so the
363
364
  * operation is retryable.
@@ -374,6 +375,12 @@ interface DecommissionResult {
374
375
  * discarded, so neither the dirty refusal nor `--force` applies — a dirty worktree is skipped by the
375
376
  * next `acquire` rather than lost. Release precedes the pane teardown, so a release that throws leaves
376
377
  * everything for a retry, as a failed removal does.
378
+ *
379
+ * A unit whose worktree is its repository's primary checkout — a seat registered on a main checkout —
380
+ * is closed without touching the checkout: its session ends and its record, brief and mailbox are
381
+ * reaped, but nothing is removed, reset or cleaned, so no dirty check applies and no `--force` is
382
+ * needed. `--force` is refused there, since it asks to discard changes in the checkout. The checkout
383
+ * is not reported as retained: it is no pool path a next unit could be spawned into.
377
384
  */
378
385
  export declare function decommission(ctx: IdContext, input: DecommissionInput): Promise<DecommissionResult>;
379
386
  //#endregion
@@ -859,6 +866,7 @@ export declare class FileStore implements Store {
859
866
  ensureMarker(): void;
860
867
  putMessage(toId: string, msg: Message): void;
861
868
  listInbox(id: string): InboxSnapshot;
869
+ hasMailbox(id: string): boolean;
862
870
  ackMessage(id: string, msgId: string): Message;
863
871
  removeMessage(id: string, msgId: string): void;
864
872
  removeMailbox(id: string): void;
package/dist/index.mjs CHANGED
@@ -599,6 +599,10 @@ function readShape(rows, shape) {
599
599
  }
600
600
  return { kind: "unknown" };
601
601
  }
602
+ /** Whether this reader knows `harness`'s input box, so a missing box on screen means it is not drawn yet. */
603
+ function knowsPromptOf(harness) {
604
+ return harness !== void 0 && SHAPES[harness] !== void 0;
605
+ }
602
606
  /**
603
607
  * Read the input box off `screen` — a plain-text scrape of the pane — with the shape of `harness`,
604
608
  * or, when the harness is not known, with each known shape in turn until one recognizes the box.
@@ -699,6 +703,27 @@ function copiesOn(screen, message) {
699
703
  return screen.split("\n").filter((row) => row.replace(/\s+/g, " ").includes(needle)).length;
700
704
  }
701
705
  /**
706
+ * Read `screen` for the ring: whether `message` sits in the input box, and how many copies of it
707
+ * are on screen outside the box. With the box read by its harness's shape, copies inside the box are
708
+ * left out of the count: a booting harness can take several typed copies as one paste, and those
709
+ * copies are staged text, not posted turns. A screen with no input box the reader knows falls back to
710
+ * cyber-mux's `isStaged`, and the box's copy is counted with the rest.
711
+ */
712
+ function readRing(screen, message, harness) {
713
+ const box = screen ? readPrompt(screen, harness) : { kind: "unknown" };
714
+ if (box.kind === "unknown") return {
715
+ boxKnown: false,
716
+ staged: isStaged(screen, message),
717
+ outside: copiesOn(screen, message)
718
+ };
719
+ const inBox = box.kind === "draft" ? copiesOn(box.text, message) : 0;
720
+ return {
721
+ boxKnown: true,
722
+ staged: inBox > 0,
723
+ outside: copiesOn(screen, message) - inBox
724
+ };
725
+ }
726
+ /**
702
727
  * Submit `message` to `target` as a taken turn: cyber-mux's `nudge`, with one more way to see that the
703
728
  * turn was taken.
704
729
  *
@@ -718,18 +743,32 @@ function copiesOn(screen, message) {
718
743
  * queue. Text that is neither staged nor posted is lost: after one more settle for a slow redraw, it
719
744
  * is typed again. It is opt-in because it needs the posted copy to stay on screen: a busy peer's
720
745
  * output can scroll earlier copies away, so a ring to it could read as lost and be typed twice.
746
+ *
747
+ * With `requirePosted` and a harness whose input box this reader knows, nothing is typed until the
748
+ * harness has drawn that box, for up to the
749
+ * whole retry budget. Keys typed while a harness boots are buffered, and the harness takes them as one
750
+ * paste once it draws the box: each Enter becomes a newline, so re-typed copies pile up in the box
751
+ * unsent. A box that never shows within the budget is typed into anyway, as before.
752
+ *
753
+ * Where the input box can be read, copies of the text inside it are not counted as posted: only a
754
+ * copy outside the box means the harness took the turn.
721
755
  */
722
756
  async function ringTurn(adapter, exec, target, message, opts = {}) {
723
757
  const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
724
758
  const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
725
759
  const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
726
760
  if (!adapter.paneExists(exec, target)) throw new Error(`nudge failed: pane ${target.id} no longer exists — the peer's session is gone, not busy.`);
727
- const before = copiesOn(adapter.read(exec, target).text, message);
761
+ let first = adapter.read(exec, target).text;
762
+ if (opts.requirePosted && knowsPromptOf(opts.harness)) for (let wait = 0; wait < attempts && !readRing(first, message, opts.harness).boxKnown; wait++) {
763
+ await sleep(settleMs);
764
+ first = adapter.read(exec, target).text;
765
+ }
766
+ const before = readRing(first, message, opts.harness).outside;
728
767
  const check = () => {
729
- const screen = adapter.read(exec, target).text;
730
- const copies = copiesOn(screen, message);
731
- if (isStaged(screen, message)) return copies >= before + 2 ? "taken" : "staged";
732
- return copies > before || !opts.requirePosted ? "taken" : "lost";
768
+ const seen = readRing(adapter.read(exec, target).text, message, opts.harness);
769
+ const posted = seen.outside >= before + (seen.boxKnown || !seen.staged ? 1 : 2);
770
+ if (seen.staged) return posted ? "taken" : "staged";
771
+ return posted || !opts.requirePosted ? "taken" : "lost";
733
772
  };
734
773
  adapter.submit(exec, target, message);
735
774
  await sleep(settleMs);
@@ -829,7 +868,8 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
829
868
  const doorbell = (input.resumed ? resumeDoorbell : spawnDoorbell)(input.briefPath);
830
869
  await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, doorbell, {
831
870
  ...nudgeOpts,
832
- requirePosted: true
871
+ requirePosted: true,
872
+ harness: input.harness
833
873
  }), {
834
874
  ...guardOpts,
835
875
  harness: input.harness,
@@ -1810,8 +1850,7 @@ function deleteMailbox(store, id) {
1810
1850
  //#endregion
1811
1851
  //#region src/decommission.ts
1812
1852
  /**
1813
- * Tear a unit down and reap its registry record — the deterministic inverse of `spawn`. Refuses
1814
- * the primary checkout (absolute — neither `--force` nor `--keep-worktree` overrides it) and a
1853
+ * Tear a unit down and reap its registry record — the deterministic inverse of `spawn`. Refuses a
1815
1854
  * dirty worktree unless `--force`. Teardown always precedes reap: an already-gone worktree or pane
1816
1855
  * is tolerated, but a genuine worktree-removal failure aborts and leaves the record intact so the
1817
1856
  * operation is retryable.
@@ -1828,6 +1867,12 @@ function deleteMailbox(store, id) {
1828
1867
  * discarded, so neither the dirty refusal nor `--force` applies — a dirty worktree is skipped by the
1829
1868
  * next `acquire` rather than lost. Release precedes the pane teardown, so a release that throws leaves
1830
1869
  * everything for a retry, as a failed removal does.
1870
+ *
1871
+ * A unit whose worktree is its repository's primary checkout — a seat registered on a main checkout —
1872
+ * is closed without touching the checkout: its session ends and its record, brief and mailbox are
1873
+ * reaped, but nothing is removed, reset or cleaned, so no dirty check applies and no `--force` is
1874
+ * needed. `--force` is refused there, since it asks to discard changes in the checkout. The checkout
1875
+ * is not reported as retained: it is no pool path a next unit could be spawned into.
1831
1876
  */
1832
1877
  async function decommission(ctx, input) {
1833
1878
  const rec = loadAgent(ctx.store, input.id);
@@ -1839,8 +1884,9 @@ async function decommission(ctx, input) {
1839
1884
  const worktreeExists = worktreeRoot != null && existsSync(worktreeRoot);
1840
1885
  const primaryRoot = worktreeExists ? await primaryRootOf(exec, worktreeRoot) : rec.worktree?.primaryRoot;
1841
1886
  const held = rec.worktree?.lease;
1842
- if (worktreeRoot && primaryRoot && resolve(worktreeRoot) === resolve(primaryRoot)) throw new Error(`refusing to decommission "${input.id}" — its worktree is the primary checkout; neither --force nor --keep-worktree overrides this`);
1843
- const removesWorktree = worktreeExists && !input.keepWorktree && !held;
1887
+ const onPrimary = worktreeRoot != null && primaryRoot != null && resolve(worktreeRoot) === resolve(primaryRoot);
1888
+ if (onPrimary && input.force) throw new Error(`refusing to close "${input.id}" with --force — its worktree is the primary checkout, which close never alters; close it without --force to end its session and reap its record`);
1889
+ const removesWorktree = worktreeExists && !input.keepWorktree && !held && !onPrimary;
1844
1890
  if (removesWorktree && !input.force && isDirty(exec, worktreeRoot)) throw new Error(`unit "${input.id}" has uncommitted changes in its worktree — pass --force to discard them`);
1845
1891
  const pane = rec.pane?.id ?? ctx.store.findPaneByAgentId(input.id);
1846
1892
  if (worktreeRoot && !worktreeExists && primaryRoot && existsSync(primaryRoot)) exec("git", [
@@ -1869,7 +1915,7 @@ async function decommission(ctx, input) {
1869
1915
  return {
1870
1916
  agent: rec,
1871
1917
  worktreeRoot,
1872
- retainedWorktree: worktreeExists && (input.keepWorktree || held) ? worktreeRoot : void 0,
1918
+ retainedWorktree: worktreeExists && !onPrimary && (input.keepWorktree || held) ? worktreeRoot : void 0,
1873
1919
  ...lease ? { lease } : {},
1874
1920
  pane
1875
1921
  };
@@ -2851,6 +2897,10 @@ var FileStore = class {
2851
2897
  read: read ? readMessages(read) : []
2852
2898
  };
2853
2899
  }
2900
+ hasMailbox(id) {
2901
+ const dir = readPathOrUndefined(() => paths.inboxDir(this.root, id));
2902
+ return dir !== void 0 && existsSync(dir);
2903
+ }
2854
2904
  ackMessage(id, msgId) {
2855
2905
  const src = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
2856
2906
  if (!src || !existsSync(src)) throw new Error(`"${msgId}" is not an unread message in this inbox`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cyberlegion",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Harness-agnostic agent session spawning and messaging over the filesystem",
5
5
  "keywords": [
6
6
  "agent",
@@ -42,7 +42,7 @@
42
42
  ],
43
43
  "dependencies": {
44
44
  "cyber-agentkit": "^2.0.0",
45
- "cyber-mux": "0.11.0"
45
+ "cyber-mux": "0.12.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "cynapse": "^0.1.0"
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "cyberlegion",
4
- "version": "1.7.0",
4
+ "version": "1.9.0",
5
5
  "description": "Agent session spawning, messaging, and dispatch — the Legion and its Legate.",
6
6
  "author": {
7
7
  "name": "unional"
@@ -17,7 +17,7 @@ satisfies verdict schema `V`.
17
17
  > **Running the CLI.** Every `node scripts/cyberlegion.mjs …` command below runs the `cyberlegion` CLI
18
18
  > this plugin ships. The path is relative to this skill's own directory, not the working directory.
19
19
  > If you cannot resolve it, run the published CLI of the version that shipped this skill instead,
20
- > with the same arguments: `npx -y cyberlegion@1.7.0`.
20
+ > with the same arguments: `npx -y cyberlegion@1.9.0`.
21
21
 
22
22
  ## 1. Resolve the agent definition
23
23