borgmcp 2.0.3 → 2.0.4

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 (71) hide show
  1. package/README.md +2 -2
  2. package/dist/assimilate-cmd.d.ts +0 -2
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +51 -43
  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 +0 -1
  8. package/dist/assimilate-deps.js.map +1 -1
  9. package/dist/cleanup-cmd.d.ts +1 -1
  10. package/dist/cleanup-cmd.d.ts.map +1 -1
  11. package/dist/cleanup-cmd.js +3 -1
  12. package/dist/cleanup-cmd.js.map +1 -1
  13. package/dist/cli-help.d.ts.map +1 -1
  14. package/dist/cli-help.js +2 -1
  15. package/dist/cli-help.js.map +1 -1
  16. package/dist/cubes.d.ts +0 -2
  17. package/dist/cubes.d.ts.map +1 -1
  18. package/dist/cubes.js +0 -2
  19. package/dist/cubes.js.map +1 -1
  20. package/dist/launch-all-cmd.d.ts.map +1 -1
  21. package/dist/launch-all-cmd.js +13 -7
  22. package/dist/launch-all-cmd.js.map +1 -1
  23. package/dist/log-stream.d.ts +0 -2
  24. package/dist/log-stream.d.ts.map +1 -1
  25. package/dist/log-stream.js +3 -30
  26. package/dist/log-stream.js.map +1 -1
  27. package/dist/remote-client.d.ts.map +1 -1
  28. package/dist/remote-client.js +8 -23
  29. package/dist/remote-client.js.map +1 -1
  30. package/dist/seat-probe.d.ts +4 -4
  31. package/dist/seat-probe.d.ts.map +1 -1
  32. package/dist/seat-probe.js +2 -0
  33. package/dist/seat-probe.js.map +1 -1
  34. package/dist/seats.d.ts +0 -74
  35. package/dist/seats.d.ts.map +1 -1
  36. package/dist/seats.js +31 -131
  37. package/dist/seats.js.map +1 -1
  38. package/dist/server-errors.d.ts +6 -1
  39. package/dist/server-errors.d.ts.map +1 -1
  40. package/dist/server-errors.js +9 -0
  41. package/dist/server-errors.js.map +1 -1
  42. package/dist/server-facade.d.ts +1 -1
  43. package/dist/server-facade.d.ts.map +1 -1
  44. package/dist/server-facade.js +2 -2
  45. package/dist/server-facade.js.map +1 -1
  46. package/dist/server-handshake.d.ts +0 -2
  47. package/dist/server-handshake.d.ts.map +1 -1
  48. package/dist/server-handshake.js +0 -5
  49. package/dist/server-handshake.js.map +1 -1
  50. package/docs/EXTRACTION_PROVENANCE.md +5 -5
  51. package/docs/LOCAL_SERVER.md +7 -3
  52. package/docs/RELEASING.md +10 -1
  53. package/package.json +1 -1
  54. package/src/assimilate-cmd.ts +54 -51
  55. package/src/assimilate-deps.ts +0 -1
  56. package/src/cleanup-cmd.ts +5 -2
  57. package/src/cli-help.ts +2 -1
  58. package/src/cubes.ts +0 -4
  59. package/src/launch-all-cmd.ts +15 -7
  60. package/src/log-stream.ts +2 -30
  61. package/src/remote-client.ts +10 -22
  62. package/src/seat-probe.ts +5 -3
  63. package/src/seats.ts +35 -186
  64. package/src/server-errors.ts +9 -2
  65. package/src/server-facade.ts +2 -2
  66. package/src/server-handshake.ts +1 -10
  67. package/dist/session-continuity.d.ts +0 -33
  68. package/dist/session-continuity.d.ts.map +0 -1
  69. package/dist/session-continuity.js +0 -220
  70. package/dist/session-continuity.js.map +0 -1
  71. package/src/session-continuity.ts +0 -280
@@ -19,9 +19,9 @@ The extraction copied the monorepo's `client/src/` production boundary and top-l
19
19
  repository-idempotent cube creation required for local dogfood.
20
20
  - Initially set the standalone package identity to `2.0.0`. Its immutable
21
21
  lightweight release tag failed before packaging. The reviewed `2.0.1`
22
- recovery and `2.0.2` successor were published and registry-verified; the next
23
- candidate identity is `2.0.3`. Extraction and versioning do not authorize
24
- publication.
22
+ recovery, `2.0.2`, and `2.0.3` successors were published and registry-verified;
23
+ the next candidate identity is `2.0.4`. Extraction and versioning do not
24
+ authorize publication.
25
25
 
26
26
  ## Review Holds
27
27
 
@@ -38,6 +38,6 @@ contract, with a pre-request `PENDING` record in the local 0600-permission seat
38
38
  store, exact-tuple ambiguous retry, and verified activation. The contract now
39
39
  resolves to the audited registry `borgmcp-shared@0.4.3`. The matching
40
40
  `borgmcp-server@0.1.8` release is published and registry-verified. Client
41
- `borgmcp@2.0.2` is published and registry-verified. Publication of the next
42
- candidate remains gated by reviewed `v2.0.3` source, a fresh annotated tag, and
41
+ `borgmcp@2.0.3` is published and registry-verified. Publication of the next
42
+ candidate remains gated by reviewed `v2.0.4` source, a fresh annotated tag, and
43
43
  exact registry integrity and signature verification.
@@ -28,6 +28,7 @@ Usage: borg server <command> [arguments]
28
28
  Commands:
29
29
  setup Prepare local server identity and data; does not start the server.
30
30
  start Start the verified server in the foreground.
31
+ stop Stop the managed local server.
31
32
  status Report verified runtime evidence.
32
33
  update Verify and activate a local server artifact.
33
34
  invite Create a single-use invitation in an interactive terminal.
@@ -119,9 +120,12 @@ The default discovery endpoint is `https://127.0.0.1:7091`. Explicit `--host` va
119
120
  - Rejected or expired invitation: keep the server running and mint a replacement
120
121
  invitation with `borg server invite`, then rerun `borg assimilate --host
121
122
  <server> --enroll` with the replacement invitation.
122
- - Rejected or unloadable local seat: run `borg reset-local-seat --host <server>`
123
- to clear ONLY this worktree's saved local seat, then rerun `borg assimilate
124
- --host <server>` with the server still running.
123
+ - Revoked or superseded local session: run `borg reset-local-seat`, ask the
124
+ operator for a new invitation from `borg server invite`, then run `borg
125
+ assimilate --host <server> --enroll` with the server still running.
126
+ - Unloadable local seat: run `borg reset-local-seat --host <server>` to clear
127
+ ONLY this worktree's saved local seat, then rerun `borg assimilate --host
128
+ <server>` with the server still running.
125
129
  - Unreachable server: start or restart it with `borg server start`, then
126
130
  rerun `borg assimilate --host <server>`.
127
131
  - Trust mismatch after an intentional server re-initialization: verify the
package/docs/RELEASING.md CHANGED
@@ -29,7 +29,16 @@ successfully published that exact source as `borgmcp@2.0.2`; the registry record
29
29
  integrity
30
30
  `sha512-w0O0t/2wzeJpXEqb4jQGuqZeavEkfFKdz6poRT8q1TxDKHTlOlwo3auctE+X1kWU2s5JkSDcUiUdQdyLwu84IA==`.
31
31
  Never move, replace, reuse, or rerun that tag or workflow. The next candidate
32
- uses the unused `v2.0.3` identity from a fresh reviewed protected-main commit
32
+ must use a fresh unused identity and requires the complete release gate again.
33
+
34
+ The annotated `v2.0.3` tag object
35
+ `47701127cbdba6499595ef0529de264b00a61840` peels to protected-main commit
36
+ `3e44e9c439c036850c6a9226d02876f4883501d9`. Workflow run `29851751102`
37
+ successfully published that exact source as `borgmcp@2.0.3`; the registry records
38
+ integrity
39
+ `sha512-3Iy4BSB+yq8F75w/gWiEU7D+mKCYYa5mIsn/iC/byy5CK/QsAWg10KgsBI3GlDBZul1CD3Qq+zpmVeK2qpIE1A==`.
40
+ Never move, replace, reuse, or rerun that tag or workflow. The next candidate
41
+ uses the unused `v2.0.4` identity from a fresh reviewed protected-main commit
33
42
  and requires the complete release gate again.
34
43
 
35
44
  ## Release Prerequisites
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "borgmcp",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "Coordinate AI coding agents in shared cubes. Works with Claude Code, Codex, and OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -42,7 +42,7 @@ import { installBorgPlugin } from './opencode-plugin.js';
42
42
  import { computeOpenCodePort, connectOpenCodeDrone, createOpenCodeLaunchKickoff, injectInitialKickoff } from './opencode-drone.js';
43
43
  import { ensureCliMcpConfigured } from './ensure-mcp-config.js';
44
44
  import { normalizeServerEndpoint } from './server-endpoint.js';
45
- import { BorgServerError } from './server-errors.js';
45
+ import { BorgServerError, LegacySessionCredentialCollisionError } from './server-errors.js';
46
46
  import type { SeatStatus } from './seat-probe.js';
47
47
  import type { ServerSessionOperation } from './config.js';
48
48
  import type { ExpectedBinding, FinalizeServerSeatOutcome, PersistedLocalSeat } from './cubes.js';
@@ -92,7 +92,6 @@ export interface AssimilateResult {
92
92
  role_id: string;
93
93
  local_session?: {
94
94
  credential_ref: string;
95
- expires_at: string | null;
96
95
  };
97
96
  // Idempotent-reattach discriminant: 'reused' when the server resolved the
98
97
  // client bearer to an existing seat, 'created' on a first/fresh attach.
@@ -126,7 +125,6 @@ export interface ActiveCube {
126
125
  /** Verified local-server CA identity; absent until a local server is selected. */
127
126
  serverTrustIdentity?: string;
128
127
  localSessionCredentialRef?: string;
129
- localSessionExpiresAt?: string | null;
130
128
  // gh#899: assimilated role, persisted for connect-time tool-surface scoping
131
129
  // (mirrors cubes.ts ActiveCube; optional → backward-compatible).
132
130
  roleName?: string;
@@ -388,25 +386,25 @@ function reportServerFailure(
388
386
  );
389
387
  return 1;
390
388
  }
389
+ if (error instanceof LegacySessionCredentialCollisionError) {
390
+ deps.stderr(
391
+ `Local session credential collision detected.\n` +
392
+ `No local credentials were changed.\n` +
393
+ `Next: run borg assimilate --host ${error.origin} --enroll.\n`,
394
+ );
395
+ return 1;
396
+ }
391
397
  // A pin-matched typed 401: the server verified its own identity but rejected
392
- // THIS worktree's session bearer (revoked, or taken over by another session).
398
+ // THIS worktree's session bearer with an explicit terminal outcome.
393
399
  // Distinct from a protocol/version mismatch and from a rejected enrollment:
394
400
  // only this worktree's saved local seat is affected, and recovery is scoped to
395
401
  // this worktree — no server/trust-anchor/cube/other-worktree reset, no restart
396
402
  // or version-alignment advice (#1082).
403
+ if (error instanceof BorgServerError && error.code === 'SESSION_REVOKED') {
404
+ return diagnoseSessionTermination(deps, apiUrl, 'revoked');
405
+ }
397
406
  if (error instanceof BorgServerError && error.code === 'SESSION_REJECTED') {
398
- // Attach is PURE DIAGNOSIS on a rejection — it mutates nothing. This copy
399
- // claims NO mutation of its own; it points at the dedicated OFFLINE
400
- // `borg reset-local-seat` command (the only writer that clears a seat).
401
- deps.stderr(
402
- `This worktree's saved local seat on ${apiUrl} is no longer accepted — it was ` +
403
- 'revoked or taken over by another session. No Borg state changed. Run ' +
404
- `${resetLocalSeatCommand(apiUrl)} to clear ONLY this worktree's saved seat ` +
405
- '(offline; add `--yes` when non-interactive), then ask the server operator for a new ' +
406
- 'invitation — the server can stay running — and re-enroll with ' +
407
- `${localAssimilateCommand(apiUrl, true)}.\n`,
408
- );
409
- return 1;
407
+ return diagnoseSessionTermination(deps, apiUrl, 'superseded');
410
408
  }
411
409
  if (error instanceof BorgServerError && error.code === 'INVITATION_REJECTED') {
412
410
  deps.stderr(
@@ -482,34 +480,19 @@ function resetLocalSeatCommand(apiUrl: string): string {
482
480
  return `\`borg reset-local-seat --host ${apiUrl}\``;
483
481
  }
484
482
 
485
- /**
486
- * Pin-matched SESSION_REJECTED diagnosis (#1082). PURE DIAGNOSIS attach makes
487
- * ZERO local mutation on a rejected seat (ratified client-seat-reset-state-model
488
- * clause 1). The local server verified its OWN pinned identity (this is reached
489
- * only after a successful pinned-TLS attach — a pin MISMATCH throws a distinct
490
- * trust error and never lands here), then rejected THIS worktree's saved session
491
- * bearer (revoked, or taken over by another session). We stop and recommend the
492
- * dedicated OFFLINE `borg reset-local-seat` command, which is the ONLY writer
493
- * that clears a worktree's saved seat. Nothing here contacts the server further,
494
- * emits unselected egress, or touches local state. The recovery is
495
- * live-safe: the operator is never told to stop/restart the server.
496
- */
497
- function diagnoseSessionRejected(
483
+ // Pin-matched terminal session diagnosis. This is intentionally output-only:
484
+ // only the explicit offline reset command may clear the saved local seat.
485
+ function diagnoseSessionTermination(
498
486
  deps: AssimilateDeps,
499
487
  apiUrl: string,
488
+ outcome: 'revoked' | 'superseded',
500
489
  ): number {
501
- const worktree = deps.findProjectRoot(deps.cwd());
502
- deps.stderr(
503
- `This worktree's saved local seat on ${apiUrl} is no longer accepted — it was ` +
504
- 'revoked or taken over by another session. No Borg state was changed: your pinned ' +
505
- 'server trust, this and every other worktree, and their cubes are all untouched.\n',
506
- );
490
+ const message = outcome === 'revoked'
491
+ ? 'Local session was revoked.'
492
+ : 'Local session was superseded by a newer enrollment.';
507
493
  deps.stderr(
508
- `To clear ONLY this worktree's saved local seat (worktree ${worktree}), run ` +
509
- `${resetLocalSeatCommand(apiUrl)} (add \`--yes\` when non-interactive). That command is ` +
510
- 'offline — it revokes nothing server-side. Then ask the server operator for a new ' +
511
- 'invitation (the server can stay running) and re-enroll with ' +
512
- `${localAssimilateCommand(apiUrl, true)}.\n`,
494
+ `${message}\n` +
495
+ `Next: run borg reset-local-seat, then borg assimilate --host ${apiUrl} --enroll.\n`,
513
496
  );
514
497
  return 1;
515
498
  }
@@ -534,9 +517,28 @@ export async function runAssimilate(
534
517
  }
535
518
  }
536
519
 
520
+ // Read local seat state before authority discovery, which may probe the local
521
+ // server. A retired replacement collision must not send either saved bearer or
522
+ // perform any other network request.
523
+ let existing: ActiveCube | null = null;
524
+ let hasPersistedIdentity = false;
525
+ let localSeatReadError: unknown;
526
+ try {
527
+ existing = await deps.getActiveCube();
528
+ hasPersistedIdentity = existing !== null || await deps.hasPersistedActiveCube();
529
+ } catch (error) {
530
+ if (error instanceof LegacySessionCredentialCollisionError) {
531
+ return reportServerFailure(deps, error.origin, error);
532
+ }
533
+ localSeatReadError = error;
534
+ }
535
+
537
536
  // ----- Step 1: Select and authenticate the local server -----
538
537
  const authority = await selectAssimilationAuthority(args.flags, deps);
539
538
  if (!authority) return 1;
539
+ if (localSeatReadError !== undefined) {
540
+ return reportServerFailure(deps, authority.apiUrl, localSeatReadError);
541
+ }
540
542
 
541
543
  // ----- Repository + cube-name preflight -----
542
544
  // Resolve and, where necessary, confirm local presentation data before an
@@ -766,8 +768,6 @@ export async function runAssimilate(
766
768
  // retain its original role so the attach request reuses the exact durable
767
769
  // retry binding instead of selecting another unoccupied role and minting a
768
770
  // duplicate seat.
769
- const existing = await deps.getActiveCube();
770
- const hasPersistedIdentity = existing !== null || await deps.hasPersistedActiveCube();
771
771
  const wantSibling =
772
772
  args.flags.worktree !== undefined || (existing !== null && !args.flags.here);
773
773
  // `let`: the bound-pending resume path (CR#2) OVERRIDES this from the stored
@@ -943,8 +943,11 @@ export async function runAssimilate(
943
943
  // rejection; it points at the offline `borg reset-local-seat` command.
944
944
  // Distinct from unreachable/404/5xx/trust-mismatch, which stay
945
945
  // indeterminate below.
946
+ if (status === 'revoked') {
947
+ return diagnoseSessionTermination(deps, auth.apiUrl, 'revoked');
948
+ }
946
949
  if (status === 'rejected') {
947
- return diagnoseSessionRejected(deps, auth.apiUrl);
950
+ return diagnoseSessionTermination(deps, auth.apiUrl, 'superseded');
948
951
  }
949
952
  // CR #6: distinct causes get cause-accurate, non-destructive recovery —
950
953
  // never the generic "restart the server" advice.
@@ -1192,17 +1195,18 @@ export async function runAssimilate(
1192
1195
  );
1193
1196
  return 1;
1194
1197
  }
1195
- // Pin-matched SESSION_REJECTED (revoked / taken-over seat): PURE DIAGNOSIS.
1198
+ // Pin-matched terminal session outcomes are pure diagnosis.
1196
1199
  // Reached only after a successful pinned-TLS attach, so it is pin-matched by
1197
1200
  // construction — a pin mismatch throws a distinct trust error and never
1198
1201
  // enters this branch. Attach mutates NOTHING; it recommends the offline
1199
1202
  // `borg reset-local-seat` command.
1200
- if (
1201
- err instanceof BorgServerError &&
1202
- err.code === 'SESSION_REJECTED' &&
1203
- reattachPriorId != null
1204
- ) {
1205
- return diagnoseSessionRejected(deps, authority.apiUrl);
1203
+ if (err instanceof BorgServerError && reattachPriorId != null) {
1204
+ if (err.code === 'SESSION_REVOKED') {
1205
+ return diagnoseSessionTermination(deps, authority.apiUrl, 'revoked');
1206
+ }
1207
+ if (err.code === 'SESSION_REJECTED') {
1208
+ return diagnoseSessionTermination(deps, authority.apiUrl, 'superseded');
1209
+ }
1206
1210
  }
1207
1211
  if (authority.kind === 'server') {
1208
1212
  return reportServerFailure(deps, authority.apiUrl, err);
@@ -1389,7 +1393,6 @@ export async function runAssimilate(
1389
1393
  apiUrl: auth.apiUrl,
1390
1394
  serverTrustIdentity: auth.serverTrustIdentity,
1391
1395
  localSessionCredentialRef: result.local_session!.credential_ref,
1392
- localSessionExpiresAt: result.local_session!.expires_at,
1393
1396
  // gh#899: persist the assimilated role so the connect-time ListTools
1394
1397
  // handler can role-scope the native tool surface.
1395
1398
  roleName: assignedRole.name,
@@ -352,7 +352,6 @@ export function buildDefaultAssimilateDeps(): AssimilateDeps {
352
352
  result: prepared.result,
353
353
  local_session: {
354
354
  credential_ref: prepared.credentialRef,
355
- expires_at: prepared.session.expiresAt,
356
355
  },
357
356
  // Handles for the cube-lock-held FINALIZE (assimilate-cmd Step 8).
358
357
  finalize: {
@@ -69,7 +69,8 @@ export type CleanupReason =
69
69
  | 'SURVIVES-clobber'
70
70
  | 'SURVIVES-unmerged'
71
71
  | 'SURVIVES-detached'
72
- | 'SURVIVES-rejected'
72
+ | 'SURVIVES-revoked'
73
+ | 'SURVIVES-superseded'
73
74
  | 'SURVIVES-live'
74
75
  | 'SURVIVES-self'
75
76
  | 'UNKNOWN-indeterminate'
@@ -345,7 +346,9 @@ async function classifyWorktree(
345
346
  case 'evicted':
346
347
  return { reason: 'PRUNABLE', detail: '410 DRONE_EVICTED (clean + merged)' };
347
348
  case 'rejected':
348
- return { reason: 'SURVIVES-rejected', detail: 'pin-matched 401 (revoked/taken over) — recoverable via re-enroll, never delete' };
349
+ return { reason: 'SURVIVES-superseded', detail: 'session superseded by a newer enrollment — recoverable via re-enroll, never delete' };
350
+ case 'revoked':
351
+ return { reason: 'SURVIVES-revoked', detail: 'session revoked — recoverable via re-enroll, never delete' };
349
352
  case 'live':
350
353
  return { reason: 'SURVIVES-live', detail: 'seat resolves (drone alive)' };
351
354
  case 'indeterminate':
package/src/cli-help.ts CHANGED
@@ -50,6 +50,7 @@ export function serverHelpText(): string {
50
50
  `Commands:\n` +
51
51
  ` setup Prepare local server identity and data; does not start the server.\n` +
52
52
  ` start Start the verified server in the foreground.\n` +
53
+ ` stop Stop the managed local server.\n` +
53
54
  ` status Report verified runtime evidence.\n` +
54
55
  ` update Verify and activate a local server artifact.\n` +
55
56
  ` invite Create a single-use invitation in an interactive terminal.\n\n` +
@@ -102,7 +103,7 @@ export function resetLocalSeatHelpText(version: string): string {
102
103
  `just this worktree's saved local seat — its credential and cube binding together — from the\n` +
103
104
  `local seat store on this machine. Server, trust anchor, cube, and every sibling worktree are\n` +
104
105
  `left untouched.\n\n` +
105
- `Use it after \`borg assimilate\` reports this worktree's seat was revoked or taken over\n` +
106
+ `Use it after \`borg assimilate\` reports this worktree's session was revoked or superseded\n` +
106
107
  `(a pin-matched rejection), then ask the operator for a new invitation and re-enroll.\n\n` +
107
108
  `Usage:\n` +
108
109
  ` borg reset-local-seat Reset this worktree's saved seat (TTY confirms [y/N])\n` +
package/src/cubes.ts CHANGED
@@ -62,7 +62,6 @@ export interface ActiveCube {
62
62
  serverTrustIdentity?: string;
63
63
  /** Opaque local-session keychain reference; never a bearer. */
64
64
  localSessionCredentialRef?: string;
65
- localSessionExpiresAt?: string | null;
66
65
  // gh#899: the assimilated role, persisted so the connect-time ListTools
67
66
  // handler can role-scope the NATIVE tool surface (UX/context only — never an
68
67
  // auth boundary). Absent on pre-gh#899 cubes.json entries → the filter
@@ -280,7 +279,6 @@ async function hydrateActiveCube(record: SeatRecord): Promise<ActiveCube | null>
280
279
  apiUrl: record.origin,
281
280
  serverTrustIdentity: record.trustIdentity,
282
281
  localSessionCredentialRef: ref,
283
- ...(record.expiresAt !== undefined ? { localSessionExpiresAt: record.expiresAt } : {}),
284
282
  ...(record.roleName !== undefined ? { roleName: record.roleName } : {}),
285
283
  ...(record.roleClass !== undefined ? { roleClass: record.roleClass } : {}),
286
284
  ...(record.isHumanSeat !== undefined ? { isHumanSeat: record.isHumanSeat } : {}),
@@ -392,7 +390,6 @@ export interface PersistedLocalSeat {
392
390
  apiUrl: string;
393
391
  serverTrustIdentity: string;
394
392
  localSessionCredentialRef: string;
395
- localSessionExpiresAt?: string | null;
396
393
  roleName?: string;
397
394
  roleClass?: 'queen' | 'worker';
398
395
  isHumanSeat?: boolean;
@@ -432,7 +429,6 @@ export async function readPersistedLocalSeat(): Promise<PersistedLocalSeat | nul
432
429
  localSessionCredentialRef: seatRef(record),
433
430
  operation: record.operation,
434
431
  state: record.state,
435
- ...(record.expiresAt !== undefined ? { localSessionExpiresAt: record.expiresAt } : {}),
436
432
  ...(record.roleName !== undefined ? { roleName: record.roleName } : {}),
437
433
  ...(record.roleClass !== undefined ? { roleClass: record.roleClass } : {}),
438
434
  ...(record.isHumanSeat !== undefined ? { isHumanSeat: record.isHumanSeat } : {}),
@@ -276,6 +276,7 @@ export async function runLaunchAll(
276
276
  // LOCK-live (seemingly-running) session.
277
277
  const launchable: DroneCandidate[] = [];
278
278
  let evictedCount = 0;
279
+ let revokedCount = 0;
279
280
  let rejectedCount = 0;
280
281
  let trustMismatchCount = 0;
281
282
  let credentialRejectedCount = 0;
@@ -294,15 +295,19 @@ export async function runLaunchAll(
294
295
  );
295
296
  continue;
296
297
  }
298
+ if (status === 'revoked') {
299
+ revokedCount += 1;
300
+ deps.stderr(
301
+ `Local session was revoked.\n` +
302
+ `Next: run borg reset-local-seat, then borg assimilate --host ${c.apiUrl} --enroll.\n`
303
+ );
304
+ continue;
305
+ }
297
306
  if (status === 'rejected') {
298
307
  rejectedCount += 1;
299
308
  deps.stderr(
300
- `skipping ${c.droneLabel} (${c.worktreeDir}): saved seat no longer accepted (revoked or ` +
301
- `taken over). Recover from that worktree: ` +
302
- `1) \`borg reset-local-seat --host ${c.apiUrl}\` (add --yes non-interactively) to clear ` +
303
- `ONLY that worktree's saved seat; ` +
304
- `2) ask the server operator to mint a fresh scoped invitation (the server stays running); ` +
305
- `3) \`borg assimilate --host ${c.apiUrl} --enroll\`.\n`
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`
306
311
  );
307
312
  continue;
308
313
  }
@@ -355,8 +360,11 @@ export async function runLaunchAll(
355
360
  if (evictedCount > 0) {
356
361
  causes.push(`${evictedCount} evicted`);
357
362
  }
363
+ if (revokedCount > 0) {
364
+ causes.push(`${revokedCount} revoked`);
365
+ }
358
366
  if (rejectedCount > 0) {
359
- causes.push(`${rejectedCount} with a saved seat no longer accepted (revoked/taken over)`);
367
+ causes.push(`${rejectedCount} superseded`);
360
368
  }
361
369
  if (trustMismatchCount > 0) {
362
370
  causes.push(`${trustMismatchCount} with a changed (terminal) server identity`);
package/src/log-stream.ts CHANGED
@@ -54,8 +54,7 @@ import {
54
54
  wakeCodexViaAppServer,
55
55
  } from './codex-app-wake.js';
56
56
  import { readBoundedResponseBody } from './server-response.js';
57
- import { BorgServerError, BorgServerUnreachableError } from './server-errors.js';
58
- import { recoverExpiredLocalSession } from './session-continuity.js';
57
+ import { BorgServerError } from './server-errors.js';
59
58
  import {
60
59
  acquireStreamLease,
61
60
  readOwnershipSnapshot,
@@ -428,7 +427,6 @@ export interface RunLoopTestDeps {
428
427
  getActiveCube?: typeof getActiveCube;
429
428
  acquireStreamLease?: typeof acquireStreamLease;
430
429
  streamOnce?: typeof streamOnce;
431
- recoverExpiredSession?: typeof recoverExpiredLocalSession;
432
430
  sleep?: (ms: number) => Promise<void>;
433
431
  maxIterations?: number;
434
432
  }
@@ -437,7 +435,6 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
437
435
  const _getActiveCube = testDeps.getActiveCube ?? getActiveCube;
438
436
  const _acquireStreamLease = testDeps.acquireStreamLease ?? acquireStreamLease;
439
437
  const _streamOnce = testDeps.streamOnce ?? streamOnce;
440
- const _recoverExpiredSession = testDeps.recoverExpiredSession ?? recoverExpiredLocalSession;
441
438
  const _sleep = testDeps.sleep ?? sleep;
442
439
  const _maxIterations = testDeps.maxIterations ?? Infinity;
443
440
  let _iterations = 0;
@@ -447,7 +444,6 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
447
444
  let currentCubeId: string | null = null;
448
445
  let lease: StreamLease | null = null;
449
446
  let leaseKey: string | null = null;
450
- let lastRecoveredToken: string | null = null;
451
447
 
452
448
  try {
453
449
  while (_iterations < _maxIterations) {
@@ -548,7 +544,6 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
548
544
  }
549
545
  // Clean disconnect (e.g. server-side rollout). Reset backoff.
550
546
  attempt = 0;
551
- lastRecoveredToken = null;
552
547
  streamState.reconnectAttempts = 0;
553
548
  } catch (err: any) {
554
549
  if (ownerLost) {
@@ -576,27 +571,7 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
576
571
  );
577
572
  throw new TerminalStreamError();
578
573
  }
579
- if (err instanceof BorgServerError) {
580
- if (err.code !== 'AUTH_EXPIRED' || lastRecoveredToken === active.sessionToken) {
581
- throw new TerminalStreamError();
582
- }
583
- try {
584
- const renewed = await _recoverExpiredSession(active);
585
- lastRecoveredToken = renewed.sessionToken;
586
- attempt = 0;
587
- streamState.reconnectAttempts = 0;
588
- continue;
589
- } catch (recoveryError) {
590
- if (!(recoveryError instanceof BorgServerUnreachableError)) {
591
- throw new TerminalStreamError();
592
- }
593
- // The replacement bearer is durable and intentionally survives an
594
- // ambiguous transport failure. Fall through to the ordinary bounded
595
- // reconnect backoff; the next AUTH_EXPIRED response resumes that exact
596
- // replacement instead of permanently silencing the stream.
597
- err = recoveryError;
598
- }
599
- }
574
+ if (err instanceof BorgServerError) throw new TerminalStreamError();
600
575
  streamState.connected = false;
601
576
  const delay =
602
577
  Math.min(RECONNECT_MIN_MS * 2 ** attempt, RECONNECT_MAX_MS) +
@@ -907,9 +882,6 @@ export async function streamOnce(
907
882
  } catch {
908
883
  code = undefined;
909
884
  }
910
- if (code === ErrorCode.AUTH_EXPIRED) {
911
- throw new BorgServerError('AUTH_EXPIRED', 'Borg server session expired');
912
- }
913
885
  if (code === ErrorCode.SESSION_REJECTED) {
914
886
  throw new BorgServerError('SESSION_REJECTED', 'Borg server session was rejected');
915
887
  }
@@ -47,7 +47,6 @@ import {
47
47
  } from './local-server-cursor.js';
48
48
  import { readBoundedResponseBody } from './server-response.js';
49
49
  import { resolveLocalLogRecipients } from './local-log-routing.js';
50
- import { ensureLocalSessionFresh, recoverExpiredLocalSession } from './session-continuity.js';
51
50
 
52
51
  export interface RemoteConnection {
53
52
  apiUrl: string;
@@ -229,15 +228,12 @@ async function localServerRequest<T>(
229
228
  method: 'GET' | 'POST' | 'PUT' | 'PATCH',
230
229
  payload?: Record<string, unknown>,
231
230
  ): Promise<T | null> {
232
- let current = active.localSessionCredentialRef && active.localSessionExpiresAt
233
- ? await ensureLocalSessionFresh(active)
234
- : active;
235
- const request = (candidate: ActiveCube) => decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
231
+ return decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
236
232
  method,
237
233
  signal,
238
- droneSession: candidate.sessionToken,
239
- apiUrl: candidate.apiUrl,
240
- serverTrustIdentity: candidate.serverTrustIdentity,
234
+ droneSession: active.sessionToken,
235
+ apiUrl: active.apiUrl,
236
+ serverTrustIdentity: active.serverTrustIdentity,
241
237
  redirect: 'error',
242
238
  ...(payload === undefined
243
239
  ? { headers: { Accept: 'application/json' } }
@@ -246,14 +242,6 @@ async function localServerRequest<T>(
246
242
  body: JSON.stringify(createProtocolEnvelope(randomUUID(), payload)),
247
243
  }),
248
244
  }), true);
249
- try {
250
- return await request(current);
251
- } catch (error) {
252
- if (!(error instanceof BorgServerError) || error.code !== 'AUTH_EXPIRED') throw error;
253
- if (current.sessionToken !== active.sessionToken) throw error;
254
- current = await recoverExpiredLocalSession(current);
255
- return request(current);
256
- }
257
245
  }
258
246
 
259
247
  export interface LocalManageOperation {
@@ -621,16 +609,16 @@ async function authedFetch(
621
609
  } catch {
622
610
  rejectedCode = undefined;
623
611
  }
624
- if (droneSession !== undefined && rejectedCode === ErrorCode.AUTH_EXPIRED) {
612
+ if (droneSession !== undefined && rejectedCode === ErrorCode.SESSION_REJECTED) {
625
613
  throw new BorgServerError(
626
- 'AUTH_EXPIRED',
627
- 'the selected Borg server session expired',
614
+ 'SESSION_REJECTED',
615
+ 'the selected Borg server superseded this worktree session with a newer enrollment',
628
616
  );
629
617
  }
630
- if (droneSession !== undefined && rejectedCode === ErrorCode.SESSION_REJECTED) {
618
+ if (droneSession !== undefined && rejectedCode === ErrorCode.SESSION_REVOKED) {
631
619
  throw new BorgServerError(
632
- 'SESSION_REJECTED',
633
- 'the selected Borg server rejected this worktree session (revoked or taken over)',
620
+ 'SESSION_REVOKED',
621
+ 'the selected Borg server revoked this worktree session',
634
622
  );
635
623
  }
636
624
  throw new BorgServerError(
package/src/seat-probe.ts CHANGED
@@ -19,9 +19,9 @@ import {
19
19
  * preserved (CR #6 / CR5 — the probe must NOT collapse them). Each verdict is
20
20
  * derived from the actual error TYPE/CODE, never from a mutable error-text regex:
21
21
  * evicted ← 410 DRONE_EVICTED (terminal; the SOLE delete authority — gh#882 S1)
22
- * rejected ← pin-matched drone-SESSION 401 carrying the EXACT typed
23
- * SESSION_REJECTED code (revoked / taken over; recover via
24
- * the offline `borg reset-local-seat` — never here)
22
+ * revoked ← pin-matched drone-SESSION 401 carrying SESSION_REVOKED
23
+ * rejected ← pin-matched drone-SESSION 401 carrying SESSION_REJECTED
24
+ * (superseded by a newer enrollment)
25
25
  * credential-rejected← any OTHER 401 on the drone session (bare/untyped or a
26
26
  * non-SESSION typed code): the saved credential is no longer
27
27
  * accepted, but this is NON-DESTRUCTIVE — re-enroll, NEVER a
@@ -40,6 +40,7 @@ import {
40
40
  */
41
41
  export type SeatStatus =
42
42
  | 'evicted'
43
+ | 'revoked'
43
44
  | 'rejected'
44
45
  | 'live'
45
46
  | 'credential-rejected'
@@ -99,6 +100,7 @@ export async function defaultProbeSeat(
99
100
  // the error TYPE, never from message text (the security boundary).
100
101
  if (err instanceof BorgServerTrustError) return 'trust-mismatch';
101
102
  if (err instanceof BorgServerError) {
103
+ if (err.code === 'SESSION_REVOKED') return 'revoked';
102
104
  if (err.code === 'SESSION_REJECTED') return 'rejected';
103
105
  // Every non-SESSION 401 (bare/untyped or a different typed code) is a
104
106
  // credential rejection — non-destructive, distinct from a takeover.