@north-light/crouter 0.3.192 → 0.3.194

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 (190) 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 +24 -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/02-lifecycle/01-resident.md +1 -1
  13. package/dist/builtin-memory/internal/examples/imessage-assistant.md +1 -1
  14. package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
  15. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  16. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/serial/provider-rotation.test.ts +101 -3
  17. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +6 -2
  18. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +68 -14
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +87 -13
  20. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +2 -2
  21. package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
  22. package/dist/clients/attach/chrome/canvas-panels.d.ts +1 -1
  23. package/dist/clients/attach/chrome/canvas-panels.js +1 -1
  24. package/dist/clients/attach/input/overlay-owner.js +1 -1
  25. package/dist/clients/attach/overlays/auth.js +34 -8
  26. package/dist/clients/attach/overlays/pickers.d.ts +5 -8
  27. package/dist/clients/attach/overlays/pickers.js +37 -43
  28. package/dist/clients/attach/viewer.js +643 -620
  29. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +38 -128
  30. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +72 -148
  31. package/dist/clients/inbox/controller.d.ts +2 -3
  32. package/dist/clients/inbox/controller.js +41 -42
  33. package/dist/clients/inbox/page-adapter.d.ts +33 -0
  34. package/dist/clients/inbox/page-adapter.js +54 -0
  35. package/dist/clients/inbox/resolve.d.ts +2 -2
  36. package/dist/clients/inbox/resolve.js +1 -1
  37. package/dist/clients/inbox/review-visibility.d.ts +1 -1
  38. package/dist/clients/inbox/review-visibility.js +1 -1
  39. package/dist/clients/inbox/tui/ansi.d.ts +1 -1
  40. package/dist/clients/inbox/tui/ansi.js +2 -2
  41. package/dist/clients/inbox/tui/input.d.ts +1 -3
  42. package/dist/clients/inbox/tui/input.js +242 -553
  43. package/dist/clients/inbox/tui/panel.d.ts +3 -1
  44. package/dist/clients/inbox/tui/panel.js +59 -187
  45. package/dist/clients/inbox/tui/render.d.ts +0 -25
  46. package/dist/clients/inbox/tui/render.js +165 -416
  47. package/dist/clients/inbox/tui/slots.d.ts +32 -0
  48. package/dist/clients/inbox/tui/slots.js +156 -0
  49. package/dist/clients/inbox/tui/types.d.ts +25 -13
  50. package/dist/clients/inbox/tui.js +5 -3
  51. package/dist/commands/attention.js +2 -2
  52. package/dist/commands/human/doc.js +66 -20
  53. package/dist/commands/human/prompts.d.ts +3 -3
  54. package/dist/commands/human/prompts.js +158 -112
  55. package/dist/commands/human/queue.d.ts +0 -1
  56. package/dist/commands/human/queue.js +34 -94
  57. package/dist/commands/human/shared.d.ts +10 -4
  58. package/dist/commands/human/shared.js +62 -13
  59. package/dist/commands/human.js +8 -4
  60. package/dist/commands/memory/read.js +13 -5
  61. package/dist/commands/node/bash.js +18 -30
  62. package/dist/commands/node/lifecycle.js +2 -2
  63. package/dist/commands/surface/node/focus.js +1 -1
  64. package/dist/core/__tests__/human-deliver.test.js +40 -46
  65. package/dist/core/__tests__/seam/broker-attach-stream.test.js +7 -1
  66. package/dist/core/__tests__/seam/dormancy-release.test.js +64 -10
  67. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +40 -31
  68. package/dist/core/__tests__/serial/spawn-root.test.js +3 -1
  69. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -1
  70. package/dist/core/auth-interaction.d.ts +2 -2
  71. package/dist/core/bash-jobs.d.ts +43 -0
  72. package/dist/core/bash-jobs.js +96 -2
  73. package/dist/core/canvas/__tests__/attention.test.js +16 -10
  74. package/dist/core/canvas/__tests__/render-remote.test.js +9 -3
  75. package/dist/core/canvas/attention.js +1 -1
  76. package/dist/core/canvas/browse/app.js +1 -1
  77. package/dist/core/canvas/nav-model.d.ts +1 -1
  78. package/dist/core/canvas/nav-model.js +1 -1
  79. package/dist/core/canvas/remote-canvas-source.js +1 -1
  80. package/dist/core/canvas/types.d.ts +2 -1
  81. package/dist/core/config.js +4 -1
  82. package/dist/core/human/__tests__/page-render.test.d.ts +1 -0
  83. package/dist/core/human/__tests__/page-render.test.js +49 -0
  84. package/dist/core/human/__tests__/page-tickets.test.d.ts +1 -0
  85. package/dist/core/human/__tests__/page-tickets.test.js +112 -0
  86. package/dist/core/human/__tests__/page.test.d.ts +1 -0
  87. package/dist/core/human/__tests__/page.test.js +139 -0
  88. package/dist/core/human/__tests__/serial/inbox-core.test.js +42 -32
  89. package/dist/core/human/claim.js +2 -2
  90. package/dist/core/human/convention.d.ts +4 -4
  91. package/dist/core/human/convention.js +6 -13
  92. package/dist/core/human/markdown-html.d.ts +8 -0
  93. package/dist/core/human/markdown-html.js +581 -0
  94. package/dist/core/human/page-catalog.d.ts +6 -0
  95. package/dist/core/human/page-catalog.js +43 -0
  96. package/dist/core/human/page-render.d.ts +3 -0
  97. package/dist/core/human/page-render.js +131 -0
  98. package/dist/core/human/page-schema.d.ts +312 -0
  99. package/dist/core/human/page-schema.js +291 -0
  100. package/dist/core/human/page-synth.d.ts +9 -0
  101. package/dist/core/human/page-synth.js +17 -0
  102. package/dist/core/human/page.d.ts +22 -0
  103. package/dist/core/human/page.js +427 -0
  104. package/dist/core/human/review-schema.d.ts +19 -0
  105. package/dist/core/human/review-schema.js +34 -0
  106. package/dist/core/human/scan.d.ts +12 -3
  107. package/dist/core/human/scan.js +69 -26
  108. package/dist/core/human/summary.d.ts +5 -6
  109. package/dist/core/human/summary.js +54 -37
  110. package/dist/core/human/tickets.d.ts +61 -11
  111. package/dist/core/human/tickets.js +100 -57
  112. package/dist/core/human/types.d.ts +21 -66
  113. package/dist/core/human/types.js +1 -5
  114. package/dist/core/inspector/model.d.ts +5 -0
  115. package/dist/core/inspector/model.js +5 -0
  116. package/dist/core/inspector/text.js +2 -2
  117. package/dist/core/inspector/tui.js +10 -3
  118. package/dist/core/keybindings/catalog.d.ts +1 -1
  119. package/dist/core/keybindings/catalog.js +4 -2
  120. package/dist/core/preview-registry.js +0 -1
  121. package/dist/core/review/ticket-filter.d.ts +1 -1
  122. package/dist/core/review/ticket-filter.js +2 -8
  123. package/dist/core/runtime/broker/read-ops.js +3 -0
  124. package/dist/core/runtime/broker.js +19 -10
  125. package/dist/core/runtime/package-health.js +1 -1
  126. package/dist/core/runtime/persona.js +2 -1
  127. package/dist/core/runtime/tmux-bindings.js +8 -1
  128. package/dist/core/termrender/termrender.d.ts +1 -1
  129. package/dist/core/termrender/termrender.js +2 -2
  130. package/dist/core/termrender/version.d.ts +1 -1
  131. package/dist/core/termrender/version.js +1 -1
  132. package/dist/core/tui/page-host.d.ts +1 -1
  133. package/dist/core/tui/page-host.js +2 -2
  134. package/dist/core/user-settings.d.ts +19 -0
  135. package/dist/core/user-settings.js +22 -0
  136. package/dist/daemon/api/handlers/bash-jobs.d.ts +2 -0
  137. package/dist/daemon/api/handlers/bash-jobs.js +90 -0
  138. package/dist/daemon/api/handlers/human.js +16 -14
  139. package/dist/daemon/api/handlers/inbox.js +311 -227
  140. package/dist/daemon/api/router.d.ts +5 -1
  141. package/dist/daemon/api/router.js +5 -0
  142. package/dist/daemon/api/server.js +2 -0
  143. package/dist/daemon/cron-run.js +23 -10
  144. package/dist/daemon/human/finish.d.ts +8 -3
  145. package/dist/daemon/human/finish.js +35 -15
  146. package/dist/daemon/reconcilers/broker-supervision.js +7 -1
  147. package/dist/daemon/reconcilers/dormant-inbox.js +8 -0
  148. package/dist/daemon/reconcilers/live-obligation.d.ts +2 -0
  149. package/dist/daemon/reconcilers/live-obligation.js +11 -0
  150. package/dist/pages/bundle.css +1 -0
  151. package/dist/pages/bundle.js +1347 -0
  152. package/dist/pages/comments.d.ts +34 -0
  153. package/dist/pages/comments.js +72 -0
  154. package/dist/pages/elements/cards.d.ts +17 -0
  155. package/dist/pages/elements/cards.js +785 -0
  156. package/dist/pages/elements/chart.d.ts +1 -0
  157. package/dist/pages/elements/chart.js +648 -0
  158. package/dist/pages/elements/options.d.ts +21 -0
  159. package/dist/pages/elements/options.js +604 -0
  160. package/dist/pages/elements/pages.d.ts +24 -0
  161. package/dist/pages/elements/pages.js +355 -0
  162. package/dist/pages/elements/slot.d.ts +21 -0
  163. package/dist/pages/elements/slot.js +105 -0
  164. package/dist/pages/elements/table.d.ts +19 -0
  165. package/dist/pages/elements/table.js +906 -0
  166. package/dist/pages/elements/text.d.ts +25 -0
  167. package/dist/pages/elements/text.js +951 -0
  168. package/dist/pages/entry.d.ts +8 -0
  169. package/dist/pages/entry.js +8 -0
  170. package/dist/pages/host.d.ts +132 -0
  171. package/dist/pages/host.js +149 -0
  172. package/dist/pages/register.d.ts +2 -0
  173. package/dist/pages/register.js +3 -0
  174. package/dist/pages/slot-config.d.ts +36 -0
  175. package/dist/pages/slot-config.js +50 -0
  176. package/dist/pages/types.d.ts +98 -0
  177. package/dist/pages/types.js +23 -0
  178. package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -2
  179. package/dist/pi-extensions/canvas-bash-valve.js +25 -7
  180. package/dist/types.d.ts +4 -0
  181. package/dist/types.js +2 -0
  182. package/package.json +8 -8
  183. package/runtime.lock.json +156 -101
  184. package/scripts/install-runtime.mjs +172 -5
  185. package/dist/clients/inbox/deck-adapter.d.ts +0 -29
  186. package/dist/clients/inbox/deck-adapter.js +0 -63
  187. package/dist/core/human/deck-factories.d.ts +0 -8
  188. package/dist/core/human/deck-factories.js +0 -15
  189. package/dist/core/human/deck-schema.d.ts +0 -70
  190. package/dist/core/human/deck-schema.js +0 -92
@@ -38,7 +38,7 @@ A push fans a ~30-token **pointer** (a ref path), not the content; subscribers d
38
38
  Two orthogonal axes:
39
39
 
40
40
  - **mode**: base (a hands-on worker — finishes its own job, yielding mode-preservingly when a task needs another window) ↔ orchestrator (`node promote` — a long-lived, roadmap-holding coordinator that delegates independent work and survives context refresh via `node yield`). Promote only when worthwhile parallel work makes coordinating and integrating children the node's primary job; task duration, sequential phases, or one helper are not enough.
41
- - **lifecycle**: terminal (owes a final up the spine, reaps when done) ↔ resident (`node config --lifecycle <value>` / interactable — stays dormant, wakes on inbox/human, never forced to finish). `node lifecycle demote` is the friendly flip-to-terminal-in-place.
41
+ - **lifecycle**: terminal (owes a final up the spine, reaps when done) ↔ resident (`node config --lifecycle <value>` / interactable — never forced to submit; stays wakeable while attached or awaiting something live, then completes after 15 unattended minutes with no live obligation). `node lifecycle demote` is the friendly flip-to-terminal-in-place.
42
42
 
43
43
  A dormant node wakes from an **inbox** message (a push, or `node message send`) or from a scheduled **cron** whose bash command performs the action (`node message send`, `node lifecycle revive`, or `node new`). A cron can gate its action on an external condition such as CI or a deploy; inspect or reap schedules with `cron list`/`cron cancel`. To monitor your own children you arm nothing — you auto-subscribe on spawn, so their finish/crash/close wakes you; a deadline to chase a delegate is a belt-and-suspenders the runtime makes redundant. A terminal node blocked on a one-off message from a parent or controller it holds no live subscription to declares that wait with `node wait controller` before going dormant, so the next inbox delivery from that sender clears the wait rather than the runtime treating the idle as finished. Use `node wait deadline` only for the atomic inbox-versus-deadline race.
44
44
 
@@ -48,4 +48,4 @@ Tear-down: `node lifecycle close` cascade-cancels a node + its exclusive subtree
48
48
 
49
49
  Use `node lifecycle revive <id>` to reopen one dormant or terminal node. It resumes an existing saved conversation by default; `--fresh` starts a new cycle on its existing session file, or a truly fresh session if no file exists. A finalized node requires `--reopen` before it can take a new mandate. Mass-reconnecting every eligible disconnected node after a reboot or outage is `canvas revive --all` instead. `reviveNode()` is the **only** sanctioned launcher of a node's broker engine — it builds the pi invocation, sets `CRTR_NODE_ID` + canvas extensions, runs `transition('revive')`, and starts the headless broker host, keeping the db row and broker process in lockstep. Never spawn `pi --session` raw, and never open a node by spawning pi directly — UIs go through `surface node focus` / `node lifecycle revive`.
50
50
 
51
- The daemon (`crtrd`, managed via `sys daemon start/stop/status`) supervises live broker exits. It applies a bounded respawn policy to interrupted nonterminal nodes: a cleanly aborted saved turn resumes with a continuation, while a dirty interrupted turn, pending refresh, or pending cycle starts a fresh cycle from the saved session. Repeated short-lived exits and boot failures terminalize the node as `dead`; no automatic recovery occurs after terminalization, so use an explicit lifecycle revive or an inbox wake. It does not host agents or open viewers. When activating source changes, build, run `npm run install-runtime`, then restart the daemon because new daemon/brokers select the atomically switched, immutable generation while live brokers retain their own. Restarting is safe: it never signals running nodes.
51
+ The daemon (`crtrd`, managed via `sys daemon start/stop/status`) supervises live broker exits. It releases an unattended broker after 15 minutes: residents with no live obligation become `done` and pruning-eligible, while terminal nodes and residents awaiting a live child, controller, human, deadline, or structured output become `idle` and remain wakeable. It applies a bounded respawn policy to interrupted nonterminal nodes: a cleanly aborted saved turn resumes with a continuation, while a dirty interrupted turn, pending refresh, or pending cycle starts a fresh cycle from the saved session. Repeated short-lived exits and boot failures terminalize the node as `dead`; no automatic recovery occurs after terminalization, so use an explicit lifecycle revive or an inbox wake. It does not host agents or open viewers. When activating source changes, build, run `npm run install-runtime`, then restart the daemon because new daemon/brokers select the atomically switched, immutable generation while live brokers retain their own. Restarting is safe: it never signals running nodes.
@@ -22,7 +22,7 @@ User-wide content with no cwd dimension also belongs here: `~/.crouter/profile-d
22
22
 
23
23
  ## 2. Canvas home — node-graph runtime state, node artifacts, and bounded diagnostics
24
24
 
25
- `~/.crouter/canvas/` (overridable with `CRTR_HOME`) is the cwd-agnostic node-graph home. `canvas.db` is the SQLite WAL topology store for nodes and edges, including durable tmux-pane focus. `nodes/<node_id>/` owns `meta.json`, `context/`, `reports/`, `messages/`, `inbox.jsonl`, `transcript.jsonl`, `session.ptr`, and `job/` state. Human ticket files (`deck.json`, `run.json`, `response.json`, `review.json`, and `branch-point.jsonl`) live directly in a terminal human bridge node directory; `nodes/` is the single ticket root, derived rather than registered.
25
+ `~/.crouter/canvas/` (overridable with `CRTR_HOME`) is the cwd-agnostic node-graph home. `canvas.db` is the SQLite WAL topology store for nodes and edges, including durable tmux-pane focus. `nodes/<node_id>/` owns `meta.json`, `context/`, `reports/`, `messages/`, `inbox.jsonl`, `transcript.jsonl`, `session.ptr`, and `job/` state. Human ticket files (`page.json`, `page.md`, `run.json`, `response.json`, `review.json`, and `branch-point.jsonl`) live directly in a terminal human bridge node directory; `nodes/` is the single ticket root, derived rather than registered.
26
26
 
27
27
  Specifications and plans are ordinary node-context artifacts. They share the node's lifetime and are removed when that node is reaped.
28
28
 
@@ -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. */