borgmcp 2.0.1 → 2.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 (41) hide show
  1. package/README.md +2 -2
  2. package/dist/codex-app-wake.d.ts +9 -11
  3. package/dist/codex-app-wake.d.ts.map +1 -1
  4. package/dist/codex-app-wake.js +15 -11
  5. package/dist/codex-app-wake.js.map +1 -1
  6. package/dist/codex-wake-resolve.d.ts +4 -4
  7. package/dist/codex-wake-resolve.js +4 -4
  8. package/dist/log-stream.d.ts +4 -0
  9. package/dist/log-stream.d.ts.map +1 -1
  10. package/dist/log-stream.js +58 -2
  11. package/dist/log-stream.js.map +1 -1
  12. package/dist/remote-client.d.ts +25 -0
  13. package/dist/remote-client.d.ts.map +1 -1
  14. package/dist/remote-client.js +59 -4
  15. package/dist/remote-client.js.map +1 -1
  16. package/dist/seats.d.ts +72 -0
  17. package/dist/seats.d.ts.map +1 -1
  18. package/dist/seats.js +119 -0
  19. package/dist/seats.js.map +1 -1
  20. package/dist/server-errors.d.ts +1 -1
  21. package/dist/server-errors.d.ts.map +1 -1
  22. package/dist/server-errors.js.map +1 -1
  23. package/dist/server-handshake.d.ts.map +1 -1
  24. package/dist/server-handshake.js +49 -21
  25. package/dist/server-handshake.js.map +1 -1
  26. package/dist/session-continuity.d.ts +33 -0
  27. package/dist/session-continuity.d.ts.map +1 -0
  28. package/dist/session-continuity.js +220 -0
  29. package/dist/session-continuity.js.map +1 -0
  30. package/docs/EXTRACTION_PROVENANCE.md +9 -7
  31. package/docs/LOCAL_SERVER.md +1 -1
  32. package/docs/RELEASING.md +13 -4
  33. package/package.json +2 -2
  34. package/src/codex-app-wake.ts +23 -11
  35. package/src/codex-wake-resolve.ts +4 -4
  36. package/src/log-stream.ts +64 -2
  37. package/src/remote-client.ts +92 -14
  38. package/src/seats.ts +170 -0
  39. package/src/server-errors.ts +3 -1
  40. package/src/server-handshake.ts +54 -21
  41. package/src/session-continuity.ts +280 -0
@@ -8,7 +8,7 @@ The extraction copied the monorepo's `client/src/` production boundary and top-l
8
8
 
9
9
  ## Deliberate Transformations
10
10
 
11
- - Replaced the monorepo dependency on `borgmcp-shared` with the exact audited registry release `borgmcp-shared@0.4.2` and a fresh standalone lockfile.
11
+ - Replaced the monorepo dependency on `borgmcp-shared` with the exact audited registry release `borgmcp-shared@0.4.3` and a fresh standalone lockfile.
12
12
  - Replaced local template, role-section, drone-address, and log high-water-mark implementations with `borgmcp-shared` exports.
13
13
  - Removed monorepo-only website anti-drift tests and re-anchored remaining filesystem tests to this repository.
14
14
  - Removed consumer lifecycle hooks, parent-directory deployment scripts, minification, and private integration-environment configuration.
@@ -18,8 +18,9 @@ The extraction copied the monorepo's `client/src/` production boundary and top-l
18
18
  client-generated PENDING credential/retry tuple and capability-gated,
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
- lightweight release tag failed before packaging, so the reviewed recovery
22
- identity is `2.0.1`; extraction and versioning do not authorize publication.
21
+ lightweight release tag failed before packaging. The reviewed `2.0.1`
22
+ recovery was published and registry-verified; the next candidate identity is
23
+ `2.0.2`. Extraction and versioning do not authorize publication.
23
24
 
24
25
  ## Review Holds
25
26
 
@@ -34,7 +35,8 @@ artifact reaches no hosted authority.
34
35
  Local enrollment now uses the reviewed client-generated credential/retry
35
36
  contract, with a pre-request `PENDING` record in the local 0600-permission seat
36
37
  store, exact-tuple ambiguous retry, and verified activation. The contract now
37
- resolves to the audited registry `borgmcp-shared@0.4.2`. The matching
38
- `borgmcp-server@0.1.7` release is published and registry-verified; client
39
- publication remains gated by reviewed `v2.0.1` source, a fresh annotated tag,
40
- and exact registry integrity and signature verification.
38
+ resolves to the audited registry `borgmcp-shared@0.4.3`. The matching
39
+ `borgmcp-server@0.1.7` release is published and registry-verified. Client
40
+ `borgmcp@2.0.1` is published and registry-verified. Publication of the next
41
+ candidate remains gated by reviewed `v2.0.2` source, a fresh annotated tag, and
42
+ exact registry integrity and signature verification.
@@ -96,7 +96,7 @@ The default discovery endpoint is `https://127.0.0.1:7091`. Explicit `--host` va
96
96
 
97
97
  ## Release status
98
98
 
99
- This self-hosted path consumes the published `borgmcp-shared@0.4.2` v2 registry
99
+ This self-hosted path consumes the published `borgmcp-shared@0.4.3` v2 registry
100
100
  release. The matching server owner-enrollment, cube-create, attach, restart, log,
101
101
  and SSE implementation must also pass the full process-level local dogfood gate.
102
102
  Until that gate opens the self-hosted path remains preview-only, and the client
package/docs/RELEASING.md CHANGED
@@ -4,15 +4,24 @@ The GitHub Actions workflow publishes one immutable, reviewed `borgmcp` version
4
4
  from a protected annotated tag. The protected publish job uses npm Trusted
5
5
  Publishing; no long-lived npm token is stored or exposed.
6
6
 
7
- ## Immutable Failed Release Evidence
7
+ ## Immutable Release Evidence
8
8
 
9
9
  The existing lightweight `v2.0.0` tag points to
10
10
  `90a078264f4d61c0140ad0a30357a4df42c34ab0`. Immutable workflow run
11
11
  `29693915689` rejected it at annotated-tag verification and failed before package
12
12
  creation or npm publication; the publish job was skipped and
13
13
  `borgmcp@2.0.0` remains absent from npm. Never delete, move, replace, reuse, or
14
- rerun that tag or run. Recovery uses the unused `v2.0.1` identity from a fresh
15
- reviewed protected-main commit and requires the complete release gate again.
14
+ rerun that tag or run.
15
+
16
+ The annotated `v2.0.1` tag object
17
+ `def12ee40af665fc6c3af4873a7d566b3f844fc1` peels to protected-main commit
18
+ `b30fc54a4d73bda98db4630864cca796c8923dd9`. Workflow run `29748931957`
19
+ successfully published that exact source as `borgmcp@2.0.1`; the registry records
20
+ integrity
21
+ `sha512-Ah8IY2izZ774gYLKthRL9lfrV+JBk2o9HSlrWUplyZgoGqwVjVboHNon0hWWF5i/fObiCGikFOMY6qZ+vaeyCw==`.
22
+ Never move, replace, reuse, or rerun that tag or workflow. The next candidate
23
+ uses the unused `v2.0.2` identity from a fresh reviewed protected-main commit
24
+ and requires the complete release gate again.
16
25
 
17
26
  ## Release Prerequisites
18
27
 
@@ -23,7 +32,7 @@ Before creating the release tag, independently verify all of these conditions:
23
32
  - the extraction review confirms no private backend secrets, deployment
24
33
  configuration, customer data, local state, or duplicated shared contracts
25
34
  entered the public package;
26
- - the exact audited registry dependency `borgmcp-shared@0.4.2` remains locked to
35
+ - the exact audited registry dependency `borgmcp-shared@0.4.3` remains locked to
27
36
  its canonical tarball and integrity;
28
37
  - the client and matching server pass the complete local dogfood gate;
29
38
  - the selected stable client version is unused and the exact release commit is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "borgmcp",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
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",
@@ -68,7 +68,7 @@
68
68
  },
69
69
  "dependencies": {
70
70
  "@modelcontextprotocol/sdk": "^1.0.4",
71
- "borgmcp-shared": "0.4.2",
71
+ "borgmcp-shared": "0.4.3",
72
72
  "chalk": "^5.3.0",
73
73
  "prompts": "^2.4.2",
74
74
  "which": "^4.0.0"
@@ -1,6 +1,12 @@
1
- import { getActiveCube, getCodexWakeTarget, setCodexWakeTarget } from './cubes.js';
1
+ import {
2
+ getActiveCube,
3
+ getCodexWakeTarget,
4
+ setCodexWakeTarget,
5
+ type ActiveCube,
6
+ } from './cubes.js';
2
7
  import { CodexAppServerClient } from './codex-app-server.js';
3
8
  import { checkCodexBridgeHealthy } from './codex-remote.js';
9
+ import { hasPendingWakeActivity } from './remote-client.js';
4
10
  import {
5
11
  BORG_CODEX_REMOTE_WAKE_ENV,
6
12
  resolveSessionAgentKind,
@@ -176,6 +182,10 @@ export interface CodexWakeDeps {
176
182
  // WAKE_RETRY_MAX_ATTEMPTS); small values let tests prove the loop terminates
177
183
  // under a non-advancing clock without running thousands of iterations.
178
184
  maxAttempts?: number;
185
+ // client#76: token-free, non-mutating preflight for the periodic backstop.
186
+ // Per-entry and retry-drain paths do not use this: their pending obligation is
187
+ // already established by a concrete delivered/deferred event.
188
+ hasPendingWork?: (active: ActiveCube) => Promise<boolean>;
179
189
  // gh#861 finding 2: lease-ownership gate for the heartbeat tick — a lease-LOSING
180
190
  // duplicate child must NOT tick/inject (symmetry with the per-entry path, which
181
191
  // only fires inside an SSE session that holds the stream lease). Heartbeat-only;
@@ -403,16 +413,13 @@ async function runRetryDrainLoop(deps: CodexWakeDeps): Promise<void> {
403
413
  export const CODEX_HEARTBEAT_CADENCE_MS = 20 * 60_000;
404
414
 
405
415
  /**
406
- * gh#857 WI-2: one tick of the codex /loop-equivalent heartbeat a periodic,
407
- * independent re-engagement that injects a borg_read-log (unread_only=true) DRAIN turn so an
408
- * idle codex drone re-syncs even if every per-entry wake was missed. SKIPS when a
409
- * delivery (per-entry wake, retry-drain, or a prior heartbeat) already landed
410
- * within the cadence window (shouldFireHeartbeat), so an active cube with flowing
411
- * wakes never gets a redundant injection. Unlike the per-entry path it does NOT
412
- * consult deliveredWakeKeys the cadence gate is the throttle, and the static
413
- * drain prompt is intentionally re-delivered each idle window. Best-effort: a
414
- * mid-turn thread / transient error / unresolved target just skips this tick (the
415
- * next tick retries). Never throws.
416
+ * gh#857/client#76: one tick of the codex catch-up backstop. The cadence only
417
+ * initiates a token-free unread-state preflight; a DRAIN turn is injected when
418
+ * that authoritative scan finds real work that a per-entry wake missed. Recent
419
+ * delivery still suppresses redundant preflights. Unlike the per-entry path it
420
+ * does not consult deliveredWakeKeys because the unread cursor is authoritative.
421
+ * Best-effort: a failed preflight, mid-turn thread, transient error, or unresolved
422
+ * target skips this tick and lets the next cadence retry. Never throws.
416
423
  */
417
424
  export async function fireCodexHeartbeatTick(
418
425
  deps: CodexWakeDeps = {},
@@ -434,6 +441,11 @@ export async function fireCodexHeartbeatTick(
434
441
  try {
435
442
  const active = await (deps.getActiveCube ?? getActiveCube)();
436
443
  if (!active) return;
444
+ // Elapsed time alone must never cross the model boundary. Query the
445
+ // authoritative unread state without advancing its cursor; only then touch
446
+ // the app-server socket or resolve a thread.
447
+ const hasPendingWork = deps.hasPendingWork ?? hasPendingWakeActivity;
448
+ if (!(await hasPendingWork(active))) return;
437
449
  const resolved = await resolveFreshCodexWakeTarget(active, deps);
438
450
  if (!resolved) return; // thread not loaded yet → next tick retries
439
451
  const client = makeCodexClient(resolved.socketPath, deps);
@@ -140,10 +140,10 @@ export function wakeRetryExpired(
140
140
  }
141
141
 
142
142
  /**
143
- * WI-2 double-fire avoidance: the periodic heartbeat fires only when no wake (or
144
- * prior heartbeat) delivery landed within the cadence window so an active cube
145
- * with flowing per-entry wakes doesn't get redundant heartbeat injections. A
146
- * never-delivered seat (null) always fires.
143
+ * WI-2 double-fire avoidance: the periodic catch-up preflight runs only when no
144
+ * wake delivery landed within the cadence window. A never-delivered seat (null)
145
+ * is eligible for preflight; client#76 separately requires pending unread work
146
+ * before a model turn can start.
147
147
  */
148
148
  export function shouldFireHeartbeat(
149
149
  lastDeliveredAt: number | null,
package/src/log-stream.ts CHANGED
@@ -28,6 +28,7 @@ import { Buffer } from 'node:buffer';
28
28
  import { promises as fs } from 'node:fs';
29
29
  import path from 'node:path';
30
30
  import { compareBroadcastHwm, type BroadcastHwm } from 'borgmcp-shared/log-stream-hwm';
31
+ import { decodeProtocolErrorEnvelope, ErrorCode } from 'borgmcp-shared/protocol';
31
32
  import { getActiveCube, inboxPathForDrone } from './cubes.js';
32
33
  import { assertUuidShape } from './evict-drone.js';
33
34
  import { loadBorgServerTrust } from './server-trust.js';
@@ -53,6 +54,8 @@ import {
53
54
  wakeCodexViaAppServer,
54
55
  } from './codex-app-wake.js';
55
56
  import { readBoundedResponseBody } from './server-response.js';
57
+ import { BorgServerError, BorgServerUnreachableError } from './server-errors.js';
58
+ import { recoverExpiredLocalSession } from './session-continuity.js';
56
59
  import {
57
60
  acquireStreamLease,
58
61
  readOwnershipSnapshot,
@@ -97,6 +100,13 @@ export class StreamCursorExpiredError extends Error {
97
100
  }
98
101
  }
99
102
 
103
+ class TerminalStreamError extends Error {
104
+ constructor() {
105
+ super('terminal local session state');
106
+ this.name = 'TerminalStreamError';
107
+ }
108
+ }
109
+
100
110
  function isProcessAlive(pid: number): boolean {
101
111
  try {
102
112
  process.kill(pid, 0);
@@ -322,6 +332,7 @@ function runStreamLoopForever(): void {
322
332
  await runLoop();
323
333
  process.stderr.write('[borg-mcp log stream] runLoop returned unexpectedly; restarting in 5s\n');
324
334
  } catch (err: any) {
335
+ if (err instanceof TerminalStreamError) return;
325
336
  process.stderr.write(`[borg-mcp log stream] runLoop threw: ${err?.message ?? err}; restarting in 5s\n`);
326
337
  }
327
338
  streamState.runLoopRestartCount += 1;
@@ -417,6 +428,7 @@ export interface RunLoopTestDeps {
417
428
  getActiveCube?: typeof getActiveCube;
418
429
  acquireStreamLease?: typeof acquireStreamLease;
419
430
  streamOnce?: typeof streamOnce;
431
+ recoverExpiredSession?: typeof recoverExpiredLocalSession;
420
432
  sleep?: (ms: number) => Promise<void>;
421
433
  maxIterations?: number;
422
434
  }
@@ -425,6 +437,7 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
425
437
  const _getActiveCube = testDeps.getActiveCube ?? getActiveCube;
426
438
  const _acquireStreamLease = testDeps.acquireStreamLease ?? acquireStreamLease;
427
439
  const _streamOnce = testDeps.streamOnce ?? streamOnce;
440
+ const _recoverExpiredSession = testDeps.recoverExpiredSession ?? recoverExpiredLocalSession;
428
441
  const _sleep = testDeps.sleep ?? sleep;
429
442
  const _maxIterations = testDeps.maxIterations ?? Infinity;
430
443
  let _iterations = 0;
@@ -434,6 +447,7 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
434
447
  let currentCubeId: string | null = null;
435
448
  let lease: StreamLease | null = null;
436
449
  let leaseKey: string | null = null;
450
+ let lastRecoveredToken: string | null = null;
437
451
 
438
452
  try {
439
453
  while (_iterations < _maxIterations) {
@@ -534,6 +548,7 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
534
548
  }
535
549
  // Clean disconnect (e.g. server-side rollout). Reset backoff.
536
550
  attempt = 0;
551
+ lastRecoveredToken = null;
537
552
  streamState.reconnectAttempts = 0;
538
553
  } catch (err: any) {
539
554
  if (ownerLost) {
@@ -559,7 +574,28 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
559
574
  process.stderr.write(
560
575
  `[borg-mcp log stream] drone evicted — stream terminated (no reconnect).\n`
561
576
  );
562
- return;
577
+ throw new TerminalStreamError();
578
+ }
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
+ }
563
599
  }
564
600
  streamState.connected = false;
565
601
  const delay =
@@ -603,6 +639,8 @@ export interface ActiveCube {
603
639
  sessionToken: string;
604
640
  apiUrl: string;
605
641
  serverTrustIdentity?: string;
642
+ localSessionCredentialRef?: string;
643
+ localSessionExpiresAt?: string | null;
606
644
  }
607
645
 
608
646
  export async function streamOnce(
@@ -762,7 +800,7 @@ export async function streamOnce(
762
800
  // Set + FIFO array for O(1) membership + bounded memory.
763
801
  const recentIds = new Set<string>();
764
802
  const recentIdsOrder: string[] = [];
765
- let isCatchingUp = lastEventId !== null;
803
+ let isCatchingUp = lastEventId !== null || cursor !== null;
766
804
 
767
805
  // gh#29 quality-stream (#5): shared inbox-write + cursor-advance helpers,
768
806
  // extracted from the previously-duplicated ack / regular-log branches in the
@@ -856,6 +894,30 @@ export async function streamOnce(
856
894
  // off forever against a dead seat. Keyed on the structured code, not the
857
895
  // bare status (SEC R2). Any other status falls through to the generic throw
858
896
  // so the reconnect loop keeps retrying transient failures.
897
+ if (response.status === 401) {
898
+ const body = await readBoundedResponseBody(
899
+ response,
900
+ LOCAL_SERVER_SSE_FRAME_LIMIT_BYTES,
901
+ 'Local Borg server SSE response exceeded the response limit',
902
+ ac.signal,
903
+ ).catch(() => '');
904
+ let code: ErrorCode | undefined;
905
+ try {
906
+ code = decodeProtocolErrorEnvelope(JSON.parse(body)).error.code;
907
+ } catch {
908
+ code = undefined;
909
+ }
910
+ if (code === ErrorCode.AUTH_EXPIRED) {
911
+ throw new BorgServerError('AUTH_EXPIRED', 'Borg server session expired');
912
+ }
913
+ if (code === ErrorCode.SESSION_REJECTED) {
914
+ throw new BorgServerError('SESSION_REJECTED', 'Borg server session was rejected');
915
+ }
916
+ if (code === ErrorCode.SESSION_REVOKED) {
917
+ throw new BorgServerError('SESSION_REVOKED', 'Borg server session was revoked');
918
+ }
919
+ throw new BorgServerError('CREDENTIAL_REJECTED', 'Borg server rejected the stream credential');
920
+ }
859
921
  if (response.status === 410) {
860
922
  const body = await readBoundedResponseBody(
861
923
  response,
@@ -47,6 +47,7 @@ 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';
50
51
 
51
52
  export interface RemoteConnection {
52
53
  apiUrl: string;
@@ -228,20 +229,31 @@ async function localServerRequest<T>(
228
229
  method: 'GET' | 'POST' | 'PUT' | 'PATCH',
229
230
  payload?: Record<string, unknown>,
230
231
  ): Promise<T | null> {
231
- return decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
232
- method,
233
- signal,
234
- droneSession: active.sessionToken,
235
- apiUrl: active.apiUrl,
236
- serverTrustIdentity: active.serverTrustIdentity,
237
- redirect: 'error',
238
- ...(payload === undefined
239
- ? { headers: { Accept: 'application/json' } }
240
- : {
241
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
242
- body: JSON.stringify(createProtocolEnvelope(randomUUID(), payload)),
243
- }),
244
- }), true);
232
+ let current = active.localSessionCredentialRef && active.localSessionExpiresAt
233
+ ? await ensureLocalSessionFresh(active)
234
+ : active;
235
+ const request = (candidate: ActiveCube) => decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
236
+ method,
237
+ signal,
238
+ droneSession: candidate.sessionToken,
239
+ apiUrl: candidate.apiUrl,
240
+ serverTrustIdentity: candidate.serverTrustIdentity,
241
+ redirect: 'error',
242
+ ...(payload === undefined
243
+ ? { headers: { Accept: 'application/json' } }
244
+ : {
245
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
246
+ body: JSON.stringify(createProtocolEnvelope(randomUUID(), payload)),
247
+ }),
248
+ }), 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
+ }
245
257
  }
246
258
 
247
259
  export interface LocalManageOperation {
@@ -404,6 +416,66 @@ async function localReadLogPage(
404
416
  return payload;
405
417
  }
406
418
 
419
+ interface PendingWakePage {
420
+ entries: Array<{
421
+ drone_id?: unknown;
422
+ message?: unknown;
423
+ visibility?: unknown;
424
+ recipient_drone_ids?: unknown;
425
+ }>;
426
+ cursor: LocalServerCursor | null;
427
+ has_more: boolean;
428
+ }
429
+
430
+ function isPendingWakeEntry(
431
+ entry: PendingWakePage['entries'][number],
432
+ droneId: string,
433
+ ): boolean {
434
+ if (entry.visibility === 'direct') {
435
+ const recipients = Array.isArray(entry.recipient_drone_ids)
436
+ ? entry.recipient_drone_ids.filter((recipient): recipient is string => typeof recipient === 'string')
437
+ : [];
438
+ if (!recipients.includes(droneId)) return false;
439
+ }
440
+
441
+ const isHeartbeatPing =
442
+ typeof entry.message === 'string' && entry.message.startsWith('[HEARTBEAT-PING]');
443
+ return entry.drone_id !== droneId || isHeartbeatPing;
444
+ }
445
+
446
+ /**
447
+ * client#76: inspect authoritative unread log state without advancing the
448
+ * agent-owned unread cursor. The scan mirrors the SSE wake filters: unaddressed
449
+ * direct entries and ordinary own posts are not work for this seat. A full
450
+ * paginated scan prevents a run of skipped entries from hiding later real work.
451
+ */
452
+ export async function hasPendingWakeActivity(
453
+ active: ActiveCube,
454
+ deps: {
455
+ getCursor?: typeof getLocalServerCursor;
456
+ readPage?: (active: ActiveCube, opts: { cursor: LocalServerCursor | null; limit: number }) => Promise<PendingWakePage>;
457
+ } = {},
458
+ ): Promise<boolean> {
459
+ if (!active.serverTrustIdentity) {
460
+ throw new Error('Selected Borg server authority state is missing or unreadable');
461
+ }
462
+
463
+ const getCursor = deps.getCursor ?? getLocalServerCursor;
464
+ const readPage = deps.readPage ?? localReadLogPage;
465
+ let cursor = await getCursor(localCursorBinding(active));
466
+
467
+ for (;;) {
468
+ const page: PendingWakePage = await readPage(active, { cursor, limit: 500 });
469
+ if (page.entries.some((entry) => isPendingWakeEntry(entry, active.droneId))) return true;
470
+ if (!page.has_more) return false;
471
+ if (!page.cursor ||
472
+ (cursor && page.cursor.id === cursor.id && page.cursor.created_at === cursor.created_at)) {
473
+ throw new Error('Local Borg server returned a non-advancing log cursor');
474
+ }
475
+ cursor = page.cursor;
476
+ }
477
+ }
478
+
407
479
  async function resolveLocalLogCursor(
408
480
  active: ActiveCube,
409
481
  since: string,
@@ -549,6 +621,12 @@ async function authedFetch(
549
621
  } catch {
550
622
  rejectedCode = undefined;
551
623
  }
624
+ if (droneSession !== undefined && rejectedCode === ErrorCode.AUTH_EXPIRED) {
625
+ throw new BorgServerError(
626
+ 'AUTH_EXPIRED',
627
+ 'the selected Borg server session expired',
628
+ );
629
+ }
552
630
  if (droneSession !== undefined && rejectedCode === ErrorCode.SESSION_REJECTED) {
553
631
  throw new BorgServerError(
554
632
  'SESSION_REJECTED',
package/src/seats.ts CHANGED
@@ -55,6 +55,9 @@ export interface SeatRecord {
55
55
  roleName?: string;
56
56
  roleClass?: 'queen' | 'worker';
57
57
  isHumanSeat?: boolean;
58
+ // One crash-safe replacement may coexist with an ACTIVE session until the
59
+ // server confirms the same-seat restore. It is never hydratable or bound.
60
+ replacement?: { credential: string };
58
61
  }
59
62
 
60
63
  interface SeatsFile {
@@ -154,6 +157,19 @@ function isValidSeatRecord(ref: string, value: unknown): value is SeatRecord {
154
157
  ) {
155
158
  return false;
156
159
  }
160
+ if (r.replacement !== undefined) {
161
+ if (
162
+ r.state !== 'active' ||
163
+ r.replacement === null ||
164
+ typeof r.replacement !== 'object' ||
165
+ Array.isArray(r.replacement) ||
166
+ Object.keys(r.replacement as object).length !== 1 ||
167
+ typeof (r.replacement as { credential?: unknown }).credential !== 'string' ||
168
+ !/^[A-Za-z0-9_-]{43}$/.test((r.replacement as { credential: string }).credential)
169
+ ) {
170
+ return false;
171
+ }
172
+ }
157
173
  // State-consistency invariants (no inconsistent active|pending).
158
174
  if (r.state === 'active') {
159
175
  // An ACTIVE record MUST carry its full server session + worktree binding.
@@ -281,6 +297,17 @@ export async function getActiveSeatCredential(
281
297
  return record.credential;
282
298
  }
283
299
 
300
+ /** Exact ACTIVE record for the internal continuity coordinator. */
301
+ export async function getActiveSeat(
302
+ ref: string,
303
+ binding: { origin: string; trustIdentity: string; cubeId: string },
304
+ ): Promise<SeatRecord | null> {
305
+ if (!REF_RE.test(ref)) return null;
306
+ const store = await readStore();
307
+ const record = store.seats[ref];
308
+ return recordMatches(record, ref, binding) && record.state === 'active' ? record : null;
309
+ }
310
+
284
311
  // ─── PREPARE / mint (no worktree yet) ────────────────────────────────────────
285
312
 
286
313
  /**
@@ -483,6 +510,149 @@ export async function activateAndBindSeat(input: {
483
510
  });
484
511
  }
485
512
 
513
+ export type PrepareSeatReplacementOutcome =
514
+ | { ok: true; credential: string; digest: string }
515
+ | { ok: false; reason: 'expectation-mismatch' };
516
+
517
+ /**
518
+ * Preserve the exact ACTIVE seat while preparing one durable replacement bearer.
519
+ * A lost response or process restart reuses the stored replacement; a changed
520
+ * authority, drone, or active bearer fails before any mutation.
521
+ */
522
+ export async function prepareSeatReplacement(input: {
523
+ ref: string;
524
+ binding: { origin: string; trustIdentity: string; cubeId: string };
525
+ expectedDroneId: string;
526
+ expectedActiveDigest: string;
527
+ replacementCredential: string;
528
+ }): Promise<PrepareSeatReplacementOutcome> {
529
+ if (!REF_RE.test(input.ref) || !/^[A-Za-z0-9_-]{43}$/.test(input.replacementCredential)) {
530
+ return { ok: false, reason: 'expectation-mismatch' };
531
+ }
532
+ return withStore<SeatsFile, PrepareSeatReplacementOutcome>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
533
+ const record = txn.data.seats[input.ref];
534
+ if (
535
+ !recordMatches(record, input.ref, input.binding) ||
536
+ record.state !== 'active' ||
537
+ record.droneId !== input.expectedDroneId ||
538
+ digestOf(record.credential) !== input.expectedActiveDigest
539
+ ) {
540
+ return { ok: false as const, reason: 'expectation-mismatch' as const };
541
+ }
542
+ if (record.replacement) {
543
+ return {
544
+ ok: true as const,
545
+ credential: record.replacement.credential,
546
+ digest: digestOf(record.replacement.credential),
547
+ };
548
+ }
549
+ record.replacement = { credential: input.replacementCredential };
550
+ await txn.commit();
551
+ return {
552
+ ok: true as const,
553
+ credential: input.replacementCredential,
554
+ digest: digestOf(input.replacementCredential),
555
+ };
556
+ });
557
+ }
558
+
559
+ export type PromoteSeatReplacementOutcome = 'promoted' | 'missing' | 'replaced';
560
+
561
+ /** Update only the committed expiry for an exact same-bearer session renewal. */
562
+ export async function refreshActiveSeatSession(input: {
563
+ ref: string;
564
+ binding: { origin: string; trustIdentity: string; cubeId: string };
565
+ expectedDroneId: string;
566
+ expectedActiveDigest: string;
567
+ expectedSessionId: string;
568
+ expiresAt: string;
569
+ }): Promise<boolean> {
570
+ if (!UUID_RE.test(input.expectedDroneId) || !UUID_RE.test(input.expectedSessionId)) {
571
+ throw new Error('invalid Borg server session identity');
572
+ }
573
+ if (!Number.isFinite(Date.parse(input.expiresAt))) {
574
+ throw new Error('invalid Borg server session expiry');
575
+ }
576
+ return withStore<SeatsFile, boolean>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
577
+ const record = txn.data.seats[input.ref];
578
+ if (
579
+ !recordMatches(record, input.ref, input.binding) ||
580
+ record.state !== 'active' ||
581
+ record.droneId !== input.expectedDroneId ||
582
+ record.sessionId !== input.expectedSessionId ||
583
+ digestOf(record.credential) !== input.expectedActiveDigest
584
+ ) {
585
+ return false;
586
+ }
587
+ record.expiresAt = input.expiresAt;
588
+ await txn.commit();
589
+ return true;
590
+ });
591
+ }
592
+
593
+ /** Atomically promote only the response-bound replacement onto the same ACTIVE seat. */
594
+ export async function promoteSeatReplacement(input: {
595
+ ref: string;
596
+ binding: { origin: string; trustIdentity: string; cubeId: string };
597
+ expectedDroneId: string;
598
+ expectedActiveDigest: string;
599
+ expectedReplacementDigest: string;
600
+ sessionId: string;
601
+ expiresAt: string;
602
+ }): Promise<PromoteSeatReplacementOutcome> {
603
+ if (!UUID_RE.test(input.expectedDroneId) || !UUID_RE.test(input.sessionId)) {
604
+ throw new Error('invalid Borg server session identity');
605
+ }
606
+ if (!Number.isFinite(Date.parse(input.expiresAt))) {
607
+ throw new Error('invalid Borg server session expiry');
608
+ }
609
+ return withStore<SeatsFile, PromoteSeatReplacementOutcome>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
610
+ const record = txn.data.seats[input.ref];
611
+ if (!recordMatches(record, input.ref, input.binding) || record.state !== 'active') return 'missing';
612
+ if (
613
+ record.droneId !== input.expectedDroneId ||
614
+ digestOf(record.credential) !== input.expectedActiveDigest ||
615
+ !record.replacement ||
616
+ digestOf(record.replacement.credential) !== input.expectedReplacementDigest
617
+ ) {
618
+ return 'replaced';
619
+ }
620
+ const { replacement, ...active } = record;
621
+ txn.data.seats[input.ref] = {
622
+ ...active,
623
+ credential: replacement.credential,
624
+ sessionId: input.sessionId,
625
+ expiresAt: input.expiresAt,
626
+ };
627
+ await txn.commit();
628
+ return 'promoted';
629
+ });
630
+ }
631
+
632
+ /** Remove only the caller's exact pending replacement, never the ACTIVE seat. */
633
+ export async function scrubSeatReplacement(input: {
634
+ ref: string;
635
+ binding: { origin: string; trustIdentity: string; cubeId: string };
636
+ expectedActiveDigest: string;
637
+ expectedReplacementDigest: string;
638
+ }): Promise<boolean> {
639
+ return withStore<SeatsFile, boolean>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
640
+ const record = txn.data.seats[input.ref];
641
+ if (
642
+ !recordMatches(record, input.ref, input.binding) ||
643
+ record.state !== 'active' ||
644
+ digestOf(record.credential) !== input.expectedActiveDigest ||
645
+ !record.replacement ||
646
+ digestOf(record.replacement.credential) !== input.expectedReplacementDigest
647
+ ) {
648
+ return false;
649
+ }
650
+ delete record.replacement;
651
+ await txn.commit();
652
+ return true;
653
+ });
654
+ }
655
+
486
656
  // ─── CR#2: bind a PENDING record to a preserved worktree (no activation) ─────
487
657
 
488
658
  export type BindPendingSeatOutcome = 'bound' | 'missing' | 'replaced';
@@ -4,7 +4,9 @@ export type BorgServerErrorCode =
4
4
  | 'INVITATION_REJECTED'
5
5
  | 'CREATE_CUBE_DENIED'
6
6
  | 'ATTACH_CONFLICT'
7
- | 'SESSION_REJECTED';
7
+ | 'SESSION_REJECTED'
8
+ | 'SESSION_REVOKED'
9
+ | 'AUTH_EXPIRED';
8
10
 
9
11
  /** Safe, non-secret state code for deterministic authority recovery copy. */
10
12
  export class BorgServerError extends Error {