@cortexkit/aft-bridge 0.59.0 → 0.60.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 (51) hide show
  1. package/dist/bash-hints.d.ts +3 -3
  2. package/dist/bash-hints.d.ts.map +1 -1
  3. package/dist/bash-hints.js +11 -4
  4. package/dist/bash-hints.js.map +1 -1
  5. package/dist/bash-host-fallback.d.ts +2 -2
  6. package/dist/bash-host-fallback.d.ts.map +1 -1
  7. package/dist/bash-host-fallback.js +2 -2
  8. package/dist/bash-host-fallback.js.map +1 -1
  9. package/dist/bridge.d.ts +33 -0
  10. package/dist/bridge.d.ts.map +1 -1
  11. package/dist/bridge.js +81 -4
  12. package/dist/bridge.js.map +1 -1
  13. package/dist/config-watch.d.ts +8 -6
  14. package/dist/config-watch.d.ts.map +1 -1
  15. package/dist/config-watch.js +11 -12
  16. package/dist/config-watch.js.map +1 -1
  17. package/dist/error-contract.d.ts +15 -0
  18. package/dist/error-contract.d.ts.map +1 -1
  19. package/dist/error-contract.js +27 -1
  20. package/dist/error-contract.js.map +1 -1
  21. package/dist/feature-config.d.ts.map +1 -1
  22. package/dist/feature-config.js +75 -0
  23. package/dist/feature-config.js.map +1 -1
  24. package/dist/index.d.ts +6 -5
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +6 -5
  27. package/dist/index.js.map +1 -1
  28. package/dist/paths.d.ts +3 -0
  29. package/dist/paths.d.ts.map +1 -1
  30. package/dist/paths.js +41 -6
  31. package/dist/paths.js.map +1 -1
  32. package/dist/pool.d.ts +2 -0
  33. package/dist/pool.d.ts.map +1 -1
  34. package/dist/pool.js +4 -1
  35. package/dist/pool.js.map +1 -1
  36. package/dist/revivable-transport.d.ts +4 -0
  37. package/dist/revivable-transport.d.ts.map +1 -1
  38. package/dist/revivable-transport.js +15 -4
  39. package/dist/revivable-transport.js.map +1 -1
  40. package/dist/subc-transport.d.ts +43 -4
  41. package/dist/subc-transport.d.ts.map +1 -1
  42. package/dist/subc-transport.js +578 -24
  43. package/dist/subc-transport.js.map +1 -1
  44. package/dist/transport-factory.d.ts.map +1 -1
  45. package/dist/transport-factory.js +5 -4
  46. package/dist/transport-factory.js.map +1 -1
  47. package/dist/transport.d.ts +25 -0
  48. package/dist/transport.d.ts.map +1 -1
  49. package/dist/transport.js +9 -0
  50. package/dist/transport.js.map +1 -1
  51. package/package.json +2 -2
@@ -17,12 +17,12 @@
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
22
  import { isRouteOpenReloadWindowError, isRouteRequestReloadRefusal } from "./error-contract.js";
23
23
  import { asCanonicalRootPath, asRootGeneration, LifecycleRegistry, } from "./lifecycle-registry.js";
24
24
  import { canonicalizeProjectRoot } from "./project-identity.js";
25
- import { callPresetFor, PRESET_FIELD, WORKER_SESSION_FIELD } from "./transport.js";
25
+ import { callPresetFor, HOST_TOOL_NAMES_FIELD, PRESET_FIELD, WORKER_SESSION_FIELD, } from "./transport.js";
26
26
  /** The subc pool is closing and cannot carry another request. */
27
27
  export class SubcTransportShuttingDownError extends SubcCallError {
28
28
  constructor() {
@@ -49,6 +49,61 @@ export class SubcRouteClosedMidCallError extends SubcCallError {
49
49
  this.reason = reason;
50
50
  }
51
51
  }
52
+ /**
53
+ * A data-plane request was queued and sent, but reading the daemon's response
54
+ * frame timed out waiting for body bytes after the header was received.
55
+ *
56
+ * subc-client raises SocketTimeoutError from readExact when the body does not
57
+ * arrive within BODY_READ_TIMEOUT_MS after the header. Because the request was
58
+ * already sent to the daemon, it may have executed: the outcome is unknown.
59
+ * The call must not be blind-retried, and bash must not re-run it on the host.
60
+ */
61
+ export class SubcFrameReadTimeoutError extends SubcCallError {
62
+ constructor(cause) {
63
+ super("outcome_unknown", "the command may have run; timed out waiting for frame body bytes from the AFT daemon", "frame_read_timeout", cause);
64
+ this.name = "SubcFrameReadTimeoutError";
65
+ }
66
+ }
67
+ /**
68
+ * Matches the frame-body read timeout raised by subc-client (socket.ts:185/190):
69
+ * "timed out waiting for <N> bytes".
70
+ *
71
+ * Excludes connect timeouts ("timed out connecting to ...") and write timeouts
72
+ * ("timed out writing to subc"): connect timeouts are provably pre-send, while
73
+ * write timeouts keep their existing transport classification.
74
+ */
75
+ function isFrameBodyReadTimeoutMessage(message) {
76
+ return (typeof message === "string" &&
77
+ (/timed out waiting for \d+ bytes/i.test(message) ||
78
+ message.includes("timed out waiting for frame body bytes")));
79
+ }
80
+ export function isSubcFrameReadTimeoutError(error) {
81
+ if (error instanceof SubcFrameReadTimeoutError)
82
+ return true;
83
+ if (error instanceof SocketTimeoutError && isFrameBodyReadTimeoutMessage(error.message)) {
84
+ return true;
85
+ }
86
+ if (error instanceof SubcError && isFrameBodyReadTimeoutMessage(error.message)) {
87
+ return true;
88
+ }
89
+ if (error instanceof SubcCallError) {
90
+ if (error.code === "frame_read_timeout")
91
+ return true;
92
+ if (error.cause !== undefined &&
93
+ error.cause !== error &&
94
+ isSubcFrameReadTimeoutError(error.cause)) {
95
+ return true;
96
+ }
97
+ if (isFrameBodyReadTimeoutMessage(error.message))
98
+ return true;
99
+ }
100
+ return false;
101
+ }
102
+ export function frameReadTimeoutError(error) {
103
+ if (!isSubcFrameReadTimeoutError(error))
104
+ return error;
105
+ return error instanceof SubcFrameReadTimeoutError ? error : new SubcFrameReadTimeoutError(error);
106
+ }
52
107
  /**
53
108
  * subc-client's rejection of a request that was pending when the client itself
54
109
  * closed the route: `closeRoute` tearing that route down, or the client closing
@@ -101,6 +156,8 @@ export function isSubcClientClosedError(error) {
101
156
  * the connection has already been torn down locally.
102
157
  */
103
158
  export function isSocketDeathError(error, depth = 0) {
159
+ if (isSubcFrameReadTimeoutError(error))
160
+ return true;
104
161
  // Raw data-plane requests wrap socket death in a SubcError; its typed
105
162
  // connection-loss reason survives even when the underlying errno does not.
106
163
  if (error instanceof SubcError && error.closeReason === "connection_lost")
@@ -164,6 +221,14 @@ export class SubcLivenessProbeTimeoutError extends Error {
164
221
  }
165
222
  /** The subc module id AFT registers under (matches the daemon manifest). */
166
223
  const AFT_MODULE_ID = "aft";
224
+ const HOST_SCOPE_LOOKUP_TIMEOUT_MS = 2_000;
225
+ const HOST_SCOPE_RETRY_FLOOR_MS = 5_000;
226
+ const HOST_SCOPE_RETRY_CAP_MS = 300_000;
227
+ function isHostScopeSyncRefusal(code) {
228
+ // crates/aft/src/gh_shim_relay/mod.rs lists scope_not_synced and scope_changed as retryable.
229
+ // A live scope freshly reported by core can also be scope_not_live until the daemon applies it.
230
+ return code === "scope_not_live" || code === "scope_not_synced" || code === "scope_changed";
231
+ }
167
232
  /**
168
233
  * A run of consecutive per-call failures that got no answer (timeout / route
169
234
  * GOODBYE / refusal) on the SAME client raises the suspicion of a dead half-open
@@ -305,7 +370,7 @@ class BgSubscription {
305
370
  identity;
306
371
  acquireClient;
307
372
  dropClient;
308
- consumerIdentity;
373
+ openRoute;
309
374
  onNudge;
310
375
  sleep;
311
376
  canAttach;
@@ -316,23 +381,25 @@ class BgSubscription {
316
381
  isCurrent;
317
382
  noteClientAlive;
318
383
  noteUnansweredCall;
384
+ onRouteAttached;
319
385
  stopped = false;
320
386
  /** The live subscription handle, read by stop() to wake the loop's `await closed`. */
321
387
  current = null;
322
388
  /** Client carrying the live subscription route, counted when that client is dropped. */
323
389
  attached = null;
390
+ route = null;
324
391
  loop;
325
392
  lifecycleLogState = new Map();
326
393
  nudgeReceiptLogState = null;
327
- constructor(identity, acquireClient, dropClient, consumerIdentity, onNudge, sleep, canAttach, onRootAttachFailure, onDormant, dispatchProbeIntervalMs, nudgeRef, isCurrent = () => true,
394
+ constructor(identity, acquireClient, dropClient, openRoute, onNudge, sleep, canAttach, onRootAttachFailure, onDormant, dispatchProbeIntervalMs, nudgeRef, isCurrent = () => true,
328
395
  /** Reports a reply seen on the client, which proves its socket still delivers. */
329
396
  noteClientAlive = () => undefined,
330
397
  /** Reports a call on the client that got no answer; enough in a row start a liveness probe. */
331
- noteUnansweredCall = () => undefined) {
398
+ noteUnansweredCall = () => undefined, onRouteAttached = () => undefined) {
332
399
  this.identity = identity;
333
400
  this.acquireClient = acquireClient;
334
401
  this.dropClient = dropClient;
335
- this.consumerIdentity = consumerIdentity;
402
+ this.openRoute = openRoute;
336
403
  this.onNudge = onNudge;
337
404
  this.sleep = sleep;
338
405
  this.canAttach = canAttach;
@@ -343,12 +410,16 @@ class BgSubscription {
343
410
  this.isCurrent = isCurrent;
344
411
  this.noteClientAlive = noteClientAlive;
345
412
  this.noteUnansweredCall = noteUnansweredCall;
413
+ this.onRouteAttached = onRouteAttached;
346
414
  this.loop = this.run();
347
415
  }
348
416
  /** The client whose route currently carries this subscription, if any. */
349
417
  get attachedClient() {
350
418
  return this.attached;
351
419
  }
420
+ get attachedRoute() {
421
+ return this.route;
422
+ }
352
423
  async stop() {
353
424
  this.stopped = true;
354
425
  // Wake a live `await sub.closed`; the loop's `finally` remains the sole
@@ -477,7 +548,7 @@ class BgSubscription {
477
548
  try {
478
549
  // A dedicated route keeps bg_events independent of the tool route's
479
550
  // 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 });
551
+ route = await this.openRoute(client);
481
552
  }
482
553
  catch (err) {
483
554
  if (this.isCurrent() && this.onRootAttachFailure(err)) {
@@ -536,6 +607,8 @@ class BgSubscription {
536
607
  });
537
608
  this.current = sub;
538
609
  this.attached = client;
610
+ this.route = route;
611
+ this.onRouteAttached();
539
612
  stopDispatchProbe = this.startDispatchProbe(client, routeId);
540
613
  this.info("subscription-open", `subscription open channel=${routeId}`);
541
614
  if (reconnecting) {
@@ -586,6 +659,7 @@ class BgSubscription {
586
659
  stopDispatchProbe();
587
660
  this.current = null;
588
661
  this.attached = null;
662
+ this.route = null;
589
663
  safeCloseRoute(client, route);
590
664
  }
591
665
  await this.backoff(backoffAttempt++);
@@ -601,7 +675,8 @@ function isRecord(value) {
601
675
  return typeof value === "object" && value !== null && !Array.isArray(value);
602
676
  }
603
677
  function isRouteProvenAbsentError(err) {
604
- return ((err instanceof SubcError && err.code === "unknown_channel") ||
678
+ return ((err instanceof SubcError &&
679
+ (err.code === "unknown_channel" || err.code === "stale_route_epoch")) ||
605
680
  err instanceof StaleRouteHandleError);
606
681
  }
607
682
  /**
@@ -799,6 +874,8 @@ class SubcTransport {
799
874
  body.preview = true;
800
875
  if (options?.workerSession === true)
801
876
  body[WORKER_SESSION_FIELD] = true;
877
+ if (options?.hostToolNames)
878
+ body[HOST_TOOL_NAMES_FIELD] = options.hostToolNames;
802
879
  body[PRESET_FIELD] = callPresetFor(options?.workerSession === true);
803
880
  const reply = await this.pool.routeRequest(this.identityFor(sessionId), body, timeoutMs, onProgress, this.generation, abortSignal);
804
881
  return reliftReply(reply);
@@ -823,10 +900,14 @@ class SubcTransport {
823
900
  // The caller's role travels beside the call, like the session (which the
824
901
  // route bind carries), not inside the command's arguments: the module
825
902
  // reads it from the call body.
826
- const { [WORKER_SESSION_FIELD]: workerSession, ...args } = params;
903
+ // The caller's tool names travel the same way: RouteBind has no field for
904
+ // them, and one route's host must not decide another route's wording.
905
+ const { [WORKER_SESSION_FIELD]: workerSession, [HOST_TOOL_NAMES_FIELD]: hostToolNames, ...args } = params;
827
906
  const body = { name: command, arguments: args };
828
907
  if (workerSession === true)
829
908
  body[WORKER_SESSION_FIELD] = true;
909
+ if (hostToolNames !== undefined)
910
+ body[HOST_TOOL_NAMES_FIELD] = hostToolNames;
830
911
  body[PRESET_FIELD] = callPresetFor(workerSession === true);
831
912
  const editSlotSurvives = this.pool.getEditSlotSurvives();
832
913
  if (editSlotSurvives !== undefined)
@@ -857,6 +938,11 @@ export class SubcTransportPool {
857
938
  connectionFile;
858
939
  handshakeTimeoutMs;
859
940
  consumerIdentity;
941
+ hostScopes;
942
+ /** Scope identity is host-session-wide, even when the session uses multiple project roots. */
943
+ hostScopeCache = new Map();
944
+ hostScopeRefusals = new Set();
945
+ hostScopeOutcomes = new Map();
860
946
  connectFn;
861
947
  onBgEventsNudge;
862
948
  onBgEventsNudgeRef;
@@ -883,6 +969,8 @@ export class SubcTransportPool {
883
969
  routeReopenRetryMs = null;
884
970
  /** Per-session records keyed by the opaque identity key. */
885
971
  sessions = new Map();
972
+ /** Keep global insertion order so a session using several roots selects the same first live route. */
973
+ sessionIndex = new Map();
886
974
  // Pending host creation events are bounded. Forgetting one only makes that
887
975
  // session unknown (retained), never falsely eligible for eviction.
888
976
  observedStarts = new Set();
@@ -916,6 +1004,7 @@ export class SubcTransportPool {
916
1004
  this.harness = options.harness;
917
1005
  this.handshakeTimeoutMs = options.handshakeTimeoutMs;
918
1006
  this.consumerIdentity = options.consumerIdentity;
1007
+ this.hostScopes = options.hostScopes ?? false;
919
1008
  this.connectFn = options.connect ?? ((opts) => SubcClient.connect(opts));
920
1009
  this.onBgEventsNudge = options.onBgEventsNudge;
921
1010
  this.onBgEventsNudgeRef = options.onBgEventsNudgeRef;
@@ -1174,7 +1263,9 @@ export class SubcTransportPool {
1174
1263
  * always has a registered root and captured generation.
1175
1264
  */
1176
1265
  getBridge(projectRoot) {
1177
- const root = this.canonicalRoot(projectRoot);
1266
+ return this.getBridgeForCanonicalRoot(this.canonicalRoot(projectRoot));
1267
+ }
1268
+ getBridgeForCanonicalRoot(root) {
1178
1269
  if (this.shuttingDown && this.lifecycleEnabled()) {
1179
1270
  throw new SubcTransportShuttingDownError();
1180
1271
  }
@@ -1279,6 +1370,12 @@ export class SubcTransportPool {
1279
1370
  pendingDetach: false,
1280
1371
  };
1281
1372
  this.sessions.set(key, record);
1373
+ let records = this.sessionIndex.get(identity.session);
1374
+ if (!records) {
1375
+ records = new Set();
1376
+ this.sessionIndex.set(identity.session, records);
1377
+ }
1378
+ records.add(record);
1282
1379
  let keys = this.rootIndex.get(root);
1283
1380
  if (!keys) {
1284
1381
  keys = new Set();
@@ -1485,6 +1582,10 @@ export class SubcTransportPool {
1485
1582
  log(`subc bg_events: ${message}${suffix}`, { sessionId: record.identity.session });
1486
1583
  }
1487
1584
  removeIndexMembership(record) {
1585
+ const records = this.sessionIndex.get(record.identity.session);
1586
+ records?.delete(record);
1587
+ if (records?.size === 0)
1588
+ this.sessionIndex.delete(record.identity.session);
1488
1589
  const keys = this.rootIndex.get(record.canonicalRoot);
1489
1590
  if (!keys)
1490
1591
  return;
@@ -1507,6 +1608,7 @@ export class SubcTransportPool {
1507
1608
  this.sessions.delete(record.identityKey);
1508
1609
  this.removeIndexMembership(record);
1509
1610
  }
1611
+ this.stopUnusedHostScopeRetry(record.identity.session);
1510
1612
  }
1511
1613
  /**
1512
1614
  * Atomically detaches one opaque identity. Nothing in this method awaits: it
@@ -1530,6 +1632,7 @@ export class SubcTransportPool {
1530
1632
  record.routeEntry = null;
1531
1633
  if (routeEntry)
1532
1634
  routeEntry.closed = true;
1635
+ this.stopUnusedHostScopeRetry(record.identity.session);
1533
1636
  return { record, bgSub, routeEntry };
1534
1637
  }
1535
1638
  async cleanupDetached(detached) {
@@ -1589,6 +1692,17 @@ export class SubcTransportPool {
1589
1692
  entry.inflight -= 1;
1590
1693
  this.closeRetiredRoute(record, entry);
1591
1694
  }
1695
+ retireHostToolRoute(record) {
1696
+ const entry = record.routeEntry;
1697
+ if (!entry)
1698
+ return;
1699
+ entry.closed = true;
1700
+ entry.closedBy ??= "route replaced";
1701
+ record.routeEntry = null;
1702
+ record.retiredRoutes.add(entry);
1703
+ this.closeRetiredRoute(record, entry);
1704
+ this.stopUnusedHostScopeRetry(record.identity.session);
1705
+ }
1592
1706
  /** Open or reuse a route while guarding every lifecycle boundary. */
1593
1707
  async routeRequest(identity, body, timeoutMs, onProgress, expectedGeneration, abortSignal) {
1594
1708
  const callStartedAt = performance.now();
@@ -1760,17 +1874,16 @@ export class SubcTransportPool {
1760
1874
  const clearRouteEntry = (entry) => {
1761
1875
  if (record.routeEntry !== entry)
1762
1876
  return;
1763
- entry.closed = true;
1764
- entry.closedBy ??= "route replaced";
1765
- record.routeEntry = null;
1766
- record.retiredRoutes.add(entry);
1767
- this.closeRetiredRoute(record, entry);
1877
+ this.retireHostToolRoute(record);
1768
1878
  };
1769
1879
  const handleRequestFailure = (error, entry) => {
1770
1880
  const aborted = error instanceof Error && error.name === "AbortError";
1771
1881
  const ownsClient = this.isCurrentSession(key, record) &&
1772
1882
  this.client === client &&
1773
1883
  !this.isReapInduced(record);
1884
+ if (ownsClient && error instanceof SubcError && error.closeReason?.startsWith("scope_")) {
1885
+ this.invalidateHostScope(identity.session);
1886
+ }
1774
1887
  if (!aborted && ownsClient && isSocketDeathError(error)) {
1775
1888
  // Dropped before the route is cleared so the drop log counts it.
1776
1889
  this.dropClient(client, {
@@ -1785,7 +1898,8 @@ export class SubcTransportPool {
1785
1898
  // route remains reusable, and no replacement may cut its other calls.
1786
1899
  if (aborted ||
1787
1900
  isRouteProvenAbsentError(error) ||
1788
- (error instanceof SubcError && error.closeReason !== undefined)) {
1901
+ (error instanceof SubcError && error.closeReason !== undefined) ||
1902
+ isSubcFrameReadTimeoutError(error)) {
1789
1903
  clearRouteEntry(entry);
1790
1904
  }
1791
1905
  if (aborted || !ownsClient || this.client !== client)
@@ -1877,10 +1991,15 @@ export class SubcTransportPool {
1877
1991
  return reply;
1878
1992
  }
1879
1993
  catch (requestError) {
1880
- const error = routeClosedMidCallError(requestError, routeAndEntry.entry, record);
1994
+ const error = frameReadTimeoutError(routeClosedMidCallError(requestError, routeAndEntry.entry, record));
1881
1995
  if (this.isReapInduced(record))
1882
1996
  throw this.annotateReapError(error, record);
1883
1997
  const ownsRoute = this.isCurrentSession(key, record) && this.client === client;
1998
+ if (ownsRoute &&
1999
+ ((error instanceof SubcError && error.code === "stale_route_epoch") ||
2000
+ error instanceof StaleRouteHandleError)) {
2001
+ this.invalidateHostScope(identity.session);
2002
+ }
1884
2003
  // A route that was bound before a module reload started gets its
1885
2004
  // requests refused by the daemon for as long as the drain lasts. The
1886
2005
  // refusal happens before the request is forwarded, so it is handled
@@ -1978,9 +2097,14 @@ export class SubcTransportPool {
1978
2097
  return this.client;
1979
2098
  if (this.connecting)
1980
2099
  return this.connecting;
2100
+ let owner;
1981
2101
  this.connecting = this.connectFn({
1982
2102
  connectionFile: this.connectionFile,
1983
2103
  handshakeTimeoutMs: this.handshakeTimeoutMs,
2104
+ onControlPush: (push) => {
2105
+ if (owner && this.client === owner)
2106
+ this.onScopeRouteClose(push);
2107
+ },
1984
2108
  })
1985
2109
  .then((client) => {
1986
2110
  this.connecting = null;
@@ -1994,6 +2118,7 @@ export class SubcTransportPool {
1994
2118
  throw new SubcTransportShuttingDownError();
1995
2119
  }
1996
2120
  this.client = client;
2121
+ owner = client;
1997
2122
  this.hadWorkingClient = true;
1998
2123
  this.transportFailures = 0;
1999
2124
  return client;
@@ -2027,10 +2152,7 @@ export class SubcTransportPool {
2027
2152
  closedBy: null,
2028
2153
  };
2029
2154
  this.assertRootCanAttach(record.canonicalRoot);
2030
- const opening = client
2031
- .routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
2032
- consumerIdentity: this.consumerIdentity,
2033
- })
2155
+ const opening = this.openHostToolRoute(client, identity, record, entry)
2034
2156
  .then((route) => {
2035
2157
  if (!this.isCurrentSession(record.identityKey, record) ||
2036
2158
  record.routeEntry !== entry ||
@@ -2046,6 +2168,15 @@ export class SubcTransportPool {
2046
2168
  entry.handle = route;
2047
2169
  entry.opening = null;
2048
2170
  this.noteClientAlive(client);
2171
+ const state = this.hostScopeCache.get(identity.session);
2172
+ if (state)
2173
+ this.scheduleHostScopeRetry(identity.session, state);
2174
+ if (state?.answer?.result === "scope" &&
2175
+ !state.scopeBindPending &&
2176
+ !entry.hostScope &&
2177
+ !this.hostScopeRefusals.has(identity.session)) {
2178
+ this.promoteHostScope(identity.session);
2179
+ }
2049
2180
  return route;
2050
2181
  })
2051
2182
  .catch((error) => {
@@ -2054,6 +2185,7 @@ export class SubcTransportPool {
2054
2185
  entry.closed = true;
2055
2186
  record.routeEntry = null;
2056
2187
  }
2188
+ this.stopUnusedHostScopeRetry(identity.session);
2057
2189
  if (!this.isCurrentSession(record.identityKey, record) &&
2058
2190
  !(error instanceof RouteTornDownError)) {
2059
2191
  throw new RouteTornDownError("subc route opened after session closed");
@@ -2067,6 +2199,393 @@ export class SubcTransportPool {
2067
2199
  record.routeEntry = entry;
2068
2200
  return { route: await opening, entry };
2069
2201
  }
2202
+ hostScope(client, identity) {
2203
+ if (this.hostScopeRefusals.has(identity.session))
2204
+ return Promise.resolve(undefined);
2205
+ let state = this.hostScopeCache.get(identity.session);
2206
+ if (!state) {
2207
+ state = { retryAt: 0, retryMs: HOST_SCOPE_RETRY_FLOOR_MS };
2208
+ this.hostScopeCache.set(identity.session, state);
2209
+ }
2210
+ if (state.answer) {
2211
+ return Promise.resolve(state.answer.result === "scope" && !state.scopeBindPending ? state.answer.scope : undefined);
2212
+ }
2213
+ if (state.pending)
2214
+ return state.pending;
2215
+ // Backoff skips the lookup, not the bind, so an unavailable core cannot delay every root.
2216
+ if (Date.now() < state.retryAt)
2217
+ return Promise.resolve(undefined);
2218
+ const current = state;
2219
+ clearTimeout(current.retryTimer);
2220
+ current.retryTimer = undefined;
2221
+ const abort = new AbortController();
2222
+ current.lookupAbort = abort;
2223
+ const lookup = this.readHostScope(client, identity, abort.signal).then((outcome) => {
2224
+ // A scope-close push may invalidate the session while the management request is in flight.
2225
+ if (this.hostScopeCache.get(identity.session) !== current)
2226
+ return undefined;
2227
+ current.pending = undefined;
2228
+ current.lookupAbort = undefined;
2229
+ if (abort.signal.aborted)
2230
+ return undefined;
2231
+ this.logHostScopeOutcome(identity.session, outcome);
2232
+ if (outcome.result === "scope" || outcome.result === "no_scope") {
2233
+ current.answer = outcome;
2234
+ current.retryAt = 0;
2235
+ current.retryMs = HOST_SCOPE_RETRY_FLOOR_MS;
2236
+ if (outcome.result === "scope")
2237
+ this.promoteHostScope(identity.session);
2238
+ }
2239
+ else {
2240
+ current.retryAt = Date.now() + current.retryMs;
2241
+ current.retryMs = Math.min(current.retryMs * 2, HOST_SCOPE_RETRY_CAP_MS);
2242
+ this.scheduleHostScopeRetry(identity.session, current);
2243
+ }
2244
+ return outcome.result === "scope" ? outcome.scope : undefined;
2245
+ });
2246
+ current.pending = lookup;
2247
+ return lookup;
2248
+ }
2249
+ unscopedHostRecord(session) {
2250
+ for (const record of this.sessionIndex.get(session) ?? []) {
2251
+ const entry = record.routeEntry;
2252
+ if (record.identity.session === session &&
2253
+ !record.closed &&
2254
+ ((entry &&
2255
+ !entry.closed &&
2256
+ !entry.hostScope &&
2257
+ entry.client === this.client &&
2258
+ (entry.handle || entry.opening)) ||
2259
+ (record.bgSub?.attachedClient === this.client && record.bgSub?.attachedRoute)))
2260
+ return record;
2261
+ }
2262
+ return undefined;
2263
+ }
2264
+ cancelHostScopeRetry(state) {
2265
+ clearTimeout(state.retryTimer);
2266
+ state.retryTimer = undefined;
2267
+ state.lookupAbort?.abort();
2268
+ }
2269
+ stopUnusedHostScopeRetry(session) {
2270
+ const state = this.hostScopeCache.get(session);
2271
+ if (state && !this.unscopedHostRecord(session)) {
2272
+ if (state.retryTimer || (state.scopeBindPending && state.bindPending))
2273
+ log(`subc head scope: session=${session} retry stopped reason=route_closed`);
2274
+ this.cancelHostScopeRetry(state);
2275
+ state.scopeBindPending = false;
2276
+ }
2277
+ }
2278
+ scheduleHostScopeRetry(session, state) {
2279
+ if (this.shuttingDown ||
2280
+ this.hostScopeCache.get(session) !== state ||
2281
+ (state.answer && !state.scopeBindPending) ||
2282
+ state.pending ||
2283
+ state.bindPending ||
2284
+ state.retryTimer ||
2285
+ this.hostScopeRefusals.has(session) ||
2286
+ !this.unscopedHostRecord(session))
2287
+ return;
2288
+ // A head can keep its tool route for its whole life, so recovery cannot rely on another bind.
2289
+ state.retryTimer = setTimeout(() => {
2290
+ state.retryTimer = undefined;
2291
+ const record = this.unscopedHostRecord(session);
2292
+ const client = record?.routeEntry?.client ?? record?.bgSub?.attachedClient;
2293
+ if (this.shuttingDown || this.hostScopeCache.get(session) !== state || !record || !client)
2294
+ return;
2295
+ let retry;
2296
+ if (state.scopeBindPending && state.answer?.result === "scope") {
2297
+ state.bindPending = this.retryHostScopeBind(client, record.identity, state, state.answer.scope).finally(() => {
2298
+ state.bindPending = undefined;
2299
+ this.scheduleHostScopeRetry(session, state);
2300
+ });
2301
+ retry = state.bindPending;
2302
+ }
2303
+ else {
2304
+ retry = this.hostScope(client, record.identity);
2305
+ }
2306
+ void retry.catch((error) => {
2307
+ warn(`subc head scope: background retry failed code=${error instanceof Error ? error.name : "Error"}`);
2308
+ });
2309
+ }, Math.max(0, state.retryAt - Date.now()));
2310
+ state.retryTimer.unref();
2311
+ }
2312
+ deferHostScopeBind(session, state, code) {
2313
+ state.scopeBindPending = true;
2314
+ if (Date.now() >= state.retryAt) {
2315
+ state.retryAt = Date.now() + state.retryMs;
2316
+ state.retryMs = Math.min(state.retryMs * 2, HOST_SCOPE_RETRY_CAP_MS);
2317
+ }
2318
+ warn(`subc head scope: session=${session} scoped bind refused code=${code}; binding unscoped; retry in=${Math.max(0, state.retryAt - Date.now())}ms`);
2319
+ this.scheduleHostScopeRetry(session, state);
2320
+ }
2321
+ refuseHostScopeBind(session, state, code) {
2322
+ if (!this.hostScopeRefusals.has(session)) {
2323
+ this.hostScopeRefusals.add(session);
2324
+ warn(`subc head scope: session=${session} scoped bind refused code=${code}; binding unscoped; retry stopped`);
2325
+ }
2326
+ if (state) {
2327
+ state.scopeBindPending = false;
2328
+ this.cancelHostScopeRetry(state);
2329
+ }
2330
+ // Remember the downgrade without misreporting it as core's no_scope answer.
2331
+ }
2332
+ async retryHostScopeBind(client, identity, state, scope) {
2333
+ try {
2334
+ // Validate daemon acceptance before retiring a working fallback route or its held requests.
2335
+ const route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, { consumerIdentity: this.consumerIdentity, scope });
2336
+ safeCloseRoute(client, route);
2337
+ if (this.hostScopeCache.get(identity.session) !== state ||
2338
+ !state.scopeBindPending ||
2339
+ this.shuttingDown ||
2340
+ client !== this.client ||
2341
+ !this.unscopedHostRecord(identity.session))
2342
+ return;
2343
+ state.scopeBindPending = false;
2344
+ state.retryAt = 0;
2345
+ state.retryMs = HOST_SCOPE_RETRY_FLOOR_MS;
2346
+ log(`subc head scope: session=${identity.session} scoped bind recovered ref=${scope.ref.slice(0, 8)} epoch=${scope.scopeEpoch}`);
2347
+ this.promoteHostScope(identity.session);
2348
+ }
2349
+ catch (error) {
2350
+ if (this.hostScopeCache.get(identity.session) !== state ||
2351
+ !state.scopeBindPending ||
2352
+ this.shuttingDown ||
2353
+ !this.unscopedHostRecord(identity.session))
2354
+ return;
2355
+ if (error instanceof SubcError &&
2356
+ error.code &&
2357
+ !isSocketDeathError(error) &&
2358
+ !isRouteOpenReloadWindowError(error) &&
2359
+ !isHostScopeSyncRefusal(error.code)) {
2360
+ this.refuseHostScopeBind(identity.session, state, error.code);
2361
+ }
2362
+ else {
2363
+ this.deferHostScopeBind(identity.session, state, error instanceof SubcError ? (error.code ?? error.name) : "Error");
2364
+ }
2365
+ }
2366
+ }
2367
+ promoteHostScope(session) {
2368
+ for (const record of this.sessions.values()) {
2369
+ const entry = record.routeEntry;
2370
+ if (record.identity.session !== session)
2371
+ continue;
2372
+ // Retirement stops new dispatches; the existing request counter defers closure of held waits.
2373
+ if (entry?.handle && !entry.hostScope)
2374
+ this.retireHostToolRoute(record);
2375
+ const sub = record.bgSub;
2376
+ if (!sub?.attachedRoute)
2377
+ continue;
2378
+ // The old subscription must finish closing before its normal attach path opens a scoped one.
2379
+ void sub.stop().then(() => {
2380
+ if (record.bgSub !== sub)
2381
+ return;
2382
+ record.bgSub = null;
2383
+ this.ensureBgSubscription(record.identity, record);
2384
+ });
2385
+ }
2386
+ }
2387
+ logHostScopeOutcome(session, outcome) {
2388
+ let key;
2389
+ let detail;
2390
+ switch (outcome.result) {
2391
+ case "scope":
2392
+ key = JSON.stringify([outcome.result, outcome.scope.ref, outcome.scope.scopeEpoch]);
2393
+ detail = `scope ref=${outcome.scope.ref.slice(0, 8)} epoch=${outcome.scope.scopeEpoch}`;
2394
+ break;
2395
+ case "error":
2396
+ key = JSON.stringify([outcome.result, outcome.code]);
2397
+ detail = `error code=${outcome.code} after=${outcome.afterMs}`;
2398
+ break;
2399
+ case "timeout":
2400
+ key = outcome.result;
2401
+ detail = `timeout after=${outcome.afterMs}`;
2402
+ break;
2403
+ default:
2404
+ key = outcome.result;
2405
+ detail = outcome.result;
2406
+ }
2407
+ // Elapsed time varies on every retry; only a changed answer or error code merits another line.
2408
+ if (this.hostScopeOutcomes.get(session) === key)
2409
+ return;
2410
+ this.hostScopeOutcomes.set(session, key);
2411
+ log(`subc head scope: session=${session} result=${detail}`);
2412
+ }
2413
+ async readHostScope(client, identity, signal) {
2414
+ const startedAt = Date.now();
2415
+ const timeout = () => ({ result: "timeout", afterMs: Date.now() - startedAt });
2416
+ let expired = false;
2417
+ let timer;
2418
+ const abort = new AbortController();
2419
+ let cancel;
2420
+ const cancelled = new Promise((resolve) => {
2421
+ cancel = () => {
2422
+ expired = true;
2423
+ abort.abort();
2424
+ resolve({ result: "error", code: "AbortError", afterMs: Date.now() - startedAt });
2425
+ };
2426
+ signal.addEventListener("abort", cancel, { once: true });
2427
+ });
2428
+ const read = async () => {
2429
+ let route;
2430
+ try {
2431
+ route = await client.routeOpen({ kind: "management_surface", module_id: "prefrontal-core" }, identity, { consumerIdentity: this.consumerIdentity });
2432
+ if (expired)
2433
+ return timeout();
2434
+ const envelope = await client.request(route, {
2435
+ // Core decodes every management request as {method, params}.
2436
+ method: "session.scope_identity",
2437
+ params: { harness: identity.harness, session: identity.session },
2438
+ }, { timeoutMs: HOST_SCOPE_LOOKUP_TIMEOUT_MS, signal: abort.signal });
2439
+ // Core answers a management request as {result: <answer>}.
2440
+ const reply = isRecord(envelope) ? envelope.result : undefined;
2441
+ if (isRecord(reply) && reply.answer === "no_scope")
2442
+ return { result: "no_scope" };
2443
+ if (!isRecord(reply) ||
2444
+ !isRecord(reply.owner) ||
2445
+ reply.owner.kind !== "reserved" ||
2446
+ reply.owner.module_id !== "prefrontal-core" ||
2447
+ typeof reply.ref !== "string" ||
2448
+ reply.ref.length === 0 ||
2449
+ !Number.isSafeInteger(reply.scope_epoch) ||
2450
+ reply.scope_epoch < 0)
2451
+ return { result: "malformed" };
2452
+ return {
2453
+ result: "scope",
2454
+ scope: {
2455
+ owner: { kind: "reserved", module_id: "prefrontal-core" },
2456
+ ref: reply.ref,
2457
+ scopeEpoch: reply.scope_epoch,
2458
+ },
2459
+ };
2460
+ }
2461
+ catch (error) {
2462
+ // A missing or unavailable scope service must not disable the host's tools.
2463
+ if (expired)
2464
+ return timeout();
2465
+ return {
2466
+ result: "error",
2467
+ code: error instanceof SubcError && error.code
2468
+ ? error.code
2469
+ : error instanceof Error
2470
+ ? error.name
2471
+ : "Error",
2472
+ afterMs: Date.now() - startedAt,
2473
+ };
2474
+ }
2475
+ finally {
2476
+ if (route)
2477
+ safeCloseRoute(client, route);
2478
+ }
2479
+ };
2480
+ try {
2481
+ // Keep the two-second bind budget during core startup; fallback routes retry in the background.
2482
+ return await Promise.race([
2483
+ read(),
2484
+ cancelled,
2485
+ new Promise((resolve) => {
2486
+ timer = setTimeout(() => {
2487
+ expired = true;
2488
+ abort.abort();
2489
+ resolve(timeout());
2490
+ }, HOST_SCOPE_LOOKUP_TIMEOUT_MS);
2491
+ }),
2492
+ ]);
2493
+ }
2494
+ finally {
2495
+ clearTimeout(timer);
2496
+ signal.removeEventListener("abort", cancel);
2497
+ }
2498
+ }
2499
+ openHostToolRoute(client, identity, record, entry) {
2500
+ if (!this.hostScopes ||
2501
+ !["opencode", "pi"].includes(identity.harness) ||
2502
+ identity.session === DEFAULT_SESSION_ID) {
2503
+ return client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
2504
+ consumerIdentity: this.consumerIdentity,
2505
+ });
2506
+ }
2507
+ return this.openScopedHostToolRoute(client, identity, record, entry);
2508
+ }
2509
+ async openScopedHostToolRoute(client, identity, record, entry) {
2510
+ const scope = await this.hostScope(client, identity);
2511
+ this.assertRecordLive(record);
2512
+ if (scope) {
2513
+ try {
2514
+ const route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, { consumerIdentity: this.consumerIdentity, scope });
2515
+ if (entry)
2516
+ entry.hostScope = scope;
2517
+ return route;
2518
+ }
2519
+ catch (error) {
2520
+ if (!(error instanceof SubcError) || !error.code || isSocketDeathError(error))
2521
+ throw error;
2522
+ // A restarting provider has not rejected the scope. Keep it for the normal reopen retry.
2523
+ if (isRouteOpenReloadWindowError(error))
2524
+ throw error;
2525
+ this.assertRecordLive(record);
2526
+ const state = this.hostScopeCache.get(identity.session);
2527
+ if (state && isHostScopeSyncRefusal(error.code)) {
2528
+ this.deferHostScopeBind(identity.session, state, error.code);
2529
+ }
2530
+ else {
2531
+ this.refuseHostScopeBind(identity.session, state, error.code);
2532
+ }
2533
+ }
2534
+ }
2535
+ this.assertRecordLive(record);
2536
+ const route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
2537
+ consumerIdentity: this.consumerIdentity,
2538
+ });
2539
+ // Scope recovery can finish while a fallback bind waits for the provider, including bg_events.
2540
+ if (this.hostScopeCache.get(identity.session)?.answer?.result === "scope" &&
2541
+ !this.hostScopeCache.get(identity.session)?.scopeBindPending &&
2542
+ !this.hostScopeRefusals.has(identity.session)) {
2543
+ safeCloseRoute(client, route);
2544
+ return this.openScopedHostToolRoute(client, identity, record, entry);
2545
+ }
2546
+ return route;
2547
+ }
2548
+ invalidateHostScope(session) {
2549
+ const state = this.hostScopeCache.get(session);
2550
+ if (state) {
2551
+ if (state.scopeBindPending)
2552
+ log(`subc head scope: session=${session} retry stopped reason=scope_route_closed`);
2553
+ this.cancelHostScopeRetry(state);
2554
+ }
2555
+ this.hostScopeCache.delete(session);
2556
+ this.hostScopeRefusals.delete(session);
2557
+ for (const record of this.sessions.values()) {
2558
+ if (record.identity.session !== session || !record.routeEntry)
2559
+ continue;
2560
+ this.retireHostToolRoute(record);
2561
+ }
2562
+ }
2563
+ onScopeRouteClose(push) {
2564
+ if (push.op !== "route.closed")
2565
+ return;
2566
+ const reason = routeCloseReason(push);
2567
+ if (!reason?.startsWith("scope_")) {
2568
+ for (const record of this.sessions.values()) {
2569
+ const route = record.routeEntry?.handle;
2570
+ if (!route)
2571
+ continue;
2572
+ if ((Array.isArray(push.body.channels) && push.body.channels.includes(route.channel)) ||
2573
+ (!Array.isArray(push.body.channels) && push.body.module_id === AFT_MODULE_ID))
2574
+ this.retireHostToolRoute(record);
2575
+ }
2576
+ return;
2577
+ }
2578
+ const sessions = new Set();
2579
+ for (const record of this.sessions.values()) {
2580
+ const routes = [record.routeEntry?.handle, record.bgSub?.attachedRoute].filter((route) => route != null);
2581
+ if ((Array.isArray(push.body.channels) &&
2582
+ routes.some((route) => push.body.channels.includes(route.channel))) ||
2583
+ (!Array.isArray(push.body.channels) && push.body.module_id === AFT_MODULE_ID))
2584
+ sessions.add(record.identity.session);
2585
+ }
2586
+ for (const session of sessions)
2587
+ this.invalidateHostScope(session);
2588
+ }
2070
2589
  ensureBgSubscription(identity, record) {
2071
2590
  if (this.shuttingDown || (!this.onBgEventsNudge && !this.onBgEventsNudgeRef))
2072
2591
  return;
@@ -2106,18 +2625,32 @@ export class SubcTransportPool {
2106
2625
  return;
2107
2626
  this.logNudgeDelivery("drop-no-compatible-handler", currentRecord, `nudge dropped cause=generation-provenance-unavailable-and-root-session-handler-unwired root=${currentRecord.canonicalRoot}`);
2108
2627
  };
2628
+ const binding = {};
2109
2629
  let sub = null;
2110
2630
  const clearDormantSubscription = () => {
2111
2631
  if (sub && record.bgSub === sub)
2112
2632
  record.bgSub = null;
2113
2633
  };
2114
- sub = new BgSubscription(identity, () => this.ensureClient(), (client, cause) => this.dropClient(client, cause), this.consumerIdentity, onNudge, this.bgBackoffSleep, () => this.rootCanAttach(record.canonicalRoot), (error) => {
2634
+ sub = new BgSubscription(identity, () => this.ensureClient(), (client, cause) => this.dropClient(client, cause), (client) => {
2635
+ binding.hostScope = undefined;
2636
+ return this.openHostToolRoute(client, identity, record, binding);
2637
+ }, onNudge, this.bgBackoffSleep, () => this.rootCanAttach(record.canonicalRoot), (error) => {
2115
2638
  if (!isAbsentRootRouteError(error))
2116
2639
  return false;
2117
2640
  this.markRootDormant(record.canonicalRoot, error);
2118
2641
  return true;
2119
2642
  }, 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));
2643
+ this.isCurrentLiveGeneration(record.canonicalRoot, record.generation), (client) => this.noteClientAlive(client), (client, cause) => this.noteUnansweredCall(client, cause), () => {
2644
+ const state = this.hostScopeCache.get(identity.session);
2645
+ if (state)
2646
+ this.scheduleHostScopeRetry(identity.session, state);
2647
+ if (state?.answer?.result === "scope" &&
2648
+ !state.scopeBindPending &&
2649
+ !binding.hostScope &&
2650
+ !this.hostScopeRefusals.has(identity.session)) {
2651
+ this.promoteHostScope(identity.session);
2652
+ }
2653
+ });
2121
2654
  record.bgSub = sub;
2122
2655
  if (!this.rootCanAttach(record.canonicalRoot)) {
2123
2656
  record.bgSub = null;
@@ -2166,6 +2699,8 @@ export class SubcTransportPool {
2166
2699
  record.routeEntry = null;
2167
2700
  this.deleteSessionIfEmpty(record.identityKey, record);
2168
2701
  }
2702
+ for (const session of this.hostScopeCache.keys())
2703
+ this.stopUnusedHostScopeRetry(session);
2169
2704
  this.transportFailures = 0;
2170
2705
  try {
2171
2706
  client.close();
@@ -2337,6 +2872,13 @@ export class SubcTransportPool {
2337
2872
  this.generationRejections.clear();
2338
2873
  this.observedStarts.clear();
2339
2874
  this.dormantRoots.clear();
2875
+ const scopeLookups = [...this.hostScopeCache.values()].flatMap((state) => {
2876
+ this.cancelHostScopeRetry(state);
2877
+ return state.pending ? [state.pending] : [];
2878
+ });
2879
+ this.hostScopeCache.clear();
2880
+ this.hostScopeRefusals.clear();
2881
+ this.hostScopeOutcomes.clear();
2340
2882
  // Deregistration synchronously tombstones all registered roots and invokes
2341
2883
  // closeProjectRoot before this method reaches its first await.
2342
2884
  const registration = this.lifecycleRegistration;
@@ -2349,11 +2891,16 @@ export class SubcTransportPool {
2349
2891
  detached.push(owner);
2350
2892
  }
2351
2893
  this.rootIndex.clear();
2894
+ this.sessionIndex.clear();
2352
2895
  this.transports.clear();
2353
2896
  const client = this.client;
2354
2897
  this.client = null;
2355
2898
  const pending = [...this.pendingRootCleanups];
2356
- await Promise.allSettled([...detached.map((owner) => this.cleanupDetached(owner)), ...pending]);
2899
+ await Promise.allSettled([
2900
+ ...detached.map((owner) => this.cleanupDetached(owner)),
2901
+ ...pending,
2902
+ ...scopeLookups,
2903
+ ]);
2357
2904
  if (client) {
2358
2905
  try {
2359
2906
  client.close();
@@ -2370,6 +2917,13 @@ export class SubcTransportPool {
2370
2917
  session: session && session.length > 0 ? session : DEFAULT_SESSION_ID,
2371
2918
  };
2372
2919
  const owner = this.detachSession(identityKey(identity), "session_closed");
2920
+ if (!this.sessionIndex.has(identity.session)) {
2921
+ const state = this.hostScopeCache.get(identity.session);
2922
+ if (state)
2923
+ this.cancelHostScopeRetry(state);
2924
+ this.hostScopeCache.delete(identity.session);
2925
+ this.hostScopeRefusals.delete(identity.session);
2926
+ }
2373
2927
  if (owner)
2374
2928
  await this.cleanupDetached(owner);
2375
2929
  }