cyberlegion 1.8.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.8.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.8.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.8.0"
2
+ "cyberlegion": "1.9.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
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
+
3
13
  ## 1.8.0
4
14
 
5
15
  ### Minor Changes
package/dist/cli.mjs CHANGED
@@ -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);
@@ -14008,6 +14085,10 @@ var FileStore = class {
14008
14085
  read: read ? readMessages(read) : []
14009
14086
  };
14010
14087
  }
14088
+ hasMailbox(id) {
14089
+ const dir = readPathOrUndefined(() => paths.inboxDir(this.root, id));
14090
+ return dir !== void 0 && existsSync(dir);
14091
+ }
14011
14092
  ackMessage(id, msgId) {
14012
14093
  const src = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
14013
14094
  if (!src || !existsSync(src)) throw new Error(`"${msgId}" is not an unread message in this inbox`);
@@ -15238,12 +15319,67 @@ function emitInbox(ctx, opts, meId, readCmd) {
15238
15319
  const firstUnread = items.find((m) => !m.read);
15239
15320
  if (firstUnread) nextStep(`${readCmd} ${firstUnread.id}`);
15240
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
+ }
15241
15373
  function runInbox(opts) {
15242
15374
  const ctx = ctxOf(opts);
15243
15375
  touch(ctx);
15376
+ if (opts.unit) {
15377
+ emitUnitInbox(ctx, opts, opts.unit);
15378
+ return;
15379
+ }
15244
15380
  emitInbox(ctx, opts, opts.owner ? resolveOwnerMailbox(ctx.store, opts.owner) : requireSelf(ctx), "cyberlegion mail read");
15245
15381
  }
15246
- 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);
15247
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) => {
15248
15384
  const ctx = ctxOf(opts);
15249
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. */
@@ -864,6 +866,7 @@ export declare class FileStore implements Store {
864
866
  ensureMarker(): void;
865
867
  putMessage(toId: string, msg: Message): void;
866
868
  listInbox(id: string): InboxSnapshot;
869
+ hasMailbox(id: string): boolean;
867
870
  ackMessage(id: string, msgId: string): Message;
868
871
  removeMessage(id: string, msgId: string): void;
869
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,
@@ -2857,6 +2897,10 @@ var FileStore = class {
2857
2897
  read: read ? readMessages(read) : []
2858
2898
  };
2859
2899
  }
2900
+ hasMailbox(id) {
2901
+ const dir = readPathOrUndefined(() => paths.inboxDir(this.root, id));
2902
+ return dir !== void 0 && existsSync(dir);
2903
+ }
2860
2904
  ackMessage(id, msgId) {
2861
2905
  const src = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
2862
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.8.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",
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.8.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.8.0`.
20
+ > with the same arguments: `npx -y cyberlegion@1.9.0`.
21
21
 
22
22
  ## 1. Resolve the agent definition
23
23