borgmcp 2.3.0 → 2.4.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.
Files changed (99) hide show
  1. package/README.md +3 -0
  2. package/dist/assimilate-cmd.d.ts +6 -0
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +31 -7
  5. package/dist/assimilate-cmd.js.map +1 -1
  6. package/dist/assimilate-deps.d.ts.map +1 -1
  7. package/dist/assimilate-deps.js +2 -0
  8. package/dist/assimilate-deps.js.map +1 -1
  9. package/dist/claude.d.ts.map +1 -1
  10. package/dist/claude.js +10 -0
  11. package/dist/claude.js.map +1 -1
  12. package/dist/cli-help.d.ts.map +1 -1
  13. package/dist/cli-help.js +2 -0
  14. package/dist/cli-help.js.map +1 -1
  15. package/dist/cli-platform.d.ts +1 -0
  16. package/dist/cli-platform.d.ts.map +1 -1
  17. package/dist/cli-platform.js +5 -1
  18. package/dist/cli-platform.js.map +1 -1
  19. package/dist/console-prefix.d.ts +9 -10
  20. package/dist/console-prefix.d.ts.map +1 -1
  21. package/dist/console-prefix.js +23 -20
  22. package/dist/console-prefix.js.map +1 -1
  23. package/dist/cubes.d.ts +11 -0
  24. package/dist/cubes.d.ts.map +1 -1
  25. package/dist/cubes.js +10 -0
  26. package/dist/cubes.js.map +1 -1
  27. package/dist/display-identity.d.ts +34 -0
  28. package/dist/display-identity.d.ts.map +1 -0
  29. package/dist/display-identity.js +88 -0
  30. package/dist/display-identity.js.map +1 -0
  31. package/dist/docs-sections.d.ts.map +1 -1
  32. package/dist/docs-sections.js +9 -0
  33. package/dist/docs-sections.js.map +1 -1
  34. package/dist/drone-management.d.ts.map +1 -1
  35. package/dist/drone-management.js +6 -0
  36. package/dist/drone-management.js.map +1 -1
  37. package/dist/inbox-monitor.d.ts.map +1 -1
  38. package/dist/inbox-monitor.js +12 -0
  39. package/dist/inbox-monitor.js.map +1 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +58 -16
  42. package/dist/index.js.map +1 -1
  43. package/dist/log-stream.d.ts.map +1 -1
  44. package/dist/log-stream.js +41 -15
  45. package/dist/log-stream.js.map +1 -1
  46. package/dist/parse-assimilate-args.d.ts +1 -1
  47. package/dist/parse-assimilate-args.d.ts.map +1 -1
  48. package/dist/parse-assimilate-args.js +5 -2
  49. package/dist/parse-assimilate-args.js.map +1 -1
  50. package/dist/regen-format.d.ts +2 -2
  51. package/dist/regen-format.d.ts.map +1 -1
  52. package/dist/regen-format.js +6 -5
  53. package/dist/regen-format.js.map +1 -1
  54. package/dist/regen.js +6 -2
  55. package/dist/regen.js.map +1 -1
  56. package/dist/remote-client.d.ts.map +1 -1
  57. package/dist/remote-client.js +8 -1
  58. package/dist/remote-client.js.map +1 -1
  59. package/dist/reset-local-seat-cmd.d.ts +8 -0
  60. package/dist/reset-local-seat-cmd.d.ts.map +1 -1
  61. package/dist/reset-local-seat-cmd.js +21 -2
  62. package/dist/reset-local-seat-cmd.js.map +1 -1
  63. package/dist/seat-reattach-guard.d.ts +21 -0
  64. package/dist/seat-reattach-guard.d.ts.map +1 -0
  65. package/dist/seat-reattach-guard.js +83 -0
  66. package/dist/seat-reattach-guard.js.map +1 -0
  67. package/dist/seats.d.ts +6 -2
  68. package/dist/seats.d.ts.map +1 -1
  69. package/dist/seats.js +37 -14
  70. package/dist/seats.js.map +1 -1
  71. package/dist/update-cmd.d.ts.map +1 -1
  72. package/dist/update-cmd.js +1 -37
  73. package/dist/update-cmd.js.map +1 -1
  74. package/docs/EXTRACTION_PROVENANCE.md +3 -3
  75. package/docs/LOCAL_SERVER.md +4 -0
  76. package/docs/RELEASING.md +23 -4
  77. package/docs/SEAT_LIFECYCLE.md +242 -0
  78. package/package.json +1 -1
  79. package/src/assimilate-cmd.ts +45 -7
  80. package/src/assimilate-deps.ts +2 -0
  81. package/src/claude.ts +11 -0
  82. package/src/cli-help.ts +2 -0
  83. package/src/cli-platform.ts +5 -2
  84. package/src/console-prefix.ts +25 -18
  85. package/src/cubes.ts +16 -0
  86. package/src/display-identity.ts +130 -0
  87. package/src/docs-sections.ts +9 -0
  88. package/src/drone-management.ts +6 -0
  89. package/src/inbox-monitor.ts +19 -0
  90. package/src/index.ts +66 -20
  91. package/src/log-stream.ts +38 -14
  92. package/src/parse-assimilate-args.ts +4 -2
  93. package/src/regen-format.ts +6 -5
  94. package/src/regen.ts +11 -2
  95. package/src/remote-client.ts +8 -0
  96. package/src/reset-local-seat-cmd.ts +26 -1
  97. package/src/seat-reattach-guard.ts +113 -0
  98. package/src/seats.ts +36 -14
  99. package/src/update-cmd.ts +0 -34
@@ -0,0 +1,242 @@
1
+ # Seat Lifecycle and Recovery
2
+
3
+ This guide describes the local seat behavior shipped by the client. A seat is
4
+ one server-side drone identity plus its client-generated session bearer and
5
+ worktree binding. The bearer and binding are stored together in the private
6
+ `seats.json` store; commands never reconstruct one from the other.
7
+
8
+ ## Stored states
9
+
10
+ Only two seat states are persisted:
11
+
12
+ | State | Meaning | What can use it |
13
+ | --- | --- | --- |
14
+ | `pending` | The client persisted a fresh bearer before attach completed. It may be unbound, or bound to a worktree after a recoverable finalize failure. | Retry logic only. It is never hydrated as a live seat. |
15
+ | `active` | Attach completed and the server session metadata, display metadata, and worktree binding were committed together. | Normal coordination and re-attach. |
16
+
17
+ `revoked`, `superseded`, and `evicted` are server verdicts, not values written
18
+ to `seats.json`. Revocation and supersession do not silently delete local
19
+ state. An authoritative eviction is the sole verdict that permits the terminal
20
+ assimilation flow to replace a rejected seat. `borg reset-local-seat` is the
21
+ explicit offline deletion path.
22
+
23
+ Seat sessions do not expire. A legacy `expiresAt` field is ignored, and current
24
+ attach responses containing `expires_at` are rejected. An enrollment
25
+ invitation can expire; recovery is to request a new invitation. An expired
26
+ stream cursor is also separate from seat identity and is recovered by resetting
27
+ the cursor and reconnecting.
28
+
29
+ The whole seat store fails closed if any persisted record is malformed. The
30
+ store reader raises:
31
+
32
+ ```text
33
+ Borg seat store is malformed or has an unsupported version; refusing to read it
34
+ ```
35
+
36
+ The file is not rewritten and no bearer material is included in the error.
37
+
38
+ ## Enrollment, attach, and retry
39
+
40
+ Enrollment establishes the authority-bound parent credential used to request
41
+ seat attachment. Invitation enrollment persists its retry tuple before network
42
+ I/O; a rejected or expired invitation requires a new invitation.
43
+
44
+ For a seat attach, the client:
45
+
46
+ 1. Mints a 32-byte base64url session bearer and persists a `pending` record
47
+ before sending it.
48
+ 2. Sends that bearer to the server. The server stores only its digest.
49
+ 3. On success, atomically changes the same record to `active` and adds the
50
+ server metadata and worktree binding.
51
+
52
+ An ambiguous retry reuses the same persisted bearer, so the server resolves the
53
+ same seat instead of minting a duplicate. A crash after server acceptance but
54
+ before local finalization is also resumed from that pending record. If a
55
+ sibling worktree was already created when activation failed, Borg binds the
56
+ still-pending record to that worktree without making it live; rerunning from
57
+ there resends the same bearer.
58
+
59
+ Only an `implicit-sibling:<id>` pending operation is eligible for automatic
60
+ implicit retry adoption. A `named-sibling:<name>` operation remains distinct
61
+ and is never silently adopted by an unnamed sibling launch.
62
+
63
+ If activation fails and the exact pending record is successfully bound to the
64
+ spawned worktree, Borg preserves that worktree and the printed retry reuses the
65
+ same seat. If the record is missing, replaced, or unavailable, Borg removes the
66
+ spawned worktree and does not print a client retry command: the server may
67
+ already have accepted the seat, and the current protocol has no client-side
68
+ operation identifier or cleanup endpoint that can prove reuse or remove it.
69
+
70
+ ## Re-attaching from a terminal
71
+
72
+ `borg assimilate --here` first resolves this worktree's saved active seat and
73
+ probes it. A live seat is reattached with the identical bearer; its saved role
74
+ is authoritative, and no new drone is minted. The success line is:
75
+
76
+ ```text
77
+ re-attached to existing seat <drone-label> (same session, no new drone minted)
78
+ ```
79
+
80
+ Before relaunch, Borg checks the seat's inbox-monitor PID. A live holder refuses
81
+ the relaunch, names its PID, and gives both the safe fresh-worktree path and the
82
+ explicit one-time override:
83
+
84
+ ```text
85
+ This worktree's Borg seat already has a live session (inbox monitor pid <pid>).
86
+ No agent was launched. Stop the existing session or use a fresh worktree with `borg assimilate --worktree <name>`. If the live monitor is wedged, override once with `borg assimilate --here --force`.
87
+ ```
88
+
89
+ For a stale or missing heartbeat, the first line adds:
90
+
91
+ ```text
92
+ Its heartbeat is <stale|missing>, so the process may be wedged.
93
+ ```
94
+
95
+ A dead, absent, malformed, or unreadable PID file is not evidence of a live
96
+ holder, so ordinary crash recovery proceeds without `--force`. The probe is
97
+ read-only; it does not reap, kill, rewrite, or clear monitor state.
98
+
99
+ If a second monitor reaches the singleton after launch, it exits successfully
100
+ without tailing and prints:
101
+
102
+ ```text
103
+ borg-inbox-monitor: seat inbox "<path>" is already monitored by a live instance (pid <pid>); yielding — another session likely holds this seat.
104
+ ```
105
+
106
+ The existing monitor remains the sole inbox reader.
107
+
108
+ ## Re-attaching from an agent session
109
+
110
+ The `borg_assimilate` MCP tool is re-attach-only. It can reuse this worktree's
111
+ saved seat for the requested cube, but it cannot create a seat or switch the
112
+ worktree to another cube.
113
+
114
+ With no saved identity, it returns:
115
+
116
+ ```text
117
+ ◼ This session has no drone seat for this worktree, and in-session borg_assimilate is re-attach-only (it never creates seats — gh#780). To create a seat for cube "<cube>", run `borg assimilate` in a terminal — it spawns the worktree, persists the identity, and launches the agent in one step.
118
+ ```
119
+
120
+ For another cube, it returns:
121
+
122
+ ```text
123
+ ◼ This worktree is attached to cube "<active-cube>"; in-session borg_assimilate is re-attach-only and cannot switch to "<requested-cube>" (gh#780). To work in "<requested-cube>", run `borg assimilate` in a terminal from that project (or spawn a fresh worktree for it).
124
+ ```
125
+
126
+ A successful in-session re-attach begins:
127
+
128
+ ```text
129
+ # Re-attached to cube: <cube>
130
+
131
+ **Drone label:** <drone-label>
132
+ **Seat:** existing identity reused — no new drone minted (gh#780)
133
+ ```
134
+
135
+ If the server no longer accepts the saved seat, the tool fails rather than
136
+ reminting:
137
+
138
+ ```text
139
+ ◼ Re-attach failed — this worktree's saved seat is unreachable (likely evicted or its session was revoked). Server said: <server-error>
140
+ Recover by running `borg assimilate` in a terminal to create a fresh seat; in-session borg_assimilate never re-mints (gh#780).
141
+ ```
142
+
143
+ ## Terminal verdicts and recovery
144
+
145
+ ### Revoked or superseded session
146
+
147
+ A pin-matched `SESSION_REVOKED` or `SESSION_REJECTED` is diagnosis only. Attach
148
+ does not mutate the saved seat. The exact output is one of:
149
+
150
+ ```text
151
+ Local session was revoked.
152
+ Next: run borg reset-local-seat, then borg assimilate --host <server> --enroll.
153
+ ```
154
+
155
+ ```text
156
+ Local session was superseded by a newer enrollment.
157
+ Next: run borg reset-local-seat, then borg assimilate --host <server> --enroll.
158
+ ```
159
+
160
+ Run the named offline reset, obtain a new invitation, and enroll again.
161
+
162
+ ### Evicted seat
163
+
164
+ Only an authoritative `410 DRONE_EVICTED` permits replacement of a rejected
165
+ saved seat. If the pre-attach probe establishes eviction, the terminal
166
+ assimilation flow may replace the bearer and attach a fresh seat. Ambiguous
167
+ transport, trust, endpoint, credential, or server failures never authorize that
168
+ replacement.
169
+
170
+ If the attach itself returns the eviction after re-attach began, Borg does not
171
+ claim recovery. It prints:
172
+
173
+ ```text
174
+ This worktree's saved seat on <server> was evicted. Remove this worktree, or from a fresh worktree run `borg assimilate --host <server>`.
175
+ ```
176
+
177
+ The stream path also treats typed `DRONE_EVICTED` as terminal: it marks that
178
+ exact local candidate rejected, stops retrying that seat, and does not restart
179
+ the loop as though the failure were transient.
180
+
181
+ ### Rejected or expired invitation
182
+
183
+ An invitation failure is enrollment recovery, not a seat reset:
184
+
185
+ ```text
186
+ The enrollment invitation for <server> was rejected or expired. Ask the server operator for a replacement invitation — the server can stay running: for an unclaimed owner client run `borg-mcp-server owner-invite`; for an ordinary client run `borg-mcp-server client-invite`. Then rerun `borg assimilate --host <server> --enroll`.
187
+ ```
188
+
189
+ ## Offline reset
190
+
191
+ `borg reset-local-seat` clears only the current worktree's saved credential and
192
+ cube binding. It makes no network call, revokes nothing server-side, and leaves
193
+ the server, trust anchor, cube, and sibling worktrees unchanged.
194
+
195
+ Its shipped help describes the command as:
196
+
197
+ ```text
198
+ borg reset-local-seat (borgmcp <version>) — clear ONLY this worktree's saved local seat
199
+
200
+ Usage:
201
+ borg reset-local-seat Reset this worktree's saved seat (TTY confirms [y/N])
202
+ borg reset-local-seat --host <host> No-op unless this worktree's seat is on <host>
203
+ borg reset-local-seat --yes Reset without a prompt (required when non-interactive)
204
+ borg reset-local-seat --help Show this help
205
+ ```
206
+
207
+ The command snapshots the exact binding and token-safe bearer observation,
208
+ prompts outside the store lock, then revalidates the same seat before deleting
209
+ it. If another process replaced or reset the seat, the command is an honest
210
+ no-op rather than deleting the successor. After a successful reset, Borg reads
211
+ the same worktree again without hydrating any bearer. If another saved active
212
+ seat remains on the same server, the command points to
213
+ `borg assimilate --host <server> --here` and states that the server will
214
+ revalidate it before launch. Otherwise it gives the fresh-enrollment path:
215
+
216
+ ```text
217
+ borg assimilate --host <server> --enroll
218
+ ```
219
+
220
+ ## Multiple seats and deterministic selection
221
+
222
+ An in-place seat uses operation kind `seat` and operation key
223
+ `current-worktree`. A sibling uses operation kind `sibling`; named siblings key
224
+ on their worktree name, while implicit siblings receive a unique operation key.
225
+ These operations derive distinct credential references and distinct bearers.
226
+ Creating a sibling therefore never moves or overwrites the original
227
+ worktree's active seat.
228
+
229
+ Historical recovery or interrupted operations can leave more than one active
230
+ record bound to one worktree. Every process uses the same total order:
231
+
232
+ 1. A candidate not definitively rejected in this process comes before a
233
+ rejected, revoked, or evicted candidate.
234
+ 2. A `sibling` candidate finalized into this worktree comes before an older
235
+ in-place `seat` candidate.
236
+ 3. Remaining ties use the credential reference's lexical order.
237
+
238
+ Only `active` records participate in normal selection. A bound `pending` record
239
+ is discoverable only by the convergence path that resends its exact bearer.
240
+ This ordering is shared by SessionStart, kickoff construction, MCP children,
241
+ stream ownership, and normal active-cube hydration, so one process does not
242
+ select different seats for different surfaces.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "borgmcp",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Coordinate AI coding agents in shared cubes. Works with Claude Code, Codex, and OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -28,6 +28,11 @@ import {
28
28
  import type { BorgCli } from './cubes.js';
29
29
  import { inboxPathForDrone } from './cubes.js';
30
30
  import { monitorStateRootForWorktree } from './inbox-monitor.js';
31
+ import {
32
+ formatSeatReattachRefusal,
33
+ inspectLiveInboxMonitor,
34
+ type LiveInboxMonitor,
35
+ } from './seat-reattach-guard.js';
31
36
  import { resolveLaunchEnv } from './model-presets.js';
32
37
  import { unlinkSync } from 'node:fs';
33
38
  import {
@@ -89,6 +94,7 @@ export interface AssimilateFlags {
89
94
  model?: string;
90
95
  server?: string;
91
96
  enroll?: boolean;
97
+ force?: boolean;
92
98
  }
93
99
 
94
100
  export interface AssimilateArgs {
@@ -154,6 +160,8 @@ export interface ActiveCube {
154
160
  /** Verified local-server CA identity; absent until a local server is selected. */
155
161
  serverTrustIdentity?: string;
156
162
  localSessionCredentialRef?: string;
163
+ /** Durable operation that produced this exact seat binding. */
164
+ operation?: ServerSessionOperation;
157
165
  // gh#899: assimilated role, persisted for connect-time tool-surface scoping
158
166
  // (mirrors cubes.ts ActiveCube; optional → backward-compatible).
159
167
  roleName?: string;
@@ -202,6 +210,11 @@ export interface AssimilateDeps {
202
210
  // The scaffold previously declared them sync; that would silently
203
211
  // mis-await in Phase F wiring. Promise<...> matches the real shape.
204
212
  getActiveCube: () => Promise<ActiveCube | null>;
213
+ /** Read-only relaunch guard for the saved seat's inbox monitor. */
214
+ inspectLiveInboxMonitor?: (
215
+ inboxPath: string,
216
+ monitorStateRoot: string,
217
+ ) => LiveInboxMonitor | null;
205
218
  hasPersistedActiveCube: () => Promise<boolean>;
206
219
  /** Read the RAW persisted local seat for this worktree WITHOUT hydrating its
207
220
  * keychain credential — used to recover a crash-in-gap PENDING seat when
@@ -848,6 +861,12 @@ export async function runAssimilate(
848
861
  : `named-sibling:${args.flags.worktree}`)
849
862
  : 'current-worktree',
850
863
  };
864
+ // A selected sibling can be the surviving live seat for this worktree (#63).
865
+ // `--here` must re-send that seat's durable operation, not reconstruct the
866
+ // in-place operation and then fail PREPARE forever on the wrong ref.
867
+ if (existing && args.flags.here && existing.operation) {
868
+ sessionOperation = existing.operation;
869
+ }
851
870
  let reattachPriorId: string | undefined;
852
871
  let remintInvalidPrior = false;
853
872
  let savedLocalRole: Role | undefined;
@@ -1083,6 +1102,16 @@ export async function runAssimilate(
1083
1102
  reattachPriorId = existing.droneId;
1084
1103
  }
1085
1104
 
1105
+ if (existing && reattachPriorId !== undefined && !args.flags.force) {
1106
+ const inboxPath = deps.getInboxPath(existing.cubeId, existing.droneId);
1107
+ const stateRoot = monitorStateRootForWorktree(projectRoot);
1108
+ const holder = (deps.inspectLiveInboxMonitor ?? inspectLiveInboxMonitor)(inboxPath, stateRoot);
1109
+ if (holder !== null) {
1110
+ deps.stderr(formatSeatReattachRefusal(holder, 'borg assimilate --here --force'));
1111
+ return 1;
1112
+ }
1113
+ }
1114
+
1086
1115
  // ----- Step 5: Role resolution -----
1087
1116
  let resolvedRole: Role | undefined;
1088
1117
  if (savedLocalRole) {
@@ -1545,14 +1574,23 @@ export async function runAssimilate(
1545
1574
  return 1;
1546
1575
  }
1547
1576
  // missing / replaced / threw / unavailable: the worktree owns NO durable
1548
- // locator, so make NO convergence claim. Roll back the just-spawned worktree
1549
- // (preserve ONLY when it owns a durable locator) and point at the offline reset.
1577
+ // locator. The server may already have accepted the seat, while the client
1578
+ // has no protocol operation id or cleanup endpoint with which to prove reuse
1579
+ // or remove it. State the exact local outcome and do not prescribe a retry
1580
+ // that can silently create a duplicate server seat (#35).
1581
+ const bindFailure =
1582
+ bindOutcome === 'missing'
1583
+ ? 'the exact pending seat record went missing locally before it could be bound'
1584
+ : bindOutcome === 'replaced'
1585
+ ? 'the exact pending seat record was replaced locally before it could be bound; the replacement was left untouched'
1586
+ : bindOutcome === 'threw'
1587
+ ? 'the local seat store could not be read or written while preserving the pending seat'
1588
+ : 'this client did not receive a pending-seat preservation handle';
1550
1589
  deps.stderr(
1551
- `This worktree's secure session on ${auth.apiUrl} did not finish activating, and its ` +
1552
- 'seat state could NOT be preserved to this worktree (it was concurrently reset or ' +
1553
- 'replaced, or the local seat store could not be written). No usable seat remains here. ' +
1554
- `Run ${resetLocalSeatCommand(auth.apiUrl)} to clear any saved seat, then re-run ` +
1555
- `${localAssimilateCommand(auth.apiUrl)} to attach against the current state.\n`,
1590
+ `This worktree's secure session on ${auth.apiUrl} did not finish activating: ` +
1591
+ `${bindFailure}. The spawned worktree will be removed. No client-only command can ` +
1592
+ 'prove reuse or safely clear the possibly accepted server-side seat; ask the server ' +
1593
+ 'operator to inspect that seat before retrying.\n',
1556
1594
  );
1557
1595
  rollbackWorktree();
1558
1596
  return 1;
@@ -17,6 +17,7 @@ import prompts from 'prompts';
17
17
  import { readinessProbeEnv } from './readiness-probe.js';
18
18
  import { resolveMcpBinaryPath } from './self-path.js';
19
19
  import { buildRuntimeMetadataReport } from './runtime-metadata.js';
20
+ import { inspectLiveInboxMonitor } from './seat-reattach-guard.js';
20
21
 
21
22
  import type { AssimilateDeps } from './assimilate-cmd.js';
22
23
  import {
@@ -170,6 +171,7 @@ export function buildDefaultAssimilateDeps(
170
171
  },
171
172
 
172
173
  getActiveCube: () => cubesGetActive(),
174
+ inspectLiveInboxMonitor,
173
175
  hasPersistedActiveCube: () => cubesHasPersistedActive(),
174
176
  readPersistedLocalSeat: () => readPersistedLocalSeat(),
175
177
  peekServerSessionRecord: async (credentialRef, binding) =>
package/src/claude.ts CHANGED
@@ -26,6 +26,7 @@ import { fileURLToPath } from 'node:url';
26
26
  import chalk from 'chalk';
27
27
  import { findProjectRoot, getActiveCube, inboxPathForDrone, setCodexWakeTarget, pruneDeadCodexWakeTargets } from './cubes.js';
28
28
  import { monitorStateRootForWorktree } from './inbox-monitor.js';
29
+ import { formatSeatReattachRefusal, inspectLiveInboxMonitor } from './seat-reattach-guard.js';
29
30
  import { handleVersionFlag, getPackageVersion } from './version.js';
30
31
  import { isHelpFlag, setupHelpText, topLevelHelpText, assimilateHelpText, resetLocalSeatHelpText } from './cli-help.js';
31
32
  import { runSpawn } from './spawn.js';
@@ -310,6 +311,16 @@ async function main() {
310
311
  cli = action.cli;
311
312
  }
312
313
 
314
+ if (active && !parsedCli.force) {
315
+ const inboxPath = inboxPathForDrone(active.cubeId, active.droneId);
316
+ const stateRoot = monitorStateRootForWorktree(findProjectRoot(process.cwd()));
317
+ const holder = inspectLiveInboxMonitor(inboxPath, stateRoot);
318
+ if (holder !== null) {
319
+ process.stderr.write(formatSeatReattachRefusal(holder, 'borg --force'));
320
+ process.exit(1);
321
+ }
322
+ }
323
+
313
324
  // client#20: inspect only the SELECTED harness after the one-shot launch
314
325
  // menu choice. Explicit consent enables a narrow per-process override;
315
326
  // Borg never rewrites the user's approval policy here.
package/src/cli-help.ts CHANGED
@@ -109,12 +109,14 @@ export function assimilateHelpText(version: string): string {
109
109
  ` borg assimilate [role] --worktree <name> Spawn the drone in an isolated git worktree\n` +
110
110
  ` (~/.borg/worktrees/<repo>/<name>)\n` +
111
111
  ` borg assimilate --here Assimilate in the current worktree (no sibling spawn)\n` +
112
+ ` borg assimilate --here --force Reattach despite a still-live inbox monitor\n` +
112
113
  ` borg assimilate --host <host> Join an authorized self-hosted cube\n` +
113
114
  ` borg assimilate --host <host> --enroll Operator-terminal enrollment, then create/join (preview)\n` +
114
115
  ` borg assimilate --help Show this help\n\n` +
115
116
  `Flags:\n` +
116
117
  ` --worktree <name> Create + launch the drone in a sibling git worktree\n` +
117
118
  ` --here Stay in the current worktree (no sibling spawn)\n` +
119
+ ` --force Override a live-seat guard for a wedged monitor\n` +
118
120
  ` --cube-name <name> Repository cube name (otherwise edit the proposed name)\n` +
119
121
  ` --host <host> Borg server host or URL (bare hosts default to HTTPS)\n` +
120
122
  ` --enroll Prompt for a hidden enrollment invitation in the operator terminal\n` +
@@ -101,9 +101,10 @@ export function defaultCliChoiceDeps(prompt: (message: string) => Promise<string
101
101
 
102
102
  const VALID_CLIS: readonly BorgCli[] = ['claude', 'codex', 'opencode'];
103
103
 
104
- export function parseCliFlag(args: string[]): { cli?: BorgCli; rest: string[]; error?: string } {
104
+ export function parseCliFlag(args: string[]): { cli?: BorgCli; force?: boolean; rest: string[]; error?: string } {
105
105
  const rest: string[] = [];
106
106
  let cli: BorgCli | undefined;
107
+ let force = false;
107
108
  for (let i = 0; i < args.length; i++) {
108
109
  const arg = args[i];
109
110
  if (arg === '--cli') {
@@ -119,9 +120,11 @@ export function parseCliFlag(args: string[]): { cli?: BorgCli; rest: string[]; e
119
120
  return { rest, error: `--cli requires one of: ${VALID_CLIS.join(', ')}` };
120
121
  }
121
122
  cli = value as BorgCli;
123
+ } else if (arg === '--force') {
124
+ force = true;
122
125
  } else {
123
126
  rest.push(arg);
124
127
  }
125
128
  }
126
- return { cli, rest };
129
+ return { ...(cli ? { cli } : {}), ...(force ? { force: true } : {}), rest };
127
130
  }
@@ -9,9 +9,9 @@
9
9
  * insufficient — the Queen reads the active terminal's output stream,
10
10
  * not its title bar.
11
11
  *
12
- * This module exports a one-shot initializer that resolves the prefix
13
- * from the local cube state cache, plus a synchronous getter that
14
- * call sites use to wrap each console.error.
12
+ * This module exports a one-shot initializer that seeds the process-local
13
+ * display identity from the selected seat, plus a synchronous getter that
14
+ * follows later server confirmations and wraps each console.error.
15
15
  *
16
16
  * Format (matches the terminal-title.ts middle-dot convention so
17
17
  * surfaces stay internally consistent):
@@ -27,8 +27,13 @@
27
27
  import { basename } from 'node:path';
28
28
  import chalk from 'chalk';
29
29
  import { getActiveCube } from './cubes.js';
30
+ import {
31
+ _resetDisplayIdentityForTests,
32
+ currentDisplayIdentity,
33
+ seedDisplayIdentity,
34
+ } from './display-identity.js';
30
35
 
31
- let cachedPrefix: string | null = null;
36
+ let initialized = false;
32
37
 
33
38
  /** Neutral prefix for a not-yet-assimilated session (gh#818 P1). */
34
39
  function unassimilatedPrefix(): string {
@@ -36,34 +41,35 @@ function unassimilatedPrefix(): string {
36
41
  }
37
42
 
38
43
  /**
39
- * Resolve the drone-self-identification prefix from cube state and
40
- * cache it for subsequent synchronous reads. Idempotent — calling
41
- * multiple times returns the same value. Falls back silently to the
42
- * unassimilated shape on any read error so console emission is never
43
- * blocked.
44
+ * Resolve the drone-self-identification prefix from cube state and seed the
45
+ * shared display source. Idempotent — later calls do not re-read the store,
46
+ * while the synchronous prefix still follows server-confirmed display changes.
47
+ * Falls back silently on any read error so console emission is never blocked.
44
48
  */
45
49
  export async function initConsolePrefix(): Promise<string> {
46
- if (cachedPrefix !== null) return cachedPrefix;
50
+ if (initialized) return droneIdPrefix();
47
51
  try {
48
52
  const active = await getActiveCube();
49
53
  if (active?.droneLabel && active?.name) {
50
- cachedPrefix = `[${active.droneLabel} · ${active.name}]`;
51
- return cachedPrefix;
54
+ seedDisplayIdentity(active);
55
+ initialized = true;
56
+ return droneIdPrefix();
52
57
  }
53
58
  } catch {
54
59
  // Fall through to unassimilated fallback.
55
60
  }
56
- cachedPrefix = unassimilatedPrefix();
57
- return cachedPrefix;
61
+ initialized = true;
62
+ return unassimilatedPrefix();
58
63
  }
59
64
 
60
65
  /**
61
- * Synchronous prefix getter. Returns the cached value if initialized,
62
- * otherwise the unassimilated fallback — safe to call before
66
+ * Synchronous prefix getter. Returns the current process-local display value
67
+ * if initialized, otherwise the unassimilated fallback — safe to call before
63
68
  * initConsolePrefix() resolves.
64
69
  */
65
70
  export function droneIdPrefix(): string {
66
- if (cachedPrefix !== null) return cachedPrefix;
71
+ const identity = currentDisplayIdentity();
72
+ if (initialized && identity) return `[${identity.droneLabel} · ${identity.cubeName}]`;
67
73
  return unassimilatedPrefix();
68
74
  }
69
75
 
@@ -95,5 +101,6 @@ export function cerr(...args: any[]): void {
95
101
  }
96
102
 
97
103
  export function _resetCachedPrefixForTests(): void {
98
- cachedPrefix = null;
104
+ initialized = false;
105
+ _resetDisplayIdentityForTests();
99
106
  }
package/src/cubes.ts CHANGED
@@ -62,6 +62,8 @@ export interface ActiveCube {
62
62
  serverTrustIdentity?: string;
63
63
  /** Opaque local-session keychain reference; never a bearer. */
64
64
  localSessionCredentialRef?: string;
65
+ /** Durable seat operation that produced this exact local binding. */
66
+ operation?: SeatOperation;
65
67
  // gh#899: the assimilated role, persisted so the connect-time ListTools
66
68
  // handler can role-scope the NATIVE tool surface (UX/context only — never an
67
69
  // auth boundary). Absent on pre-gh#899 cubes.json entries → the filter
@@ -279,12 +281,26 @@ async function hydrateActiveCube(record: SeatRecord): Promise<ActiveCube | null>
279
281
  apiUrl: record.origin,
280
282
  serverTrustIdentity: record.trustIdentity,
281
283
  localSessionCredentialRef: ref,
284
+ operation: record.operation,
282
285
  ...(record.roleName !== undefined ? { roleName: record.roleName } : {}),
283
286
  ...(record.roleClass !== undefined ? { roleClass: record.roleClass } : {}),
284
287
  ...(record.isHumanSeat !== undefined ? { isHumanSeat: record.isHumanSeat } : {}),
285
288
  };
286
289
  }
287
290
 
291
+ /**
292
+ * Token-free lookup used after an offline reset. A surviving seat is only
293
+ * described as saved local state; the caller must still revalidate it with the
294
+ * server before launch.
295
+ */
296
+ export async function findRemainingActiveSeatForWorktree(worktree: string): Promise<{
297
+ apiUrl: string;
298
+ operation: SeatOperation;
299
+ } | null> {
300
+ const record = await getActiveSeatForWorktree(worktree);
301
+ return record ? { apiUrl: record.origin, operation: record.operation } : null;
302
+ }
303
+
288
304
  /**
289
305
  * Legacy binding-only writer. In the collapsed single-store model an ACTIVE seat is
290
306
  * created ONLY by the atomic mint→activate+bind path in seats.ts (driven by the