borgmcp 2.16.1 → 3.0.2

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 (135) hide show
  1. package/README.md +5 -3
  2. package/dist/assimilate-cmd.js +43 -43
  3. package/dist/assimilate-cmd.js.map +1 -1
  4. package/dist/assimilate-deps.js +2 -2
  5. package/dist/assimilate-deps.js.map +1 -1
  6. package/dist/assimilate-guard.js +4 -4
  7. package/dist/assimilate-guard.js.map +1 -1
  8. package/dist/assimilate-welcome.js +2 -2
  9. package/dist/assimilate-welcome.js.map +1 -1
  10. package/dist/bare-launch-menu.d.ts +5 -5
  11. package/dist/bare-launch-menu.d.ts.map +1 -1
  12. package/dist/bare-launch-menu.js +2 -2
  13. package/dist/bare-launch-menu.js.map +1 -1
  14. package/dist/claude.d.ts +0 -2
  15. package/dist/claude.d.ts.map +1 -1
  16. package/dist/claude.js +5 -18
  17. package/dist/claude.js.map +1 -1
  18. package/dist/cleanup-cmd.d.ts +1 -1
  19. package/dist/cleanup-cmd.d.ts.map +1 -1
  20. package/dist/cleanup-cmd.js +3 -3
  21. package/dist/cleanup-cmd.js.map +1 -1
  22. package/dist/cli-help.d.ts +1 -2
  23. package/dist/cli-help.d.ts.map +1 -1
  24. package/dist/cli-help.js +15 -24
  25. package/dist/cli-help.js.map +1 -1
  26. package/dist/cli-platform.d.ts +4 -0
  27. package/dist/cli-platform.d.ts.map +1 -1
  28. package/dist/cli-platform.js +29 -10
  29. package/dist/cli-platform.js.map +1 -1
  30. package/dist/cubes.d.ts.map +1 -1
  31. package/dist/cubes.js +30 -12
  32. package/dist/cubes.js.map +1 -1
  33. package/dist/docs-sections.js +1 -1
  34. package/dist/docs-sections.js.map +1 -1
  35. package/dist/drone-lifecycle.d.ts.map +1 -1
  36. package/dist/drone-lifecycle.js +3 -3
  37. package/dist/drone-lifecycle.js.map +1 -1
  38. package/dist/drone-management.js +1 -1
  39. package/dist/drone-management.js.map +1 -1
  40. package/dist/evict-drone.js +2 -2
  41. package/dist/evict-drone.js.map +1 -1
  42. package/dist/first-run-server.d.ts.map +1 -1
  43. package/dist/first-run-server.js +4 -1
  44. package/dist/first-run-server.js.map +1 -1
  45. package/dist/foreign-path-reminder.js +1 -1
  46. package/dist/foreign-path-reminder.js.map +1 -1
  47. package/dist/inbox-monitor.js +2 -2
  48. package/dist/inbox-monitor.js.map +1 -1
  49. package/dist/index.js +1 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/launch-all-cmd.js +12 -12
  52. package/dist/launch-all-cmd.js.map +1 -1
  53. package/dist/launch-all-discovery.js +1 -1
  54. package/dist/launch-all-discovery.js.map +1 -1
  55. package/dist/lifecycle-log-guard.d.ts.map +1 -1
  56. package/dist/lifecycle-log-guard.js +12 -3
  57. package/dist/lifecycle-log-guard.js.map +1 -1
  58. package/dist/opencode-seat-identity.js +5 -5
  59. package/dist/opencode-seat-identity.js.map +1 -1
  60. package/dist/regen-format.d.ts.map +1 -1
  61. package/dist/regen-format.js +2 -1
  62. package/dist/regen-format.js.map +1 -1
  63. package/dist/remote-client.d.ts.map +1 -1
  64. package/dist/remote-client.js +10 -5
  65. package/dist/remote-client.js.map +1 -1
  66. package/dist/repository-cube-init.js +1 -1
  67. package/dist/repository-cube-init.js.map +1 -1
  68. package/dist/reset-local-seat-cmd.d.ts +2 -2
  69. package/dist/reset-local-seat-cmd.d.ts.map +1 -1
  70. package/dist/reset-local-seat-cmd.js +17 -17
  71. package/dist/reset-local-seat-cmd.js.map +1 -1
  72. package/dist/seat-reattach-guard.js +1 -1
  73. package/dist/seat-reattach-guard.js.map +1 -1
  74. package/dist/seat-store.d.ts +1 -1
  75. package/dist/seat-store.js +6 -6
  76. package/dist/seat-store.js.map +1 -1
  77. package/dist/seats.js +1 -1
  78. package/dist/seats.js.map +1 -1
  79. package/dist/server-handshake.js +1 -1
  80. package/dist/server-handshake.js.map +1 -1
  81. package/dist/stream-status.js +3 -3
  82. package/dist/stream-status.js.map +1 -1
  83. package/dist/tool-manifest.js +1 -1
  84. package/dist/tool-manifest.js.map +1 -1
  85. package/dist/unknown-subcommand.d.ts +1 -1
  86. package/dist/unknown-subcommand.d.ts.map +1 -1
  87. package/dist/unknown-subcommand.js +1 -2
  88. package/dist/unknown-subcommand.js.map +1 -1
  89. package/dist/worktree-lifecycle.d.ts +0 -16
  90. package/dist/worktree-lifecycle.d.ts.map +1 -1
  91. package/dist/worktree-lifecycle.js +0 -38
  92. package/dist/worktree-lifecycle.js.map +1 -1
  93. package/docs/EXTRACTION_PROVENANCE.md +5 -3
  94. package/docs/LOCAL_SERVER.md +2 -2
  95. package/docs/SEAT_LIFECYCLE.md +19 -19
  96. package/package.json +1 -1
  97. package/src/assimilate-cmd.ts +43 -43
  98. package/src/assimilate-deps.ts +2 -2
  99. package/src/assimilate-guard.ts +4 -4
  100. package/src/assimilate-welcome.ts +2 -2
  101. package/src/bare-launch-menu.ts +6 -6
  102. package/src/claude.ts +8 -21
  103. package/src/cleanup-cmd.ts +4 -4
  104. package/src/cli-help.ts +15 -27
  105. package/src/cli-platform.ts +42 -10
  106. package/src/cubes.ts +29 -14
  107. package/src/docs-sections.ts +1 -1
  108. package/src/drone-lifecycle.ts +3 -3
  109. package/src/drone-management.ts +1 -1
  110. package/src/evict-drone.ts +2 -2
  111. package/src/first-run-server.ts +4 -1
  112. package/src/foreign-path-reminder.ts +1 -1
  113. package/src/inbox-monitor.ts +2 -2
  114. package/src/index.ts +1 -1
  115. package/src/launch-all-cmd.ts +12 -12
  116. package/src/launch-all-discovery.ts +1 -1
  117. package/src/lifecycle-log-guard.ts +15 -3
  118. package/src/opencode-seat-identity.ts +5 -5
  119. package/src/regen-format.ts +2 -1
  120. package/src/remote-client.ts +12 -6
  121. package/src/repository-cube-init.ts +1 -1
  122. package/src/reset-local-seat-cmd.ts +17 -17
  123. package/src/seat-reattach-guard.ts +1 -1
  124. package/src/seat-store.ts +6 -6
  125. package/src/seats.ts +1 -1
  126. package/src/server-handshake.ts +1 -1
  127. package/src/stream-status.ts +3 -3
  128. package/src/tool-manifest.ts +1 -1
  129. package/src/unknown-subcommand.ts +1 -2
  130. package/src/worktree-lifecycle.ts +0 -45
  131. package/dist/sync.d.ts +0 -129
  132. package/dist/sync.d.ts.map +0 -1
  133. package/dist/sync.js +0 -286
  134. package/dist/sync.js.map +0 -1
  135. package/src/sync.ts +0 -363
package/src/cli-help.ts CHANGED
@@ -16,16 +16,6 @@ export function isHelpFlag(arg: string | undefined): boolean {
16
16
  return arg === '--help' || arg === '-h';
17
17
  }
18
18
 
19
- export function syncHelpText(version: string): string {
20
- return (
21
- `borg sync (borgmcp ${version}) — update this Borg worktree from origin/main\n\n` +
22
- `Usage:\n` +
23
- ` borg sync Update safely; refuse if the worktree is dirty\n` +
24
- ` borg sync --prune Also delete a merged local feature branch\n` +
25
- ` borg sync --help Show this help\n`
26
- );
27
- }
28
-
29
19
  export function cleanupHelpText(version: string): string {
30
20
  return (
31
21
  `borg cleanup (borgmcp ${version}) — review orphaned Borg-managed worktrees\n\n` +
@@ -63,9 +53,8 @@ export function clientSubcommandHelpText(
63
53
  switch (command) {
64
54
  case 'setup': return setupHelpText(version);
65
55
  case 'assimilate': return assimilateHelpText(version);
66
- case 'reset-local-seat': return resetLocalSeatHelpText(version);
56
+ case 'reset-local-connection': return resetLocalSeatHelpText(version);
67
57
  case 'recover-enrollment': return recoverEnrollmentHelpText(version);
68
- case 'sync': return syncHelpText(version);
69
58
  case 'cleanup': return cleanupHelpText(version);
70
59
  case 'launch-all': return launchAllHelpText(version);
71
60
  default: return null;
@@ -101,9 +90,8 @@ export function topLevelHelpText(version: string): string {
101
90
  ` borg assimilate --host <host> Join or create on an explicit server\n` +
102
91
  ` borg assimilate --worktree <name> Spawn a worktree drone (in ~/.borg/worktrees/<repo>/<name>)\n` +
103
92
  ` borg server cube init Initialize this repository's cube without creating a drone\n` +
104
- ` borg reset-local-seat Clear ONLY this worktree's saved local seat (offline; after a rejection)\n` +
93
+ ` borg reset-local-connection Clear ONLY this worktree's saved connection to its cube (offline; after a rejection)\n` +
105
94
  ` borg recover-enrollment Restore or clear ONLY one failed server enrollment transaction\n` +
106
- ` borg sync [--prune] Sync this worktree's branch to origin/main\n` +
107
95
  ` borg cleanup [--prune] Report (or --prune) worktrees orphaned by evicted drones\n` +
108
96
  ` borg launch-all [cube] Launch all drone worktrees of a cube (default: active cube)\n` +
109
97
  ` borg launch-all [cube] --cli claude|codex|opencode\n` +
@@ -157,8 +145,8 @@ export function serverHelpText(): string {
157
145
  ` update Verify and activate a local server artifact.\n` +
158
146
  ` invite Create a single-use invitation in an interactive terminal.\n` +
159
147
  ` cert-reissue Widen the server certificate to cover another address without replacing the CA.\n` +
160
- ` client-list List enrolled clients, states, and cube grants while the server is stopped.\n` +
161
- ` client-grant Grant a client read, write, or manage access to a cube while the server is stopped.\n` +
148
+ ` client-list List enrolled clients, states, and cube grants while the server is live; committed changes take effect on the next request.\n` +
149
+ ` client-grant Grant a client read, write, or manage access to a cube while the server is live; committed changes take effect on the next request.\n` +
162
150
  ` dashboard View the running local server dashboard.\n` +
163
151
  ` cube init Initialize this Git repository's cube; does not create a drone.\n\n` +
164
152
  `Run borg server <command> --help for server command options.\n`
@@ -203,7 +191,7 @@ export function assimilateHelpText(version: string): string {
203
191
  `Flags:\n` +
204
192
  ` --worktree <name> Create + launch the drone in a sibling git worktree\n` +
205
193
  ` --here Stay in the current worktree (no sibling spawn)\n` +
206
- ` --force Override a live-seat guard for a wedged monitor\n` +
194
+ ` --force Reattach despite a still-live inbox monitor in this worktree\n` +
207
195
  ` --cube-name <name> Repository cube name (otherwise edit the proposed name)\n` +
208
196
  ` --host <host> Borg server host or URL (bare hosts default to HTTPS)\n` +
209
197
  ` --enroll Prompt for a hidden enrollment invitation in the operator terminal\n` +
@@ -216,7 +204,7 @@ export function assimilateHelpText(version: string): string {
216
204
  `repository association skips all prompts. One accessible exact-name legacy cube requires\n` +
217
205
  `explicit interactive adoption; ambiguous matches fail closed. An enrolled owner client may\n` +
218
206
  `create an idempotent repository cube; ordinary clients require an explicit cube grant.\n` +
219
- `Agent seats begin only after enrollment. Preview only.\n` +
207
+ `A drone is created only after enrollment. Preview only.\n` +
220
208
  `See docs/LOCAL_SERVER.md for self-hosted setup and current status.\n\n` +
221
209
  `For local or provider-specific models, configure the selected agent CLI directly.\n` +
222
210
  `OpenCode supports Ollama and other providers through its own model configuration.\n`
@@ -224,25 +212,25 @@ export function assimilateHelpText(version: string): string {
224
212
  }
225
213
 
226
214
  /**
227
- * Help text for `borg reset-local-seat --help`. The offline, network-free seat
215
+ * Help text for `borg reset-local-connection --help`. The offline, network-free seat
228
216
  * reset recommended by the pin-matched SESSION_REJECTED diagnostic (#1082).
229
217
  */
230
218
  export function resetLocalSeatHelpText(version: string): string {
231
219
  return (
232
- `borg reset-local-seat (borgmcp ${version}) — clear ONLY this worktree's saved local seat\n\n` +
220
+ `borg reset-local-connection (borgmcp ${version}) — clear ONLY this worktree's saved connection to its cube\n\n` +
233
221
  `Offline and network-free: it contacts no server and revokes nothing server-side. It clears\n` +
234
- `just this worktree's saved local seat — its credential and cube binding together — from the\n` +
235
- `local seat store on this machine. Server, trust anchor, cube, and every sibling worktree are\n` +
222
+ `just this worktree's saved connection — its credential and cube binding together — from the\n` +
223
+ `private store on this machine. Server, trust anchor, cube, and every sibling worktree are\n` +
236
224
  `left untouched.\n\n` +
237
225
  `Use it after \`borg assimilate\` reports this worktree's session was revoked or superseded\n` +
238
226
  `(a pin-matched rejection), then ask the operator for a new invitation and re-enroll.\n\n` +
239
227
  `Usage:\n` +
240
- ` borg reset-local-seat Reset this worktree's saved seat (TTY confirms [y/N])\n` +
241
- ` borg reset-local-seat --host <host> No-op unless this worktree's seat is on <host>\n` +
242
- ` borg reset-local-seat --yes Reset without a prompt (required when non-interactive)\n` +
243
- ` borg reset-local-seat --help Show this help\n\n` +
228
+ ` borg reset-local-connection Reset this worktree's saved connection (TTY confirms [y/N])\n` +
229
+ ` borg reset-local-connection --host <host> No-op unless this worktree connects to <host>\n` +
230
+ ` borg reset-local-connection --yes Reset without a prompt (required when non-interactive)\n` +
231
+ ` borg reset-local-connection --help Show this help\n\n` +
244
232
  `Flags:\n` +
245
- ` --host <host> Only act if this worktree's saved seat is on <host> (else no-op)\n` +
233
+ ` --host <host> Only act if this worktree connects to <host> (else no-op)\n` +
246
234
  ` --yes, -y Skip the confirmation prompt (required in non-TTY contexts)\n`
247
235
  );
248
236
  }
@@ -1,6 +1,11 @@
1
1
  import which from 'which';
2
2
  import type { BorgCli } from './cubes.js';
3
3
  import { getProjectCliPreference, setProjectCliPreference } from './cubes.js';
4
+ import {
5
+ isCodexMcpServerConfigured,
6
+ isMcpServerConfigured,
7
+ isOpenCodeMcpServerConfigured,
8
+ } from './config-utils.js';
4
9
 
5
10
  export interface CliAvailability {
6
11
  claude: string | null;
@@ -8,8 +13,11 @@ export interface CliAvailability {
8
13
  opencode: string | null;
9
14
  }
10
15
 
16
+ export type CliConfiguration = Record<BorgCli, boolean>;
17
+
11
18
  export interface CliChoiceDeps {
12
19
  detectCli: () => CliAvailability;
20
+ detectConfigured: () => CliConfiguration;
13
21
  getPreference: () => Promise<BorgCli | null>;
14
22
  setPreference: (cli: BorgCli) => Promise<void>;
15
23
  prompt: (message: string) => Promise<string>;
@@ -40,6 +48,21 @@ export function installedCliNames(availability: CliAvailability): BorgCli[] {
40
48
  return out;
41
49
  }
42
50
 
51
+ export function detectCliConfiguration(): CliConfiguration {
52
+ return {
53
+ claude: isMcpServerConfigured(),
54
+ codex: isCodexMcpServerConfigured(),
55
+ opencode: isOpenCodeMcpServerConfigured(),
56
+ };
57
+ }
58
+
59
+ export function configuredCliNames(
60
+ availability: CliAvailability,
61
+ configuration: CliConfiguration,
62
+ ): BorgCli[] {
63
+ return installedCliNames(availability).filter((cli) => configuration[cli]);
64
+ }
65
+
43
66
  async function setPreferenceAndReturn(cli: BorgCli, deps: CliChoiceDeps): Promise<BorgCli> {
44
67
  await deps.setPreference(cli);
45
68
  return cli;
@@ -59,31 +82,39 @@ export async function resolveCliChoice(
59
82
  if (!installed.includes(explicit)) {
60
83
  throw new Error(`${explicit} CLI is not installed.`);
61
84
  }
85
+ // An explicit --cli is an intentional request, so the caller may still
86
+ // configure that installed CLI on demand. Automatic selection below is
87
+ // restricted to registrations already present in the agent config.
62
88
  await deps.setPreference(explicit);
63
89
  return explicit;
64
90
  }
65
91
 
92
+ const configured = configuredCliNames(availability, deps.detectConfigured());
93
+ if (configured.length === 0) {
94
+ throw new Error('No supported agent CLI is configured for Borg. Run `borg setup` to configure one, then run `borg` or `borg assimilate` again.');
95
+ }
96
+
66
97
  const stored = await deps.getPreference();
67
- if (stored && installed.includes(stored)) return stored;
98
+ if (stored && configured.includes(stored)) return stored;
68
99
 
69
- if (installed.length === 1) {
70
- await deps.setPreference(installed[0]);
71
- return installed[0];
100
+ if (configured.length === 1) {
101
+ await deps.setPreference(configured[0]);
102
+ return configured[0];
72
103
  }
73
104
 
74
105
  if (!deps.isTTY()) {
75
- throw new Error('Multiple agent CLIs detected. Pass --cli claude, --cli codex, or --cli opencode to choose.');
106
+ throw new Error('Multiple configured agent CLIs detected. Pass --cli claude, --cli codex, or --cli opencode to choose.');
76
107
  }
77
108
 
78
- const promptLines = installed.map((cli, i) => ` ${i + 1}) ${cli}`);
109
+ const promptLines = configured.map((cli, i) => ` ${i + 1}) ${cli}`);
79
110
  const answer = (await deps.prompt(`Use which CLI for this project?\n${promptLines.join('\n')}\n[1]: `)).trim();
80
- if (answer === '' || answer === '1') return setPreferenceAndReturn(installed[0], deps);
111
+ if (answer === '' || answer === '1') return setPreferenceAndReturn(configured[0], deps);
81
112
  const num = parseInt(answer, 10);
82
- if (!Number.isNaN(num) && num >= 1 && num <= installed.length) {
83
- return setPreferenceAndReturn(installed[num - 1], deps);
113
+ if (!Number.isNaN(num) && num >= 1 && num <= configured.length) {
114
+ return setPreferenceAndReturn(configured[num - 1], deps);
84
115
  }
85
116
  const lower = answer.toLowerCase();
86
- for (const cli of installed) {
117
+ for (const cli of configured) {
87
118
  if (lower === cli) return setPreferenceAndReturn(cli, deps);
88
119
  }
89
120
  throw new Error(`invalid CLI choice "${answer}"`);
@@ -92,6 +123,7 @@ export async function resolveCliChoice(
92
123
  export function defaultCliChoiceDeps(prompt: (message: string) => Promise<string>, isTTY: () => boolean): CliChoiceDeps {
93
124
  return {
94
125
  detectCli: detectCliAvailability,
126
+ detectConfigured: detectCliConfiguration,
95
127
  getPreference: getProjectCliPreference,
96
128
  setPreference: setProjectCliPreference,
97
129
  prompt,
package/src/cubes.ts CHANGED
@@ -84,6 +84,13 @@ interface LaunchFile {
84
84
  projects: Record<string, { cli: BorgCli }>;
85
85
  }
86
86
 
87
+ const UNREADABLE_STATE = Symbol('unreadable-state-file');
88
+ type StateFileRead<T> = T | null | typeof UNREADABLE_STATE;
89
+
90
+ function unreadableStateError(filePath: string): Error {
91
+ return new Error(`Borg state file is unreadable; refusing to overwrite it: ${filePath}`);
92
+ }
93
+
87
94
  export interface CodexWakeTargetRecord {
88
95
  threadId: string;
89
96
  socketPath: string;
@@ -110,7 +117,7 @@ let pinnedMcpSeatIdentity: {
110
117
  export class McpSeatIdentityChangedError extends Error {
111
118
  readonly code = 'SEAT_IDENTITY_CHANGED';
112
119
  constructor() {
113
- super('The saved Borg seat changed after this MCP session pinned its identity. Exit this session and relaunch from the intended worktree.');
120
+ super('This worktree\'s saved connection changed after this MCP session pinned its identity. Exit this session and relaunch from the intended worktree.');
114
121
  this.name = 'McpSeatIdentityChangedError';
115
122
  }
116
123
  }
@@ -225,7 +232,7 @@ function isLaunchFile(data: any): data is LaunchFile {
225
232
  );
226
233
  }
227
234
 
228
- async function readLaunchFile(): Promise<LaunchFile | null> {
235
+ async function readLaunchFile(): Promise<StateFileRead<LaunchFile>> {
229
236
  let raw: string;
230
237
  try {
231
238
  raw = await readFile(LAUNCH_FILE, 'utf8');
@@ -235,9 +242,9 @@ async function readLaunchFile(): Promise<LaunchFile | null> {
235
242
  }
236
243
  try {
237
244
  const parsed = JSON.parse(raw);
238
- return isLaunchFile(parsed) ? parsed : null;
245
+ return isLaunchFile(parsed) ? parsed : UNREADABLE_STATE;
239
246
  } catch {
240
- return null;
247
+ return UNREADABLE_STATE;
241
248
  }
242
249
  }
243
250
 
@@ -263,7 +270,7 @@ function isCodexWakeTargetsFile(data: any): data is CodexWakeTargetsFile {
263
270
  );
264
271
  }
265
272
 
266
- async function readCodexWakeTargetsFile(): Promise<CodexWakeTargetsFile | null> {
273
+ async function readCodexWakeTargetsFile(): Promise<StateFileRead<CodexWakeTargetsFile>> {
267
274
  let raw: string;
268
275
  try {
269
276
  raw = await readFile(CODEX_WAKE_TARGETS_FILE, 'utf8');
@@ -273,9 +280,9 @@ async function readCodexWakeTargetsFile(): Promise<CodexWakeTargetsFile | null>
273
280
  }
274
281
  try {
275
282
  const parsed = JSON.parse(raw);
276
- return isCodexWakeTargetsFile(parsed) ? parsed : null;
283
+ return isCodexWakeTargetsFile(parsed) ? parsed : UNREADABLE_STATE;
277
284
  } catch {
278
- return null;
285
+ return UNREADABLE_STATE;
279
286
  }
280
287
  }
281
288
 
@@ -439,7 +446,7 @@ export async function snapshotLocalSeat(): Promise<LocalSeatSnapshot | null> {
439
446
  // CR#4: discover an ACTIVE seat OR a bound-PENDING record (a sibling whose
440
447
  // activation failed, bound to THIS worktree by the attach bind-pending step).
441
448
  // getActiveSeatForWorktree would MISS the bound-pending record (it requires
442
- // state==='active' + a drone id), so `reset-local-seat` would FALSELY report
449
+ // state==='active' + a drone id), so `reset-local-connection` would FALSELY report
443
450
  // "nothing to reset" (exit 0) while a resumable, server-digest-bound bearer
444
451
  // persists at rest — a FALSE-SUCCESS revocation failure. getSeatForWorktree sees
445
452
  // both, and the offline reset's exact re-check + delete cover the bound-pending
@@ -574,6 +581,7 @@ export async function refreshActiveCubeMetadata(active: ActiveCubeInput): Promis
574
581
 
575
582
  export async function getProjectCliPreference(): Promise<BorgCli | null> {
576
583
  const data = await readLaunchFile();
584
+ if (data === UNREADABLE_STATE) throw unreadableStateError(LAUNCH_FILE);
577
585
  if (!data) return null;
578
586
  const entry = data.projects[findProjectRoot()];
579
587
  return entry?.cli === 'claude' || entry?.cli === 'codex' || entry?.cli === 'opencode' ? entry.cli : null;
@@ -586,6 +594,7 @@ export async function getProjectCliPreference(): Promise<BorgCli | null> {
586
594
  */
587
595
  export async function getProjectCliPreferenceForPath(dir: string): Promise<BorgCli | null> {
588
596
  const data = await readLaunchFile();
597
+ if (data === UNREADABLE_STATE) throw unreadableStateError(LAUNCH_FILE);
589
598
  if (!data) return null;
590
599
  const entry = data.projects[findProjectRoot(dir)];
591
600
  return entry?.cli === 'claude' || entry?.cli === 'codex' || entry?.cli === 'opencode' ? entry.cli : null;
@@ -617,9 +626,11 @@ export async function readAllProjectIdentities(): Promise<
617
626
  * sibling worktree but the process still began in the invoking checkout.
618
627
  */
619
628
  export async function setProjectCliPreference(cli: BorgCli, dir?: string): Promise<void> {
620
- const existing = (await readLaunchFile()) ?? { projects: {} };
621
- existing.projects[findProjectRoot(dir)] = { cli };
622
- await writeLaunchFile(existing);
629
+ const existing = await readLaunchFile();
630
+ if (existing === UNREADABLE_STATE) throw unreadableStateError(LAUNCH_FILE);
631
+ const next = existing ?? { projects: {} };
632
+ next.projects[findProjectRoot(dir)] = { cli };
633
+ await writeLaunchFile(next);
623
634
  }
624
635
 
625
636
  export async function setCodexWakeTarget(
@@ -627,12 +638,14 @@ export async function setCodexWakeTarget(
627
638
  droneId: string,
628
639
  target: Omit<CodexWakeTargetRecord, 'updatedAt'>
629
640
  ): Promise<void> {
630
- const existing = (await readCodexWakeTargetsFile()) ?? { targets: {} };
631
- existing.targets[codexWakeTargetKey(cubeId, droneId)] = {
641
+ const existing = await readCodexWakeTargetsFile();
642
+ if (existing === UNREADABLE_STATE) throw unreadableStateError(CODEX_WAKE_TARGETS_FILE);
643
+ const next = existing ?? { targets: {} };
644
+ next.targets[codexWakeTargetKey(cubeId, droneId)] = {
632
645
  ...target,
633
646
  updatedAt: new Date().toISOString(),
634
647
  };
635
- await writeCodexWakeTargetsFile(existing);
648
+ await writeCodexWakeTargetsFile(next);
636
649
  }
637
650
 
638
651
  export async function getCodexWakeTarget(
@@ -640,6 +653,7 @@ export async function getCodexWakeTarget(
640
653
  droneId: string
641
654
  ): Promise<CodexWakeTargetRecord | null> {
642
655
  const existing = await readCodexWakeTargetsFile();
656
+ if (existing === UNREADABLE_STATE) throw unreadableStateError(CODEX_WAKE_TARGETS_FILE);
643
657
  if (!existing) return null;
644
658
  const target = existing.targets[codexWakeTargetKey(cubeId, droneId)];
645
659
  if (!target || typeof target.threadId !== 'string' || typeof target.socketPath !== 'string') {
@@ -661,6 +675,7 @@ export async function pruneDeadCodexWakeTargets(
661
675
  socketLiveness: (socketPath: string) => boolean | null
662
676
  ): Promise<void> {
663
677
  const existing = await readCodexWakeTargetsFile();
678
+ if (existing === UNREADABLE_STATE) throw unreadableStateError(CODEX_WAKE_TARGETS_FILE);
664
679
  if (!existing) return;
665
680
  const { targets, changed } = pruneDeadWakeTargets(existing.targets, socketLiveness);
666
681
  if (changed) await writeCodexWakeTargetsFile({ ...existing, targets });
@@ -78,7 +78,7 @@ export const DOCS_SECTIONS: DocsSection[] = [
78
78
  url: SEAT_LIFECYCLE_URL,
79
79
  page: "docs/SEAT_LIFECYCLE.md",
80
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"],
81
+ keywords: ["seat", "lifecycle", "reattach", "re-attach", "reset-local-connection", "evicted", "revoked", "superseded", "inbox monitor", "multiple seats", "silent deafness"],
82
82
  },
83
83
  {
84
84
  slug: "self-hosting",
@@ -16,7 +16,7 @@ export const CUBE_DELETED_CODE = 'CUBE_DELETED';
16
16
 
17
17
  export class DroneEvictedError extends Error {
18
18
  constructor(
19
- message = 'This seat was removed from the cube.'
19
+ message = 'This drone was removed from the cube.'
20
20
  ) {
21
21
  super(message);
22
22
  this.name = 'DroneEvictedError';
@@ -45,8 +45,8 @@ export const CUBE_DELETED_RESULT_MARKER = '[CUBE-DELETED]';
45
45
  export function formatEvictedToolResult(cubeName?: string): string {
46
46
  const cube = cubeName ?? 'the selected cube';
47
47
  return (
48
- `${EVICTED_RESULT_MARKER} This seat was removed from cube ${cube}.\n\n` +
49
- 'Borg has stopped listening for activity for this seat. Do not retry this request or restart the loop.\n\n' +
48
+ `${EVICTED_RESULT_MARKER} This drone was removed from cube ${cube}.\n\n` +
49
+ 'Borg has stopped listening for activity for this drone. Do not retry this request or restart the loop.\n\n' +
50
50
  'Your worktree and project files are unchanged. Finish any local file safety checks, then end this agent session.\n\n' +
51
51
  'To rejoin later, start a new session and use a new invitation from the server operator. Do not re-assimilate from this evicted session.'
52
52
  );
@@ -50,7 +50,7 @@ const defaultDeps: DroneManagementDeps = {
50
50
  };
51
51
 
52
52
  export const STALE_ROLE_DISPLAY_WARNING =
53
- 'Local display warning: The server committed this change, but Borg could not refresh this worktree\'s saved seat metadata. Local role details may be stale. Do not retry the reassignment.\n\n' +
53
+ 'Local display warning: The server committed this change, but Borg could not refresh this worktree\'s saved connection metadata. Local role details may be stale. Do not retry the reassignment.\n\n' +
54
54
  'Run `borg_regen` to refresh the server-authoritative role. If this session still shows the previous role, restart this agent session once. Do not re-assimilate or repeat the management request.';
55
55
 
56
56
  function opaqueNotFound(): never {
@@ -75,8 +75,8 @@ export function formatReassignDroneSuccess(input: {
75
75
  export function formatEvictDroneSuccess(droneLabel: string, cubeName: string): string {
76
76
  return (
77
77
  `Removed ${droneLabel} from cube ${cubeName}.\n` +
78
- 'The seat credential is revoked. The session will stop after its next Borg request.\n' +
79
- 'The worktree and project files were not deleted. Activity history remains attributed to the removed seat.\n' +
78
+ 'The drone\'s credential is revoked. The session will stop after its next Borg request.\n' +
79
+ 'The worktree and project files were not deleted. Activity history remains attributed to the removed drone.\n' +
80
80
  'After its work is merged, run `borg cleanup` to review whether the worktree can be pruned.'
81
81
  );
82
82
  }
@@ -177,8 +177,11 @@ export async function offerFirstRunServerInstall(
177
177
  : decision === 'eof'
178
178
  ? 'Installation was cancelled because confirmation input ended.'
179
179
  : 'Installation was declined.';
180
+ const setupRecovery = options.initializeServer
181
+ ? ', and no agent configuration was written. Run `borg setup` again to configure your agents.'
182
+ : '.';
180
183
  deps.stderr(
181
- `${reason} No server package or server state was changed.\n` +
184
+ `${reason} No server package or server state was changed${setupRecovery}\n` +
182
185
  `To install it later, run \`${command}\`. Then run \`borg server setup\` and \`borg server start\`.\n`,
183
186
  );
184
187
  return { kind: 'declined' };
@@ -17,7 +17,7 @@ import {
17
17
  BORG_LAUNCH_WORKTREE_ENV,
18
18
  } from './launch-access.js';
19
19
 
20
- const REMINDER = 'Reminder: this seat is scoped to its own worktree and scratch root; coordinate before working on a foreign path.';
20
+ const REMINDER = 'Reminder: this drone is scoped to its own worktree and scratch root; coordinate before working on a foreign path.';
21
21
 
22
22
  const PATH_KEYS = new Set([
23
23
  'cwd',
@@ -797,8 +797,8 @@ function liveMonitorYieldLine(
797
797
  : null;
798
798
  const holder = livePid === null ? 'another monitor instance' : `a live instance (pid ${livePid})`;
799
799
  return (
800
- `borg-inbox-monitor: seat inbox ${JSON.stringify(inboxPath)} is already monitored by ${holder}; ` +
801
- 'yielding — another session likely holds this seat.'
800
+ `borg-inbox-monitor: inbox ${JSON.stringify(inboxPath)} is already monitored by ${holder}; ` +
801
+ 'yielding — another session likely holds this worktree\'s connection.'
802
802
  );
803
803
  }
804
804
 
package/src/index.ts CHANGED
@@ -561,7 +561,7 @@ export async function main() {
561
561
  `# Re-attached to cube: ${displayIdentity.cubeName}`,
562
562
  ``,
563
563
  `**Drone label:** ${displayIdentity.droneLabel}`,
564
- `**Seat:** existing identity reused — no new drone minted (gh#780)`,
564
+ `**Drone:** existing identity reused — no new drone minted (gh#780)`,
565
565
  ``,
566
566
  ``,
567
567
  ].join('\n');
@@ -53,7 +53,7 @@ async function resolveTargetCube(
53
53
  const identities = await deps.readAllProjectIdentities();
54
54
  const matches = identities.filter((e) => e.cube.name === args.cubeName);
55
55
  if (matches.length === 0) {
56
- return { error: `no cube named '${args.cubeName}' found among this machine's saved seats — has any drone assimilated into it?` };
56
+ return { error: `no cube named '${args.cubeName}' found among this machine's saved connections — has any drone assimilated into it?` };
57
57
  }
58
58
  // gh#850: distinct cubes can share a name (same name across accounts/
59
59
  // environments, or a stale seat). Silently taking matches[0] could launch
@@ -61,13 +61,13 @@ async function resolveTargetCube(
61
61
  // cubeId + the project that holds the seat and refuse to guess.
62
62
  if (matches.length > 1) {
63
63
  const list = matches
64
- .map((m) => ` ${m.cube.cubeId} (seat in ${m.projectPath})`)
64
+ .map((m) => ` ${m.cube.cubeId} (drone in ${m.projectPath})`)
65
65
  .join('\n');
66
66
  return {
67
67
  error:
68
- `'${args.cubeName}' is ambiguous — ${matches.length} saved seats on this machine share that name:\n${list}\n` +
68
+ `'${args.cubeName}' is ambiguous — ${matches.length} saved connections on this machine share that name:\n${list}\n` +
69
69
  'cd into the intended project and re-run without --cube-name (resolves the active cube), ' +
70
- 'or clear the stale seat(s) by running `borg reset-local-seat` from the worktree that holds each.',
70
+ 'or clear the stale connection(s) by running `borg reset-local-connection` from the worktree that holds each.',
71
71
  };
72
72
  }
73
73
  return { cubeId: matches[0].cube.cubeId, name: args.cubeName };
@@ -237,7 +237,7 @@ export async function runLaunchAll(
237
237
  }
238
238
  } else {
239
239
  deps.stdout(
240
- `No worktrees found for cube '${cubeName}' — have you run \`borg assimilate --worktree\` to create any drone seats?\n`
240
+ `No worktrees found for cube '${cubeName}' — have you run \`borg assimilate --worktree\` to create any drones?\n`
241
241
  );
242
242
  }
243
243
  return 0;
@@ -290,8 +290,8 @@ export async function runLaunchAll(
290
290
  if (status === 'evicted') {
291
291
  evictedCount += 1;
292
292
  deps.stderr(
293
- `skipping ${c.droneLabel} (${c.worktreeDir}): seat no longer in cube (evicted) — ` +
294
- `run \`borg cleanup --prune\` to remove the worktree, or \`borg assimilate\` to re-seat fresh.\n`
293
+ `skipping ${c.droneLabel} (${c.worktreeDir}): drone no longer in cube (evicted) — ` +
294
+ `run \`borg cleanup --prune\` to remove the worktree, or \`borg assimilate\` to start fresh.\n`
295
295
  );
296
296
  continue;
297
297
  }
@@ -299,7 +299,7 @@ export async function runLaunchAll(
299
299
  revokedCount += 1;
300
300
  deps.stderr(
301
301
  `Local session was revoked.\n` +
302
- `Next: run borg reset-local-seat, then borg assimilate --host ${c.apiUrl} --enroll.\n`
302
+ `Next: run borg reset-local-connection, then borg assimilate --host ${c.apiUrl} --enroll.\n`
303
303
  );
304
304
  continue;
305
305
  }
@@ -307,7 +307,7 @@ export async function runLaunchAll(
307
307
  rejectedCount += 1;
308
308
  deps.stderr(
309
309
  `Local session was superseded by a newer enrollment.\n` +
310
- `Next: run borg reset-local-seat, then borg assimilate --host ${c.apiUrl} --enroll.\n`
310
+ `Next: run borg reset-local-connection, then borg assimilate --host ${c.apiUrl} --enroll.\n`
311
311
  );
312
312
  continue;
313
313
  }
@@ -336,7 +336,7 @@ export async function runLaunchAll(
336
336
  // cause-accurate note. Only the authoritative/terminal causes above skip.
337
337
  if (status === 'unreachable') {
338
338
  deps.stderr(
339
- `note: could not reach ${c.droneLabel}'s server to confirm its seat (network/timeout) — launching anyway.\n`
339
+ `note: could not reach ${c.droneLabel}'s server to confirm that drone (network/timeout) — launching anyway.\n`
340
340
  );
341
341
  } else if (status === 'endpoint-mismatch') {
342
342
  deps.stderr(
@@ -344,11 +344,11 @@ export async function runLaunchAll(
344
344
  );
345
345
  } else if (status === 'server-failure') {
346
346
  deps.stderr(
347
- `note: ${c.droneLabel}'s server returned an error while confirming its seat (transient) — launching anyway.\n`
347
+ `note: ${c.droneLabel}'s server returned an error while confirming that drone (transient) — launching anyway.\n`
348
348
  );
349
349
  } else if (status === 'indeterminate') {
350
350
  deps.stderr(
351
- `note: could not confirm ${c.droneLabel}'s seat is live (network/transient) — launching anyway.\n`
351
+ `note: could not confirm ${c.droneLabel} is live (network/transient) — launching anyway.\n`
352
352
  );
353
353
  }
354
354
  launchable.push(c);
@@ -95,7 +95,7 @@ export async function discoverDroneCandidates(
95
95
  // 4. malformed entry (cubeId/droneId not UUID) → warn + skip
96
96
  if (!isUuid(cube.cubeId) || !isUuid(cube.droneId)) {
97
97
  deps.stderr(
98
- `skipping ${worktreeDir}: its saved seat has a malformed cubeId/droneId — re-assimilate to fix\n`
98
+ `skipping ${worktreeDir}: its saved connection has a malformed cubeId/droneId — re-assimilate to fix\n`
99
99
  );
100
100
  continue;
101
101
  }
@@ -28,6 +28,15 @@ interface LifecycleStateFile {
28
28
  entries: Record<string, LifecycleStateEntry>;
29
29
  }
30
30
 
31
+ const UNREADABLE_STATE = Symbol('unreadable-lifecycle-state');
32
+ type LifecycleStateRead = LifecycleStateFile | typeof UNREADABLE_STATE;
33
+
34
+ function unreadableStateError(): Error {
35
+ return new Error(
36
+ `Lifecycle log state is unreadable; refusing to overwrite it: ${STATE_FILE}`,
37
+ );
38
+ }
39
+
31
40
  export function lifecycleSignalForMessage(message: string): LifecycleSignal | null {
32
41
  if (message.startsWith('ARRIVAL: ')) return 'arrival';
33
42
  if (
@@ -44,7 +53,7 @@ function stateKey(subject: LifecycleLogSubject): string {
44
53
  return `${subject.cubeId}:${subject.droneId}`;
45
54
  }
46
55
 
47
- async function readState(): Promise<LifecycleStateFile> {
56
+ async function readState(): Promise<LifecycleStateRead> {
48
57
  try {
49
58
  const raw = await readFile(STATE_FILE, 'utf8');
50
59
  const parsed = JSON.parse(raw);
@@ -58,9 +67,10 @@ async function readState(): Promise<LifecycleStateFile> {
58
67
  return parsed as LifecycleStateFile;
59
68
  }
60
69
  } catch (err: any) {
61
- if (err?.code !== 'ENOENT') throw err;
70
+ if (err?.code === 'ENOENT') return { entries: {} };
71
+ return UNREADABLE_STATE;
62
72
  }
63
- return { entries: {} };
73
+ return UNREADABLE_STATE;
64
74
  }
65
75
 
66
76
  async function writeState(state: LifecycleStateFile): Promise<void> {
@@ -100,6 +110,7 @@ export async function shouldSuppressLifecycleLog(
100
110
  message: string
101
111
  ): Promise<{ suppress: boolean; signal: LifecycleSignal | null }> {
102
112
  const state = await readState();
113
+ if (state === UNREADABLE_STATE) throw unreadableStateError();
103
114
  return shouldSuppressLifecycleLogFromState(
104
115
  message,
105
116
  state.entries[stateKey(subject)]
@@ -138,6 +149,7 @@ export async function recordLifecycleLog(
138
149
  message: string
139
150
  ): Promise<void> {
140
151
  const state = await readState();
152
+ if (state === UNREADABLE_STATE) throw unreadableStateError();
141
153
  const key = stateKey(subject);
142
154
  state.entries[key] = nextLifecycleStateAfterLog(message, state.entries[key]);
143
155
  await writeState(state);
@@ -71,14 +71,14 @@ export async function resolveOpenCodeSeatIdentity(
71
71
  if (!active) {
72
72
  throw new OpenCodeSeatIdentityError(
73
73
  'SEAT_NOT_FOUND',
74
- 'No active Borg seat is bound to the OpenCode session directory.',
74
+ 'No Borg drone is bound to the OpenCode session directory.',
75
75
  sessionWorktree,
76
76
  );
77
77
  }
78
78
  if (typeof active.worktree !== 'string' || resolve(active.worktree) !== resolve(sessionWorktree)) {
79
79
  throw new OpenCodeSeatIdentityError(
80
80
  'SEAT_WORKTREE_MISMATCH',
81
- 'The resolved Borg seat belongs to a different worktree than the OpenCode session.',
81
+ 'The resolved Borg drone belongs to a different worktree than the OpenCode session.',
82
82
  sessionWorktree,
83
83
  active,
84
84
  );
@@ -93,19 +93,19 @@ export function formatOpenCodeSeatIdentityError(
93
93
  childCwd: string,
94
94
  ): string {
95
95
  const lines = [
96
- `Borg OpenCode seat identity error [${error.code}]`,
96
+ `Borg OpenCode identity error [${error.code}]`,
97
97
  '',
98
98
  error.message,
99
99
  `- OpenCode session directory: ${error.sessionDirectory ?? 'unavailable'}`,
100
100
  `- Borg MCP child cwd: ${childCwd}`,
101
101
  ];
102
102
  if (error.seat) {
103
- lines.push(`- Resolved seat: ${error.seat.droneLabel} (${error.seat.worktree})`);
103
+ lines.push(`- Resolved drone: ${error.seat.droneLabel} (${error.seat.worktree})`);
104
104
  }
105
105
  lines.push(
106
106
  '',
107
107
  'The Borg stream and OpenCode wake injection were not started.',
108
- 'Exit this session and run `borg --cli opencode` from the intended worktree. If that worktrees saved seat is stale, run `borg reset-local-seat` from that exact worktree before assimilating again.',
108
+ 'Exit this session and run `borg --cli opencode` from the intended worktree. If that worktree\'s saved connection is stale, run `borg reset-local-connection` from that exact worktree before assimilating again.',
109
109
  );
110
110
  return lines.join('\n');
111
111
  }
@@ -6,6 +6,7 @@
6
6
  * index.ts's stdio MCP server bootstrap.
7
7
  */
8
8
 
9
+ import { hostname as osHostname } from 'node:os';
9
10
  import {
10
11
  ROLE_SCOPED_SAFETY_DISCIPLINES,
11
12
  UNIVERSAL_SAFETY_DISCIPLINES,
@@ -243,7 +244,7 @@ export function markArrivalAnnouncedThisProcess(): void {
243
244
  export function getDronePlaybook(): string {
244
245
  const arrivalInstruction = arrivalAnnouncedThisProcess
245
246
  ? ''
246
- : '\n**When this MCP session first starts:** post one `ARRIVAL: <your-label> (<your-role>) online on <hostname>` (run `hostname`). After the post succeeds, the client suppresses this instruction until the MCP process restarts; an explicit `/mcp` reconnect may show it again.\n';
247
+ : `\n**When this MCP session first starts:** post one \`ARRIVAL: <your-label> (<your-role>) online on ${osHostname()}\`. After the post succeeds, the client suppresses this instruction until the MCP process restarts; an explicit \`/mcp\` reconnect may show it again.\n`;
247
248
  return `## How to operate as a Drone
248
249
 
249
250
  You're a Drone in a Cube. Coordinate with other drones through the activity log.