@north-light/crouter 0.3.192 → 0.3.193

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 (167) hide show
  1. package/dist/api/client.d.ts +31 -10
  2. package/dist/api/client.js +83 -24
  3. package/dist/api/dto/bash-jobs.d.ts +22 -0
  4. package/dist/api/dto/bash-jobs.js +9 -0
  5. package/dist/api/dto/human.d.ts +3 -4
  6. package/dist/api/dto/inbox.d.ts +106 -65
  7. package/dist/api/dto/inbox.js +2 -8
  8. package/dist/api/index.d.ts +1 -0
  9. package/dist/api/index.js +1 -0
  10. package/dist/api/routes.d.ts +7 -0
  11. package/dist/api/routes.js +7 -0
  12. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/serial/provider-rotation.test.ts +101 -3
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +6 -2
  15. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +68 -14
  16. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +87 -13
  17. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +2 -2
  18. package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
  19. package/dist/clients/attach/chrome/canvas-panels.d.ts +1 -1
  20. package/dist/clients/attach/chrome/canvas-panels.js +1 -1
  21. package/dist/clients/attach/input/overlay-owner.js +1 -1
  22. package/dist/clients/attach/overlays/auth.js +34 -8
  23. package/dist/clients/attach/overlays/pickers.d.ts +5 -8
  24. package/dist/clients/attach/overlays/pickers.js +37 -43
  25. package/dist/clients/attach/viewer.js +643 -620
  26. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +38 -128
  27. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +72 -148
  28. package/dist/clients/inbox/controller.d.ts +2 -3
  29. package/dist/clients/inbox/controller.js +41 -42
  30. package/dist/clients/inbox/page-adapter.d.ts +33 -0
  31. package/dist/clients/inbox/page-adapter.js +54 -0
  32. package/dist/clients/inbox/resolve.d.ts +2 -2
  33. package/dist/clients/inbox/resolve.js +1 -1
  34. package/dist/clients/inbox/review-visibility.d.ts +1 -1
  35. package/dist/clients/inbox/review-visibility.js +1 -1
  36. package/dist/clients/inbox/tui/ansi.d.ts +1 -1
  37. package/dist/clients/inbox/tui/ansi.js +2 -2
  38. package/dist/clients/inbox/tui/input.d.ts +1 -3
  39. package/dist/clients/inbox/tui/input.js +242 -553
  40. package/dist/clients/inbox/tui/panel.d.ts +3 -1
  41. package/dist/clients/inbox/tui/panel.js +59 -187
  42. package/dist/clients/inbox/tui/render.d.ts +0 -25
  43. package/dist/clients/inbox/tui/render.js +165 -416
  44. package/dist/clients/inbox/tui/slots.d.ts +32 -0
  45. package/dist/clients/inbox/tui/slots.js +156 -0
  46. package/dist/clients/inbox/tui/types.d.ts +25 -13
  47. package/dist/clients/inbox/tui.js +5 -3
  48. package/dist/commands/attention.js +2 -2
  49. package/dist/commands/human/doc.js +66 -20
  50. package/dist/commands/human/prompts.d.ts +3 -3
  51. package/dist/commands/human/prompts.js +158 -112
  52. package/dist/commands/human/queue.d.ts +0 -1
  53. package/dist/commands/human/queue.js +34 -94
  54. package/dist/commands/human/shared.d.ts +10 -4
  55. package/dist/commands/human/shared.js +62 -13
  56. package/dist/commands/human.js +8 -4
  57. package/dist/commands/memory/read.js +13 -5
  58. package/dist/commands/node/bash.js +15 -28
  59. package/dist/commands/surface/node/focus.js +1 -1
  60. package/dist/core/__tests__/human-deliver.test.js +40 -46
  61. package/dist/core/__tests__/seam/broker-attach-stream.test.js +7 -1
  62. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +40 -31
  63. package/dist/core/__tests__/serial/spawn-root.test.js +3 -1
  64. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -1
  65. package/dist/core/auth-interaction.d.ts +2 -2
  66. package/dist/core/bash-jobs.d.ts +32 -0
  67. package/dist/core/bash-jobs.js +72 -1
  68. package/dist/core/canvas/__tests__/attention.test.js +16 -10
  69. package/dist/core/canvas/__tests__/render-remote.test.js +9 -3
  70. package/dist/core/canvas/attention.js +1 -1
  71. package/dist/core/canvas/browse/app.js +1 -1
  72. package/dist/core/canvas/nav-model.d.ts +1 -1
  73. package/dist/core/canvas/nav-model.js +1 -1
  74. package/dist/core/canvas/remote-canvas-source.js +1 -1
  75. package/dist/core/config.js +4 -1
  76. package/dist/core/human/__tests__/page-render.test.d.ts +1 -0
  77. package/dist/core/human/__tests__/page-render.test.js +49 -0
  78. package/dist/core/human/__tests__/page-tickets.test.d.ts +1 -0
  79. package/dist/core/human/__tests__/page-tickets.test.js +112 -0
  80. package/dist/core/human/__tests__/page.test.d.ts +1 -0
  81. package/dist/core/human/__tests__/page.test.js +139 -0
  82. package/dist/core/human/__tests__/serial/inbox-core.test.js +42 -32
  83. package/dist/core/human/claim.js +2 -2
  84. package/dist/core/human/convention.d.ts +4 -4
  85. package/dist/core/human/convention.js +6 -13
  86. package/dist/core/human/markdown-html.d.ts +8 -0
  87. package/dist/core/human/markdown-html.js +581 -0
  88. package/dist/core/human/page-catalog.d.ts +6 -0
  89. package/dist/core/human/page-catalog.js +43 -0
  90. package/dist/core/human/page-render.d.ts +3 -0
  91. package/dist/core/human/page-render.js +131 -0
  92. package/dist/core/human/page-schema.d.ts +312 -0
  93. package/dist/core/human/page-schema.js +291 -0
  94. package/dist/core/human/page-synth.d.ts +9 -0
  95. package/dist/core/human/page-synth.js +17 -0
  96. package/dist/core/human/page.d.ts +22 -0
  97. package/dist/core/human/page.js +427 -0
  98. package/dist/core/human/review-schema.d.ts +19 -0
  99. package/dist/core/human/review-schema.js +34 -0
  100. package/dist/core/human/scan.d.ts +12 -3
  101. package/dist/core/human/scan.js +69 -26
  102. package/dist/core/human/summary.d.ts +5 -6
  103. package/dist/core/human/summary.js +54 -37
  104. package/dist/core/human/tickets.d.ts +61 -11
  105. package/dist/core/human/tickets.js +100 -57
  106. package/dist/core/human/types.d.ts +21 -66
  107. package/dist/core/human/types.js +1 -5
  108. package/dist/core/preview-registry.js +0 -1
  109. package/dist/core/review/ticket-filter.d.ts +1 -1
  110. package/dist/core/review/ticket-filter.js +2 -8
  111. package/dist/core/runtime/broker/read-ops.js +3 -0
  112. package/dist/core/runtime/broker.js +19 -10
  113. package/dist/core/runtime/package-health.js +1 -1
  114. package/dist/core/termrender/termrender.d.ts +1 -1
  115. package/dist/core/termrender/termrender.js +2 -2
  116. package/dist/core/tui/page-host.d.ts +1 -1
  117. package/dist/core/tui/page-host.js +2 -2
  118. package/dist/core/user-settings.d.ts +19 -0
  119. package/dist/core/user-settings.js +22 -0
  120. package/dist/daemon/api/handlers/bash-jobs.d.ts +2 -0
  121. package/dist/daemon/api/handlers/bash-jobs.js +89 -0
  122. package/dist/daemon/api/handlers/human.js +16 -14
  123. package/dist/daemon/api/handlers/inbox.js +311 -227
  124. package/dist/daemon/api/router.d.ts +5 -1
  125. package/dist/daemon/api/router.js +5 -0
  126. package/dist/daemon/api/server.js +2 -0
  127. package/dist/daemon/cron-run.js +23 -10
  128. package/dist/daemon/human/finish.d.ts +8 -3
  129. package/dist/daemon/human/finish.js +35 -15
  130. package/dist/pages/bundle.css +1 -0
  131. package/dist/pages/bundle.js +1347 -0
  132. package/dist/pages/comments.d.ts +34 -0
  133. package/dist/pages/comments.js +72 -0
  134. package/dist/pages/elements/cards.d.ts +17 -0
  135. package/dist/pages/elements/cards.js +785 -0
  136. package/dist/pages/elements/chart.d.ts +1 -0
  137. package/dist/pages/elements/chart.js +648 -0
  138. package/dist/pages/elements/options.d.ts +21 -0
  139. package/dist/pages/elements/options.js +604 -0
  140. package/dist/pages/elements/pages.d.ts +24 -0
  141. package/dist/pages/elements/pages.js +355 -0
  142. package/dist/pages/elements/slot.d.ts +21 -0
  143. package/dist/pages/elements/slot.js +105 -0
  144. package/dist/pages/elements/table.d.ts +19 -0
  145. package/dist/pages/elements/table.js +906 -0
  146. package/dist/pages/elements/text.d.ts +25 -0
  147. package/dist/pages/elements/text.js +951 -0
  148. package/dist/pages/entry.d.ts +8 -0
  149. package/dist/pages/entry.js +8 -0
  150. package/dist/pages/host.d.ts +132 -0
  151. package/dist/pages/host.js +149 -0
  152. package/dist/pages/register.d.ts +2 -0
  153. package/dist/pages/register.js +3 -0
  154. package/dist/pages/slot-config.d.ts +36 -0
  155. package/dist/pages/slot-config.js +50 -0
  156. package/dist/pages/types.d.ts +98 -0
  157. package/dist/pages/types.js +23 -0
  158. package/dist/types.d.ts +4 -0
  159. package/dist/types.js +2 -0
  160. package/package.json +8 -8
  161. package/runtime.lock.json +156 -101
  162. package/dist/clients/inbox/deck-adapter.d.ts +0 -29
  163. package/dist/clients/inbox/deck-adapter.js +0 -63
  164. package/dist/core/human/deck-factories.d.ts +0 -8
  165. package/dist/core/human/deck-factories.js +0 -15
  166. package/dist/core/human/deck-schema.d.ts +0 -70
  167. package/dist/core/human/deck-schema.js +0 -92
@@ -138,8 +138,11 @@ test("brick-repro: concurrent refreshes of the same expired pool token serialize
138
138
  assert.equal(poolEntry.label, "anthropic");
139
139
 
140
140
  let refreshCalls = 0;
141
- rotationModule.__setRefreshForProviderForTest(async (_providerId, refreshToken) => {
141
+ const requestController = new AbortController();
142
+ const refreshSignals: (AbortSignal | undefined)[] = [];
143
+ rotationModule.__setRefreshForProviderForTest(async (_providerId, refreshToken, signal) => {
142
144
  refreshCalls += 1;
145
+ refreshSignals.push(signal);
143
146
  if (refreshCalls === 1) {
144
147
  return { refresh: "shared-refresh-rotated", access: "access-fresh", expires: Date.now() + 60_000 };
145
148
  }
@@ -150,11 +153,12 @@ test("brick-repro: concurrent refreshes of the same expired pool token serialize
150
153
 
151
154
  try {
152
155
  const [a, b] = await Promise.all([
153
- rotationModule.__refreshCredentialIfNeededForTest("anthropic", poolEntry),
154
- rotationModule.__refreshCredentialIfNeededForTest("anthropic", poolEntry),
156
+ rotationModule.__refreshCredentialIfNeededForTest("anthropic", poolEntry, requestController.signal),
157
+ rotationModule.__refreshCredentialIfNeededForTest("anthropic", poolEntry, requestController.signal),
155
158
  ]);
156
159
 
157
160
  assert.equal(refreshCalls, 1);
161
+ assert.deepEqual(refreshSignals, [requestController.signal], 'the request abort signal reaches the serialized token exchange');
158
162
  assert.equal(a.access, "access-fresh");
159
163
  assert.equal(b.access, "access-fresh");
160
164
 
@@ -1192,6 +1196,100 @@ test("surfaces the underlying transient error without reporting a rate limit whe
1192
1196
  }
1193
1197
  });
1194
1198
 
1199
+ // Regression: a transport-poisoned process (a corrupted TLS upload leaves undici's H2
1200
+ // session destroyed, so EVERY request fails with no HTTP response while fresh processes
1201
+ // succeed) must self-terminate after consecutive transport-only exhausted passes so the
1202
+ // daemon respawns the broker fresh — and any pass that saw a real HTTP status must reset
1203
+ // the counter so ordinary provider blips never kill a healthy process.
1204
+ test("self-terminates after consecutive transport-only exhausted passes; an HTTP status resets the counter", async () => {
1205
+ await resetRotationState();
1206
+ writeLadderConfig();
1207
+ await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
1208
+
1209
+ const { ctx, providers, events, pi } = makeRotationCtx(undefined);
1210
+ rotationModule.__setSleepForTest(async () => {});
1211
+ let mode: "success" | "transport" | "http503" = "success";
1212
+ rotationModule.__setStreamForProviderForTest(() => {
1213
+ const current = mode;
1214
+ return (async function* () {
1215
+ if (current === "success") {
1216
+ yield { type: "text_delta", text: "ok" };
1217
+ return;
1218
+ }
1219
+ if (current === "http503") {
1220
+ // Transient by status — but an HTTP response nonetheless: proof the
1221
+ // process can still reach the provider.
1222
+ yield { type: "error", error: Object.assign(new Error("Service Unavailable"), { status: 503 }) };
1223
+ return;
1224
+ }
1225
+ // No status at all — the poisoned-transport signature.
1226
+ yield {
1227
+ type: "error",
1228
+ error: Object.assign(new Error("fetch failed: The session has been destroyed"), {
1229
+ errorMessage: "fetch failed: The session has been destroyed",
1230
+ }),
1231
+ };
1232
+ })() as any;
1233
+ });
1234
+ const terminateCalls: string[] = [];
1235
+ let resolveTerminated: (tried: string) => void;
1236
+ const terminated = new Promise<string>((resolve) => { resolveTerminated = resolve; });
1237
+ rotationModule.__setTerminatePoisonedProcessForTest((tried) => {
1238
+ terminateCalls.push(tried);
1239
+ resolveTerminated(tried);
1240
+ return true;
1241
+ });
1242
+
1243
+ const runPass = async () => {
1244
+ const stream = providers["openai-codex"].streamSimple(
1245
+ { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
1246
+ ctx,
1247
+ );
1248
+ const emitted: any[] = [];
1249
+ for await (const event of stream) emitted.push(event);
1250
+ return emitted;
1251
+ };
1252
+
1253
+ try {
1254
+ await providerRotationExtension(pi);
1255
+ await events.session_start?.({}, ctx);
1256
+
1257
+ // Baseline success pass: the cross-turn counter starts at zero.
1258
+ assert.ok((await runPass()).some((e) => e.type === "text_delta"));
1259
+
1260
+ // First transport-only exhaustion: ordinary terminal error, no termination.
1261
+ mode = "transport";
1262
+ assert.ok((await runPass()).some((e) => e.type === "error"));
1263
+ assert.equal(terminateCalls.length, 0);
1264
+
1265
+ // An exhausted pass WITH an HTTP status resets the counter.
1266
+ mode = "http503";
1267
+ assert.ok((await runPass()).some((e) => e.type === "error"));
1268
+
1269
+ // One transport-only pass after the reset: still below the limit.
1270
+ mode = "transport";
1271
+ assert.ok((await runPass()).some((e) => e.type === "error"));
1272
+ assert.equal(terminateCalls.length, 0);
1273
+
1274
+ // Second consecutive transport-only pass: terminate fires and the stream is
1275
+ // deliberately left open (no terminal error), so the SIGTERM abort path owns
1276
+ // how this turn settles. Don't await the never-ending stream.
1277
+ const stream = providers["openai-codex"].streamSimple(
1278
+ { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
1279
+ ctx,
1280
+ );
1281
+ const emitted: any[] = [];
1282
+ void (async () => { for await (const event of stream) emitted.push(event); })();
1283
+ await within(terminated, 5_000, "poisoned-process termination never fired");
1284
+ assert.equal(terminateCalls.length, 1);
1285
+ assert.equal(emitted.length, 0);
1286
+ } finally {
1287
+ rotationModule.__setTerminatePoisonedProcessForTest(undefined);
1288
+ rotationModule.__setStreamForProviderForTest(undefined);
1289
+ rotationModule.__setSleepForTest(undefined);
1290
+ }
1291
+ });
1292
+
1195
1293
  // Regression: a genuine rate limit (HTTP 429) must never get the transient in-place
1196
1294
  // retry treatment -- it cools the credential down and rotates on the FIRST failure. The
1197
1295
  // cooldown duration must reflect a real Retry-After header (5s here) rather than always
@@ -9,6 +9,10 @@ export interface ProviderRotationOptions {
9
9
  declare function defaultSleep(ms: number): Promise<void>;
10
10
  /** Test-only seam: override the sleep implementation used for in-place/wait-out retries. Pass undefined to restore the default. */
11
11
  export declare function __setSleepForTest(fn: typeof defaultSleep | undefined): void;
12
+ export declare const POISONED_TRANSPORT_PASS_LIMIT = 2;
13
+ declare function defaultTerminatePoisonedProcess(tried: string): boolean;
14
+ /** Test-only seam: override poisoned-process self-termination. Pass undefined to restore the default. */
15
+ export declare function __setTerminatePoisonedProcessForTest(fn: typeof defaultTerminatePoisonedProcess | undefined): void;
12
16
  /** Test-only seam for the bounded, non-fatal subscription bookkeeping path. */
13
17
  export declare function __recordSubscriptionMutationForTest(description: string, mutate: () => Promise<unknown>): Promise<boolean>;
14
18
  type RefreshedCredential = {
@@ -25,12 +29,12 @@ type LoginCredential = {
25
29
  type ResolvedCredential = SubscriptionCredential & {
26
30
  access: string;
27
31
  };
28
- type RefreshForProvider = (providerId: ManagedProviderId, refreshToken: string) => Promise<RefreshedCredential>;
32
+ type RefreshForProvider = (providerId: ManagedProviderId, refreshToken: string, signal?: AbortSignal) => Promise<RefreshedCredential>;
29
33
  /** Test-only seam: override the per-provider refresh function. Pass undefined to restore the default. */
30
34
  export declare function __setRefreshForProviderForTest(fn: RefreshForProvider | undefined): void;
31
35
  /** Test-only seam: exercises the real `refreshCredentialIfNeeded` (including its locked refresh
32
36
  * transaction) without duplicating its logic. */
33
- export declare function __refreshCredentialIfNeededForTest(providerId: ManagedProviderId, credential: SubscriptionCredential): Promise<ResolvedCredential>;
37
+ export declare function __refreshCredentialIfNeededForTest(providerId: ManagedProviderId, credential: SubscriptionCredential, signal?: AbortSignal): Promise<ResolvedCredential>;
34
38
  declare function defaultLoginForProvider(providerId: ManagedProviderId, ctx: ExtensionContext): Promise<LoginCredential>;
35
39
  /** Test-only seam: override the per-provider login function. Pass undefined to restore the default. */
36
40
  export declare function __setLoginForProviderForTest(fn: typeof defaultLoginForProvider | undefined): void;
@@ -24,7 +24,7 @@ const TRANSIENT_ROTATE_COOLDOWN_MS = 60 * 1000;
24
24
  // healthy model response into a fatal generation fault.
25
25
  const SUBSCRIPTION_LOCK_RETRY_BACKOFFS_MS = [25, 75, 150];
26
26
  function createProviderRotationState(options) {
27
- return { delegatedProviderStreams: new Map(), ...(options === undefined ? {} : { options: { ...options } }) };
27
+ return { delegatedProviderStreams: new Map(), transportOnlyExhaustedPasses: 0, ...(options === undefined ? {} : { options: { ...options } }) };
28
28
  }
29
29
  function configuredValue(state, key, envName) {
30
30
  return state.options === undefined ? process.env[envName] : state.options[key];
@@ -83,6 +83,33 @@ let activeSleep = defaultSleep;
83
83
  export function __setSleepForTest(fn) {
84
84
  activeSleep = fn ?? defaultSleep;
85
85
  }
86
+ // After this many CONSECUTIVE route-queue passes in which every failure was
87
+ // transport-level (no HTTP status and no Retry-After header — the provider was
88
+ // never reached at all), the broker process itself is presumed poisoned: a
89
+ // corrupted large TLS upload can leave Node/undici's HTTP/2 session permanently
90
+ // destroyed, after which EVERY provider request from this process fails while
91
+ // curl and fresh processes on the same host succeed. There is no clean in-process
92
+ // heal (the dispatcher cannot be safely re-instantiated), so the recovery is a
93
+ // fresh process: self-terminate via SIGTERM and let the daemon's exit policy
94
+ // respawn this broker against the same session. A genuine full network outage
95
+ // also trips this — harmlessly: the respawned broker fails the same way and the
96
+ // daemon's respawn backoff paces the loop.
97
+ export const POISONED_TRANSPORT_PASS_LIMIT = 2;
98
+ function defaultTerminatePoisonedProcess(tried) {
99
+ // Only the broker process itself may self-terminate: runBroker stamps
100
+ // CRTR_BROKER_PID with its own pid at boot. Any other host (tests, tooling)
101
+ // keeps the ordinary terminal-error path.
102
+ if (process.env["CRTR_BROKER_PID"] !== String(process.pid))
103
+ return false;
104
+ logRotation(`process transport poisoned: ${POISONED_TRANSPORT_PASS_LIMIT} consecutive route-queue passes failed with no HTTP response (tried ${tried}); exiting so the daemon respawns this broker fresh`);
105
+ process.kill(process.pid, "SIGTERM");
106
+ return true;
107
+ }
108
+ let activeTerminatePoisonedProcess = defaultTerminatePoisonedProcess;
109
+ /** Test-only seam: override poisoned-process self-termination. Pass undefined to restore the default. */
110
+ export function __setTerminatePoisonedProcessForTest(fn) {
111
+ activeTerminatePoisonedProcess = fn ?? defaultTerminatePoisonedProcess;
112
+ }
86
113
  function isSubscriptionLockContention(error) {
87
114
  const value = error;
88
115
  return value?.code === "ELOCKED" || (typeof value?.message === "string" && /lock file is already being held/i.test(value.message));
@@ -188,8 +215,15 @@ function oauthFlow(providerId, ctx) {
188
215
  }
189
216
  // `refresh` is a pure token exchange that reads only `credential.refresh` and persists
190
217
  // nothing; crouter records the rotated token in the selected pool row.
191
- async function defaultRefreshForProvider(state, providerId, refreshToken) {
192
- return oauthFlow(providerId, state.runtimeContext).refresh({ type: "oauth", refresh: refreshToken, access: "", expires: 0 });
218
+ async function defaultRefreshForProvider(state, providerId, refreshToken, parentSignal) {
219
+ const localController = parentSignal === undefined ? new AbortController() : undefined;
220
+ const signal = parentSignal ?? localController.signal;
221
+ try {
222
+ return await oauthFlow(providerId, state.runtimeContext).refresh({ type: "oauth", refresh: refreshToken, access: "", expires: 0 }, signal);
223
+ }
224
+ finally {
225
+ localController?.abort();
226
+ }
193
227
  }
194
228
  // Indirection so tests can inject refresh failures without real network calls.
195
229
  let activeRefreshForProvider;
@@ -220,19 +254,19 @@ function openBrowser(url) {
220
254
  // `getApiKey`, which wraps pi-ai's `getOAuthApiKey` and collapses a refresh failure into a
221
255
  // generic message; calling `activeRefreshForProvider` directly keeps the real error intact so
222
256
  // `isInvalidRefreshTokenError` can classify it (cooldown -> reauth -> cross-provider-fallback).
223
- async function refreshCredentialIfNeeded(state, providerId, credential) {
257
+ async function refreshCredentialIfNeeded(state, providerId, credential, signal) {
224
258
  if (!credential.refresh || !credential.access || credential.expires === undefined) {
225
259
  throw new Error(`${getProviderLabel(providerId)} subscription "${credential.label}" is missing its credential value`);
226
260
  }
227
261
  if (credential.expires > Date.now())
228
262
  return credential;
229
- const refreshed = await refreshSubscriptionCredential(providerId, credential.label, (token) => activeRefreshForProvider?.(providerId, token) ?? defaultRefreshForProvider(state, providerId, token));
263
+ const refreshed = await refreshSubscriptionCredential(providerId, credential.label, (token) => activeRefreshForProvider?.(providerId, token, signal) ?? defaultRefreshForProvider(state, providerId, token, signal));
230
264
  return { ...credential, ...refreshed };
231
265
  }
232
266
  /** Test-only seam: exercises the real `refreshCredentialIfNeeded` (including its locked refresh
233
267
  * transaction) without duplicating its logic. */
234
- export async function __refreshCredentialIfNeededForTest(providerId, credential) {
235
- return refreshCredentialIfNeeded(createProviderRotationState(), providerId, credential);
268
+ export async function __refreshCredentialIfNeededForTest(providerId, credential, signal) {
269
+ return refreshCredentialIfNeeded(createProviderRotationState(), providerId, credential, signal);
236
270
  }
237
271
  async function defaultLoginForProvider(providerId, ctx) {
238
272
  return loginProvider(providerId, ctx);
@@ -535,6 +569,10 @@ async function runManagedProvider(state, model, context, options, stream) {
535
569
  const cooldownAttempts = new Map();
536
570
  let terminalModel = model;
537
571
  let lastTransientFailure;
572
+ // Any HTTP response observed this pass (a status, a Retry-After header, or a
573
+ // rate-limit classification) proves this process can still reach a provider,
574
+ // clearing the poisoned-transport suspicion.
575
+ let sawHttpEvidence = false;
538
576
  for (let candidate = candidates.shift(); candidate !== undefined; candidate = candidates.shift()) {
539
577
  const { routeId, providerId, model: candidateModel } = candidate;
540
578
  queuedRouteIds.delete(routeId);
@@ -547,14 +585,13 @@ async function runManagedProvider(state, model, context, options, stream) {
547
585
  }
548
586
  if (!isManagedProvider(providerId)) {
549
587
  const result = await streamProviderAttempt(state, providerId, candidateModel, context, options, undefined, stream);
550
- if (result.result === 'success') {
551
- stream.end();
552
- return;
553
- }
554
- if (result.result === 'fatal') {
588
+ if (result.result === 'success' || result.result === 'fatal') {
589
+ state.transportOnlyExhaustedPasses = 0;
555
590
  stream.end();
556
591
  return;
557
592
  }
593
+ if (result.result === 'rate_limited' || result.classification?.status !== undefined || result.classification?.hadRetryAfterHeader === true)
594
+ sawHttpEvidence = true;
558
595
  const cooldownMs = result.result === 'rate_limited' ? result.retryAfterMs ?? DEFAULT_RATE_LIMIT_BACKOFF_MS : TRANSIENT_ROTATE_COOLDOWN_MS;
559
596
  if (result.result === 'transient_exhausted')
560
597
  lastTransientFailure = result;
@@ -575,7 +612,7 @@ async function runManagedProvider(state, model, context, options, stream) {
575
612
  for (const credential of available) {
576
613
  let freshCredential;
577
614
  try {
578
- freshCredential = await refreshCredentialIfNeeded(state, providerId, credential);
615
+ freshCredential = await refreshCredentialIfNeeded(state, providerId, credential, options?.signal);
579
616
  }
580
617
  catch (error) {
581
618
  if (!isInvalidRefreshTokenError(error))
@@ -597,15 +634,19 @@ async function runManagedProvider(state, model, context, options, stream) {
597
634
  }
598
635
  const result = await streamProviderAttempt(state, providerId, candidateModel, context, options, freshCredential, stream);
599
636
  if (result.result === 'success') {
637
+ state.transportOnlyExhaustedPasses = 0;
600
638
  if (freshCredential.rateLimitedUntil || freshCredential.authFailure)
601
639
  await recordSubscriptionMutation(`recording ${getProviderLabel(providerId)} success`, () => markSubscriptionSuccess(providerId, freshCredential.label, result.attemptAt));
602
640
  stream.end();
603
641
  return;
604
642
  }
605
643
  if (result.result === 'fatal') {
644
+ state.transportOnlyExhaustedPasses = 0;
606
645
  stream.end();
607
646
  return;
608
647
  }
648
+ if (result.result === 'rate_limited' || result.classification?.status !== undefined || result.classification?.hadRetryAfterHeader === true)
649
+ sawHttpEvidence = true;
609
650
  const cooldownMs = result.result === 'rate_limited' ? result.retryAfterMs ?? DEFAULT_RATE_LIMIT_BACKOFF_MS : TRANSIENT_ROTATE_COOLDOWN_MS;
610
651
  if (result.result === 'transient_exhausted')
611
652
  lastTransientFailure = result;
@@ -628,10 +669,21 @@ async function runManagedProvider(state, model, context, options, stream) {
628
669
  const coolingDeadline = earliestCoolingDeadline([...knownRoutes.values()], localCoolingDeadlines);
629
670
  const tried = [...attemptedRoutes].join(', ');
630
671
  if (lastTransientFailure !== undefined) {
672
+ state.transportOnlyExhaustedPasses = sawHttpEvidence ? 0 : state.transportOnlyExhaustedPasses + 1;
673
+ if (state.transportOnlyExhaustedPasses >= POISONED_TRANSPORT_PASS_LIMIT && activeTerminatePoisonedProcess(tried)) {
674
+ // The stream is deliberately left OPEN: the SIGTERM handler sees a live
675
+ // turn, so it either certifies a clean abort (→ the daemon respawns and
676
+ // resumes with a continuation prompt) or marks the turn dirty (→ the
677
+ // respawn re-cycles the turn). Ending the stream first could settle the
678
+ // turn idle before the signal lands, leaving the respawned node parked
679
+ // with a terminal error and nothing to re-drive it.
680
+ return;
681
+ }
631
682
  const detail = lastTransientFailure.failureMessage ?? lastTransientFailure.classification?.reason ?? 'unknown connection failure';
632
683
  emitTerminalError(stream, terminalModel, `Provider routes failed after transient retries: ${detail} (tried ${tried})`);
633
684
  return;
634
685
  }
686
+ state.transportOnlyExhaustedPasses = 0;
635
687
  if (coolingDeadline !== undefined) {
636
688
  emitCoolingError(stream, terminalModel, new ManagedProviderCoolingError(`All provider routes are rate limited (cooling down ~${Math.ceil((coolingDeadline - Date.now()) / 1000)}s; tried ${tried})`, coolingDeadline));
637
689
  return;
@@ -803,8 +855,10 @@ const MANUAL_CODE_DIALOG = {
803
855
  async function loginProvider(providerId, ctx) {
804
856
  const oauth = oauthFlow(providerId, ctx);
805
857
  const manual = MANUAL_CODE_DIALOG[providerId];
858
+ const controller = new AbortController();
806
859
  let authorizeUrl;
807
860
  return loginWithManualInputCleanup(ctx, (readCode, dismissManual) => oauth.login({
861
+ signal: controller.signal,
808
862
  notify: (event) => {
809
863
  switch (event.type) {
810
864
  case "auth_url":
@@ -843,7 +897,7 @@ async function loginProvider(providerId, ctx) {
843
897
  }
844
898
  return (await ctx.ui.input(prompt.message, prompt.placeholder ?? "")) ?? "";
845
899
  },
846
- }));
900
+ })).finally(() => controller.abort());
847
901
  }
848
902
  export function createProviderRotationExtension(options) {
849
903
  const state = createProviderRotationState(options);
@@ -77,10 +77,13 @@ type ProviderRotationState = {
77
77
  extensionAPI?: ExtensionAPI;
78
78
  delegatedProviderStreams: Map<string, ProviderStream>;
79
79
  options?: Readonly<ProviderRotationOptions>;
80
+ /** Consecutive route-queue passes that exhausted every route without a single
81
+ * HTTP response — the poisoned-process signature counted across turns. */
82
+ transportOnlyExhaustedPasses: number;
80
83
  };
81
84
 
82
85
  function createProviderRotationState(options?: ProviderRotationOptions): ProviderRotationState {
83
- return { delegatedProviderStreams: new Map(), ...(options === undefined ? {} : { options: { ...options } }) };
86
+ return { delegatedProviderStreams: new Map(), transportOnlyExhaustedPasses: 0, ...(options === undefined ? {} : { options: { ...options } }) };
84
87
  }
85
88
 
86
89
  function configuredValue(state: ProviderRotationState, key: keyof ProviderRotationOptions, envName: string): string | undefined {
@@ -169,6 +172,38 @@ export function __setSleepForTest(fn: typeof defaultSleep | undefined): void {
169
172
  activeSleep = fn ?? defaultSleep;
170
173
  }
171
174
 
175
+ // After this many CONSECUTIVE route-queue passes in which every failure was
176
+ // transport-level (no HTTP status and no Retry-After header — the provider was
177
+ // never reached at all), the broker process itself is presumed poisoned: a
178
+ // corrupted large TLS upload can leave Node/undici's HTTP/2 session permanently
179
+ // destroyed, after which EVERY provider request from this process fails while
180
+ // curl and fresh processes on the same host succeed. There is no clean in-process
181
+ // heal (the dispatcher cannot be safely re-instantiated), so the recovery is a
182
+ // fresh process: self-terminate via SIGTERM and let the daemon's exit policy
183
+ // respawn this broker against the same session. A genuine full network outage
184
+ // also trips this — harmlessly: the respawned broker fails the same way and the
185
+ // daemon's respawn backoff paces the loop.
186
+ export const POISONED_TRANSPORT_PASS_LIMIT = 2;
187
+
188
+ function defaultTerminatePoisonedProcess(tried: string): boolean {
189
+ // Only the broker process itself may self-terminate: runBroker stamps
190
+ // CRTR_BROKER_PID with its own pid at boot. Any other host (tests, tooling)
191
+ // keeps the ordinary terminal-error path.
192
+ if (process.env["CRTR_BROKER_PID"] !== String(process.pid)) return false;
193
+ logRotation(
194
+ `process transport poisoned: ${POISONED_TRANSPORT_PASS_LIMIT} consecutive route-queue passes failed with no HTTP response (tried ${tried}); exiting so the daemon respawns this broker fresh`,
195
+ );
196
+ process.kill(process.pid, "SIGTERM");
197
+ return true;
198
+ }
199
+
200
+ let activeTerminatePoisonedProcess = defaultTerminatePoisonedProcess;
201
+
202
+ /** Test-only seam: override poisoned-process self-termination. Pass undefined to restore the default. */
203
+ export function __setTerminatePoisonedProcessForTest(fn: typeof defaultTerminatePoisonedProcess | undefined): void {
204
+ activeTerminatePoisonedProcess = fn ?? defaultTerminatePoisonedProcess;
205
+ }
206
+
172
207
  function isSubscriptionLockContention(error: unknown): boolean {
173
208
  const value = error as { code?: unknown; message?: unknown } | undefined;
174
209
  return value?.code === "ELOCKED" || (typeof value?.message === "string" && /lock file is already being held/i.test(value.message));
@@ -290,11 +325,22 @@ function oauthFlow(providerId: ManagedProviderId, ctx: ExtensionContext | undefi
290
325
 
291
326
  // `refresh` is a pure token exchange that reads only `credential.refresh` and persists
292
327
  // nothing; crouter records the rotated token in the selected pool row.
293
- async function defaultRefreshForProvider(state: ProviderRotationState, providerId: ManagedProviderId, refreshToken: string): Promise<RefreshedCredential> {
294
- return oauthFlow(providerId, state.runtimeContext).refresh({ type: "oauth", refresh: refreshToken, access: "", expires: 0 });
328
+ async function defaultRefreshForProvider(
329
+ state: ProviderRotationState,
330
+ providerId: ManagedProviderId,
331
+ refreshToken: string,
332
+ parentSignal?: AbortSignal,
333
+ ): Promise<RefreshedCredential> {
334
+ const localController = parentSignal === undefined ? new AbortController() : undefined;
335
+ const signal = parentSignal ?? localController!.signal;
336
+ try {
337
+ return await oauthFlow(providerId, state.runtimeContext).refresh({ type: "oauth", refresh: refreshToken, access: "", expires: 0 }, signal);
338
+ } finally {
339
+ localController?.abort();
340
+ }
295
341
  }
296
342
 
297
- type RefreshForProvider = (providerId: ManagedProviderId, refreshToken: string) => Promise<RefreshedCredential>;
343
+ type RefreshForProvider = (providerId: ManagedProviderId, refreshToken: string, signal?: AbortSignal) => Promise<RefreshedCredential>;
298
344
 
299
345
  // Indirection so tests can inject refresh failures without real network calls.
300
346
  let activeRefreshForProvider: RefreshForProvider | undefined;
@@ -328,21 +374,30 @@ function openBrowser(url: string): void {
328
374
  // `getApiKey`, which wraps pi-ai's `getOAuthApiKey` and collapses a refresh failure into a
329
375
  // generic message; calling `activeRefreshForProvider` directly keeps the real error intact so
330
376
  // `isInvalidRefreshTokenError` can classify it (cooldown -> reauth -> cross-provider-fallback).
331
- async function refreshCredentialIfNeeded(state: ProviderRotationState, providerId: ManagedProviderId, credential: SubscriptionCredential): Promise<ResolvedCredential> {
377
+ async function refreshCredentialIfNeeded(
378
+ state: ProviderRotationState,
379
+ providerId: ManagedProviderId,
380
+ credential: SubscriptionCredential,
381
+ signal?: AbortSignal,
382
+ ): Promise<ResolvedCredential> {
332
383
  if (!credential.refresh || !credential.access || credential.expires === undefined) {
333
384
  throw new Error(`${getProviderLabel(providerId)} subscription "${credential.label}" is missing its credential value`);
334
385
  }
335
386
  if (credential.expires > Date.now()) return credential as ResolvedCredential;
336
387
  const refreshed = await refreshSubscriptionCredential(providerId, credential.label, (token) =>
337
- activeRefreshForProvider?.(providerId, token) ?? defaultRefreshForProvider(state, providerId, token),
388
+ activeRefreshForProvider?.(providerId, token, signal) ?? defaultRefreshForProvider(state, providerId, token, signal),
338
389
  );
339
390
  return { ...credential, ...refreshed } as ResolvedCredential;
340
391
  }
341
392
 
342
393
  /** Test-only seam: exercises the real `refreshCredentialIfNeeded` (including its locked refresh
343
394
  * transaction) without duplicating its logic. */
344
- export async function __refreshCredentialIfNeededForTest(providerId: ManagedProviderId, credential: SubscriptionCredential): Promise<ResolvedCredential> {
345
- return refreshCredentialIfNeeded(createProviderRotationState(), providerId, credential);
395
+ export async function __refreshCredentialIfNeededForTest(
396
+ providerId: ManagedProviderId,
397
+ credential: SubscriptionCredential,
398
+ signal?: AbortSignal,
399
+ ): Promise<ResolvedCredential> {
400
+ return refreshCredentialIfNeeded(createProviderRotationState(), providerId, credential, signal);
346
401
  }
347
402
 
348
403
  async function defaultLoginForProvider(providerId: ManagedProviderId, ctx: ExtensionContext): Promise<LoginCredential> {
@@ -706,6 +761,10 @@ async function runManagedProvider(
706
761
  const cooldownAttempts = new Map<string, number>();
707
762
  let terminalModel = model;
708
763
  let lastTransientFailure: AttemptResult | undefined;
764
+ // Any HTTP response observed this pass (a status, a Retry-After header, or a
765
+ // rate-limit classification) proves this process can still reach a provider,
766
+ // clearing the poisoned-transport suspicion.
767
+ let sawHttpEvidence = false;
709
768
 
710
769
  for (let candidate = candidates.shift(); candidate !== undefined; candidate = candidates.shift()) {
711
770
  const { routeId, providerId, model: candidateModel } = candidate;
@@ -721,8 +780,8 @@ async function runManagedProvider(
721
780
 
722
781
  if (!isManagedProvider(providerId)) {
723
782
  const result = await streamProviderAttempt(state, providerId, candidateModel, context, options, undefined, stream);
724
- if (result.result === 'success') { stream.end(); return; }
725
- if (result.result === 'fatal') { stream.end(); return; }
783
+ if (result.result === 'success' || result.result === 'fatal') { state.transportOnlyExhaustedPasses = 0; stream.end(); return; }
784
+ if (result.result === 'rate_limited' || result.classification?.status !== undefined || result.classification?.hadRetryAfterHeader === true) sawHttpEvidence = true;
726
785
  const cooldownMs = result.result === 'rate_limited' ? result.retryAfterMs ?? DEFAULT_RATE_LIMIT_BACKOFF_MS : TRANSIENT_ROTATE_COOLDOWN_MS;
727
786
  if (result.result === 'transient_exhausted') lastTransientFailure = result;
728
787
  localCoolingDeadlines.set(routeId, Math.max(localCoolingDeadlines.get(routeId) ?? 0, Date.now() + cooldownMs));
@@ -741,7 +800,7 @@ async function runManagedProvider(
741
800
  for (const credential of available) {
742
801
  let freshCredential: ResolvedCredential;
743
802
  try {
744
- freshCredential = await refreshCredentialIfNeeded(state, providerId, credential);
803
+ freshCredential = await refreshCredentialIfNeeded(state, providerId, credential, options?.signal);
745
804
  } catch (error) {
746
805
  if (!isInvalidRefreshTokenError(error)) throw error;
747
806
  let reauthenticated: ResolvedCredential | undefined;
@@ -758,11 +817,13 @@ async function runManagedProvider(
758
817
  }
759
818
  const result = await streamProviderAttempt(state, providerId, candidateModel, context, options, freshCredential, stream);
760
819
  if (result.result === 'success') {
820
+ state.transportOnlyExhaustedPasses = 0;
761
821
  if (freshCredential.rateLimitedUntil || freshCredential.authFailure) await recordSubscriptionMutation(`recording ${getProviderLabel(providerId)} success`, () => markSubscriptionSuccess(providerId, freshCredential.label, result.attemptAt));
762
822
  stream.end();
763
823
  return;
764
824
  }
765
- if (result.result === 'fatal') { stream.end(); return; }
825
+ if (result.result === 'fatal') { state.transportOnlyExhaustedPasses = 0; stream.end(); return; }
826
+ if (result.result === 'rate_limited' || result.classification?.status !== undefined || result.classification?.hadRetryAfterHeader === true) sawHttpEvidence = true;
766
827
  const cooldownMs = result.result === 'rate_limited' ? result.retryAfterMs ?? DEFAULT_RATE_LIMIT_BACKOFF_MS : TRANSIENT_ROTATE_COOLDOWN_MS;
767
828
  if (result.result === 'transient_exhausted') lastTransientFailure = result;
768
829
  if (result.result === 'rate_limited' && result.retryAfterMs !== undefined) {
@@ -782,10 +843,21 @@ async function runManagedProvider(
782
843
  const coolingDeadline = earliestCoolingDeadline([...knownRoutes.values()], localCoolingDeadlines);
783
844
  const tried = [...attemptedRoutes].join(', ');
784
845
  if (lastTransientFailure !== undefined) {
846
+ state.transportOnlyExhaustedPasses = sawHttpEvidence ? 0 : state.transportOnlyExhaustedPasses + 1;
847
+ if (state.transportOnlyExhaustedPasses >= POISONED_TRANSPORT_PASS_LIMIT && activeTerminatePoisonedProcess(tried)) {
848
+ // The stream is deliberately left OPEN: the SIGTERM handler sees a live
849
+ // turn, so it either certifies a clean abort (→ the daemon respawns and
850
+ // resumes with a continuation prompt) or marks the turn dirty (→ the
851
+ // respawn re-cycles the turn). Ending the stream first could settle the
852
+ // turn idle before the signal lands, leaving the respawned node parked
853
+ // with a terminal error and nothing to re-drive it.
854
+ return;
855
+ }
785
856
  const detail = lastTransientFailure.failureMessage ?? lastTransientFailure.classification?.reason ?? 'unknown connection failure';
786
857
  emitTerminalError(stream, terminalModel, `Provider routes failed after transient retries: ${detail} (tried ${tried})`);
787
858
  return;
788
859
  }
860
+ state.transportOnlyExhaustedPasses = 0;
789
861
  if (coolingDeadline !== undefined) {
790
862
  emitCoolingError(stream, terminalModel, new ManagedProviderCoolingError(`All provider routes are rate limited (cooling down ~${Math.ceil((coolingDeadline - Date.now()) / 1000)}s; tried ${tried})`, coolingDeadline));
791
863
  return;
@@ -971,9 +1043,11 @@ const MANUAL_CODE_DIALOG: Record<ManagedProviderId, { title: (url: string | unde
971
1043
  async function loginProvider(providerId: ManagedProviderId, ctx: ExtensionContext): Promise<LoginCredential> {
972
1044
  const oauth = oauthFlow(providerId, ctx);
973
1045
  const manual = MANUAL_CODE_DIALOG[providerId];
1046
+ const controller = new AbortController();
974
1047
  let authorizeUrl: string | undefined;
975
1048
  return loginWithManualInputCleanup(ctx, (readCode, dismissManual) =>
976
1049
  oauth.login({
1050
+ signal: controller.signal,
977
1051
  notify: (event: AuthEvent): void => {
978
1052
  switch (event.type) {
979
1053
  case "auth_url":
@@ -1010,7 +1084,7 @@ async function loginProvider(providerId: ManagedProviderId, ctx: ExtensionContex
1010
1084
  return (await ctx.ui.input(prompt.message, prompt.placeholder ?? "")) ?? "";
1011
1085
  },
1012
1086
  }),
1013
- );
1087
+ ).finally(() => controller.abort());
1014
1088
  }
1015
1089
 
1016
1090
  export function createProviderRotationExtension(options?: ProviderRotationOptions): (pi: ExtensionAPI) => Promise<void> {
@@ -17,7 +17,7 @@
17
17
  // otherwise its unmounted Loader keeps requesting renders every 80 ms.
18
18
  import assert from 'node:assert/strict';
19
19
  import test from 'node:test';
20
- import { Container, TUI } from '@earendil-works/pi-tui';
20
+ import { Container, TuiMainScreen } from '@earendil-works/pi-tui';
21
21
  import { AssistantMessageComponent, UserMessageComponent, initTheme, } from '@earendil-works/pi-coding-agent';
22
22
  import { ChatView } from '../render/chat-view.js';
23
23
  /** A headless terminal so TUI.requestRender's scheduled writes are no-ops (the
@@ -42,7 +42,7 @@ function fakeTerminal() {
42
42
  };
43
43
  }
44
44
  function makeChatView() {
45
- const tui = new TUI(fakeTerminal());
45
+ const tui = new TuiMainScreen(fakeTerminal());
46
46
  const container = new Container();
47
47
  const view = new ChatView(tui, container);
48
48
  const internals = view;
@@ -7,7 +7,7 @@
7
7
  // must never leak into it.
8
8
  import assert from 'node:assert/strict';
9
9
  import test from 'node:test';
10
- import { Container, TUI } from '@earendil-works/pi-tui';
10
+ import { Container, TuiMainScreen } from '@earendil-works/pi-tui';
11
11
  import { initTheme } from '@earendil-works/pi-coding-agent';
12
12
  import { ChatView } from '../render/chat-view.js';
13
13
  import { GroupActivityRecorder } from '../render/group-activity.js';
@@ -21,7 +21,7 @@ function fakeTerminal() {
21
21
  }
22
22
  initTheme(undefined, false);
23
23
  async function summarizedGroup(messages, key) {
24
- const view = new ChatView(new TUI(fakeTerminal()), new Container());
24
+ const view = new ChatView(new TuiMainScreen(fakeTerminal()), new Container());
25
25
  const internals = view;
26
26
  internals.foldSettledTools = true;
27
27
  internals.showGroupSummaries = true;
@@ -35,7 +35,7 @@ export interface CanvasPanelLines {
35
35
  items: CanvasPanelItem[];
36
36
  }
37
37
  /** Build the BASE panel line stacks for `nodeId`. `asks` is the per-node
38
- * pending human-ticket count map the caller polls (deck/review/notify); pass
38
+ * pending human-ticket count map the caller polls (page/review); pass
39
39
  * `{}` to omit the ⚑ badges. `crons` are this node's armed schedules.
40
40
  * `palette`
41
41
  * drives the headers + dim cells; omit it and the panels render uncolored. */
@@ -39,7 +39,7 @@ function padStartVisible(s, w) {
39
39
  return s.length >= w ? s.slice(0, w) : ' '.repeat(w - s.length) + s;
40
40
  }
41
41
  /** Build the BASE panel line stacks for `nodeId`. `asks` is the per-node
42
- * pending human-ticket count map the caller polls (deck/review/notify); pass
42
+ * pending human-ticket count map the caller polls (page/review); pass
43
43
  * `{}` to omit the ⚑ badges. `crons` are this node's armed schedules.
44
44
  * `palette`
45
45
  * drives the headers + dim cells; omit it and the panels render uncolored. */
@@ -67,7 +67,7 @@ export class InputOverlayOwner {
67
67
  return this.openPicker({ type: 'get_tree' }, 'get_tree', 'fork', (data, close) => buildForkPicker(data, this.hooks.send, close));
68
68
  }
69
69
  openSettingsPicker() {
70
- return this.openPicker({ type: 'get_settings' }, 'get_settings', 'settings', (data, close) => buildSettingsPicker(data, this.hooks.send, close, this.hooks.notify));
70
+ return this.openPicker({ type: 'get_settings' }, 'get_settings', 'settings', (data, close) => buildSettingsPicker(data, this.hooks.send, close));
71
71
  }
72
72
  openScopedModelsPicker() {
73
73
  return this.openPicker({ type: 'list_scoped_models' }, 'list_scoped_models', 'scoped-models', (data, close) => buildScopedModelsPicker(data, close, this.hooks.notify));