cyberlegion 1.8.0 → 1.10.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.10.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.10.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.10.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # cyberlegion
2
2
 
3
+ ## 1.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7a1c849: A unit now records a worktree it works in but does not own as `sharedWorktree` (`root`, `branch`, `primaryRoot`, and `owner`, the id of the unit that owns it). `unit spawn --cwd` records the checkout its directory sits in, and `unit register` records a worktree another unit's record owns this way instead of taking it as its own `worktree`. `unit close` never removes, prunes, or releases a shared worktree, so closing such a unit can no longer remove another unit's worktree or release its lease. `unit who` gains a `shared` column and `unit show` a `shared` field (`sharedWorktree` in JSON), so a sweep can judge the unit by that checkout. Records without the field read as before.
8
+
9
+ ## 1.9.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 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.
14
+
15
+ ### Patch Changes
16
+
17
+ - 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.
18
+
3
19
  ## 1.8.0
4
20
 
5
21
  ### Minor Changes
package/dist/cli.mjs CHANGED
@@ -10517,7 +10517,7 @@ function register(ctx, input) {
10517
10517
  handle: input.handle ?? existing?.handle ?? id.slice(0, 6),
10518
10518
  harness,
10519
10519
  cwd: process.cwd(),
10520
- worktree: existing?.worktree ? keptWorktree(existing.worktree, exec) : gitWorktree(exec),
10520
+ ...checkoutFor(ctx.store, exec, id, existing),
10521
10521
  pane: cur ? paneLocator(cur, exec, env) : null,
10522
10522
  status: "active",
10523
10523
  createdAt: existing?.createdAt ?? ts,
@@ -10584,24 +10584,71 @@ function writeStanding(ctx, id, input) {
10584
10584
  saveAgent(ctx.store, rec);
10585
10585
  return rec;
10586
10586
  }
10587
- /** The checkout the cwd sits in: its root, its branch, and the repository's primary checkout — the
10588
- * same `primaryRoot` spawn records, so a lookup by repository finds a unit in a linked worktree by
10589
- * the repository's name rather than the worktree folder's. */
10590
- function gitWorktree(exec) {
10591
- const root = exec("git", ["rev-parse", "--show-toplevel"]);
10587
+ /** The checkout `dir` (else the cwd) sits in: its root, its branch, and the repository's primary
10588
+ * checkout — the same `primaryRoot` spawn records, so a lookup by repository finds a unit in a linked
10589
+ * worktree by the repository's name rather than the worktree folder's. */
10590
+ function gitWorktree(exec, dir) {
10591
+ const git = dir === void 0 ? exec : (cmd, args) => exec(cmd, cmd === "git" ? [
10592
+ "-C",
10593
+ dir,
10594
+ ...args
10595
+ ] : args);
10596
+ const root = git("git", ["rev-parse", "--show-toplevel"]);
10592
10597
  if (!root) return null;
10593
- const branch = exec("git", [
10598
+ const branch = git("git", [
10594
10599
  "rev-parse",
10595
10600
  "--abbrev-ref",
10596
10601
  "HEAD"
10597
10602
  ]) ?? void 0;
10598
- const primaryRoot = primaryRootHere(exec);
10603
+ const primaryRoot = primaryRootHere(git);
10599
10604
  return {
10600
10605
  root,
10601
10606
  ...branch !== void 0 ? { branch } : {},
10602
10607
  ...primaryRoot !== void 0 ? { primaryRoot } : {}
10603
10608
  };
10604
10609
  }
10610
+ /** The id of the unit, other than `self`, whose record owns the worktree at `root`. */
10611
+ function ownerOf(store, root, self) {
10612
+ const at = resolve(root);
10613
+ return listAgents(store).find((a) => a.id !== self && a.worktree?.root && resolve(a.worktree.root) === at)?.id;
10614
+ }
10615
+ /** `checkout` as a shared reference, naming the unit that owns it when one does. */
10616
+ function asShared(store, checkout, self) {
10617
+ const owner = ownerOf(store, checkout.root, self);
10618
+ return {
10619
+ ...checkout,
10620
+ ...owner ? { owner } : {}
10621
+ };
10622
+ }
10623
+ /** The checkout a `--cwd` spawn opens into, as a shared reference — spawn created none, so the unit
10624
+ * owns none, whoever does. `undefined` when `dir` is in no git checkout. */
10625
+ function sharedCheckoutAt(store, exec, dir) {
10626
+ const checkout = gitWorktree(exec, dir);
10627
+ return checkout ? asShared(store, checkout) : void 0;
10628
+ }
10629
+ /**
10630
+ * Which checkout a registering session owns and which it only shares. An existing owned worktree is
10631
+ * kept. Otherwise the cwd's checkout is the session's own — unless another unit's record owns it, or
10632
+ * this unit already records it as shared (a `--cwd` spawn): then it is a shared reference, and the
10633
+ * session owns none, so its close can never remove another unit's worktree or release its lease.
10634
+ */
10635
+ function checkoutFor(store, exec, id, existing) {
10636
+ const kept = existing?.sharedWorktree ? { sharedWorktree: existing.sharedWorktree } : {};
10637
+ if (existing?.worktree) return {
10638
+ worktree: keptWorktree(existing.worktree, exec),
10639
+ ...kept
10640
+ };
10641
+ const here = gitWorktree(exec);
10642
+ if (!here) return {
10643
+ worktree: null,
10644
+ ...kept
10645
+ };
10646
+ if (existing?.sharedWorktree || ownerOf(store, here.root, id)) return {
10647
+ worktree: null,
10648
+ sharedWorktree: asShared(store, here, id)
10649
+ };
10650
+ return { worktree: here };
10651
+ }
10605
10652
  function primaryRootHere(exec) {
10606
10653
  try {
10607
10654
  return resolvePrimaryRoot(exec);
@@ -10671,6 +10718,20 @@ function resolveOwnerMailbox(store, ref) {
10671
10718
  if (services.length > 1) throw new Error(`"${ref}" names ${services.length} service endpoints — pass an endpoint id (${services.map((a) => a.id).join(", ")})`);
10672
10719
  return resolveStandingOwner(store, ref);
10673
10720
  }
10721
+ /** Resolve the unit whose mailbox `mail inbox --unit` reads, by id or handle. Unlike a recipient, an
10722
+ * exited unit resolves: the caller is deciding about that unit (a sweep asking whether a dead unit left
10723
+ * unread mail), not writing to it. A live holder of the handle wins; several exited holders and no live
10724
+ * one fail rather than picking one. */
10725
+ function resolveUnitMailbox(store, ref) {
10726
+ const byId = loadAgent(store, ref);
10727
+ if (byId) return byId;
10728
+ const { live, exited } = matchHandle(listAgents(store), ref);
10729
+ const byHandle = pickLive(ref, live);
10730
+ if (byHandle) return byHandle;
10731
+ if (exited.length > 1) throw new Error(`"${ref}" names ${exited.length} exited units — pass an id (${exited.map((a) => a.id).join(", ")})`);
10732
+ if (exited[0]) return exited[0];
10733
+ throw new Error(`no unit addressable as "${ref}" (tried id and handle)`);
10734
+ }
10674
10735
  /**
10675
10736
  * Bind the caller's own unit as a standing owner's presence — the live unit standing in for a
10676
10737
  * durable record that has no session of its own. Order matters: resolve the standing record first
@@ -11016,6 +11077,10 @@ function readShape(rows, shape) {
11016
11077
  }
11017
11078
  return { kind: "unknown" };
11018
11079
  }
11080
+ /** Whether this reader knows `harness`'s input box, so a missing box on screen means it is not drawn yet. */
11081
+ function knowsPromptOf(harness) {
11082
+ return harness !== void 0 && SHAPES[harness] !== void 0;
11083
+ }
11019
11084
  /**
11020
11085
  * Read the input box off `screen` — a plain-text scrape of the pane — with the shape of `harness`,
11021
11086
  * or, when the harness is not known, with each known shape in turn until one recognizes the box.
@@ -11116,6 +11181,27 @@ function copiesOn(screen, message) {
11116
11181
  return screen.split("\n").filter((row) => row.replace(/\s+/g, " ").includes(needle)).length;
11117
11182
  }
11118
11183
  /**
11184
+ * Read `screen` for the ring: whether `message` sits in the input box, and how many copies of it
11185
+ * are on screen outside the box. With the box read by its harness's shape, copies inside the box are
11186
+ * left out of the count: a booting harness can take several typed copies as one paste, and those
11187
+ * copies are staged text, not posted turns. A screen with no input box the reader knows falls back to
11188
+ * cyber-mux's `isStaged`, and the box's copy is counted with the rest.
11189
+ */
11190
+ function readRing(screen, message, harness) {
11191
+ const box = screen ? readPrompt(screen, harness) : { kind: "unknown" };
11192
+ if (box.kind === "unknown") return {
11193
+ boxKnown: false,
11194
+ staged: isStaged(screen, message),
11195
+ outside: copiesOn(screen, message)
11196
+ };
11197
+ const inBox = box.kind === "draft" ? copiesOn(box.text, message) : 0;
11198
+ return {
11199
+ boxKnown: true,
11200
+ staged: inBox > 0,
11201
+ outside: copiesOn(screen, message) - inBox
11202
+ };
11203
+ }
11204
+ /**
11119
11205
  * Submit `message` to `target` as a taken turn: cyber-mux's `nudge`, with one more way to see that the
11120
11206
  * turn was taken.
11121
11207
  *
@@ -11135,18 +11221,32 @@ function copiesOn(screen, message) {
11135
11221
  * queue. Text that is neither staged nor posted is lost: after one more settle for a slow redraw, it
11136
11222
  * is typed again. It is opt-in because it needs the posted copy to stay on screen: a busy peer's
11137
11223
  * output can scroll earlier copies away, so a ring to it could read as lost and be typed twice.
11224
+ *
11225
+ * With `requirePosted` and a harness whose input box this reader knows, nothing is typed until the
11226
+ * harness has drawn that box, for up to the
11227
+ * whole retry budget. Keys typed while a harness boots are buffered, and the harness takes them as one
11228
+ * paste once it draws the box: each Enter becomes a newline, so re-typed copies pile up in the box
11229
+ * unsent. A box that never shows within the budget is typed into anyway, as before.
11230
+ *
11231
+ * Where the input box can be read, copies of the text inside it are not counted as posted: only a
11232
+ * copy outside the box means the harness took the turn.
11138
11233
  */
11139
11234
  async function ringTurn(adapter, exec, target, message, opts = {}) {
11140
11235
  const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
11141
11236
  const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
11142
11237
  const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
11143
11238
  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);
11239
+ let first = adapter.read(exec, target).text;
11240
+ if (opts.requirePosted && knowsPromptOf(opts.harness)) for (let wait = 0; wait < attempts && !readRing(first, message, opts.harness).boxKnown; wait++) {
11241
+ await sleep(settleMs);
11242
+ first = adapter.read(exec, target).text;
11243
+ }
11244
+ const before = readRing(first, message, opts.harness).outside;
11145
11245
  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";
11246
+ const seen = readRing(adapter.read(exec, target).text, message, opts.harness);
11247
+ const posted = seen.outside >= before + (seen.boxKnown || !seen.staged ? 1 : 2);
11248
+ if (seen.staged) return posted ? "taken" : "staged";
11249
+ return posted || !opts.requirePosted ? "taken" : "lost";
11150
11250
  };
11151
11251
  adapter.submit(exec, target, message);
11152
11252
  await sleep(settleMs);
@@ -11306,7 +11406,10 @@ async function wakeRecipient(store, getAdapter, exec, input, nudgeOpts, guardOpt
11306
11406
  try {
11307
11407
  const adapter = getAdapter();
11308
11408
  const target = { id: pane };
11309
- await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, DELIVERY_DOORBELL, nudgeOpts), {
11409
+ await withDraftGuard(adapter, exec, target, () => ringTurn(adapter, exec, target, DELIVERY_DOORBELL, {
11410
+ ...nudgeOpts,
11411
+ harness
11412
+ }), {
11310
11413
  ...guardOpts,
11311
11414
  harness,
11312
11415
  ownText: (text) => isRingText(text)
@@ -11351,7 +11454,8 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
11351
11454
  const doorbell = (input.resumed ? resumeDoorbell : spawnDoorbell)(input.briefPath);
11352
11455
  await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, doorbell, {
11353
11456
  ...nudgeOpts,
11354
- requirePosted: true
11457
+ requirePosted: true,
11458
+ harness: input.harness
11355
11459
  }), {
11356
11460
  ...guardOpts,
11357
11461
  harness: input.harness,
@@ -11795,6 +11899,7 @@ async function spawn(ctx, input) {
11795
11899
  const from = callerPane(sessionAdapter, normalizedEnv);
11796
11900
  let cwd;
11797
11901
  let worktree;
11902
+ let sharedWorktree;
11798
11903
  let base;
11799
11904
  let reused;
11800
11905
  let target;
@@ -11803,6 +11908,7 @@ async function spawn(ctx, input) {
11803
11908
  cwd = resolve(input.cwd);
11804
11909
  if (primaryRoot$1 !== void 0) assertDistinctFromPrimary(cwd, primaryRoot$1);
11805
11910
  worktree = null;
11911
+ sharedWorktree = sharedCheckoutAt(ctx.store, exec, cwd);
11806
11912
  const at = input.at ?? "tab";
11807
11913
  target = sessionAdapter.open(exec, {
11808
11914
  cwd,
@@ -11893,6 +11999,7 @@ async function spawn(ctx, input) {
11893
11999
  harness,
11894
12000
  cwd,
11895
12001
  worktree,
12002
+ ...sharedWorktree ? { sharedWorktree } : {},
11896
12003
  pane: muxName === "tmux" || muxName === "herdr" ? {
11897
12004
  mux: muxName,
11898
12005
  id: target.id
@@ -12575,6 +12682,26 @@ function inbox(ctx, q) {
12575
12682
  if (q.thread) items = items.filter((m) => m.thread === q.thread);
12576
12683
  return items.sort((a, b) => a.id.localeCompare(b.id));
12577
12684
  }
12685
+ /** List another unit's mail read-only, for a caller deciding about that unit (a cleanup sweep): never
12686
+ * acks, and reports a missing or unreadable mailbox as its own state rather than as an empty one. */
12687
+ function unitInbox(ctx, q) {
12688
+ if (!ctx.store.hasMailbox(q.meId)) return {
12689
+ mailbox: "none",
12690
+ messages: []
12691
+ };
12692
+ try {
12693
+ return {
12694
+ mailbox: "present",
12695
+ messages: inbox(ctx, q)
12696
+ };
12697
+ } catch (err) {
12698
+ return {
12699
+ mailbox: "unreadable",
12700
+ messages: [],
12701
+ error: err instanceof Error ? err.message : String(err)
12702
+ };
12703
+ }
12704
+ }
12578
12705
  /** Peek at a message (unread or already-acked) without changing its state. */
12579
12706
  function peek(ctx, meId, msgId) {
12580
12707
  const snap = ctx.store.listInbox(meId);
@@ -14008,6 +14135,10 @@ var FileStore = class {
14008
14135
  read: read ? readMessages(read) : []
14009
14136
  };
14010
14137
  }
14138
+ hasMailbox(id) {
14139
+ const dir = readPathOrUndefined(() => paths.inboxDir(this.root, id));
14140
+ return dir !== void 0 && existsSync(dir);
14141
+ }
14011
14142
  ackMessage(id, msgId) {
14012
14143
  const src = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
14013
14144
  if (!src || !existsSync(src)) throw new Error(`"${msgId}" is not an unread message in this inbox`);
@@ -14379,6 +14510,7 @@ function showUnit(ctx, ref) {
14379
14510
  } : null,
14380
14511
  cwd: rec.cwd,
14381
14512
  worktree: rec.worktree ?? null,
14513
+ sharedWorktree: rec.sharedWorktree ?? null,
14382
14514
  lastSeen: rec.lastSeen,
14383
14515
  controls: controlsFor(ctx, rec, liveness)
14384
14516
  };
@@ -14678,6 +14810,10 @@ function runWho(opts) {
14678
14810
  {
14679
14811
  key: "pane",
14680
14812
  get: (a) => a.pane?.id ?? "-"
14813
+ },
14814
+ {
14815
+ key: "shared",
14816
+ get: (a) => a.sharedWorktree?.root ?? "-"
14681
14817
  }
14682
14818
  ], `${agents.length} units`),
14683
14819
  json: agents
@@ -14877,6 +15013,7 @@ withGlobals(unit.command("show")).description("show a unit's runtime: where it i
14877
15013
  pane: view.pane ? `${view.pane.mux}:${view.pane.id}` : "-",
14878
15014
  cwd: view.cwd,
14879
15015
  worktree: view.worktree?.root ?? "-",
15016
+ shared: view.sharedWorktree?.root ?? "-",
14880
15017
  lastSeen: view.lastSeen,
14881
15018
  controls: view.controls.join(",") || "-"
14882
15019
  }),
@@ -15238,12 +15375,67 @@ function emitInbox(ctx, opts, meId, readCmd) {
15238
15375
  const firstUnread = items.find((m) => !m.read);
15239
15376
  if (firstUnread) nextStep(`${readCmd} ${firstUnread.id}`);
15240
15377
  }
15378
+ /** List another unit's mailbox read-only (`--unit`). Its `mailbox` state tells a missing mailbox
15379
+ * from an empty one; an unreadable one is reported on stdout and exits 1, never read as empty. */
15380
+ function emitUnitInbox(ctx, opts, ref) {
15381
+ let unit;
15382
+ try {
15383
+ unit = resolveUnitMailbox(ctx.store, ref);
15384
+ } catch (err) {
15385
+ fail(err instanceof Error ? err.message : String(err));
15386
+ }
15387
+ const result = unitInbox({ store: ctx.store }, {
15388
+ meId: unit.id,
15389
+ unread: opts.unread,
15390
+ from: opts.from,
15391
+ thread: opts.thread
15392
+ });
15393
+ const unreadCount = result.messages.filter((m) => !m.read).length;
15394
+ const summary = result.mailbox === "none" ? "no mailbox" : result.mailbox === "unreadable" ? `mailbox unreadable: ${result.error}` : `${result.messages.length} messages (${unreadCount} unread)`;
15395
+ emit(formatOf(opts), {
15396
+ toon: [toonObject({
15397
+ unit: unit.id,
15398
+ handle: unit.handle,
15399
+ mailbox: result.mailbox
15400
+ }), toonList("messages", result.messages, [
15401
+ {
15402
+ key: "id",
15403
+ get: (m) => m.id
15404
+ },
15405
+ {
15406
+ key: "from",
15407
+ get: (m) => m.fromHandle
15408
+ },
15409
+ {
15410
+ key: "subject",
15411
+ get: (m) => m.subject ?? ""
15412
+ },
15413
+ {
15414
+ key: "read",
15415
+ get: (m) => m.read
15416
+ }
15417
+ ], summary)].join("\n"),
15418
+ json: {
15419
+ unit: unit.id,
15420
+ handle: unit.handle,
15421
+ ...result
15422
+ }
15423
+ });
15424
+ if (result.mailbox === "unreadable") {
15425
+ console.error(JSON.stringify({ error: `mailbox of ${unit.id} is unreadable: ${result.error}` }));
15426
+ process.exitCode = 1;
15427
+ }
15428
+ }
15241
15429
  function runInbox(opts) {
15242
15430
  const ctx = ctxOf(opts);
15243
15431
  touch(ctx);
15432
+ if (opts.unit) {
15433
+ emitUnitInbox(ctx, opts, opts.unit);
15434
+ return;
15435
+ }
15244
15436
  emitInbox(ctx, opts, opts.owner ? resolveOwnerMailbox(ctx.store, opts.owner) : requireSelf(ctx), "cyberlegion mail read");
15245
15437
  }
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);
15438
+ 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
15439
  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
15440
  const ctx = ctxOf(opts);
15249
15441
  const meId = opts.owner ? resolveOwnerMailbox(ctx.store, opts.owner) : requireSelf(ctx);
package/dist/index.d.mts CHANGED
@@ -40,6 +40,16 @@ interface AgentRecord {
40
40
  holder: string;
41
41
  };
42
42
  } | null;
43
+ /** A checkout the unit works in but does not own: a `--cwd` spawn into an existing checkout, or a
44
+ * session registered in a worktree another unit's record owns. `owner` is that unit's id, when one
45
+ * owned the root at the time of recording. Close never removes, prunes, or releases it — it is read
46
+ * only, so a sweep can judge the unit by the work there. Absent on records from before it existed. */
47
+ sharedWorktree?: {
48
+ root: string;
49
+ branch?: string;
50
+ primaryRoot?: string;
51
+ owner?: string;
52
+ };
43
53
  /** Where this session lives, tagged with its multiplexer so `prune` runs the right liveness check.
44
54
  * `null` for a session in no pane and for a standing record. `window`/`session` are tmux-only. */
45
55
  pane?: {
@@ -162,6 +172,8 @@ interface MailboxStore {
162
172
  putMessage(toId: string, msg: Message): void;
163
173
  /** The full unread/read split for one agent's inbox. */
164
174
  listInbox(id: string): InboxSnapshot;
175
+ /** Whether `id` has a mailbox at all — one exists once mail was first delivered to it. */
176
+ hasMailbox(id: string): boolean;
165
177
  /** Ack a message by moving it out of the unread set; throws if it is not currently unread. */
166
178
  ackMessage(id: string, msgId: string): Message;
167
179
  /** Permanently remove a message (unread or already-acked) from `id`'s inbox; throws if absent. */
@@ -864,6 +876,7 @@ export declare class FileStore implements Store {
864
876
  ensureMarker(): void;
865
877
  putMessage(toId: string, msg: Message): void;
866
878
  listInbox(id: string): InboxSnapshot;
879
+ hasMailbox(id: string): boolean;
867
880
  ackMessage(id: string, msgId: string): Message;
868
881
  removeMessage(id: string, msgId: string): void;
869
882
  removeMailbox(id: string): void;
package/dist/index.mjs CHANGED
@@ -247,7 +247,7 @@ function register(ctx, input) {
247
247
  handle: input.handle ?? existing?.handle ?? id.slice(0, 6),
248
248
  harness,
249
249
  cwd: process.cwd(),
250
- worktree: existing?.worktree ? keptWorktree(existing.worktree, exec) : gitWorktree(exec),
250
+ ...checkoutFor(ctx.store, exec, id, existing),
251
251
  pane: cur ? paneLocator(cur, exec, env) : null,
252
252
  status: "active",
253
253
  createdAt: existing?.createdAt ?? ts,
@@ -279,24 +279,71 @@ function paneLocator(cur, exec, env) {
279
279
  id: cur.pane
280
280
  };
281
281
  }
282
- /** The checkout the cwd sits in: its root, its branch, and the repository's primary checkout — the
283
- * same `primaryRoot` spawn records, so a lookup by repository finds a unit in a linked worktree by
284
- * the repository's name rather than the worktree folder's. */
285
- function gitWorktree(exec) {
286
- const root = exec("git", ["rev-parse", "--show-toplevel"]);
282
+ /** The checkout `dir` (else the cwd) sits in: its root, its branch, and the repository's primary
283
+ * checkout — the same `primaryRoot` spawn records, so a lookup by repository finds a unit in a linked
284
+ * worktree by the repository's name rather than the worktree folder's. */
285
+ function gitWorktree(exec, dir) {
286
+ const git = dir === void 0 ? exec : (cmd, args) => exec(cmd, cmd === "git" ? [
287
+ "-C",
288
+ dir,
289
+ ...args
290
+ ] : args);
291
+ const root = git("git", ["rev-parse", "--show-toplevel"]);
287
292
  if (!root) return null;
288
- const branch = exec("git", [
293
+ const branch = git("git", [
289
294
  "rev-parse",
290
295
  "--abbrev-ref",
291
296
  "HEAD"
292
297
  ]) ?? void 0;
293
- const primaryRoot = primaryRootHere(exec);
298
+ const primaryRoot = primaryRootHere(git);
294
299
  return {
295
300
  root,
296
301
  ...branch !== void 0 ? { branch } : {},
297
302
  ...primaryRoot !== void 0 ? { primaryRoot } : {}
298
303
  };
299
304
  }
305
+ /** The id of the unit, other than `self`, whose record owns the worktree at `root`. */
306
+ function ownerOf(store, root, self) {
307
+ const at = resolve(root);
308
+ return listAgents(store).find((a) => a.id !== self && a.worktree?.root && resolve(a.worktree.root) === at)?.id;
309
+ }
310
+ /** `checkout` as a shared reference, naming the unit that owns it when one does. */
311
+ function asShared(store, checkout, self) {
312
+ const owner = ownerOf(store, checkout.root, self);
313
+ return {
314
+ ...checkout,
315
+ ...owner ? { owner } : {}
316
+ };
317
+ }
318
+ /** The checkout a `--cwd` spawn opens into, as a shared reference — spawn created none, so the unit
319
+ * owns none, whoever does. `undefined` when `dir` is in no git checkout. */
320
+ function sharedCheckoutAt(store, exec, dir) {
321
+ const checkout = gitWorktree(exec, dir);
322
+ return checkout ? asShared(store, checkout) : void 0;
323
+ }
324
+ /**
325
+ * Which checkout a registering session owns and which it only shares. An existing owned worktree is
326
+ * kept. Otherwise the cwd's checkout is the session's own — unless another unit's record owns it, or
327
+ * this unit already records it as shared (a `--cwd` spawn): then it is a shared reference, and the
328
+ * session owns none, so its close can never remove another unit's worktree or release its lease.
329
+ */
330
+ function checkoutFor(store, exec, id, existing) {
331
+ const kept = existing?.sharedWorktree ? { sharedWorktree: existing.sharedWorktree } : {};
332
+ if (existing?.worktree) return {
333
+ worktree: keptWorktree(existing.worktree, exec),
334
+ ...kept
335
+ };
336
+ const here = gitWorktree(exec);
337
+ if (!here) return {
338
+ worktree: null,
339
+ ...kept
340
+ };
341
+ if (existing?.sharedWorktree || ownerOf(store, here.root, id)) return {
342
+ worktree: null,
343
+ sharedWorktree: asShared(store, here, id)
344
+ };
345
+ return { worktree: here };
346
+ }
300
347
  function primaryRootHere(exec) {
301
348
  try {
302
349
  return resolvePrimaryRoot$1(exec);
@@ -599,6 +646,10 @@ function readShape(rows, shape) {
599
646
  }
600
647
  return { kind: "unknown" };
601
648
  }
649
+ /** Whether this reader knows `harness`'s input box, so a missing box on screen means it is not drawn yet. */
650
+ function knowsPromptOf(harness) {
651
+ return harness !== void 0 && SHAPES[harness] !== void 0;
652
+ }
602
653
  /**
603
654
  * Read the input box off `screen` — a plain-text scrape of the pane — with the shape of `harness`,
604
655
  * or, when the harness is not known, with each known shape in turn until one recognizes the box.
@@ -699,6 +750,27 @@ function copiesOn(screen, message) {
699
750
  return screen.split("\n").filter((row) => row.replace(/\s+/g, " ").includes(needle)).length;
700
751
  }
701
752
  /**
753
+ * Read `screen` for the ring: whether `message` sits in the input box, and how many copies of it
754
+ * are on screen outside the box. With the box read by its harness's shape, copies inside the box are
755
+ * left out of the count: a booting harness can take several typed copies as one paste, and those
756
+ * copies are staged text, not posted turns. A screen with no input box the reader knows falls back to
757
+ * cyber-mux's `isStaged`, and the box's copy is counted with the rest.
758
+ */
759
+ function readRing(screen, message, harness) {
760
+ const box = screen ? readPrompt(screen, harness) : { kind: "unknown" };
761
+ if (box.kind === "unknown") return {
762
+ boxKnown: false,
763
+ staged: isStaged(screen, message),
764
+ outside: copiesOn(screen, message)
765
+ };
766
+ const inBox = box.kind === "draft" ? copiesOn(box.text, message) : 0;
767
+ return {
768
+ boxKnown: true,
769
+ staged: inBox > 0,
770
+ outside: copiesOn(screen, message) - inBox
771
+ };
772
+ }
773
+ /**
702
774
  * Submit `message` to `target` as a taken turn: cyber-mux's `nudge`, with one more way to see that the
703
775
  * turn was taken.
704
776
  *
@@ -718,18 +790,32 @@ function copiesOn(screen, message) {
718
790
  * queue. Text that is neither staged nor posted is lost: after one more settle for a slow redraw, it
719
791
  * is typed again. It is opt-in because it needs the posted copy to stay on screen: a busy peer's
720
792
  * output can scroll earlier copies away, so a ring to it could read as lost and be typed twice.
793
+ *
794
+ * With `requirePosted` and a harness whose input box this reader knows, nothing is typed until the
795
+ * harness has drawn that box, for up to the
796
+ * whole retry budget. Keys typed while a harness boots are buffered, and the harness takes them as one
797
+ * paste once it draws the box: each Enter becomes a newline, so re-typed copies pile up in the box
798
+ * unsent. A box that never shows within the budget is typed into anyway, as before.
799
+ *
800
+ * Where the input box can be read, copies of the text inside it are not counted as posted: only a
801
+ * copy outside the box means the harness took the turn.
721
802
  */
722
803
  async function ringTurn(adapter, exec, target, message, opts = {}) {
723
804
  const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
724
805
  const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
725
806
  const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
726
807
  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);
808
+ let first = adapter.read(exec, target).text;
809
+ if (opts.requirePosted && knowsPromptOf(opts.harness)) for (let wait = 0; wait < attempts && !readRing(first, message, opts.harness).boxKnown; wait++) {
810
+ await sleep(settleMs);
811
+ first = adapter.read(exec, target).text;
812
+ }
813
+ const before = readRing(first, message, opts.harness).outside;
728
814
  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";
815
+ const seen = readRing(adapter.read(exec, target).text, message, opts.harness);
816
+ const posted = seen.outside >= before + (seen.boxKnown || !seen.staged ? 1 : 2);
817
+ if (seen.staged) return posted ? "taken" : "staged";
818
+ return posted || !opts.requirePosted ? "taken" : "lost";
733
819
  };
734
820
  adapter.submit(exec, target, message);
735
821
  await sleep(settleMs);
@@ -829,7 +915,8 @@ async function wakeSpawn(getAdapter, exec, input, nudgeOpts = SPAWN_NUDGE_OPTS,
829
915
  const doorbell = (input.resumed ? resumeDoorbell : spawnDoorbell)(input.briefPath);
830
916
  await withDraftGuard(adapter, exec, input.target, () => ringTurn(adapter, exec, input.target, doorbell, {
831
917
  ...nudgeOpts,
832
- requirePosted: true
918
+ requirePosted: true,
919
+ harness: input.harness
833
920
  }), {
834
921
  ...guardOpts,
835
922
  harness: input.harness,
@@ -1242,6 +1329,7 @@ async function spawn(ctx, input) {
1242
1329
  const from = callerPane(sessionAdapter, normalizedEnv);
1243
1330
  let cwd;
1244
1331
  let worktree;
1332
+ let sharedWorktree;
1245
1333
  let base;
1246
1334
  let reused;
1247
1335
  let target;
@@ -1250,6 +1338,7 @@ async function spawn(ctx, input) {
1250
1338
  cwd = resolve(input.cwd);
1251
1339
  if (primaryRoot$1 !== void 0) assertDistinctFromPrimary$1(cwd, primaryRoot$1);
1252
1340
  worktree = null;
1341
+ sharedWorktree = sharedCheckoutAt(ctx.store, exec, cwd);
1253
1342
  const at = input.at ?? "tab";
1254
1343
  target = sessionAdapter.open(exec, {
1255
1344
  cwd,
@@ -1340,6 +1429,7 @@ async function spawn(ctx, input) {
1340
1429
  harness,
1341
1430
  cwd,
1342
1431
  worktree,
1432
+ ...sharedWorktree ? { sharedWorktree } : {},
1343
1433
  pane: muxName === "tmux" || muxName === "herdr" ? {
1344
1434
  mux: muxName,
1345
1435
  id: target.id
@@ -2857,6 +2947,10 @@ var FileStore = class {
2857
2947
  read: read ? readMessages(read) : []
2858
2948
  };
2859
2949
  }
2950
+ hasMailbox(id) {
2951
+ const dir = readPathOrUndefined(() => paths.inboxDir(this.root, id));
2952
+ return dir !== void 0 && existsSync(dir);
2953
+ }
2860
2954
  ackMessage(id, msgId) {
2861
2955
  const src = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
2862
2956
  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.10.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.10.0",
5
5
  "description": "Agent session spawning, messaging, and dispatch — the Legion and its Legate.",
6
6
  "author": {
7
7
  "name": "unional"