borgmcp 2.2.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 (106) hide show
  1. package/README.md +36 -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 +14 -0
  11. package/dist/claude.js.map +1 -1
  12. package/dist/cli-help.d.ts +2 -0
  13. package/dist/cli-help.d.ts.map +1 -1
  14. package/dist/cli-help.js +19 -0
  15. package/dist/cli-help.js.map +1 -1
  16. package/dist/cli-platform.d.ts +1 -0
  17. package/dist/cli-platform.d.ts.map +1 -1
  18. package/dist/cli-platform.js +5 -1
  19. package/dist/cli-platform.js.map +1 -1
  20. package/dist/console-prefix.d.ts +9 -10
  21. package/dist/console-prefix.d.ts.map +1 -1
  22. package/dist/console-prefix.js +23 -20
  23. package/dist/console-prefix.js.map +1 -1
  24. package/dist/cubes.d.ts +11 -0
  25. package/dist/cubes.d.ts.map +1 -1
  26. package/dist/cubes.js +10 -0
  27. package/dist/cubes.js.map +1 -1
  28. package/dist/display-identity.d.ts +34 -0
  29. package/dist/display-identity.d.ts.map +1 -0
  30. package/dist/display-identity.js +88 -0
  31. package/dist/display-identity.js.map +1 -0
  32. package/dist/docs-sections.d.ts.map +1 -1
  33. package/dist/docs-sections.js +9 -0
  34. package/dist/docs-sections.js.map +1 -1
  35. package/dist/drone-management.d.ts.map +1 -1
  36. package/dist/drone-management.js +6 -0
  37. package/dist/drone-management.js.map +1 -1
  38. package/dist/inbox-monitor.d.ts.map +1 -1
  39. package/dist/inbox-monitor.js +12 -0
  40. package/dist/inbox-monitor.js.map +1 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +58 -16
  43. package/dist/index.js.map +1 -1
  44. package/dist/log-stream.d.ts.map +1 -1
  45. package/dist/log-stream.js +41 -15
  46. package/dist/log-stream.js.map +1 -1
  47. package/dist/parse-assimilate-args.d.ts +1 -1
  48. package/dist/parse-assimilate-args.d.ts.map +1 -1
  49. package/dist/parse-assimilate-args.js +5 -2
  50. package/dist/parse-assimilate-args.js.map +1 -1
  51. package/dist/regen-format.d.ts +2 -2
  52. package/dist/regen-format.d.ts.map +1 -1
  53. package/dist/regen-format.js +6 -5
  54. package/dist/regen-format.js.map +1 -1
  55. package/dist/regen.js +6 -2
  56. package/dist/regen.js.map +1 -1
  57. package/dist/remote-client.d.ts.map +1 -1
  58. package/dist/remote-client.js +8 -1
  59. package/dist/remote-client.js.map +1 -1
  60. package/dist/reset-local-seat-cmd.d.ts +8 -0
  61. package/dist/reset-local-seat-cmd.d.ts.map +1 -1
  62. package/dist/reset-local-seat-cmd.js +21 -2
  63. package/dist/reset-local-seat-cmd.js.map +1 -1
  64. package/dist/seat-reattach-guard.d.ts +21 -0
  65. package/dist/seat-reattach-guard.d.ts.map +1 -0
  66. package/dist/seat-reattach-guard.js +83 -0
  67. package/dist/seat-reattach-guard.js.map +1 -0
  68. package/dist/seats.d.ts +6 -2
  69. package/dist/seats.d.ts.map +1 -1
  70. package/dist/seats.js +37 -14
  71. package/dist/seats.js.map +1 -1
  72. package/dist/unknown-subcommand.d.ts +1 -1
  73. package/dist/unknown-subcommand.d.ts.map +1 -1
  74. package/dist/unknown-subcommand.js +1 -0
  75. package/dist/unknown-subcommand.js.map +1 -1
  76. package/dist/update-cmd.d.ts +58 -0
  77. package/dist/update-cmd.d.ts.map +1 -0
  78. package/dist/update-cmd.js +801 -0
  79. package/dist/update-cmd.js.map +1 -0
  80. package/docs/EXTRACTION_PROVENANCE.md +3 -3
  81. package/docs/LOCAL_SERVER.md +43 -0
  82. package/docs/RELEASING.md +145 -4
  83. package/docs/SEAT_LIFECYCLE.md +242 -0
  84. package/package.json +6 -3
  85. package/src/assimilate-cmd.ts +45 -7
  86. package/src/assimilate-deps.ts +2 -0
  87. package/src/claude.ts +15 -0
  88. package/src/cli-help.ts +22 -0
  89. package/src/cli-platform.ts +5 -2
  90. package/src/console-prefix.ts +25 -18
  91. package/src/cubes.ts +16 -0
  92. package/src/display-identity.ts +130 -0
  93. package/src/docs-sections.ts +9 -0
  94. package/src/drone-management.ts +6 -0
  95. package/src/inbox-monitor.ts +19 -0
  96. package/src/index.ts +66 -20
  97. package/src/log-stream.ts +38 -14
  98. package/src/parse-assimilate-args.ts +4 -2
  99. package/src/regen-format.ts +6 -5
  100. package/src/regen.ts +11 -2
  101. package/src/remote-client.ts +8 -0
  102. package/src/reset-local-seat-cmd.ts +26 -1
  103. package/src/seat-reattach-guard.ts +113 -0
  104. package/src/seats.ts +36 -14
  105. package/src/unknown-subcommand.ts +1 -0
  106. package/src/update-cmd.ts +1006 -0
@@ -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';
@@ -82,6 +83,7 @@ import { installBorgPlugin } from './opencode-plugin.js';
82
83
  import { connectOpenCodeDrone, computeOpenCodePort, createOpenCodeLaunchKickoff, injectInitialKickoff } from './opencode-drone.js';
83
84
  import { buildOpenCodeLaunchArgs, defaultApprovalIo, resolveLaunchBorgApprovals } from './cli-tool-approval.js';
84
85
  import { runEarlyServerFacade } from './server-facade.js';
86
+ import { runEarlyUpdate } from './update-cmd.js';
85
87
 
86
88
  export type AssimilateDepsBuilder = typeof buildDefaultAssimilateDeps;
87
89
 
@@ -101,6 +103,9 @@ export async function runAssimilateEntry(
101
103
  }
102
104
 
103
105
  async function main() {
106
+ const updateExitCode = await runEarlyUpdate(process.argv);
107
+ if (updateExitCode !== null) process.exit(updateExitCode);
108
+
104
109
  const serverExitCode = await runEarlyServerFacade(process.argv);
105
110
  if (serverExitCode !== null) process.exit(serverExitCode);
106
111
 
@@ -306,6 +311,16 @@ async function main() {
306
311
  cli = action.cli;
307
312
  }
308
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
+
309
324
  // client#20: inspect only the SELECTED harness after the one-shot launch
310
325
  // menu choice. Explicit consent enables a narrow per-process override;
311
326
  // Borg never rewrites the user's approval policy here.
package/src/cli-help.ts CHANGED
@@ -27,6 +27,7 @@ export function topLevelHelpText(version: string): string {
27
27
  `Usage:\n` +
28
28
  ` borg Launch your agent CLI; in a TTY, bare borg may show the launch menu\n` +
29
29
  ` borg setup Set up borg MCP server + agent CLI integration\n` +
30
+ ` borg update Update the client and installed local server together\n` +
30
31
  ` borg assimilate [role] Join or create a cube\n` +
31
32
  ` borg assimilate --host <host> Join or create on an explicit server\n` +
32
33
  ` borg assimilate --worktree <name> Spawn a worktree drone (in ~/.borg/worktrees/<repo>/<name>)\n` +
@@ -44,6 +45,25 @@ export function topLevelHelpText(version: string): string {
44
45
  );
45
46
  }
46
47
 
48
+ /** Help for the whole-product, npm-owned update journey. */
49
+ export function updateHelpText(version: string): string {
50
+ return (
51
+ `borg update${version ? ` (borgmcp ${version})` : ''} — update Borg's client and local server together\n\n` +
52
+ `Usage:\n` +
53
+ ` borg update Confirm interactively, then update\n` +
54
+ ` borg update --yes Update without prompting (required outside a TTY)\n` +
55
+ ` borg update --help Show this help\n\n` +
56
+ `Before changing anything, Borg reads the exact published client and server manifests,\n` +
57
+ `requires matching exact borgmcp-shared pins, and verifies canonical npm ownership. The client is\n` +
58
+ `installed first and the update continues under that new client before the server controller\n` +
59
+ `and runtime are updated. Alternate registries and unsupported or ambiguous package-manager\n` +
60
+ `provenance fail closed with manual-update guidance.\n\n` +
61
+ `If no local server is installed, the server phase is skipped. A failure after the client\n` +
62
+ `succeeds is reported as partial completion with the safe retry command. Borg never starts a\n` +
63
+ `stopped server and never restarts agent processes; restart active agent sessions yourself.\n`
64
+ );
65
+ }
66
+
47
67
  /** Product Design-approved client-owned copy for `borg server --help`. */
48
68
  export function serverHelpText(): string {
49
69
  return (
@@ -89,12 +109,14 @@ export function assimilateHelpText(version: string): string {
89
109
  ` borg assimilate [role] --worktree <name> Spawn the drone in an isolated git worktree\n` +
90
110
  ` (~/.borg/worktrees/<repo>/<name>)\n` +
91
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` +
92
113
  ` borg assimilate --host <host> Join an authorized self-hosted cube\n` +
93
114
  ` borg assimilate --host <host> --enroll Operator-terminal enrollment, then create/join (preview)\n` +
94
115
  ` borg assimilate --help Show this help\n\n` +
95
116
  `Flags:\n` +
96
117
  ` --worktree <name> Create + launch the drone in a sibling git worktree\n` +
97
118
  ` --here Stay in the current worktree (no sibling spawn)\n` +
119
+ ` --force Override a live-seat guard for a wedged monitor\n` +
98
120
  ` --cube-name <name> Repository cube name (otherwise edit the proposed name)\n` +
99
121
  ` --host <host> Borg server host or URL (bare hosts default to HTTPS)\n` +
100
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
@@ -0,0 +1,130 @@
1
+ import type { ActiveCube } from './cubes.js';
2
+
3
+ const LAST_CONFIRMED = ' (last confirmed)';
4
+
5
+ type DisplayField = {
6
+ value: string | null;
7
+ uncertain: boolean;
8
+ };
9
+
10
+ type DisplayState = {
11
+ seatKey: string;
12
+ cubeName: DisplayField;
13
+ droneLabel: DisplayField;
14
+ roleName: DisplayField;
15
+ };
16
+
17
+ export type DisplayIdentity = {
18
+ cubeName: string;
19
+ droneLabel: string;
20
+ roleName: string | null;
21
+ };
22
+
23
+ export type ServerDisplayIdentity = Partial<DisplayIdentity>;
24
+
25
+ let state: DisplayState | null = null;
26
+
27
+ function seatKey(active: Pick<ActiveCube, 'cubeId' | 'droneId'>): string {
28
+ return `${active.cubeId}\0${active.droneId}`;
29
+ }
30
+
31
+ function initialState(active: ActiveCube): DisplayState {
32
+ return {
33
+ seatKey: seatKey(active),
34
+ cubeName: { value: active.name, uncertain: false },
35
+ droneLabel: { value: active.droneLabel, uncertain: false },
36
+ roleName: { value: active.roleName ?? null, uncertain: false },
37
+ };
38
+ }
39
+
40
+ function ensureState(active: ActiveCube): DisplayState {
41
+ const key = seatKey(active);
42
+ if (state === null || state.seatKey !== key) {
43
+ state = initialState(active);
44
+ }
45
+ return state;
46
+ }
47
+
48
+ function confirmField(field: DisplayField, value: string | null | undefined): void {
49
+ if (value === undefined || value === null) return;
50
+ field.value = value;
51
+ field.uncertain = false;
52
+ }
53
+
54
+ function renderField(field: DisplayField): string | null {
55
+ if (field.value === null) return null;
56
+ return field.uncertain ? `${field.value}${LAST_CONFIRMED}` : field.value;
57
+ }
58
+
59
+ /** Seed the invocation-local display source from the exact seat selected by #63. */
60
+ export function seedDisplayIdentity(active: ActiveCube): void {
61
+ ensureState(active);
62
+ }
63
+
64
+ /** Apply server-authoritative fields and clear uncertainty only for those fields. */
65
+ export function confirmDisplayIdentity(
66
+ active: ActiveCube,
67
+ identity: ServerDisplayIdentity,
68
+ ): DisplayIdentity {
69
+ const current = ensureState(active);
70
+ confirmField(current.cubeName, identity.cubeName);
71
+ confirmField(current.droneLabel, identity.droneLabel);
72
+ confirmField(current.roleName, identity.roleName);
73
+ return renderDisplayIdentity(active);
74
+ }
75
+
76
+ /** Mark the current seat identity as last-confirmed after an identity read fails. */
77
+ export function markDisplayIdentityReadFailed(active: ActiveCube): void {
78
+ const current = ensureState(active);
79
+ current.cubeName.uncertain = true;
80
+ current.droneLabel.uncertain = true;
81
+ if (current.roleName.value !== null) current.roleName.uncertain = true;
82
+ }
83
+
84
+ export function renderDisplayIdentity(active: ActiveCube): DisplayIdentity {
85
+ const current = ensureState(active);
86
+ return {
87
+ cubeName: renderField(current.cubeName)!,
88
+ droneLabel: renderField(current.droneLabel)!,
89
+ roleName: renderField(current.roleName),
90
+ };
91
+ }
92
+
93
+ /** Synchronous view for console-prefix after initConsolePrefix seeds the seat. */
94
+ export function currentDisplayIdentity(): DisplayIdentity | null {
95
+ if (state === null) return null;
96
+ return {
97
+ cubeName: renderField(state.cubeName)!,
98
+ droneLabel: renderField(state.droneLabel)!,
99
+ roleName: renderField(state.roleName),
100
+ };
101
+ }
102
+
103
+ export function identityFromRegen(result: {
104
+ cube?: { name?: string | null };
105
+ drone?: { label?: string | null };
106
+ role?: { name?: string | null };
107
+ }): ServerDisplayIdentity {
108
+ return {
109
+ cubeName: result.cube?.name ?? undefined,
110
+ droneLabel: result.drone?.label ?? undefined,
111
+ roleName: result.role?.name ?? undefined,
112
+ };
113
+ }
114
+
115
+ export function withRenderedRegenIdentity<T extends {
116
+ cube?: Record<string, unknown>;
117
+ drone?: Record<string, unknown>;
118
+ role?: Record<string, unknown>;
119
+ }>(result: T, identity: DisplayIdentity): T {
120
+ return {
121
+ ...result,
122
+ cube: { ...result.cube, name: identity.cubeName },
123
+ drone: { ...result.drone, label: identity.droneLabel },
124
+ role: { ...result.role, name: identity.roleName },
125
+ };
126
+ }
127
+
128
+ export function _resetDisplayIdentityForTests(): void {
129
+ state = null;
130
+ }
@@ -16,6 +16,7 @@ export const DOCS_BASE_URL = "https://github.com/Byte-Ventures/borg-mcp-client";
16
16
 
17
17
  const README_URL = `${DOCS_BASE_URL}#readme`;
18
18
  const LOCAL_SERVER_URL = `${DOCS_BASE_URL}/blob/main/docs/LOCAL_SERVER.md`;
19
+ const SEAT_LIFECYCLE_URL = `${DOCS_BASE_URL}/blob/main/docs/SEAT_LIFECYCLE.md`;
19
20
 
20
21
  export interface DocsSection {
21
22
  /** logical topic key */
@@ -71,6 +72,14 @@ export const DOCS_SECTIONS: DocsSection[] = [
71
72
  summary: "Connect a client to a self-hosted server: invitations, assimilate --host --enroll, credentials.",
72
73
  keywords: ["enroll", "enrollment", "invitation", "invite", "assimilate", "--host", "credential", "credentials", "owner", "join server"],
73
74
  },
75
+ {
76
+ slug: "seat-lifecycle",
77
+ title: "Seat lifecycle and recovery",
78
+ url: SEAT_LIFECYCLE_URL,
79
+ page: "docs/SEAT_LIFECYCLE.md",
80
+ summary: "Saved-seat states, re-attach and reset recovery, duplicate-session guards, and deterministic multi-seat selection.",
81
+ keywords: ["seat", "lifecycle", "reattach", "re-attach", "reset-local-seat", "evicted", "revoked", "superseded", "inbox monitor", "multiple seats", "silent deafness"],
82
+ },
74
83
  {
75
84
  slug: "self-hosting",
76
85
  title: "Self-hosting operations",
@@ -17,6 +17,7 @@ import {
17
17
  reassignDrone,
18
18
  type LocalManageOperation,
19
19
  } from './remote-client.js';
20
+ import { confirmDisplayIdentity } from './display-identity.js';
20
21
 
21
22
  interface ManagedCube {
22
23
  id: string;
@@ -95,6 +96,11 @@ export async function runReassignDroneTool(
95
96
 
96
97
  let warning = '';
97
98
  if (drone.id === active.droneId) {
99
+ confirmDisplayIdentity(active, {
100
+ cubeName: cube.name,
101
+ droneLabel: drone.label,
102
+ roleName: role.name,
103
+ });
98
104
  try {
99
105
  const refreshed = await deps.refreshActiveCubeMetadata(activeCubeWithFreshRegenIdentity(active, {
100
106
  cube,
@@ -753,6 +753,23 @@ export function parseMonitorInvocation(argv: string[]): MonitorInvocation | null
753
753
  return null;
754
754
  }
755
755
 
756
+ function liveMonitorYieldLine(
757
+ inboxPath: string,
758
+ holderPidfilePath: string,
759
+ deps: Pick<InboxLockDeps, 'read' | 'isAlive'>
760
+ ): string {
761
+ const raw = deps.read(holderPidfilePath);
762
+ const parsed = raw === null ? null : parsePidfileContent(raw.trim());
763
+ const livePid = parsed && Number.isSafeInteger(parsed.pid) && parsed.pid > 0 && deps.isAlive(parsed.pid)
764
+ ? parsed.pid
765
+ : null;
766
+ const holder = livePid === null ? 'another monitor instance' : `a live instance (pid ${livePid})`;
767
+ return (
768
+ `borg-inbox-monitor: seat inbox ${JSON.stringify(inboxPath)} is already monitored by ${holder}; ` +
769
+ 'yielding — another session likely holds this seat.'
770
+ );
771
+ }
772
+
756
773
  function main(): void {
757
774
  const invocation = parseMonitorInvocation(process.argv.slice(2));
758
775
  if (!invocation) {
@@ -812,6 +829,7 @@ function main(): void {
812
829
  if (claimResult === 'legacy-live') {
813
830
  // An already-running old monitor still owns the wake path; yield without
814
831
  // touching its inbox-adjacent state.
832
+ console.log(liveMonitorYieldLine(inboxPath, legacyPidfilePathFor(inboxPath), lockDeps));
815
833
  process.exit(0);
816
834
  }
817
835
  if (claimResult === 'legacy-blocked') {
@@ -822,6 +840,7 @@ function main(): void {
822
840
  process.exit(1);
823
841
  }
824
842
  if (claimResult === 'modern-live') {
843
+ console.log(liveMonitorYieldLine(inboxPath, pidfilePath, lockDeps));
825
844
  process.exit(0);
826
845
  }
827
846