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
@@ -25,6 +25,7 @@ import { createInterface } from 'node:readline/promises';
25
25
  import {
26
26
  snapshotLocalSeat as cubesSnapshotLocalSeat,
27
27
  resetLocalSeatBinding as cubesResetLocalSeatBinding,
28
+ findRemainingActiveSeatForWorktree as cubesFindRemainingActiveSeatForWorktree,
28
29
  findProjectRoot as cubesFindProjectRoot,
29
30
  type LocalSeatSnapshot,
30
31
  type ResetLocalSeatOutcome,
@@ -39,6 +40,10 @@ export interface ResetLocalSeatFlags {
39
40
  export interface ResetLocalSeatDeps {
40
41
  snapshotLocalSeat: () => Promise<LocalSeatSnapshot | null>;
41
42
  resetLocalSeatBinding: (expected: LocalSeatSnapshot) => Promise<ResetLocalSeatOutcome>;
43
+ findRemainingActiveSeat: (worktree: string) => Promise<{
44
+ apiUrl: string;
45
+ operation: { projectRoot: string; kind: 'seat' | 'sibling'; operationKey: string };
46
+ } | null>;
42
47
  findProjectRoot: (cwd: string) => string;
43
48
  normalizeHost: (host: string) => string;
44
49
  cwd: () => string;
@@ -52,6 +57,10 @@ function reenrollCommand(apiUrl: string): string {
52
57
  return `\`borg assimilate --host ${apiUrl} --enroll\``;
53
58
  }
54
59
 
60
+ function reattachCommand(apiUrl: string): string {
61
+ return `\`borg assimilate --host ${apiUrl} --here\``;
62
+ }
63
+
55
64
  /**
56
65
  * Recovery guidance shared by the success + honest-no-op copy. Makes NO
57
66
  * server-revocation claim: it recommends the operator issue a LIVE scoped
@@ -149,7 +158,22 @@ export async function runResetLocalSeat(
149
158
  `audit: this worktree's saved local seat for ${snapshot.apiUrl} (worktree ${worktree}) ` +
150
159
  'was cleared; server, trust anchor, cube, and sibling worktrees unchanged.\n',
151
160
  );
152
- deps.stdout(recoveryGuidance(snapshot.apiUrl));
161
+ let remaining: Awaited<ReturnType<ResetLocalSeatDeps['findRemainingActiveSeat']>> = null;
162
+ try {
163
+ remaining = await deps.findRemainingActiveSeat(worktree);
164
+ } catch {
165
+ // The reset already committed. A follow-up read must not turn that success
166
+ // into a false failure; fall back to the universally valid enrollment path.
167
+ }
168
+ if (remaining?.apiUrl === snapshot.apiUrl) {
169
+ deps.stdout(
170
+ `Another saved active seat remains for this worktree on ${snapshot.apiUrl}. ` +
171
+ `Re-attach with ${reattachCommand(snapshot.apiUrl)}; Borg will revalidate that ` +
172
+ 'seat with the server before launch.\n',
173
+ );
174
+ } else {
175
+ deps.stdout(recoveryGuidance(snapshot.apiUrl));
176
+ }
153
177
  return 0;
154
178
  }
155
179
 
@@ -175,6 +199,7 @@ export function buildDefaultResetLocalSeatDeps(): ResetLocalSeatDeps {
175
199
  return {
176
200
  snapshotLocalSeat: () => cubesSnapshotLocalSeat(),
177
201
  resetLocalSeatBinding: (expected) => cubesResetLocalSeatBinding(expected),
202
+ findRemainingActiveSeat: (worktree) => cubesFindRemainingActiveSeatForWorktree(worktree),
178
203
  findProjectRoot: (cwd) => cubesFindProjectRoot(cwd),
179
204
  normalizeHost: (host) => normalizeServerEndpoint(host),
180
205
  cwd: () => process.cwd(),
@@ -0,0 +1,113 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import {
3
+ HEARTBEAT_STALE_MS,
4
+ heartbeatPathFor,
5
+ legacyHeartbeatPathFor,
6
+ legacyPidfilePathFor,
7
+ pidfilePathFor,
8
+ } from './inbox-monitor.js';
9
+
10
+ export type MonitorHeartbeatState = 'fresh' | 'stale' | 'missing';
11
+
12
+ export interface LiveInboxMonitor {
13
+ pid: number;
14
+ heartbeat: MonitorHeartbeatState;
15
+ }
16
+
17
+ export interface SeatReattachGuardDeps {
18
+ readPidfile: (path: string) => string | null;
19
+ readHeartbeatMtimeMs: (path: string) => number | null;
20
+ isAlive: (pid: number) => boolean;
21
+ now: number;
22
+ heartbeatStaleMs?: number;
23
+ }
24
+
25
+ function parseMonitorPid(raw: string | null): number | null {
26
+ if (raw === null) return null;
27
+ const match = /^([1-9]\d*)(?::[^:\s]+)?$/.exec(raw.trim());
28
+ if (!match) return null;
29
+ const pid = Number(match[1]);
30
+ return Number.isSafeInteger(pid) ? pid : null;
31
+ }
32
+
33
+ /**
34
+ * Read-only preflight for reusing a worktree seat. A live PID always blocks:
35
+ * a stale heartbeat may mean the holder is wedged, but only an explicit
36
+ * `--force` decision may launch a second session onto that seat.
37
+ */
38
+ export function inspectLiveInboxMonitor(
39
+ inboxPath: string,
40
+ monitorStateRoot: string,
41
+ deps: SeatReattachGuardDeps = defaultSeatReattachGuardDeps(),
42
+ ): LiveInboxMonitor | null {
43
+ const candidates = [
44
+ {
45
+ pidfile: pidfilePathFor(inboxPath, monitorStateRoot),
46
+ heartbeat: heartbeatPathFor(inboxPath, monitorStateRoot),
47
+ },
48
+ {
49
+ pidfile: legacyPidfilePathFor(inboxPath),
50
+ heartbeat: legacyHeartbeatPathFor(inboxPath),
51
+ },
52
+ ];
53
+ const staleMs = deps.heartbeatStaleMs ?? HEARTBEAT_STALE_MS;
54
+ for (const candidate of candidates) {
55
+ const pid = parseMonitorPid(deps.readPidfile(candidate.pidfile));
56
+ if (pid === null || !deps.isAlive(pid)) continue;
57
+ const heartbeatMtime = deps.readHeartbeatMtimeMs(candidate.heartbeat);
58
+ return {
59
+ pid,
60
+ heartbeat: heartbeatMtime === null
61
+ ? 'missing'
62
+ : deps.now - heartbeatMtime < staleMs
63
+ ? 'fresh'
64
+ : 'stale',
65
+ };
66
+ }
67
+ return null;
68
+ }
69
+
70
+ export function formatSeatReattachRefusal(
71
+ holder: LiveInboxMonitor,
72
+ forcedCommand: string,
73
+ ): string {
74
+ const heartbeat =
75
+ holder.heartbeat === 'fresh'
76
+ ? ''
77
+ : ` Its heartbeat is ${holder.heartbeat}, so the process may be wedged.`;
78
+ return (
79
+ `This worktree's Borg seat already has a live session (inbox monitor pid ${holder.pid}).${heartbeat}\n` +
80
+ 'No agent was launched. Stop the existing session or use a fresh worktree with `borg assimilate --worktree <name>`. ' +
81
+ `If the live monitor is wedged, override once with \`${forcedCommand}\`.\n`
82
+ );
83
+ }
84
+
85
+ export function defaultSeatReattachGuardDeps(
86
+ now: number = Date.now(),
87
+ ): SeatReattachGuardDeps {
88
+ return {
89
+ readPidfile: (path) => {
90
+ try {
91
+ return readFileSync(path, 'utf8');
92
+ } catch {
93
+ return null;
94
+ }
95
+ },
96
+ readHeartbeatMtimeMs: (path) => {
97
+ try {
98
+ return statSync(path).mtimeMs;
99
+ } catch {
100
+ return null;
101
+ }
102
+ },
103
+ isAlive: (pid) => {
104
+ try {
105
+ process.kill(pid, 0);
106
+ return true;
107
+ } catch (error: any) {
108
+ return error?.code === 'EPERM';
109
+ }
110
+ },
111
+ now,
112
+ };
113
+ }
package/src/seats.ts CHANGED
@@ -98,6 +98,8 @@ function emptyStore(): SeatsFile {
98
98
 
99
99
  const ROLE_CLASSES = new Set(['queen', 'worker']);
100
100
  const OPERATION_KINDS = new Set(['seat', 'sibling']);
101
+ const SEAT_BEARER_RE = /^[A-Za-z0-9_-]{43,1024}$/;
102
+ const rejectedSeatRefs = new Set<string>();
101
103
 
102
104
  function isNonEmptyString(value: unknown): value is string {
103
105
  return typeof value === 'string' && value.length > 0;
@@ -117,10 +119,10 @@ function isValidOperation(value: unknown): value is SeatOperation {
117
119
  /**
118
120
  * CR#2: FULL per-entry validation. Every key/value/invariant of a seat record is
119
121
  * checked — the ref is well-formed and self-consistent (the map key equals the
120
- * record's derived ref), state ∈ {pending,active}, the credential is a non-empty
121
- * string, the operation is well-shaped, an ACTIVE record carries ALL its required
122
- * server + binding fields, and a PENDING record carries NO active-only session
123
- * id. A single invalid entry ⇒ the whole store is rejected
122
+ * record's derived ref), state ∈ {pending,active}, the credential is an exact
123
+ * 32-byte base64url bearer, the operation is well-shaped, an ACTIVE record carries
124
+ * ALL its required server + binding fields, and a PENDING record carries NO
125
+ * active-only session id. A single invalid entry ⇒ the whole store is rejected
124
126
  * (fail closed at the caller, bytes preserved) — never a silent cast.
125
127
  */
126
128
  function isValidSeatRecord(ref: string, value: unknown): value is SeatRecord {
@@ -134,7 +136,8 @@ function isValidSeatRecord(ref: string, value: unknown): value is SeatRecord {
134
136
  !isNonEmptyString(r.cubeId) ||
135
137
  !isNonEmptyString(r.roleId) ||
136
138
  !isValidOperation(r.operation) ||
137
- !isNonEmptyString(r.credential)
139
+ typeof r.credential !== 'string' ||
140
+ !SEAT_BEARER_RE.test(r.credential)
138
141
  ) {
139
142
  return false;
140
143
  }
@@ -204,7 +207,7 @@ function parseStore(raw: string): SeatsFile | null {
204
207
  !Array.isArray(replacement) &&
205
208
  Object.keys(replacement).length === 1 &&
206
209
  typeof (replacement as { credential?: unknown }).credential === 'string' &&
207
- /^[A-Za-z0-9_-]{43}$/.test((replacement as { credential: string }).credential);
210
+ SEAT_BEARER_RE.test((replacement as { credential: string }).credential);
208
211
  let canonicalOrigin = false;
209
212
  try {
210
213
  const origin = new URL(String(withoutReplacement.origin));
@@ -338,6 +341,7 @@ export async function mintPendingSeat(input: {
338
341
  };
339
342
  txn.data.seats[ref] = record;
340
343
  await txn.commit();
344
+ rejectedSeatRefs.delete(ref);
341
345
  return record;
342
346
  });
343
347
  }
@@ -430,6 +434,7 @@ export async function prepareSeat(input: {
430
434
  };
431
435
  txn.data.seats[ref] = record;
432
436
  await txn.commit();
437
+ rejectedSeatRefs.delete(ref);
433
438
  return { ok: true as const, record };
434
439
  });
435
440
  }
@@ -498,6 +503,7 @@ export async function activateAndBindSeat(input: {
498
503
  ...(input.isHumanSeat !== undefined ? { isHumanSeat: input.isHumanSeat } : {}),
499
504
  };
500
505
  await txn.commit();
506
+ rejectedSeatRefs.delete(ref);
501
507
  return 'activated';
502
508
  });
503
509
  }
@@ -567,16 +573,28 @@ export async function bindPendingSeatToWorktree(input: {
567
573
 
568
574
  // ─── Hydration / enumeration (scan by worktree) ──────────────────────────────
569
575
 
570
- /** The exact ACTIVE seat bound to `worktree`, or null. A pending record (no
571
- * worktree, or non-active) is NEVER surfaced as a live binding. */
576
+ /** The preferred ACTIVE seat bound to `worktree`, or null. A pending record (no
577
+ * worktree, or non-active) is NEVER surfaced as a live binding. Candidates use
578
+ * one total order across every process: unrejected before rejected, a sibling
579
+ * finalized into this worktree before an older in-place binding, then seat ref. */
572
580
  export async function getActiveSeatForWorktree(worktree: string): Promise<SeatRecord | null> {
573
581
  const store = await readStore();
574
- for (const [ref, record] of Object.entries(store.seats)) {
575
- if (record.state === 'active' && record.worktree === worktree && seatRef(record) === ref) {
576
- return record;
577
- }
578
- }
579
- return null;
582
+ const candidates = Object.entries(store.seats)
583
+ .filter(([ref, record]) =>
584
+ record.state === 'active' && record.worktree === worktree && seatRef(record) === ref)
585
+ .sort(([leftRef, left], [rightRef, right]) => {
586
+ const rejectionOrder = Number(rejectedSeatRefs.has(leftRef)) - Number(rejectedSeatRefs.has(rightRef));
587
+ if (rejectionOrder !== 0) return rejectionOrder;
588
+ const operationOrder = Number(left.operation.kind === 'seat') - Number(right.operation.kind === 'seat');
589
+ if (operationOrder !== 0) return operationOrder;
590
+ return leftRef < rightRef ? -1 : leftRef > rightRef ? 1 : 0;
591
+ });
592
+ return candidates[0]?.[1] ?? null;
593
+ }
594
+
595
+ /** Deprioritize an exact seat after a definitive server auth or eviction verdict. */
596
+ export function markSeatRejected(ref: string): void {
597
+ if (REF_RE.test(ref)) rejectedSeatRefs.add(ref);
580
598
  }
581
599
 
582
600
  /**
@@ -624,6 +642,8 @@ export async function findIncompleteSiblingAttempt(binding: {
624
642
  record.state === 'pending' &&
625
643
  record.worktree === undefined &&
626
644
  record.operation.kind === 'sibling' &&
645
+ record.operation.operationKey.startsWith('implicit-sibling:') &&
646
+ record.operation.operationKey.length > 'implicit-sibling:'.length &&
627
647
  record.origin === binding.origin &&
628
648
  record.trustIdentity === binding.trustIdentity &&
629
649
  record.cubeId === binding.cubeId &&
@@ -696,6 +716,7 @@ export async function resetSeatForWorktree(expected: {
696
716
  }
697
717
  delete txn.data.seats[expected.ref];
698
718
  await txn.commit();
719
+ rejectedSeatRefs.delete(expected.ref);
699
720
  return { outcome: 'reset' as const, ref: expected.ref };
700
721
  });
701
722
  }
@@ -726,6 +747,7 @@ export async function clearSeat(ref: string): Promise<void> {
726
747
  if (txn.data.seats[ref] !== undefined) {
727
748
  delete txn.data.seats[ref];
728
749
  await txn.commit();
750
+ rejectedSeatRefs.delete(ref);
729
751
  }
730
752
  });
731
753
  }
package/src/update-cmd.ts CHANGED
@@ -308,23 +308,6 @@ function decodeServerStatus(value: unknown): ServerStatus {
308
308
  throw new Error('server returned invalid JSON status');
309
309
  }
310
310
  const record = value as Record<string, unknown>;
311
- const allowed = new Set([
312
- 'status',
313
- 'installed_controller',
314
- 'prepared_runtime',
315
- 'prepared_integrity',
316
- 'running_runtime',
317
- 'running_integrity',
318
- 'build_identity',
319
- 'endpoint',
320
- 'mode',
321
- 'service_adapter',
322
- 'data_identity',
323
- 'next_action',
324
- ]);
325
- for (const key of Object.keys(record)) {
326
- if (!allowed.has(key)) throw new Error(`server status contains unknown field ${key}`);
327
- }
328
311
  if (
329
312
  (record.status !== 'running' && record.status !== 'stopped') ||
330
313
  typeof record.installed_controller !== 'string' ||
@@ -382,10 +365,7 @@ function decodeServerUpdate(value: unknown): ServerUpdateResult {
382
365
  }
383
366
  const record = value as Record<string, unknown>;
384
367
  if (record.status === 'failed') {
385
- const failureKeys = Object.keys(record);
386
368
  if (
387
- failureKeys.length !== 4 ||
388
- !failureKeys.every((key) => ['status', 'error_code', 'recovery', 'data_identity'].includes(key)) ||
389
369
  (record.error_code !== 'ARTIFACT_VERIFICATION_FAILED' && record.error_code !== 'ACTIVATION_FAILED') ||
390
370
  !['verification_failed', 'restored', 'stopped', 'recovery_failed'].includes(record.recovery as string) ||
391
371
  record.data_identity !== 'preserved' ||
@@ -400,20 +380,6 @@ function decodeServerUpdate(value: unknown): ServerUpdateResult {
400
380
  recovery: record.recovery as ServerUpdateFailure['recovery'],
401
381
  };
402
382
  }
403
- const allowed = new Set([
404
- 'status',
405
- 'installed_controller',
406
- 'artifact',
407
- 'artifact_integrity',
408
- 'running_runtime',
409
- 'build_identity',
410
- 'mode',
411
- 'data_identity',
412
- 'next_action',
413
- ]);
414
- for (const key of Object.keys(record)) {
415
- if (!allowed.has(key)) throw new Error(`server update contains unknown field ${key}`);
416
- }
417
383
  if (
418
384
  (record.status !== 'prepared' && record.status !== 'updated') ||
419
385
  typeof record.installed_controller !== 'string' ||