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
package/src/index.ts CHANGED
@@ -112,6 +112,14 @@ import {
112
112
  import { gateAllowsActivation, borgSessionToolNotice } from './launch-gate.js';
113
113
  import { renderSyncRolesResult } from './sync-roles-render.js';
114
114
  import { initConsolePrefix, consolePrefix } from './console-prefix.js';
115
+ import {
116
+ confirmDisplayIdentity,
117
+ identityFromRegen,
118
+ markDisplayIdentityReadFailed,
119
+ renderDisplayIdentity,
120
+ seedDisplayIdentity,
121
+ withRenderedRegenIdentity,
122
+ } from './display-identity.js';
115
123
  import {
116
124
  resolveSessionAgentKind,
117
125
  } from './codex-app-wake.js';
@@ -369,16 +377,25 @@ export async function main() {
369
377
  ],
370
378
  };
371
379
  }
380
+ seedDisplayIdentity(active);
372
381
  const since = typeof args?.since === 'string' ? args.since : undefined;
373
382
  const mode = args?.mode === 'lite' ? 'lite' : 'full';
374
383
  const reportedModel = typeof args?.model === 'string' ? args.model : undefined;
375
- const result = await regen(active.sessionToken, active.apiUrl, {
376
- since,
377
- reportedModel,
378
- agentKind: resolveReportableSessionAgentKind(),
379
- workingRepo: resolveWorkingRepo(),
380
- serverTrustIdentity: active.serverTrustIdentity,
381
- });
384
+ let result: Awaited<ReturnType<typeof regen>>;
385
+ try {
386
+ result = await regen(active.sessionToken, active.apiUrl, {
387
+ since,
388
+ reportedModel,
389
+ agentKind: resolveReportableSessionAgentKind(),
390
+ workingRepo: resolveWorkingRepo(),
391
+ serverTrustIdentity: active.serverTrustIdentity,
392
+ });
393
+ } catch (error) {
394
+ markDisplayIdentityReadFailed(active);
395
+ throw error;
396
+ }
397
+ const displayIdentity = confirmDisplayIdentity(active, identityFromRegen(result));
398
+ const displayedResult = withRenderedRegenIdentity(result, displayIdentity);
382
399
  const freshActive = activeCubeWithFreshRegenIdentity(active, result);
383
400
  if (freshActive !== active) {
384
401
  await refreshActiveCubeMetadata(freshActive);
@@ -407,8 +424,8 @@ export async function main() {
407
424
  ? formatWakePathPrefix({
408
425
  inboxPath,
409
426
  monitorStateRoot,
410
- droneLabel: regenWakePathDroneLabel(result, freshActive.droneLabel),
411
- cubeName: freshActive.name,
427
+ droneLabel: regenWakePathDroneLabel(displayedResult, displayIdentity.droneLabel),
428
+ cubeName: displayIdentity.cubeName,
412
429
  })
413
430
  : '';
414
431
 
@@ -427,7 +444,7 @@ export async function main() {
427
444
  }
428
445
  } catch { /* never break regen */ }
429
446
 
430
- return { content: [{ type: 'text', text: versionHeader + prefix + formatRegenMarkdown(result, { mode }) }] };
447
+ return { content: [{ type: 'text', text: versionHeader + prefix + formatRegenMarkdown(displayedResult, { mode }) }] };
431
448
  }
432
449
 
433
450
  case 'borg_assimilate': {
@@ -451,27 +468,31 @@ export async function main() {
451
468
  // server-validated regen path (SR cond-4: no fabricated success
452
469
  // — an evicted/revoked seat FAILS server-side and is surfaced).
453
470
  try {
471
+ seedDisplayIdentity(active!);
454
472
  const result = await regen(active!.sessionToken, active!.apiUrl, {
455
473
  agentKind: resolveReportableSessionAgentKind(),
456
474
  workingRepo: resolveWorkingRepo(),
457
475
  serverTrustIdentity: active!.serverTrustIdentity,
458
476
  });
477
+ const displayIdentity = confirmDisplayIdentity(active!, identityFromRegen(result));
478
+ const displayedResult = withRenderedRegenIdentity(result, displayIdentity);
459
479
  const freshActive = activeCubeWithFreshRegenIdentity(active!, result);
460
480
  if (freshActive !== active) {
461
481
  await refreshActiveCubeMetadata(freshActive);
462
482
  }
463
483
  const header = [
464
- `# Re-attached to cube: ${freshActive.name}`,
484
+ `# Re-attached to cube: ${displayIdentity.cubeName}`,
465
485
  ``,
466
- `**Drone label:** ${freshActive.droneLabel}`,
486
+ `**Drone label:** ${displayIdentity.droneLabel}`,
467
487
  `**Seat:** existing identity reused — no new drone minted (gh#780)`,
468
488
  ``,
469
489
  ``,
470
490
  ].join('\n');
471
491
  return {
472
- content: [{ type: 'text', text: header + formatRegenMarkdown(result, { mode: 'full' }) }],
492
+ content: [{ type: 'text', text: header + formatRegenMarkdown(displayedResult, { mode: 'full' }) }],
473
493
  };
474
494
  } catch (err: any) {
495
+ markDisplayIdentityReadFailed(active!);
475
496
  const failure = reattachFailureMessage(err ?? {});
476
497
  return { content: [{ type: 'text', text: failure }], isError: true };
477
498
  }
@@ -506,11 +527,34 @@ export async function main() {
506
527
 
507
528
  case 'borg_whoami': {
508
529
  const active = await requireActiveCube();
509
- const result = await whoami(
510
- active.sessionToken,
511
- active.apiUrl,
512
- active.serverTrustIdentity,
513
- );
530
+ seedDisplayIdentity(active);
531
+ let result: Awaited<ReturnType<typeof whoami>>;
532
+ try {
533
+ result = await whoami(
534
+ active.sessionToken,
535
+ active.apiUrl,
536
+ active.serverTrustIdentity,
537
+ );
538
+ } catch (error) {
539
+ markDisplayIdentityReadFailed(active);
540
+ throw error;
541
+ }
542
+ confirmDisplayIdentity(active, {
543
+ cubeName: result.cube_name,
544
+ droneLabel: result.drone_label,
545
+ roleName: result.role_name,
546
+ });
547
+ try {
548
+ await refreshActiveCubeMetadata({
549
+ ...active,
550
+ name: result.cube_name,
551
+ droneLabel: result.drone_label,
552
+ roleName: result.role_name,
553
+ });
554
+ } catch {
555
+ // The invocation-local server truth remains authoritative even if
556
+ // the display-only persistence refresh cannot be written.
557
+ }
514
558
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
515
559
  }
516
560
 
@@ -729,6 +773,8 @@ export async function main() {
729
773
  if (!message || typeof message !== 'string') throw new Error('message is required');
730
774
  const active = await getActiveCube();
731
775
  if (!active) throw new Error('Not assimilated to a cube. Use borg_assimilate <cube-name> first.');
776
+ seedDisplayIdentity(active);
777
+ const displayIdentity = renderDisplayIdentity(active);
732
778
  const lifecycleSignal = lifecycleSignalForMessage(message);
733
779
  if (lifecycleSignal) {
734
780
  const decision = await shouldSuppressLifecycleLog(active, message);
@@ -739,7 +785,7 @@ export async function main() {
739
785
  content: [
740
786
  {
741
787
  type: 'text',
742
- text: `Suppressed duplicate ${decision.signal?.toUpperCase()} lifecycle log for ${active.droneLabel}; recent cube log already contains this signal.`,
788
+ text: `Suppressed duplicate ${decision.signal?.toUpperCase()} lifecycle log for ${displayIdentity.droneLabel}; recent cube log already contains this signal.`,
743
789
  },
744
790
  ],
745
791
  };
@@ -773,7 +819,7 @@ export async function main() {
773
819
  .map((r) => r.label)
774
820
  .join(', ')}. Message delivered — they'll read it when they return.`
775
821
  : '';
776
- const text = `Logged to cube "${active.name}" as ${active.droneLabel}. (entry id: ${result.entry.id})${echo}${unreachable}`;
822
+ const text = `Logged to cube "${displayIdentity.cubeName}" as ${displayIdentity.droneLabel}. (entry id: ${result.entry.id})${echo}${unreachable}`;
777
823
  return { content: [{ type: 'text', text }] };
778
824
  }
779
825
 
package/src/log-stream.ts CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  } from './codex-app-wake.js';
56
56
  import { readBoundedResponseBody } from './server-response.js';
57
57
  import { BorgServerError } from './server-errors.js';
58
+ import { markSeatRejected } from './seats.js';
58
59
  import {
59
60
  acquireStreamLease,
60
61
  readOwnershipSnapshot,
@@ -441,7 +442,7 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
441
442
 
442
443
  let attempt = 0;
443
444
  let lastEventId: string | null = null;
444
- let currentCubeId: string | null = null;
445
+ let currentStreamKey: string | null = null;
445
446
  let lease: StreamLease | null = null;
446
447
  let leaseKey: string | null = null;
447
448
 
@@ -469,10 +470,11 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
469
470
  // teardown (cube-cleared / dead app-server socket).
470
471
  ensureCodexHeartbeatStarted();
471
472
 
472
- // Reset resume cursor on cube switch — entries from a prior cube
473
- // mean nothing for the new cube's stream.
474
- if (active.cubeId !== currentCubeId) {
475
- currentCubeId = active.cubeId;
473
+ // Reset resume cursor on cube/drone switch — entries from a prior seat
474
+ // mean nothing for the newly selected stream.
475
+ const activeStreamKey = `${active.cubeId}:${active.droneId}`;
476
+ if (activeStreamKey !== currentStreamKey) {
477
+ currentStreamKey = activeStreamKey;
476
478
  lastEventId = null;
477
479
  }
478
480
 
@@ -560,18 +562,36 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
560
562
  // loop quiesces cleanly. The agent's graceful shutdown (TaskStop Monitor,
561
563
  // no /loop reschedule) is driven separately by the EVICTED tool-result it
562
564
  // already received on the authed call that produced this verdict.
563
- if (err instanceof DroneEvictedError) {
564
- if (lease) await lease.release().catch(() => {});
565
- lease = null;
566
- leaseKey = null;
567
- streamState.connected = false;
568
- streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
569
- process.stderr.write(
570
- `[borg-mcp log stream] drone evicted — stream terminated (no reconnect).\n`
565
+ if (err instanceof DroneEvictedError || err instanceof BorgServerError) {
566
+ if (active.localSessionCredentialRef) {
567
+ markSeatRejected(active.localSessionCredentialRef);
568
+ }
569
+ const replacement = await _getActiveCube().catch(() => null);
570
+ const replacementChanged = replacement !== null && (
571
+ replacement.localSessionCredentialRef !== active.localSessionCredentialRef ||
572
+ replacement.cubeId !== active.cubeId ||
573
+ replacement.droneId !== active.droneId
571
574
  );
575
+ if (replacementChanged) {
576
+ if (lease) await lease.release().catch(() => {});
577
+ lease = null;
578
+ leaseKey = null;
579
+ streamState.connected = false;
580
+ streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
581
+ continue;
582
+ }
583
+ if (err instanceof DroneEvictedError) {
584
+ if (lease) await lease.release().catch(() => {});
585
+ lease = null;
586
+ leaseKey = null;
587
+ streamState.connected = false;
588
+ streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
589
+ process.stderr.write(
590
+ `[borg-mcp log stream] drone evicted — stream terminated (no reconnect).\n`
591
+ );
592
+ }
572
593
  throw new TerminalStreamError();
573
594
  }
574
- if (err instanceof BorgServerError) throw new TerminalStreamError();
575
595
  streamState.connected = false;
576
596
  const delay =
577
597
  Math.min(RECONNECT_MIN_MS * 2 ** attempt, RECONNECT_MAX_MS) +
@@ -883,11 +903,14 @@ export async function streamOnce(
883
903
  code = undefined;
884
904
  }
885
905
  if (code === ErrorCode.SESSION_REJECTED) {
906
+ if (active.localSessionCredentialRef) markSeatRejected(active.localSessionCredentialRef);
886
907
  throw new BorgServerError('SESSION_REJECTED', 'Borg server session was rejected');
887
908
  }
888
909
  if (code === ErrorCode.SESSION_REVOKED) {
910
+ if (active.localSessionCredentialRef) markSeatRejected(active.localSessionCredentialRef);
889
911
  throw new BorgServerError('SESSION_REVOKED', 'Borg server session was revoked');
890
912
  }
913
+ if (active.localSessionCredentialRef) markSeatRejected(active.localSessionCredentialRef);
891
914
  throw new BorgServerError('CREDENTIAL_REJECTED', 'Borg server rejected the stream credential');
892
915
  }
893
916
  if (response.status === 410) {
@@ -899,6 +922,7 @@ export async function streamOnce(
899
922
  ).catch(() => '');
900
923
  const code = errorCodeFromBody(body);
901
924
  if (code === DRONE_EVICTED_CODE) {
925
+ if (active.localSessionCredentialRef) markSeatRejected(active.localSessionCredentialRef);
902
926
  throw new DroneEvictedError();
903
927
  }
904
928
  // client#42: an expired resume cursor is RECOVERABLE, not terminal. The
@@ -12,7 +12,7 @@ export type ParseResult =
12
12
 
13
13
  /**
14
14
  * Parse argv for `borg assimilate [role] [--worktree <n>] [--template <n>]
15
- * [--no-template] [--cube-name <n>] [--host <host>] [--enroll] [--here] [--yes]`. The `assimilate`
15
+ * [--no-template] [--cube-name <n>] [--host <host>] [--enroll] [--here] [--force] [--yes]`. The `assimilate`
16
16
  * subcommand token must already be stripped by the caller.
17
17
  */
18
18
  export function parseAssimilateArgs(rawArgs: string[]): ParseResult {
@@ -46,6 +46,8 @@ export function parseAssimilateArgs(rawArgs: string[]): ParseResult {
46
46
  i += 1;
47
47
  } else if (arg === '--here') {
48
48
  flags.here = true;
49
+ } else if (arg === '--force') {
50
+ flags.force = true;
49
51
  } else if (arg === '--host') {
50
52
  const next = rawArgs[i + 1];
51
53
  if (typeof next !== 'string' || next.length === 0 || next.startsWith('-')) {
@@ -99,7 +101,7 @@ export function parseAssimilateArgs(rawArgs: string[]): ParseResult {
99
101
  } else if (arg.startsWith('--')) {
100
102
  return {
101
103
  ok: false,
102
- error: `unknown flag: ${arg}. Supported: --worktree, --template, --no-template, --cube-name, --host, --enroll, --here, --yes, --cli, --model`,
104
+ error: `unknown flag: ${arg}. Supported: --worktree, --template, --no-template, --cube-name, --host, --enroll, --here, --force, --yes, --cli, --model`,
103
105
  };
104
106
  } else {
105
107
  if (role !== undefined) {
@@ -112,17 +112,18 @@ export function wakePathArming(
112
112
  * network `regen()` result and falling back per-field to the local
113
113
  * `getActiveCube` state. When `result` is null — the net-free fallback path
114
114
  * taken on a `regen()` network failure — identity comes entirely from local
115
- * state, so a weak drone that hits a SessionStart network blip still gets
116
- * oriented (with its wake-path arming) instead of left dormant.
115
+ * state, explicitly qualified as last-confirmed so a weak drone still gets
116
+ * oriented without presenting a failed identity read as current server truth.
117
117
  */
118
118
  export function resolveLeanIdentity(
119
119
  active: { name: string; droneLabel: string; roleName?: string | null },
120
120
  result: { cube?: any; drone?: any; role?: any } | null
121
121
  ): { cubeName: string; droneLabel: string; roleName: string | null } {
122
+ const qualifier = result === null ? ' (last confirmed)' : '';
122
123
  return {
123
- cubeName: result?.cube?.name ?? active.name,
124
- droneLabel: result?.drone?.label ?? active.droneLabel,
125
- roleName: result?.role?.name ?? active.roleName ?? null,
124
+ cubeName: result?.cube?.name ?? `${active.name}${qualifier}`,
125
+ droneLabel: result?.drone?.label ?? `${active.droneLabel}${qualifier}`,
126
+ roleName: result?.role?.name ?? (active.roleName ? `${active.roleName}${qualifier}` : null),
126
127
  };
127
128
  }
128
129
 
package/src/regen.ts CHANGED
@@ -22,7 +22,6 @@ import { monitorStateRootForWorktree } from './inbox-monitor.js';
22
22
  import {
23
23
  parseHookSource,
24
24
  formatLeanOrientation,
25
- resolveLeanIdentity,
26
25
  type AgentKind,
27
26
  } from './regen-format.js';
28
27
  import { resolveSessionAgentKind } from './codex-app-wake.js';
@@ -30,6 +29,13 @@ import { resolveReportableSessionAgentKind } from './agent-runtime.js';
30
29
  import { handleVersionFlag } from './version.js';
31
30
  import { gateAllowsActivation } from './launch-gate.js';
32
31
  import { resolveWorkingRepo } from './working-repo.js';
32
+ import {
33
+ confirmDisplayIdentity,
34
+ identityFromRegen,
35
+ markDisplayIdentityReadFailed,
36
+ renderDisplayIdentity,
37
+ seedDisplayIdentity,
38
+ } from './display-identity.js';
33
39
 
34
40
  /**
35
41
  * Drain the SessionStart hook's stdin payload (best-effort). Mirrors
@@ -68,6 +74,7 @@ async function main(): Promise<void> {
68
74
  await emitUnassimilatedNotice();
69
75
  return;
70
76
  }
77
+ seedDisplayIdentity(active);
71
78
 
72
79
  const inboxPath = inboxPathForDrone(active.cubeId, active.droneId);
73
80
  const monitorStateRoot = monitorStateRootForWorktree(findProjectRoot());
@@ -90,10 +97,12 @@ async function main(): Promise<void> {
90
97
  workingRepo: resolveWorkingRepo(),
91
98
  serverTrustIdentity: active.serverTrustIdentity,
92
99
  });
100
+ confirmDisplayIdentity(active, identityFromRegen(result));
93
101
  } catch {
102
+ markDisplayIdentityReadFailed(active);
94
103
  result = null;
95
104
  }
96
- const identity = resolveLeanIdentity(active, result);
105
+ const identity = renderDisplayIdentity(active);
97
106
  process.stdout.write(
98
107
  formatLeanOrientation({ ...identity, inboxPath, monitorStateRoot, agentKind, source }) + '\n'
99
108
  );
@@ -47,6 +47,7 @@ import {
47
47
  LocalManageRequiredError,
48
48
  } from './server-errors.js';
49
49
  import { getActiveCube, type ActiveCube } from './cubes.js';
50
+ import { markSeatRejected } from './seats.js';
50
51
  import {
51
52
  advanceLocalServerCursor,
52
53
  getLocalServerCursor,
@@ -241,6 +242,7 @@ async function localServerRequest<T>(
241
242
  method,
242
243
  signal,
243
244
  droneSession: active.sessionToken,
245
+ localSessionCredentialRef: active.localSessionCredentialRef,
244
246
  apiUrl: active.apiUrl,
245
247
  serverTrustIdentity: active.serverTrustIdentity,
246
248
  redirect: 'error',
@@ -587,6 +589,7 @@ async function authedFetch(
587
589
  apiUrl?: string;
588
590
  authToken?: string;
589
591
  serverTrustIdentity?: string;
592
+ localSessionCredentialRef?: string;
590
593
  } = {}
591
594
  ): Promise<Response> {
592
595
  const {
@@ -594,6 +597,7 @@ async function authedFetch(
594
597
  apiUrl,
595
598
  authToken,
596
599
  serverTrustIdentity: suppliedTrustIdentity,
600
+ localSessionCredentialRef,
597
601
  headers,
598
602
  ...rest
599
603
  } = init;
@@ -673,6 +677,9 @@ async function authedFetch(
673
677
  } catch {
674
678
  rejectedCode = undefined;
675
679
  }
680
+ if (droneSession !== undefined && localSessionCredentialRef !== undefined) {
681
+ markSeatRejected(localSessionCredentialRef);
682
+ }
676
683
  if (droneSession !== undefined && rejectedCode === ErrorCode.SESSION_REJECTED) {
677
684
  throw new BorgServerError(
678
685
  'SESSION_REJECTED',
@@ -732,6 +739,7 @@ async function authedFetch(
732
739
  }
733
740
  debugLog(`✗ ${response.status} ${method} ${path}`);
734
741
  if (droneSession !== undefined && response.status === 410 && code === DRONE_EVICTED_CODE) {
742
+ if (localSessionCredentialRef !== undefined) markSeatRejected(localSessionCredentialRef);
735
743
  throw new DroneEvictedError();
736
744
  }
737
745
  throw new BorgServerHttpError(
@@ -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
+ }