@cortexkit/aft-bridge 0.59.0 → 0.61.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 (93) hide show
  1. package/dist/bash-format.d.ts +5 -0
  2. package/dist/bash-format.d.ts.map +1 -1
  3. package/dist/bash-format.js +12 -5
  4. package/dist/bash-format.js.map +1 -1
  5. package/dist/bash-hints.d.ts +3 -3
  6. package/dist/bash-hints.d.ts.map +1 -1
  7. package/dist/bash-hints.js +14 -13
  8. package/dist/bash-hints.js.map +1 -1
  9. package/dist/bash-host-fallback.d.ts +2 -2
  10. package/dist/bash-host-fallback.d.ts.map +1 -1
  11. package/dist/bash-host-fallback.js +2 -2
  12. package/dist/bash-host-fallback.js.map +1 -1
  13. package/dist/bridge.d.ts +87 -9
  14. package/dist/bridge.d.ts.map +1 -1
  15. package/dist/bridge.js +289 -108
  16. package/dist/bridge.js.map +1 -1
  17. package/dist/config-watch.d.ts +8 -6
  18. package/dist/config-watch.d.ts.map +1 -1
  19. package/dist/config-watch.js +17 -12
  20. package/dist/config-watch.js.map +1 -1
  21. package/dist/downloader.js +4 -3
  22. package/dist/downloader.js.map +1 -1
  23. package/dist/error-contract.d.ts +30 -2
  24. package/dist/error-contract.d.ts.map +1 -1
  25. package/dist/error-contract.js +69 -7
  26. package/dist/error-contract.js.map +1 -1
  27. package/dist/feature-config.d.ts +3 -0
  28. package/dist/feature-config.d.ts.map +1 -1
  29. package/dist/feature-config.js +124 -0
  30. package/dist/feature-config.js.map +1 -1
  31. package/dist/index.d.ts +11 -7
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +10 -6
  34. package/dist/index.js.map +1 -1
  35. package/dist/lsp-github-install.d.ts +104 -0
  36. package/dist/lsp-github-install.d.ts.map +1 -0
  37. package/dist/lsp-github-install.js +1455 -0
  38. package/dist/lsp-github-install.js.map +1 -0
  39. package/dist/lsp-github-probe.d.ts +81 -0
  40. package/dist/lsp-github-probe.d.ts.map +1 -0
  41. package/dist/lsp-github-probe.js +131 -0
  42. package/dist/lsp-github-probe.js.map +1 -0
  43. package/dist/lsp-github-table.d.ts +64 -0
  44. package/dist/lsp-github-table.d.ts.map +1 -0
  45. package/dist/lsp-github-table.js +174 -0
  46. package/dist/lsp-github-table.js.map +1 -0
  47. package/dist/migration.d.ts +10 -3
  48. package/dist/migration.d.ts.map +1 -1
  49. package/dist/migration.js +19 -14
  50. package/dist/migration.js.map +1 -1
  51. package/dist/onnx-runtime.d.ts +2 -0
  52. package/dist/onnx-runtime.d.ts.map +1 -1
  53. package/dist/onnx-runtime.js +12 -7
  54. package/dist/onnx-runtime.js.map +1 -1
  55. package/dist/paths.d.ts +3 -0
  56. package/dist/paths.d.ts.map +1 -1
  57. package/dist/paths.js +41 -6
  58. package/dist/paths.js.map +1 -1
  59. package/dist/pool.d.ts +8 -0
  60. package/dist/pool.d.ts.map +1 -1
  61. package/dist/pool.js +31 -8
  62. package/dist/pool.js.map +1 -1
  63. package/dist/project-identity.d.ts +2 -0
  64. package/dist/project-identity.d.ts.map +1 -1
  65. package/dist/project-identity.js +8 -2
  66. package/dist/project-identity.js.map +1 -1
  67. package/dist/protocol.d.ts +3 -0
  68. package/dist/protocol.d.ts.map +1 -1
  69. package/dist/protocol.js +14 -1
  70. package/dist/protocol.js.map +1 -1
  71. package/dist/resolver.d.ts.map +1 -1
  72. package/dist/resolver.js +5 -2
  73. package/dist/resolver.js.map +1 -1
  74. package/dist/revivable-transport.d.ts +16 -1
  75. package/dist/revivable-transport.d.ts.map +1 -1
  76. package/dist/revivable-transport.js +83 -11
  77. package/dist/revivable-transport.js.map +1 -1
  78. package/dist/subc-transport.d.ts +45 -4
  79. package/dist/subc-transport.d.ts.map +1 -1
  80. package/dist/subc-transport.js +659 -49
  81. package/dist/subc-transport.js.map +1 -1
  82. package/dist/tar-executable.d.ts +3 -1
  83. package/dist/tar-executable.d.ts.map +1 -1
  84. package/dist/tar-executable.js +35 -15
  85. package/dist/tar-executable.js.map +1 -1
  86. package/dist/transport-factory.d.ts.map +1 -1
  87. package/dist/transport-factory.js +8 -32
  88. package/dist/transport-factory.js.map +1 -1
  89. package/dist/transport.d.ts +27 -0
  90. package/dist/transport.d.ts.map +1 -1
  91. package/dist/transport.js +9 -0
  92. package/dist/transport.js.map +1 -1
  93. package/package.json +2 -2
@@ -17,12 +17,13 @@
17
17
  * published plugin dist; it is never a published runtime dependency.
18
18
  */
19
19
  import { statSync } from "node:fs";
20
- import { AuthError, connectionFileExists, isConsumerReconnectTransient, StaleRouteHandleError, SubcCallError, SubcClient, SubcError, } from "@cortexkit/subc-client";
20
+ import { AuthError, connectionFileExists, isConsumerReconnectTransient, routeCloseReason, SocketTimeoutError, StaleRouteHandleError, SubcCallError, SubcClient, SubcError, } from "@cortexkit/subc-client";
21
21
  import { log, warn } from "./active-logger.js";
22
+ import { isTerminalStatus } from "./bash-format.js";
22
23
  import { isRouteOpenReloadWindowError, isRouteRequestReloadRefusal } from "./error-contract.js";
23
24
  import { asCanonicalRootPath, asRootGeneration, LifecycleRegistry, } from "./lifecycle-registry.js";
24
25
  import { canonicalizeProjectRoot } from "./project-identity.js";
25
- import { callPresetFor, PRESET_FIELD, WORKER_SESSION_FIELD } from "./transport.js";
26
+ import { callPresetFor, HOST_TOOL_NAMES_FIELD, PRESET_FIELD, WORKER_SESSION_FIELD, } from "./transport.js";
26
27
  /** The subc pool is closing and cannot carry another request. */
27
28
  export class SubcTransportShuttingDownError extends SubcCallError {
28
29
  constructor() {
@@ -49,6 +50,61 @@ export class SubcRouteClosedMidCallError extends SubcCallError {
49
50
  this.reason = reason;
50
51
  }
51
52
  }
53
+ /**
54
+ * A data-plane request was queued and sent, but reading the daemon's response
55
+ * frame timed out waiting for body bytes after the header was received.
56
+ *
57
+ * subc-client raises SocketTimeoutError from readExact when the body does not
58
+ * arrive within BODY_READ_TIMEOUT_MS after the header. Because the request was
59
+ * already sent to the daemon, it may have executed: the outcome is unknown.
60
+ * The call must not be blind-retried, and bash must not re-run it on the host.
61
+ */
62
+ export class SubcFrameReadTimeoutError extends SubcCallError {
63
+ constructor(cause) {
64
+ super("outcome_unknown", "the command may have run; timed out waiting for frame body bytes from the AFT daemon", "frame_read_timeout", cause);
65
+ this.name = "SubcFrameReadTimeoutError";
66
+ }
67
+ }
68
+ /**
69
+ * Matches the frame-body read timeout raised by subc-client (socket.ts:185/190):
70
+ * "timed out waiting for <N> bytes".
71
+ *
72
+ * Excludes connect timeouts ("timed out connecting to ...") and write timeouts
73
+ * ("timed out writing to subc"): connect timeouts are provably pre-send, while
74
+ * write timeouts keep their existing transport classification.
75
+ */
76
+ function isFrameBodyReadTimeoutMessage(message) {
77
+ return (typeof message === "string" &&
78
+ (/timed out waiting for \d+ bytes/i.test(message) ||
79
+ message.includes("timed out waiting for frame body bytes")));
80
+ }
81
+ export function isSubcFrameReadTimeoutError(error) {
82
+ if (error instanceof SubcFrameReadTimeoutError)
83
+ return true;
84
+ if (error instanceof SocketTimeoutError && isFrameBodyReadTimeoutMessage(error.message)) {
85
+ return true;
86
+ }
87
+ if (error instanceof SubcError && isFrameBodyReadTimeoutMessage(error.message)) {
88
+ return true;
89
+ }
90
+ if (error instanceof SubcCallError) {
91
+ if (error.code === "frame_read_timeout")
92
+ return true;
93
+ if (error.cause !== undefined &&
94
+ error.cause !== error &&
95
+ isSubcFrameReadTimeoutError(error.cause)) {
96
+ return true;
97
+ }
98
+ if (isFrameBodyReadTimeoutMessage(error.message))
99
+ return true;
100
+ }
101
+ return false;
102
+ }
103
+ export function frameReadTimeoutError(error) {
104
+ if (!isSubcFrameReadTimeoutError(error))
105
+ return error;
106
+ return error instanceof SubcFrameReadTimeoutError ? error : new SubcFrameReadTimeoutError(error);
107
+ }
52
108
  /**
53
109
  * subc-client's rejection of a request that was pending when the client itself
54
110
  * closed the route: `closeRoute` tearing that route down, or the client closing
@@ -101,6 +157,8 @@ export function isSubcClientClosedError(error) {
101
157
  * the connection has already been torn down locally.
102
158
  */
103
159
  export function isSocketDeathError(error, depth = 0) {
160
+ if (isSubcFrameReadTimeoutError(error))
161
+ return true;
104
162
  // Raw data-plane requests wrap socket death in a SubcError; its typed
105
163
  // connection-loss reason survives even when the underlying errno does not.
106
164
  if (error instanceof SubcError && error.closeReason === "connection_lost")
@@ -164,6 +222,14 @@ export class SubcLivenessProbeTimeoutError extends Error {
164
222
  }
165
223
  /** The subc module id AFT registers under (matches the daemon manifest). */
166
224
  const AFT_MODULE_ID = "aft";
225
+ const HOST_SCOPE_LOOKUP_TIMEOUT_MS = 2_000;
226
+ const HOST_SCOPE_RETRY_FLOOR_MS = 5_000;
227
+ const HOST_SCOPE_RETRY_CAP_MS = 300_000;
228
+ function isHostScopeSyncRefusal(code) {
229
+ // crates/aft/src/gh_shim_relay/mod.rs lists scope_not_synced and scope_changed as retryable.
230
+ // A live scope freshly reported by core can also be scope_not_live until the daemon applies it.
231
+ return code === "scope_not_live" || code === "scope_not_synced" || code === "scope_changed";
232
+ }
167
233
  /**
168
234
  * A run of consecutive per-call failures that got no answer (timeout / route
169
235
  * GOODBYE / refusal) on the SAME client raises the suspicion of a dead half-open
@@ -305,7 +371,7 @@ class BgSubscription {
305
371
  identity;
306
372
  acquireClient;
307
373
  dropClient;
308
- consumerIdentity;
374
+ openRoute;
309
375
  onNudge;
310
376
  sleep;
311
377
  canAttach;
@@ -316,23 +382,25 @@ class BgSubscription {
316
382
  isCurrent;
317
383
  noteClientAlive;
318
384
  noteUnansweredCall;
385
+ onRouteAttached;
319
386
  stopped = false;
320
387
  /** The live subscription handle, read by stop() to wake the loop's `await closed`. */
321
388
  current = null;
322
389
  /** Client carrying the live subscription route, counted when that client is dropped. */
323
390
  attached = null;
391
+ route = null;
324
392
  loop;
325
393
  lifecycleLogState = new Map();
326
394
  nudgeReceiptLogState = null;
327
- constructor(identity, acquireClient, dropClient, consumerIdentity, onNudge, sleep, canAttach, onRootAttachFailure, onDormant, dispatchProbeIntervalMs, nudgeRef, isCurrent = () => true,
395
+ constructor(identity, acquireClient, dropClient, openRoute, onNudge, sleep, canAttach, onRootAttachFailure, onDormant, dispatchProbeIntervalMs, nudgeRef, isCurrent = () => true,
328
396
  /** Reports a reply seen on the client, which proves its socket still delivers. */
329
397
  noteClientAlive = () => undefined,
330
398
  /** Reports a call on the client that got no answer; enough in a row start a liveness probe. */
331
- noteUnansweredCall = () => undefined) {
399
+ noteUnansweredCall = () => undefined, onRouteAttached = () => undefined) {
332
400
  this.identity = identity;
333
401
  this.acquireClient = acquireClient;
334
402
  this.dropClient = dropClient;
335
- this.consumerIdentity = consumerIdentity;
403
+ this.openRoute = openRoute;
336
404
  this.onNudge = onNudge;
337
405
  this.sleep = sleep;
338
406
  this.canAttach = canAttach;
@@ -343,14 +411,20 @@ class BgSubscription {
343
411
  this.isCurrent = isCurrent;
344
412
  this.noteClientAlive = noteClientAlive;
345
413
  this.noteUnansweredCall = noteUnansweredCall;
414
+ this.onRouteAttached = onRouteAttached;
346
415
  this.loop = this.run();
347
416
  }
348
417
  /** The client whose route currently carries this subscription, if any. */
349
418
  get attachedClient() {
350
419
  return this.attached;
351
420
  }
421
+ get attachedRoute() {
422
+ return this.route;
423
+ }
424
+ cancelBackoff = null;
352
425
  async stop() {
353
426
  this.stopped = true;
427
+ this.cancelBackoff?.();
354
428
  // Wake a live `await sub.closed`; the loop's `finally` remains the sole
355
429
  // owner of closeRouteChannel, avoiding a double close from stop() + run().
356
430
  const sub = this.current;
@@ -477,7 +551,7 @@ class BgSubscription {
477
551
  try {
478
552
  // A dedicated route keeps bg_events independent of the tool route's
479
553
  // credit window while preserving the client's connection-bound handle.
480
- route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, this.identity, { consumerIdentity: this.consumerIdentity });
554
+ route = await this.openRoute(client);
481
555
  }
482
556
  catch (err) {
483
557
  if (this.isCurrent() && this.onRootAttachFailure(err)) {
@@ -536,6 +610,8 @@ class BgSubscription {
536
610
  });
537
611
  this.current = sub;
538
612
  this.attached = client;
613
+ this.route = route;
614
+ this.onRouteAttached();
539
615
  stopDispatchProbe = this.startDispatchProbe(client, routeId);
540
616
  this.info("subscription-open", `subscription open channel=${routeId}`);
541
617
  if (reconnecting) {
@@ -586,6 +662,7 @@ class BgSubscription {
586
662
  stopDispatchProbe();
587
663
  this.current = null;
588
664
  this.attached = null;
665
+ this.route = null;
589
666
  safeCloseRoute(client, route);
590
667
  }
591
668
  await this.backoff(backoffAttempt++);
@@ -594,14 +671,47 @@ class BgSubscription {
594
671
  giveUp("stopped");
595
672
  }
596
673
  async backoff(attempt) {
597
- await this.sleep(reconnectBackoffMs(attempt));
674
+ if (this.stopped)
675
+ return;
676
+ await new Promise((resolve, reject) => {
677
+ let timer;
678
+ let finished = false;
679
+ const settle = (error) => {
680
+ if (finished)
681
+ return;
682
+ finished = true;
683
+ if (timer)
684
+ clearTimeout(timer);
685
+ this.cancelBackoff = null;
686
+ if (error !== undefined)
687
+ reject(error);
688
+ else
689
+ resolve();
690
+ };
691
+ this.cancelBackoff = () => settle();
692
+ const delay = reconnectBackoffMs(attempt);
693
+ if (this.sleep) {
694
+ // An injected sleeper may not be cancellable. Observe its eventual
695
+ // rejection, but let stop release the subscription without awaiting it.
696
+ try {
697
+ void this.sleep(delay).then(() => settle(), settle);
698
+ }
699
+ catch (error) {
700
+ settle(error);
701
+ }
702
+ }
703
+ else {
704
+ timer = setTimeout(() => settle(), delay);
705
+ }
706
+ });
598
707
  }
599
708
  }
600
709
  function isRecord(value) {
601
710
  return typeof value === "object" && value !== null && !Array.isArray(value);
602
711
  }
603
712
  function isRouteProvenAbsentError(err) {
604
- return ((err instanceof SubcError && err.code === "unknown_channel") ||
713
+ return ((err instanceof SubcError &&
714
+ (err.code === "unknown_channel" || err.code === "stale_route_epoch")) ||
605
715
  err instanceof StaleRouteHandleError);
606
716
  }
607
717
  /**
@@ -636,17 +746,25 @@ function callDeadlinePassedError(lastError, callDeadlineMs) {
636
746
  return withDeadlineSuffix(lastError, suffix);
637
747
  return new SubcError(`route to module '${AFT_MODULE_ID}' was not ready before the call ran out of time.${suffix}`, REQUEST_DEADLINE_CODE);
638
748
  }
749
+ export const __withDeadlineSuffixForTests = withDeadlineSuffix;
639
750
  function withDeadlineSuffix(error, suffix) {
640
- if (error instanceof Error) {
641
- try {
642
- error.message += suffix;
643
- return error;
644
- }
645
- catch {
646
- // A frozen SDK error cannot carry the suffix; retain its message in a wrapper.
647
- }
648
- }
649
- return new Error(`${String(error)}${suffix}`);
751
+ if (!(error instanceof Error))
752
+ return new Error(`${String(error)}${suffix}`, { cause: error });
753
+ const message = `${error.message}${suffix}`;
754
+ if (error instanceof SubcCallError) {
755
+ const wrapped = new SubcCallError(error.kind, message, error.code, error);
756
+ // SubcCallError's detail getter only unwraps a direct SubcError cause.
757
+ Object.defineProperty(wrapped, "detail", { value: error.detail });
758
+ return wrapped;
759
+ }
760
+ if (error instanceof SubcError) {
761
+ const wrapped = new SubcError(message, error.code, error.detail, error.closeReason);
762
+ Object.defineProperty(wrapped, "cause", { value: error });
763
+ return wrapped;
764
+ }
765
+ const wrapped = new Error(message, { cause: error });
766
+ wrapped.name = error.name;
767
+ return wrapped;
650
768
  }
651
769
  /**
652
770
  * Fire-and-forget route close that can never throw — neither synchronously (a
@@ -799,6 +917,8 @@ class SubcTransport {
799
917
  body.preview = true;
800
918
  if (options?.workerSession === true)
801
919
  body[WORKER_SESSION_FIELD] = true;
920
+ if (options?.hostToolNames)
921
+ body[HOST_TOOL_NAMES_FIELD] = options.hostToolNames;
802
922
  body[PRESET_FIELD] = callPresetFor(options?.workerSession === true);
803
923
  const reply = await this.pool.routeRequest(this.identityFor(sessionId), body, timeoutMs, onProgress, this.generation, abortSignal);
804
924
  return reliftReply(reply);
@@ -823,10 +943,14 @@ class SubcTransport {
823
943
  // The caller's role travels beside the call, like the session (which the
824
944
  // route bind carries), not inside the command's arguments: the module
825
945
  // reads it from the call body.
826
- const { [WORKER_SESSION_FIELD]: workerSession, ...args } = params;
946
+ // The caller's tool names travel the same way: RouteBind has no field for
947
+ // them, and one route's host must not decide another route's wording.
948
+ const { [WORKER_SESSION_FIELD]: workerSession, [HOST_TOOL_NAMES_FIELD]: hostToolNames, ...args } = params;
827
949
  const body = { name: command, arguments: args };
828
950
  if (workerSession === true)
829
951
  body[WORKER_SESSION_FIELD] = true;
952
+ if (hostToolNames !== undefined)
953
+ body[HOST_TOOL_NAMES_FIELD] = hostToolNames;
830
954
  body[PRESET_FIELD] = callPresetFor(workerSession === true);
831
955
  const editSlotSurvives = this.pool.getEditSlotSurvives();
832
956
  if (editSlotSurvives !== undefined)
@@ -857,6 +981,11 @@ export class SubcTransportPool {
857
981
  connectionFile;
858
982
  handshakeTimeoutMs;
859
983
  consumerIdentity;
984
+ hostScopes;
985
+ /** Scope identity is host-session-wide, even when the session uses multiple project roots. */
986
+ hostScopeCache = new Map();
987
+ hostScopeRefusals = new Set();
988
+ hostScopeOutcomes = new Map();
860
989
  connectFn;
861
990
  onBgEventsNudge;
862
991
  onBgEventsNudgeRef;
@@ -883,6 +1012,8 @@ export class SubcTransportPool {
883
1012
  routeReopenRetryMs = null;
884
1013
  /** Per-session records keyed by the opaque identity key. */
885
1014
  sessions = new Map();
1015
+ /** Keep global insertion order so a session using several roots selects the same first live route. */
1016
+ sessionIndex = new Map();
886
1017
  // Pending host creation events are bounded. Forgetting one only makes that
887
1018
  // session unknown (retained), never falsely eligible for eviction.
888
1019
  observedStarts = new Set();
@@ -916,11 +1047,11 @@ export class SubcTransportPool {
916
1047
  this.harness = options.harness;
917
1048
  this.handshakeTimeoutMs = options.handshakeTimeoutMs;
918
1049
  this.consumerIdentity = options.consumerIdentity;
1050
+ this.hostScopes = options.hostScopes ?? false;
919
1051
  this.connectFn = options.connect ?? ((opts) => SubcClient.connect(opts));
920
1052
  this.onBgEventsNudge = options.onBgEventsNudge;
921
1053
  this.onBgEventsNudgeRef = options.onBgEventsNudgeRef;
922
- this.bgBackoffSleep =
923
- options.bgBackoffSleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1054
+ this.bgBackoffSleep = options.bgBackoffSleep;
924
1055
  this.routeRetrySleep =
925
1056
  options.routeRetrySleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
926
1057
  this.bgDispatchProbeIntervalMs =
@@ -1174,7 +1305,9 @@ export class SubcTransportPool {
1174
1305
  * always has a registered root and captured generation.
1175
1306
  */
1176
1307
  getBridge(projectRoot) {
1177
- const root = this.canonicalRoot(projectRoot);
1308
+ return this.getBridgeForCanonicalRoot(this.canonicalRoot(projectRoot));
1309
+ }
1310
+ getBridgeForCanonicalRoot(root) {
1178
1311
  if (this.shuttingDown && this.lifecycleEnabled()) {
1179
1312
  throw new SubcTransportShuttingDownError();
1180
1313
  }
@@ -1279,6 +1412,12 @@ export class SubcTransportPool {
1279
1412
  pendingDetach: false,
1280
1413
  };
1281
1414
  this.sessions.set(key, record);
1415
+ let records = this.sessionIndex.get(identity.session);
1416
+ if (!records) {
1417
+ records = new Set();
1418
+ this.sessionIndex.set(identity.session, records);
1419
+ }
1420
+ records.add(record);
1282
1421
  let keys = this.rootIndex.get(root);
1283
1422
  if (!keys) {
1284
1423
  keys = new Set();
@@ -1298,8 +1437,12 @@ export class SubcTransportPool {
1298
1437
  * always nudges a drain, replaying completions queued while no carrier existed.
1299
1438
  */
1300
1439
  async reapIdleSessions(now = Date.now()) {
1301
- for (const [key, state] of this.nudgeDeliveryLogState) {
1302
- if (now - state.lastEmittedAt >= 15 * 60_000)
1440
+ for (const [key, bucket] of this.nudgeDeliveryLogState) {
1441
+ for (const [kind, state] of bucket) {
1442
+ if (now - state.lastEmittedAt >= 15 * 60_000)
1443
+ bucket.delete(kind);
1444
+ }
1445
+ if (bucket.size === 0)
1303
1446
  this.nudgeDeliveryLogState.delete(key);
1304
1447
  }
1305
1448
  const detached = [];
@@ -1387,7 +1530,7 @@ export class SubcTransportPool {
1387
1530
  }
1388
1531
  if (typeof data.task_id === "string") {
1389
1532
  // Terminal tasks remain pinned until the daemon explicitly ACKs them.
1390
- const terminal = ["completed", "failed", "killed", "timed_out"].includes(String(data.status));
1533
+ const terminal = isTerminalStatus(data.status);
1391
1534
  const running = ["starting", "running", "killing"].includes(String(data.status));
1392
1535
  record.backgroundTasks.set(data.task_id, terminal ? "terminal" : running ? "running" : "unknown");
1393
1536
  if (name === "bash" || name === "powershell")
@@ -1472,19 +1615,27 @@ export class SubcTransportPool {
1472
1615
  };
1473
1616
  }
1474
1617
  logNudgeDelivery(kind, record, message) {
1475
- const key = `${kind}\u0000${record.identityKey}`;
1618
+ let bucket = this.nudgeDeliveryLogState.get(record.identityKey);
1619
+ if (!bucket) {
1620
+ bucket = new Map();
1621
+ this.nudgeDeliveryLogState.set(record.identityKey, bucket);
1622
+ }
1476
1623
  const now = Date.now();
1477
- const state = this.nudgeDeliveryLogState.get(key);
1624
+ const state = bucket.get(kind);
1478
1625
  if (state && now - state.lastEmittedAt < BG_LIFECYCLE_LOG_INTERVAL_MS) {
1479
1626
  state.suppressed += 1;
1480
1627
  return;
1481
1628
  }
1482
1629
  const suppressed = state?.suppressed ?? 0;
1483
- this.nudgeDeliveryLogState.set(key, { lastEmittedAt: now, suppressed: 0 });
1630
+ bucket.set(kind, { lastEmittedAt: now, suppressed: 0 });
1484
1631
  const suffix = suppressed > 0 ? ` suppressed=${suppressed}` : "";
1485
1632
  log(`subc bg_events: ${message}${suffix}`, { sessionId: record.identity.session });
1486
1633
  }
1487
1634
  removeIndexMembership(record) {
1635
+ const records = this.sessionIndex.get(record.identity.session);
1636
+ records?.delete(record);
1637
+ if (records?.size === 0)
1638
+ this.sessionIndex.delete(record.identity.session);
1488
1639
  const keys = this.rootIndex.get(record.canonicalRoot);
1489
1640
  if (!keys)
1490
1641
  return;
@@ -1507,6 +1658,7 @@ export class SubcTransportPool {
1507
1658
  this.sessions.delete(record.identityKey);
1508
1659
  this.removeIndexMembership(record);
1509
1660
  }
1661
+ this.stopUnusedHostScopeRetry(record.identity.session);
1510
1662
  }
1511
1663
  /**
1512
1664
  * Atomically detaches one opaque identity. Nothing in this method awaits: it
@@ -1520,16 +1672,14 @@ export class SubcTransportPool {
1520
1672
  record.teardownReason = reason;
1521
1673
  this.sessions.delete(key);
1522
1674
  this.removeIndexMembership(record);
1523
- for (const logKey of this.nudgeDeliveryLogState.keys()) {
1524
- if (logKey.endsWith(`\u0000${key}`))
1525
- this.nudgeDeliveryLogState.delete(logKey);
1526
- }
1675
+ this.nudgeDeliveryLogState.delete(key);
1527
1676
  const bgSub = record.bgSub;
1528
1677
  record.bgSub = null;
1529
1678
  const routeEntry = record.routeEntry;
1530
1679
  record.routeEntry = null;
1531
1680
  if (routeEntry)
1532
1681
  routeEntry.closed = true;
1682
+ this.stopUnusedHostScopeRetry(record.identity.session);
1533
1683
  return { record, bgSub, routeEntry };
1534
1684
  }
1535
1685
  async cleanupDetached(detached) {
@@ -1589,6 +1739,17 @@ export class SubcTransportPool {
1589
1739
  entry.inflight -= 1;
1590
1740
  this.closeRetiredRoute(record, entry);
1591
1741
  }
1742
+ retireHostToolRoute(record) {
1743
+ const entry = record.routeEntry;
1744
+ if (!entry)
1745
+ return;
1746
+ entry.closed = true;
1747
+ entry.closedBy ??= "route replaced";
1748
+ record.routeEntry = null;
1749
+ record.retiredRoutes.add(entry);
1750
+ this.closeRetiredRoute(record, entry);
1751
+ this.stopUnusedHostScopeRetry(record.identity.session);
1752
+ }
1592
1753
  /** Open or reuse a route while guarding every lifecycle boundary. */
1593
1754
  async routeRequest(identity, body, timeoutMs, onProgress, expectedGeneration, abortSignal) {
1594
1755
  const callStartedAt = performance.now();
@@ -1760,17 +1921,16 @@ export class SubcTransportPool {
1760
1921
  const clearRouteEntry = (entry) => {
1761
1922
  if (record.routeEntry !== entry)
1762
1923
  return;
1763
- entry.closed = true;
1764
- entry.closedBy ??= "route replaced";
1765
- record.routeEntry = null;
1766
- record.retiredRoutes.add(entry);
1767
- this.closeRetiredRoute(record, entry);
1924
+ this.retireHostToolRoute(record);
1768
1925
  };
1769
1926
  const handleRequestFailure = (error, entry) => {
1770
1927
  const aborted = error instanceof Error && error.name === "AbortError";
1771
1928
  const ownsClient = this.isCurrentSession(key, record) &&
1772
1929
  this.client === client &&
1773
1930
  !this.isReapInduced(record);
1931
+ if (ownsClient && error instanceof SubcError && error.closeReason?.startsWith("scope_")) {
1932
+ this.invalidateHostScope(identity.session);
1933
+ }
1774
1934
  if (!aborted && ownsClient && isSocketDeathError(error)) {
1775
1935
  // Dropped before the route is cleared so the drop log counts it.
1776
1936
  this.dropClient(client, {
@@ -1785,7 +1945,8 @@ export class SubcTransportPool {
1785
1945
  // route remains reusable, and no replacement may cut its other calls.
1786
1946
  if (aborted ||
1787
1947
  isRouteProvenAbsentError(error) ||
1788
- (error instanceof SubcError && error.closeReason !== undefined)) {
1948
+ (error instanceof SubcError && error.closeReason !== undefined) ||
1949
+ isSubcFrameReadTimeoutError(error)) {
1789
1950
  clearRouteEntry(entry);
1790
1951
  }
1791
1952
  if (aborted || !ownsClient || this.client !== client)
@@ -1877,10 +2038,15 @@ export class SubcTransportPool {
1877
2038
  return reply;
1878
2039
  }
1879
2040
  catch (requestError) {
1880
- const error = routeClosedMidCallError(requestError, routeAndEntry.entry, record);
2041
+ const error = frameReadTimeoutError(routeClosedMidCallError(requestError, routeAndEntry.entry, record));
1881
2042
  if (this.isReapInduced(record))
1882
2043
  throw this.annotateReapError(error, record);
1883
2044
  const ownsRoute = this.isCurrentSession(key, record) && this.client === client;
2045
+ if (ownsRoute &&
2046
+ ((error instanceof SubcError && error.code === "stale_route_epoch") ||
2047
+ error instanceof StaleRouteHandleError)) {
2048
+ this.invalidateHostScope(identity.session);
2049
+ }
1884
2050
  // A route that was bound before a module reload started gets its
1885
2051
  // requests refused by the daemon for as long as the drain lasts. The
1886
2052
  // refusal happens before the request is forwarded, so it is handled
@@ -1978,9 +2144,14 @@ export class SubcTransportPool {
1978
2144
  return this.client;
1979
2145
  if (this.connecting)
1980
2146
  return this.connecting;
2147
+ let owner;
1981
2148
  this.connecting = this.connectFn({
1982
2149
  connectionFile: this.connectionFile,
1983
2150
  handshakeTimeoutMs: this.handshakeTimeoutMs,
2151
+ onControlPush: (push) => {
2152
+ if (owner && this.client === owner)
2153
+ this.onScopeRouteClose(push);
2154
+ },
1984
2155
  })
1985
2156
  .then((client) => {
1986
2157
  this.connecting = null;
@@ -1994,6 +2165,7 @@ export class SubcTransportPool {
1994
2165
  throw new SubcTransportShuttingDownError();
1995
2166
  }
1996
2167
  this.client = client;
2168
+ owner = client;
1997
2169
  this.hadWorkingClient = true;
1998
2170
  this.transportFailures = 0;
1999
2171
  return client;
@@ -2027,10 +2199,7 @@ export class SubcTransportPool {
2027
2199
  closedBy: null,
2028
2200
  };
2029
2201
  this.assertRootCanAttach(record.canonicalRoot);
2030
- const opening = client
2031
- .routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
2032
- consumerIdentity: this.consumerIdentity,
2033
- })
2202
+ const opening = this.openHostToolRoute(client, identity, record, entry)
2034
2203
  .then((route) => {
2035
2204
  if (!this.isCurrentSession(record.identityKey, record) ||
2036
2205
  record.routeEntry !== entry ||
@@ -2046,6 +2215,15 @@ export class SubcTransportPool {
2046
2215
  entry.handle = route;
2047
2216
  entry.opening = null;
2048
2217
  this.noteClientAlive(client);
2218
+ const state = this.hostScopeCache.get(identity.session);
2219
+ if (state)
2220
+ this.scheduleHostScopeRetry(identity.session, state);
2221
+ if (state?.answer?.result === "scope" &&
2222
+ !state.scopeBindPending &&
2223
+ !entry.hostScope &&
2224
+ !this.hostScopeRefusals.has(identity.session)) {
2225
+ this.promoteHostScope(identity.session);
2226
+ }
2049
2227
  return route;
2050
2228
  })
2051
2229
  .catch((error) => {
@@ -2054,6 +2232,7 @@ export class SubcTransportPool {
2054
2232
  entry.closed = true;
2055
2233
  record.routeEntry = null;
2056
2234
  }
2235
+ this.stopUnusedHostScopeRetry(identity.session);
2057
2236
  if (!this.isCurrentSession(record.identityKey, record) &&
2058
2237
  !(error instanceof RouteTornDownError)) {
2059
2238
  throw new RouteTornDownError("subc route opened after session closed");
@@ -2067,6 +2246,393 @@ export class SubcTransportPool {
2067
2246
  record.routeEntry = entry;
2068
2247
  return { route: await opening, entry };
2069
2248
  }
2249
+ hostScope(client, identity) {
2250
+ if (this.hostScopeRefusals.has(identity.session))
2251
+ return Promise.resolve(undefined);
2252
+ let state = this.hostScopeCache.get(identity.session);
2253
+ if (!state) {
2254
+ state = { retryAt: 0, retryMs: HOST_SCOPE_RETRY_FLOOR_MS };
2255
+ this.hostScopeCache.set(identity.session, state);
2256
+ }
2257
+ if (state.answer) {
2258
+ return Promise.resolve(state.answer.result === "scope" && !state.scopeBindPending ? state.answer.scope : undefined);
2259
+ }
2260
+ if (state.pending)
2261
+ return state.pending;
2262
+ // Backoff skips the lookup, not the bind, so an unavailable core cannot delay every root.
2263
+ if (Date.now() < state.retryAt)
2264
+ return Promise.resolve(undefined);
2265
+ const current = state;
2266
+ clearTimeout(current.retryTimer);
2267
+ current.retryTimer = undefined;
2268
+ const abort = new AbortController();
2269
+ current.lookupAbort = abort;
2270
+ const lookup = this.readHostScope(client, identity, abort.signal).then((outcome) => {
2271
+ // A scope-close push may invalidate the session while the management request is in flight.
2272
+ if (this.hostScopeCache.get(identity.session) !== current)
2273
+ return undefined;
2274
+ current.pending = undefined;
2275
+ current.lookupAbort = undefined;
2276
+ if (abort.signal.aborted)
2277
+ return undefined;
2278
+ this.logHostScopeOutcome(identity.session, outcome);
2279
+ if (outcome.result === "scope" || outcome.result === "no_scope") {
2280
+ current.answer = outcome;
2281
+ current.retryAt = 0;
2282
+ current.retryMs = HOST_SCOPE_RETRY_FLOOR_MS;
2283
+ if (outcome.result === "scope")
2284
+ this.promoteHostScope(identity.session);
2285
+ }
2286
+ else {
2287
+ current.retryAt = Date.now() + current.retryMs;
2288
+ current.retryMs = Math.min(current.retryMs * 2, HOST_SCOPE_RETRY_CAP_MS);
2289
+ this.scheduleHostScopeRetry(identity.session, current);
2290
+ }
2291
+ return outcome.result === "scope" ? outcome.scope : undefined;
2292
+ });
2293
+ current.pending = lookup;
2294
+ return lookup;
2295
+ }
2296
+ unscopedHostRecord(session) {
2297
+ for (const record of this.sessionIndex.get(session) ?? []) {
2298
+ const entry = record.routeEntry;
2299
+ if (record.identity.session === session &&
2300
+ !record.closed &&
2301
+ ((entry &&
2302
+ !entry.closed &&
2303
+ !entry.hostScope &&
2304
+ entry.client === this.client &&
2305
+ (entry.handle || entry.opening)) ||
2306
+ (record.bgSub?.attachedClient === this.client && record.bgSub?.attachedRoute)))
2307
+ return record;
2308
+ }
2309
+ return undefined;
2310
+ }
2311
+ cancelHostScopeRetry(state) {
2312
+ clearTimeout(state.retryTimer);
2313
+ state.retryTimer = undefined;
2314
+ state.lookupAbort?.abort();
2315
+ }
2316
+ stopUnusedHostScopeRetry(session) {
2317
+ const state = this.hostScopeCache.get(session);
2318
+ if (state && !this.unscopedHostRecord(session)) {
2319
+ if (state.retryTimer || (state.scopeBindPending && state.bindPending))
2320
+ log(`subc head scope: session=${session} retry stopped reason=route_closed`);
2321
+ this.cancelHostScopeRetry(state);
2322
+ state.scopeBindPending = false;
2323
+ }
2324
+ }
2325
+ scheduleHostScopeRetry(session, state) {
2326
+ if (this.shuttingDown ||
2327
+ this.hostScopeCache.get(session) !== state ||
2328
+ (state.answer && !state.scopeBindPending) ||
2329
+ state.pending ||
2330
+ state.bindPending ||
2331
+ state.retryTimer ||
2332
+ this.hostScopeRefusals.has(session) ||
2333
+ !this.unscopedHostRecord(session))
2334
+ return;
2335
+ // A head can keep its tool route for its whole life, so recovery cannot rely on another bind.
2336
+ state.retryTimer = setTimeout(() => {
2337
+ state.retryTimer = undefined;
2338
+ const record = this.unscopedHostRecord(session);
2339
+ const client = record?.routeEntry?.client ?? record?.bgSub?.attachedClient;
2340
+ if (this.shuttingDown || this.hostScopeCache.get(session) !== state || !record || !client)
2341
+ return;
2342
+ let retry;
2343
+ if (state.scopeBindPending && state.answer?.result === "scope") {
2344
+ state.bindPending = this.retryHostScopeBind(client, record.identity, state, state.answer.scope).finally(() => {
2345
+ state.bindPending = undefined;
2346
+ this.scheduleHostScopeRetry(session, state);
2347
+ });
2348
+ retry = state.bindPending;
2349
+ }
2350
+ else {
2351
+ retry = this.hostScope(client, record.identity);
2352
+ }
2353
+ void retry.catch((error) => {
2354
+ warn(`subc head scope: background retry failed code=${error instanceof Error ? error.name : "Error"}`);
2355
+ });
2356
+ }, Math.max(0, state.retryAt - Date.now()));
2357
+ state.retryTimer.unref();
2358
+ }
2359
+ deferHostScopeBind(session, state, code) {
2360
+ state.scopeBindPending = true;
2361
+ if (Date.now() >= state.retryAt) {
2362
+ state.retryAt = Date.now() + state.retryMs;
2363
+ state.retryMs = Math.min(state.retryMs * 2, HOST_SCOPE_RETRY_CAP_MS);
2364
+ }
2365
+ warn(`subc head scope: session=${session} scoped bind refused code=${code}; binding unscoped; retry in=${Math.max(0, state.retryAt - Date.now())}ms`);
2366
+ this.scheduleHostScopeRetry(session, state);
2367
+ }
2368
+ refuseHostScopeBind(session, state, code) {
2369
+ if (!this.hostScopeRefusals.has(session)) {
2370
+ this.hostScopeRefusals.add(session);
2371
+ warn(`subc head scope: session=${session} scoped bind refused code=${code}; binding unscoped; retry stopped`);
2372
+ }
2373
+ if (state) {
2374
+ state.scopeBindPending = false;
2375
+ this.cancelHostScopeRetry(state);
2376
+ }
2377
+ // Remember the downgrade without misreporting it as core's no_scope answer.
2378
+ }
2379
+ async retryHostScopeBind(client, identity, state, scope) {
2380
+ try {
2381
+ // Validate daemon acceptance before retiring a working fallback route or its held requests.
2382
+ const route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, { consumerIdentity: this.consumerIdentity, scope });
2383
+ safeCloseRoute(client, route);
2384
+ if (this.hostScopeCache.get(identity.session) !== state ||
2385
+ !state.scopeBindPending ||
2386
+ this.shuttingDown ||
2387
+ client !== this.client ||
2388
+ !this.unscopedHostRecord(identity.session))
2389
+ return;
2390
+ state.scopeBindPending = false;
2391
+ state.retryAt = 0;
2392
+ state.retryMs = HOST_SCOPE_RETRY_FLOOR_MS;
2393
+ log(`subc head scope: session=${identity.session} scoped bind recovered ref=${scope.ref.slice(0, 8)} epoch=${scope.scopeEpoch}`);
2394
+ this.promoteHostScope(identity.session);
2395
+ }
2396
+ catch (error) {
2397
+ if (this.hostScopeCache.get(identity.session) !== state ||
2398
+ !state.scopeBindPending ||
2399
+ this.shuttingDown ||
2400
+ !this.unscopedHostRecord(identity.session))
2401
+ return;
2402
+ if (error instanceof SubcError &&
2403
+ error.code &&
2404
+ !isSocketDeathError(error) &&
2405
+ !isRouteOpenReloadWindowError(error) &&
2406
+ !isHostScopeSyncRefusal(error.code)) {
2407
+ this.refuseHostScopeBind(identity.session, state, error.code);
2408
+ }
2409
+ else {
2410
+ this.deferHostScopeBind(identity.session, state, error instanceof SubcError ? (error.code ?? error.name) : "Error");
2411
+ }
2412
+ }
2413
+ }
2414
+ promoteHostScope(session) {
2415
+ for (const record of this.sessions.values()) {
2416
+ const entry = record.routeEntry;
2417
+ if (record.identity.session !== session)
2418
+ continue;
2419
+ // Retirement stops new dispatches; the existing request counter defers closure of held waits.
2420
+ if (entry?.handle && !entry.hostScope)
2421
+ this.retireHostToolRoute(record);
2422
+ const sub = record.bgSub;
2423
+ if (!sub?.attachedRoute)
2424
+ continue;
2425
+ // The old subscription must finish closing before its normal attach path opens a scoped one.
2426
+ void sub.stop().then(() => {
2427
+ if (record.bgSub !== sub)
2428
+ return;
2429
+ record.bgSub = null;
2430
+ this.ensureBgSubscription(record.identity, record);
2431
+ });
2432
+ }
2433
+ }
2434
+ logHostScopeOutcome(session, outcome) {
2435
+ let key;
2436
+ let detail;
2437
+ switch (outcome.result) {
2438
+ case "scope":
2439
+ key = JSON.stringify([outcome.result, outcome.scope.ref, outcome.scope.scopeEpoch]);
2440
+ detail = `scope ref=${outcome.scope.ref.slice(0, 8)} epoch=${outcome.scope.scopeEpoch}`;
2441
+ break;
2442
+ case "error":
2443
+ key = JSON.stringify([outcome.result, outcome.code]);
2444
+ detail = `error code=${outcome.code} after=${outcome.afterMs}`;
2445
+ break;
2446
+ case "timeout":
2447
+ key = outcome.result;
2448
+ detail = `timeout after=${outcome.afterMs}`;
2449
+ break;
2450
+ default:
2451
+ key = outcome.result;
2452
+ detail = outcome.result;
2453
+ }
2454
+ // Elapsed time varies on every retry; only a changed answer or error code merits another line.
2455
+ if (this.hostScopeOutcomes.get(session) === key)
2456
+ return;
2457
+ this.hostScopeOutcomes.set(session, key);
2458
+ log(`subc head scope: session=${session} result=${detail}`);
2459
+ }
2460
+ async readHostScope(client, identity, signal) {
2461
+ const startedAt = Date.now();
2462
+ const timeout = () => ({ result: "timeout", afterMs: Date.now() - startedAt });
2463
+ let expired = false;
2464
+ let timer;
2465
+ const abort = new AbortController();
2466
+ let cancel;
2467
+ const cancelled = new Promise((resolve) => {
2468
+ cancel = () => {
2469
+ expired = true;
2470
+ abort.abort();
2471
+ resolve({ result: "error", code: "AbortError", afterMs: Date.now() - startedAt });
2472
+ };
2473
+ signal.addEventListener("abort", cancel, { once: true });
2474
+ });
2475
+ const read = async () => {
2476
+ let route;
2477
+ try {
2478
+ route = await client.routeOpen({ kind: "management_surface", module_id: "prefrontal-core" }, identity, { consumerIdentity: this.consumerIdentity });
2479
+ if (expired)
2480
+ return timeout();
2481
+ const envelope = await client.request(route, {
2482
+ // Core decodes every management request as {method, params}.
2483
+ method: "session.scope_identity",
2484
+ params: { harness: identity.harness, session: identity.session },
2485
+ }, { timeoutMs: HOST_SCOPE_LOOKUP_TIMEOUT_MS, signal: abort.signal });
2486
+ // Core answers a management request as {result: <answer>}.
2487
+ const reply = isRecord(envelope) ? envelope.result : undefined;
2488
+ if (isRecord(reply) && reply.answer === "no_scope")
2489
+ return { result: "no_scope" };
2490
+ if (!isRecord(reply) ||
2491
+ !isRecord(reply.owner) ||
2492
+ reply.owner.kind !== "reserved" ||
2493
+ reply.owner.module_id !== "prefrontal-core" ||
2494
+ typeof reply.ref !== "string" ||
2495
+ reply.ref.length === 0 ||
2496
+ !Number.isSafeInteger(reply.scope_epoch) ||
2497
+ reply.scope_epoch < 0)
2498
+ return { result: "malformed" };
2499
+ return {
2500
+ result: "scope",
2501
+ scope: {
2502
+ owner: { kind: "reserved", module_id: "prefrontal-core" },
2503
+ ref: reply.ref,
2504
+ scopeEpoch: reply.scope_epoch,
2505
+ },
2506
+ };
2507
+ }
2508
+ catch (error) {
2509
+ // A missing or unavailable scope service must not disable the host's tools.
2510
+ if (expired)
2511
+ return timeout();
2512
+ return {
2513
+ result: "error",
2514
+ code: error instanceof SubcError && error.code
2515
+ ? error.code
2516
+ : error instanceof Error
2517
+ ? error.name
2518
+ : "Error",
2519
+ afterMs: Date.now() - startedAt,
2520
+ };
2521
+ }
2522
+ finally {
2523
+ if (route)
2524
+ safeCloseRoute(client, route);
2525
+ }
2526
+ };
2527
+ try {
2528
+ // Keep the two-second bind budget during core startup; fallback routes retry in the background.
2529
+ return await Promise.race([
2530
+ read(),
2531
+ cancelled,
2532
+ new Promise((resolve) => {
2533
+ timer = setTimeout(() => {
2534
+ expired = true;
2535
+ abort.abort();
2536
+ resolve(timeout());
2537
+ }, HOST_SCOPE_LOOKUP_TIMEOUT_MS);
2538
+ }),
2539
+ ]);
2540
+ }
2541
+ finally {
2542
+ clearTimeout(timer);
2543
+ signal.removeEventListener("abort", cancel);
2544
+ }
2545
+ }
2546
+ openHostToolRoute(client, identity, record, entry) {
2547
+ if (!this.hostScopes ||
2548
+ !["opencode", "pi"].includes(identity.harness) ||
2549
+ identity.session === DEFAULT_SESSION_ID) {
2550
+ return client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
2551
+ consumerIdentity: this.consumerIdentity,
2552
+ });
2553
+ }
2554
+ return this.openScopedHostToolRoute(client, identity, record, entry);
2555
+ }
2556
+ async openScopedHostToolRoute(client, identity, record, entry) {
2557
+ const scope = await this.hostScope(client, identity);
2558
+ this.assertRecordLive(record);
2559
+ if (scope) {
2560
+ try {
2561
+ const route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, { consumerIdentity: this.consumerIdentity, scope });
2562
+ if (entry)
2563
+ entry.hostScope = scope;
2564
+ return route;
2565
+ }
2566
+ catch (error) {
2567
+ if (!(error instanceof SubcError) || !error.code || isSocketDeathError(error))
2568
+ throw error;
2569
+ // A restarting provider has not rejected the scope. Keep it for the normal reopen retry.
2570
+ if (isRouteOpenReloadWindowError(error))
2571
+ throw error;
2572
+ this.assertRecordLive(record);
2573
+ const state = this.hostScopeCache.get(identity.session);
2574
+ if (state && isHostScopeSyncRefusal(error.code)) {
2575
+ this.deferHostScopeBind(identity.session, state, error.code);
2576
+ }
2577
+ else {
2578
+ this.refuseHostScopeBind(identity.session, state, error.code);
2579
+ }
2580
+ }
2581
+ }
2582
+ this.assertRecordLive(record);
2583
+ const route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
2584
+ consumerIdentity: this.consumerIdentity,
2585
+ });
2586
+ // Scope recovery can finish while a fallback bind waits for the provider, including bg_events.
2587
+ if (this.hostScopeCache.get(identity.session)?.answer?.result === "scope" &&
2588
+ !this.hostScopeCache.get(identity.session)?.scopeBindPending &&
2589
+ !this.hostScopeRefusals.has(identity.session)) {
2590
+ safeCloseRoute(client, route);
2591
+ return this.openScopedHostToolRoute(client, identity, record, entry);
2592
+ }
2593
+ return route;
2594
+ }
2595
+ invalidateHostScope(session) {
2596
+ const state = this.hostScopeCache.get(session);
2597
+ if (state) {
2598
+ if (state.scopeBindPending)
2599
+ log(`subc head scope: session=${session} retry stopped reason=scope_route_closed`);
2600
+ this.cancelHostScopeRetry(state);
2601
+ }
2602
+ this.hostScopeCache.delete(session);
2603
+ this.hostScopeRefusals.delete(session);
2604
+ for (const record of this.sessions.values()) {
2605
+ if (record.identity.session !== session || !record.routeEntry)
2606
+ continue;
2607
+ this.retireHostToolRoute(record);
2608
+ }
2609
+ }
2610
+ onScopeRouteClose(push) {
2611
+ if (push.op !== "route.closed")
2612
+ return;
2613
+ const reason = routeCloseReason(push);
2614
+ if (!reason?.startsWith("scope_")) {
2615
+ for (const record of this.sessions.values()) {
2616
+ const route = record.routeEntry?.handle;
2617
+ if (!route)
2618
+ continue;
2619
+ if ((Array.isArray(push.body.channels) && push.body.channels.includes(route.channel)) ||
2620
+ (!Array.isArray(push.body.channels) && push.body.module_id === AFT_MODULE_ID))
2621
+ this.retireHostToolRoute(record);
2622
+ }
2623
+ return;
2624
+ }
2625
+ const sessions = new Set();
2626
+ for (const record of this.sessions.values()) {
2627
+ const routes = [record.routeEntry?.handle, record.bgSub?.attachedRoute].filter((route) => route != null);
2628
+ if ((Array.isArray(push.body.channels) &&
2629
+ routes.some((route) => push.body.channels.includes(route.channel))) ||
2630
+ (!Array.isArray(push.body.channels) && push.body.module_id === AFT_MODULE_ID))
2631
+ sessions.add(record.identity.session);
2632
+ }
2633
+ for (const session of sessions)
2634
+ this.invalidateHostScope(session);
2635
+ }
2070
2636
  ensureBgSubscription(identity, record) {
2071
2637
  if (this.shuttingDown || (!this.onBgEventsNudge && !this.onBgEventsNudgeRef))
2072
2638
  return;
@@ -2077,6 +2643,15 @@ export class SubcTransportPool {
2077
2643
  if (!this.rootCanAttach(record.canonicalRoot))
2078
2644
  return;
2079
2645
  const nudgeRef = this.nudgeRefFor(record);
2646
+ const dispatch = (callback) => {
2647
+ const failed = (error) => warn(`subc bg nudge delivery failed: ${describeFailure(error)}`);
2648
+ try {
2649
+ void Promise.resolve(callback()).catch(failed);
2650
+ }
2651
+ catch (error) {
2652
+ failed(error);
2653
+ }
2654
+ };
2080
2655
  const onNudge = (observedEvent) => {
2081
2656
  const currentRecord = this.currentSessionForNudge(identity);
2082
2657
  if (!currentRecord) {
@@ -2092,32 +2667,46 @@ export class SubcTransportPool {
2092
2667
  const currentRef = this.nudgeRefFor(currentRecord);
2093
2668
  let delivered = false;
2094
2669
  if (currentRef && this.onBgEventsNudgeRef) {
2095
- this.onBgEventsNudgeRef(currentRef);
2670
+ dispatch(() => this.onBgEventsNudgeRef?.(currentRef));
2096
2671
  delivered = true;
2097
2672
  }
2098
2673
  if (this.onBgEventsNudge) {
2099
2674
  if (!currentRef && this.onBgEventsNudgeRef) {
2100
2675
  this.logNudgeDelivery("fallback-missing-generation", currentRecord, `nudge dispatch fallback=root-session-handler cause=generation-provenance-unavailable root=${currentRecord.canonicalRoot}`);
2101
2676
  }
2102
- this.onBgEventsNudge(currentRecord.identity.project_root, currentRecord.identity.session);
2677
+ dispatch(() => this.onBgEventsNudge?.(currentRecord.identity.project_root, currentRecord.identity.session));
2103
2678
  delivered = true;
2104
2679
  }
2105
2680
  if (delivered)
2106
2681
  return;
2107
2682
  this.logNudgeDelivery("drop-no-compatible-handler", currentRecord, `nudge dropped cause=generation-provenance-unavailable-and-root-session-handler-unwired root=${currentRecord.canonicalRoot}`);
2108
2683
  };
2684
+ const binding = {};
2109
2685
  let sub = null;
2110
2686
  const clearDormantSubscription = () => {
2111
2687
  if (sub && record.bgSub === sub)
2112
2688
  record.bgSub = null;
2113
2689
  };
2114
- sub = new BgSubscription(identity, () => this.ensureClient(), (client, cause) => this.dropClient(client, cause), this.consumerIdentity, onNudge, this.bgBackoffSleep, () => this.rootCanAttach(record.canonicalRoot), (error) => {
2690
+ sub = new BgSubscription(identity, () => this.ensureClient(), (client, cause) => this.dropClient(client, cause), (client) => {
2691
+ binding.hostScope = undefined;
2692
+ return this.openHostToolRoute(client, identity, record, binding);
2693
+ }, onNudge, this.bgBackoffSleep, () => this.rootCanAttach(record.canonicalRoot), (error) => {
2115
2694
  if (!isAbsentRootRouteError(error))
2116
2695
  return false;
2117
2696
  this.markRootDormant(record.canonicalRoot, error);
2118
2697
  return true;
2119
2698
  }, clearDormantSubscription, this.bgDispatchProbeIntervalMs, nudgeRef, () => this.isCurrentSession(record.identityKey, record) &&
2120
- this.isCurrentLiveGeneration(record.canonicalRoot, record.generation), (client) => this.noteClientAlive(client), (client, cause) => this.noteUnansweredCall(client, cause));
2699
+ this.isCurrentLiveGeneration(record.canonicalRoot, record.generation), (client) => this.noteClientAlive(client), (client, cause) => this.noteUnansweredCall(client, cause), () => {
2700
+ const state = this.hostScopeCache.get(identity.session);
2701
+ if (state)
2702
+ this.scheduleHostScopeRetry(identity.session, state);
2703
+ if (state?.answer?.result === "scope" &&
2704
+ !state.scopeBindPending &&
2705
+ !binding.hostScope &&
2706
+ !this.hostScopeRefusals.has(identity.session)) {
2707
+ this.promoteHostScope(identity.session);
2708
+ }
2709
+ });
2121
2710
  record.bgSub = sub;
2122
2711
  if (!this.rootCanAttach(record.canonicalRoot)) {
2123
2712
  record.bgSub = null;
@@ -2166,6 +2755,8 @@ export class SubcTransportPool {
2166
2755
  record.routeEntry = null;
2167
2756
  this.deleteSessionIfEmpty(record.identityKey, record);
2168
2757
  }
2758
+ for (const session of this.hostScopeCache.keys())
2759
+ this.stopUnusedHostScopeRetry(session);
2169
2760
  this.transportFailures = 0;
2170
2761
  try {
2171
2762
  client.close();
@@ -2337,6 +2928,13 @@ export class SubcTransportPool {
2337
2928
  this.generationRejections.clear();
2338
2929
  this.observedStarts.clear();
2339
2930
  this.dormantRoots.clear();
2931
+ const scopeLookups = [...this.hostScopeCache.values()].flatMap((state) => {
2932
+ this.cancelHostScopeRetry(state);
2933
+ return state.pending ? [state.pending] : [];
2934
+ });
2935
+ this.hostScopeCache.clear();
2936
+ this.hostScopeRefusals.clear();
2937
+ this.hostScopeOutcomes.clear();
2340
2938
  // Deregistration synchronously tombstones all registered roots and invokes
2341
2939
  // closeProjectRoot before this method reaches its first await.
2342
2940
  const registration = this.lifecycleRegistration;
@@ -2349,11 +2947,16 @@ export class SubcTransportPool {
2349
2947
  detached.push(owner);
2350
2948
  }
2351
2949
  this.rootIndex.clear();
2950
+ this.sessionIndex.clear();
2352
2951
  this.transports.clear();
2353
2952
  const client = this.client;
2354
2953
  this.client = null;
2355
2954
  const pending = [...this.pendingRootCleanups];
2356
- await Promise.allSettled([...detached.map((owner) => this.cleanupDetached(owner)), ...pending]);
2955
+ await Promise.allSettled([
2956
+ ...detached.map((owner) => this.cleanupDetached(owner)),
2957
+ ...pending,
2958
+ ...scopeLookups,
2959
+ ]);
2357
2960
  if (client) {
2358
2961
  try {
2359
2962
  client.close();
@@ -2370,6 +2973,13 @@ export class SubcTransportPool {
2370
2973
  session: session && session.length > 0 ? session : DEFAULT_SESSION_ID,
2371
2974
  };
2372
2975
  const owner = this.detachSession(identityKey(identity), "session_closed");
2976
+ if (!this.sessionIndex.has(identity.session)) {
2977
+ const state = this.hostScopeCache.get(identity.session);
2978
+ if (state)
2979
+ this.cancelHostScopeRetry(state);
2980
+ this.hostScopeCache.delete(identity.session);
2981
+ this.hostScopeRefusals.delete(identity.session);
2982
+ }
2373
2983
  if (owner)
2374
2984
  await this.cleanupDetached(owner);
2375
2985
  }