@crouton-kit/crouter 0.3.70 → 0.3.79

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 (193) hide show
  1. package/README.md +1 -1
  2. package/dist/build-root.d.ts +12 -4
  3. package/dist/build-root.js +25 -6
  4. package/dist/builtin-memory/00-runtime-base.md +3 -10
  5. package/dist/builtin-memory/04-base-worker.md +18 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  14. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  15. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  16. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  17. package/dist/builtin-views/canvas/tui.mjs +8 -9
  18. package/dist/builtin-views/chat/tui.mjs +12 -12
  19. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  20. package/dist/builtin-views/inbox/tui.mjs +27 -41
  21. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  22. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  23. package/dist/builtin-views/settings/tui.mjs +11 -11
  24. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  25. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
  26. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  28. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  29. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  30. package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
  31. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  32. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  34. package/dist/clients/attach/attach-cmd.js +841 -792
  35. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  36. package/dist/clients/attach/auth-pickers.js +64 -15
  37. package/dist/clients/attach/canvas-panels.js +2 -3
  38. package/dist/clients/attach/chat-view.d.ts +7 -8
  39. package/dist/clients/attach/chat-view.js +119 -79
  40. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  41. package/dist/clients/attach/edit-diff-render.js +263 -0
  42. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  43. package/dist/clients/attach/graph-overlay.js +83 -33
  44. package/dist/clients/attach/input-controller.d.ts +28 -2
  45. package/dist/clients/attach/input-controller.js +38 -5
  46. package/dist/clients/attach/mermaid-render.js +5 -2
  47. package/dist/clients/attach/pickers.d.ts +8 -7
  48. package/dist/clients/attach/pickers.js +11 -17
  49. package/dist/clients/attach/slash-commands.d.ts +9 -0
  50. package/dist/clients/attach/slash-commands.js +127 -7
  51. package/dist/clients/attach/titled-editor.d.ts +12 -1
  52. package/dist/clients/attach/titled-editor.js +103 -8
  53. package/dist/commands/canvas-browse.js +2 -2
  54. package/dist/commands/human/queue.js +3 -4
  55. package/dist/commands/memory/lint.js +39 -5
  56. package/dist/commands/memory/write.js +1 -0
  57. package/dist/commands/node.js +9 -2
  58. package/dist/commands/pkg/plugin-inspect.js +22 -1
  59. package/dist/commands/pkg/plugin-manage.js +31 -9
  60. package/dist/commands/surface-tmux-spread.js +1 -3
  61. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  62. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  63. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  64. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  65. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  66. package/dist/commands/sys/config.js +18 -21
  67. package/dist/commands/sys/doctor.js +42 -4
  68. package/dist/commands/sys/setup-core.d.ts +49 -1
  69. package/dist/commands/sys/setup-core.js +161 -4
  70. package/dist/commands/sys/setup.d.ts +88 -0
  71. package/dist/commands/sys/setup.js +943 -169
  72. package/dist/commands/view-pick.d.ts +4 -0
  73. package/dist/commands/view-pick.js +17 -7
  74. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  75. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  76. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  77. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  78. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  79. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  80. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  81. package/dist/core/__tests__/command-plugins.test.js +444 -0
  82. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  83. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  84. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  85. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  86. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  87. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  88. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  89. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  90. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  91. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  92. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  93. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  94. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  95. package/dist/core/canvas/browse/app.d.ts +6 -0
  96. package/dist/core/canvas/browse/app.js +230 -41
  97. package/dist/core/canvas/browse/model.d.ts +10 -6
  98. package/dist/core/canvas/browse/model.js +20 -12
  99. package/dist/core/canvas/browse/pins.d.ts +4 -0
  100. package/dist/core/canvas/browse/pins.js +29 -0
  101. package/dist/core/canvas/browse/render.d.ts +13 -1
  102. package/dist/core/canvas/browse/render.js +72 -15
  103. package/dist/core/canvas/nav-model.js +5 -11
  104. package/dist/core/canvas/paths.d.ts +5 -0
  105. package/dist/core/canvas/paths.js +7 -0
  106. package/dist/core/command-plugins/adapter.d.ts +15 -0
  107. package/dist/core/command-plugins/adapter.js +145 -0
  108. package/dist/core/command-plugins/compose.d.ts +5 -0
  109. package/dist/core/command-plugins/compose.js +56 -0
  110. package/dist/core/command-plugins/discovery.d.ts +104 -0
  111. package/dist/core/command-plugins/discovery.js +565 -0
  112. package/dist/core/config.d.ts +48 -0
  113. package/dist/core/config.js +228 -110
  114. package/dist/core/fault-classifier.js +1 -1
  115. package/dist/core/fs-utils.d.ts +1 -0
  116. package/dist/core/fs-utils.js +15 -1
  117. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  118. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  119. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  120. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  121. package/dist/core/keybindings/catalog.d.ts +14 -0
  122. package/dist/core/keybindings/catalog.js +257 -0
  123. package/dist/core/keybindings/index.d.ts +5 -0
  124. package/dist/core/keybindings/index.js +4 -0
  125. package/dist/core/keybindings/match.d.ts +29 -0
  126. package/dist/core/keybindings/match.js +99 -0
  127. package/dist/core/keybindings/persistence.d.ts +14 -0
  128. package/dist/core/keybindings/persistence.js +31 -0
  129. package/dist/core/keybindings/resolve.d.ts +14 -0
  130. package/dist/core/keybindings/resolve.js +305 -0
  131. package/dist/core/keybindings/types.d.ts +37 -0
  132. package/dist/core/keybindings/types.js +1 -0
  133. package/dist/core/memory-resolver.d.ts +1 -1
  134. package/dist/core/memory-resolver.js +10 -3
  135. package/dist/core/predicate.d.ts +5 -3
  136. package/dist/core/predicate.js +5 -3
  137. package/dist/core/profiles/select.d.ts +6 -0
  138. package/dist/core/profiles/select.js +86 -52
  139. package/dist/core/provider-management.d.ts +12 -0
  140. package/dist/core/provider-management.js +24 -0
  141. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  142. package/dist/core/runtime/broker.js +164 -14
  143. package/dist/core/runtime/launch.d.ts +39 -6
  144. package/dist/core/runtime/launch.js +78 -20
  145. package/dist/core/runtime/naming.js +3 -3
  146. package/dist/core/runtime/pi-cli.d.ts +6 -0
  147. package/dist/core/runtime/pi-cli.js +16 -2
  148. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  149. package/dist/core/runtime/pi-vendored.js +14 -0
  150. package/dist/core/runtime/placement.d.ts +2 -2
  151. package/dist/core/runtime/placement.js +4 -1
  152. package/dist/core/runtime/promote.js +4 -0
  153. package/dist/core/runtime/recap.d.ts +3 -3
  154. package/dist/core/runtime/recap.js +75 -47
  155. package/dist/core/runtime/recycle.js +6 -1
  156. package/dist/core/runtime/reset.js +5 -0
  157. package/dist/core/runtime/session-list-cache.d.ts +33 -0
  158. package/dist/core/runtime/session-list-cache.js +338 -0
  159. package/dist/core/runtime/spawn.js +52 -45
  160. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  161. package/dist/core/runtime/stream-watchdog.js +75 -0
  162. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  163. package/dist/core/runtime/tmux-chrome.js +2 -2
  164. package/dist/core/runtime/tmux.d.ts +18 -15
  165. package/dist/core/runtime/tmux.js +217 -110
  166. package/dist/core/scope.js +27 -4
  167. package/dist/core/subscription-state.d.ts +90 -0
  168. package/dist/core/subscription-state.js +762 -0
  169. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  170. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  171. package/dist/core/tui/host.d.ts +30 -1
  172. package/dist/core/tui/host.js +148 -34
  173. package/dist/core/view/contract.d.ts +17 -3
  174. package/dist/daemon/crtrd.js +253 -12
  175. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  176. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  177. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  178. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  179. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  180. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  181. package/dist/pi-extensions/canvas-recap.js +73 -46
  182. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  183. package/dist/pi-extensions/canvas-stophook.js +33 -4
  184. package/dist/types.d.ts +9 -26
  185. package/dist/types.js +1 -28
  186. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  187. package/dist/web-client/index.html +1 -1
  188. package/dist/web-client/sw.js +1 -1
  189. package/package.json +8 -4
  190. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  191. package/dist/pi-extensions/canvas-nav.js +0 -640
  192. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  193. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -20,8 +20,11 @@ import {
20
20
  ANTHROPIC_PROVIDER_ID,
21
21
  OPENAI_CODEX_PROVIDER_ID,
22
22
  formatStatusLine,
23
+ getLadderProviderId,
23
24
  getProviderLabel,
24
25
  isManagedProvider,
26
+ addSubscription,
27
+ commitDefaultIdentity,
25
28
  markSubscriptionAttempt,
26
29
  markSubscriptionRateLimited,
27
30
  markSubscriptionSuccess,
@@ -29,15 +32,16 @@ import {
29
32
  promoteSubscription,
30
33
  readRotationConfig,
31
34
  readSubscriptionPool,
32
- removeSubscription,
35
+ removeManagedAccount,
33
36
  resolveFallbackTarget,
34
37
  restorePreferredModelIfPossible,
35
38
  switchToFallbackIfPossible,
36
39
  upsertSubscription,
40
+ upsertSubscriptionWithUniqueAccount,
37
41
  type FallbackTarget,
38
42
  type ManagedProviderId,
39
43
  type SubscriptionCredential,
40
- } from "../lib/subscription-state.js";
44
+ } from "../../../core/subscription-state.js";
41
45
 
42
46
  const DEFAULT_RATE_LIMIT_BACKOFF_MS = 5 * 60 * 1000;
43
47
  const INVALID_REFRESH_TOKEN_BACKOFF_MS = 30 * 24 * 60 * 60 * 1000;
@@ -206,7 +210,21 @@ function retryAfterMs(headers: Record<string, string>): number | undefined {
206
210
  }
207
211
 
208
212
  type RefreshedCredential = { refresh: string; access: string; expires: number };
209
- type LoginCredential = { refresh: string; access: string; expires: number };
213
+ // Codex login carries the ChatGPT `accountId` (JWT `chatgpt_account_id`); Anthropic login does
214
+ // not (its OAuth token has no account claim). Optional so both providers and the test seam fit.
215
+ type LoginCredential = { refresh: string; access: string; expires: number; accountId?: string };
216
+
217
+ // Codex login returns the account identity; extract it as a trimmed string (login results type
218
+ // it loosely through the OAuthCredentials index signature). Absent for Anthropic.
219
+ function loginAccountId(cred: { accountId?: unknown }): string | undefined {
220
+ return typeof cred.accountId === "string" && cred.accountId.trim() ? cred.accountId.trim() : undefined;
221
+ }
222
+
223
+ // Short, display-only rendering of a Codex account id for `/provider-sub list`, so distinct
224
+ // accounts are observable without dumping the full opaque id.
225
+ function shortAccountId(accountId: string): string {
226
+ return accountId.length > 12 ? `${accountId.slice(0, 8)}\u2026${accountId.slice(-4)}` : accountId;
227
+ }
210
228
 
211
229
  // The default slot's live value only ever exists inside auth.json (see
212
230
  // `refreshDefaultSlotCredential`) -- once a credential has been through refresh/reauth for
@@ -238,7 +256,7 @@ function openBrowser(url: string): void {
238
256
  try {
239
257
  const child = spawn(cmd, args, { stdio: "ignore", detached: true });
240
258
  child.on("error", () => {
241
- /* the notification still contains the URL */
259
+ /* best effort -- the manual-code dialog's title carries the URL too */
242
260
  });
243
261
  child.unref();
244
262
  } catch {
@@ -282,15 +300,6 @@ async function refreshDefaultSlotCredential(providerId: ManagedProviderId, crede
282
300
  return { ...credential, access };
283
301
  }
284
302
 
285
- async function persistDefaultSlotCredential(providerId: ManagedProviderId, credential: RefreshedCredential): Promise<void> {
286
- const backend = new FileAuthStorageBackend();
287
- await backend.withLockAsync(async (current) => {
288
- const data: AuthFileData = current ? JSON.parse(current) : {};
289
- data[providerId] = { type: "oauth", ...credential };
290
- return { result: undefined, next: `${JSON.stringify(data, null, 2)}\n` };
291
- });
292
- }
293
-
294
303
  async function refreshCredentialIfNeeded(providerId: ManagedProviderId, credential: SubscriptionCredential): Promise<ResolvedCredential> {
295
304
  if (credential.label === providerId) return refreshDefaultSlotCredential(providerId, credential);
296
305
  if (!credential.refresh || !credential.access || credential.expires === undefined) {
@@ -325,23 +334,34 @@ async function reauthenticateCredential(providerId: ManagedProviderId, credentia
325
334
  ctx.ui.notify(`${getProviderLabel(providerId)} subscription "${credential.label}" needs re-authentication. Opening OAuth login...`, "warn");
326
335
  const authenticated = await activeLoginForProvider(providerId, ctx);
327
336
  if (credential.label === providerId) {
328
- // Same single owner as the refresh path: persist the reauthenticated value to
329
- // auth.json through the same locked FileAuthStorageBackend seam the refresh path uses.
330
- await persistDefaultSlotCredential(providerId, authenticated);
331
- const pool = markSubscriptionSuccess(providerId, credential.label, Date.now());
332
- const meta = pool.find((entry) => entry.label === credential.label) ?? credential;
337
+ // Atomically commit the default (re)login: validate account-uniqueness (excluding the
338
+ // default slot), THEN persist auth.json, THEN causally reconcile the metadata-only pool entry
339
+ // under the pool lock (pool -> auth order), so a collision rejects BEFORE auth.json switches
340
+ // and a newer same-account 429 survives. Re-read the healed pool for returned metadata.
341
+ commitDefaultIdentity(providerId, {
342
+ refresh: authenticated.refresh,
343
+ access: authenticated.access,
344
+ expires: authenticated.expires,
345
+ accountId: loginAccountId(authenticated),
346
+ lastRateLimitedAt: credential.lastRateLimitedAt,
347
+ });
348
+ const meta = readSubscriptionPool(providerId).find((entry) => entry.label === credential.label) ?? credential;
333
349
  return { ...meta, access: authenticated.access };
334
350
  }
351
+ const reauthedAccountId = loginAccountId(authenticated) ?? credential.accountId;
335
352
  const next: SubscriptionCredential = {
336
353
  ...credential,
337
354
  refresh: authenticated.refresh,
338
355
  access: authenticated.access,
339
356
  expires: authenticated.expires,
357
+ ...(reauthedAccountId ? { accountId: reauthedAccountId } : {}),
340
358
  rateLimitedUntil: 0,
341
359
  lastAttemptAt: Date.now(),
342
360
  lastRateLimitedAt: credential.lastRateLimitedAt,
343
361
  };
344
- return (upsertSubscription(providerId, next).find((entry) => entry.label === next.label) ?? next) as ResolvedCredential;
362
+ // Identity-checked upsert: reauthenticating this explicit slot into an account already held by
363
+ // ANOTHER slot throws DuplicateSubscriptionError rather than creating a duplicate quota entry.
364
+ return (upsertSubscriptionWithUniqueAccount(providerId, next).find((entry) => entry.label === next.label) ?? next) as ResolvedCredential;
345
365
  }
346
366
 
347
367
  function defaultStreamForProvider(model: Model<any>, context: Context, options: SimpleStreamOptions | undefined): AssistantMessageEventStream {
@@ -363,6 +383,12 @@ export function __setStreamForProviderForTest(fn: typeof defaultStreamForProvide
363
383
  activeStreamForProvider = fn ?? defaultStreamForProvider;
364
384
  }
365
385
 
386
+ /** Test-only seam: exercise `fallbackRejectionHint` for one rejection discriminant
387
+ * (with an optional resolved-but-rejected candidate) without staging a full turn. */
388
+ export function __fallbackRejectionHintForTest(reason: FallbackRejectionReason, candidate?: FallbackTarget): string {
389
+ return fallbackRejectionHint({ reason, candidate });
390
+ }
391
+
366
392
  // Drives ONE credential, retrying transient (non-rate-limit) failures in place on the
367
393
  // SAME credential per #122, and reports back a classified result for #124's logging.
368
394
  // A genuine rate limit ends the attempt immediately (the caller cools the credential down
@@ -495,22 +521,70 @@ function emitTerminalError(stream: AssistantMessageEventStream, model: Model<any
495
521
  stream.end();
496
522
  }
497
523
 
524
+ // Why a candidate fallback was rejected -- each reason gets its OWN accurate hint
525
+ // (`fallbackRejectionHint`) so the terminal error never gives advice that can't fix the
526
+ // actual blocker. Only `no_credentials` is a genuine "go authenticate" case; the others
527
+ // are config/exhaustion states where telling the user to authenticate would be misleading.
528
+ type FallbackRejectionReason = "no_ladder_entry" | "already_attempted" | "no_credentials" | "model_unregistered";
529
+
530
+ // On rejection we carry the resolved-but-rejected `candidate` FallbackTarget whenever one
531
+ // exists (every reason except `no_ladder_entry`, where no target resolved at all), so the
532
+ // hint can name the exact provider/model and build an executable repair command from the
533
+ // candidate's config ladder key + strength.
534
+ type FallbackResolution =
535
+ | { target: FallbackTarget }
536
+ | { target?: undefined; reason: FallbackRejectionReason; candidate?: FallbackTarget };
537
+
498
538
  // #123: gates cross-provider fallback on the target actually being usable right now --
499
539
  // authenticated (its subscription pool has at least one credential, even if currently
500
540
  // cooling down) AND registered in the model registry -- rather than blindly switching the
501
- // session model to a provider nobody logged into. Returns undefined when there is nothing
502
- // viable to switch to (config has no ladder rung, the target was already tried this turn,
503
- // the target has zero credentials, or its resolved model isn't in the registry).
541
+ // session model to a provider nobody logged into. Returns the rejection reason (config has
542
+ // no ladder rung, the target was already tried this turn, the target has zero credentials,
543
+ // or its resolved model isn't in the registry) rather than just `undefined`, so callers can
544
+ // surface an accurate terminal error instead of always defaulting to "authenticate a
545
+ // fallback provider" -- which is actively wrong when the real blocker is a stale ladder.
504
546
  function resolveViableFallback(
505
547
  providerId: ManagedProviderId,
506
548
  modelId: string | undefined,
507
549
  attempted: Set<ManagedProviderId>,
508
- ): FallbackTarget | undefined {
550
+ ): FallbackResolution {
509
551
  const target = resolveFallbackTarget(providerId, modelId, readRotationConfig(), extensionAPI?.getThinkingLevel?.());
510
- if (!target || attempted.has(target.providerId)) return undefined;
511
- if (readSubscriptionPool(target.providerId).length === 0) return undefined;
512
- if (!runtimeContext?.modelRegistry.find(target.providerId, target.modelId)) return undefined;
513
- return target;
552
+ if (!target) return { reason: "no_ladder_entry" };
553
+ if (attempted.has(target.providerId)) return { reason: "already_attempted", candidate: target };
554
+ if (readSubscriptionPool(target.providerId).length === 0) return { reason: "no_credentials", candidate: target };
555
+ if (!runtimeContext?.modelRegistry.find(target.providerId, target.modelId)) {
556
+ return { reason: "model_unregistered", candidate: target };
557
+ }
558
+ return { target };
559
+ }
560
+
561
+ // Actionable hint appended to a terminal error when no fallback was viable. Each rejection
562
+ // reason gets advice that can actually resolve IT -- only `no_credentials` says
563
+ // "authenticate", since that's the sole case where a login is the missing piece. Telling a
564
+ // user to authenticate when both providers are already exhausted, or when the ladder points
565
+ // at an unregistered model, is actively wrong and was the original bug.
566
+ function fallbackRejectionHint(resolution: FallbackResolution): string {
567
+ if (resolution.target) return "try again later";
568
+ const candidate = resolution.candidate;
569
+ switch (resolution.reason) {
570
+ case "no_credentials": {
571
+ const who = candidate ? ` (${getProviderLabel(candidate.providerId)}, e.g. /provider-sub ${candidate.providerId} add)` : " with /provider-sub";
572
+ return `authenticate a fallback provider${who}, or try again later`;
573
+ }
574
+ case "model_unregistered": {
575
+ // Repair command must be EXECUTABLE as printed: `--value`, and the CONFIG ladder
576
+ // provider key (`openai`, not the runtime `openai-codex`) plus the candidate's own
577
+ // strength -- so the user pastes it, swaps in an installed model id, and it works.
578
+ const ladderKey = candidate ? getLadderProviderId(candidate.providerId) : "<provider>";
579
+ const strength = candidate?.strength ?? "<strength>";
580
+ const named = candidate ? candidate.label : "the configured fallback";
581
+ return `configured fallback ${named} is not in this build's model registry -- the modelLadders config is out of sync with the installed model catalog. Point it at an installed model with \`crtr sys config set modelLadders.${ladderKey}.${strength} --value <installed-model-id>\`, or upgrade/downgrade to a build whose ladder matches`;
582
+ }
583
+ case "already_attempted":
584
+ return "every managed provider was already tried this turn and is exhausted or cooling down -- try again later";
585
+ case "no_ladder_entry":
586
+ return "no cross-provider fallback is configured for this model -- set one with `crtr sys config set modelLadders.<provider>.<strength> --value <model-id>`, or try again later";
587
+ }
514
588
  }
515
589
 
516
590
  // Switches the session model to an already-viability-checked fallback target. On the rare
@@ -648,8 +722,8 @@ async function runManagedProvider(
648
722
  const originalModel = getCurrentModelRef(model);
649
723
  if (!originalModel) throw new Error(`Provider fallback unavailable: missing current model`);
650
724
 
651
- const viableTarget = resolveViableFallback(originalModel.providerId, originalModel.modelId, attempted);
652
- if (viableTarget) {
725
+ const fallbackResolution = resolveViableFallback(originalModel.providerId, originalModel.modelId, attempted);
726
+ if (fallbackResolution.target) {
653
727
  const target = await switchToFallbackModel(stream, model, originalModel);
654
728
  if (!target) return; // terminal error already emitted
655
729
  logRotation(`${getProviderLabel(providerId)} pool exhausted -> ${target.label}`);
@@ -691,12 +765,14 @@ async function runManagedProvider(
691
765
  return;
692
766
  }
693
767
 
768
+ const fallbackHint = fallbackRejectionHint(fallbackResolution);
769
+
694
770
  emitTerminalError(
695
771
  stream,
696
772
  model,
697
773
  waitMs > SOLE_PROVIDER_MAX_WAIT_MS
698
- ? `${label} cooling down for ${Math.ceil(waitMs / 1000)}s — too long to wait automatically; authenticate a fallback provider with /provider-sub, or try again later (tried ${triedList})`
699
- : `All managed provider pools exhausted (tried ${triedList})`,
774
+ ? `${label} cooling down for ${Math.ceil(waitMs / 1000)}s — too long to wait automatically; ${fallbackHint} (tried ${triedList})`
775
+ : `All managed provider pools exhausted; ${fallbackHint} (tried ${triedList})`,
700
776
  );
701
777
  }
702
778
 
@@ -742,46 +818,117 @@ function updateRotationStatus(ctx: ExtensionContext): void {
742
818
  // confirm/select default.
743
819
  const OAUTH_MANUAL_INPUT_TIMEOUT_MS = 600_000;
744
820
 
821
+ /**
822
+ * pi-ai races the manual-code-paste dialog against its own loopback callback
823
+ * server (local attach: the SAME browser can hit both). If the loopback wins,
824
+ * pi-ai abandons the manual-input promise without ever awaiting it again -- so
825
+ * left alone it stays open, on the broker AND onscreen, until
826
+ * OAUTH_MANUAL_INPUT_TIMEOUT_MS elapses (review mrf5doek: local-attach
827
+ * regression). `run` receives two things:
828
+ * - `readCode`, a `ctx.ui.input` wrapper it uses for the manual-code prompt. It
829
+ * threads an AbortSignal into the dialog: aborting it makes the broker drop
830
+ * the abandoned pending entry AND emit an `extension_ui_dismiss` frame keyed
831
+ * to THIS request's id, so the attach viewer tears down exactly this overlay
832
+ * (input-controller.ts `dismissDialog`) and nothing else.
833
+ * - `dismissManual`, which aborts that signal on demand. `run` fires it at the
834
+ * race-winner boundary -- pi-ai's `onProgress` "exchanging..." step, which
835
+ * runs the instant it HAS a code and BEFORE the token exchange completes.
836
+ * That dismisses the losing overlay promptly instead of after the (for
837
+ * OpenAI, unbounded/timeout-less) token exchange returns (review
838
+ * mrf68z4h). `onProgress` only ever fires post-code in pi-ai's browser login
839
+ * (anthropic.js exchange step; OpenAI's browser path emits none, so its
840
+ * dismissal falls back to `finally` below), so firing it never dismisses a
841
+ * dialog the user still needs -- a remote paste has already resolved
842
+ * `readCode` by then, making the abort a no-op.
843
+ * `finally` re-aborts unconditionally as the backstop for any path that reported
844
+ * no progress; AbortController.abort() is idempotent, so a second call is inert.
845
+ *
846
+ * `undefined` from `readCode` can ONLY mean the human cancelled or the dialog's
847
+ * own timeout fired (or our own late abort, which pi-ai's abandoned-promise
848
+ * `.catch` swallows as inert). pi-ai treats a falsy manual result as "no code
849
+ * yet" and falls through to its `onPrompt` paste-only fallback -- a second,
850
+ * URL-less dialog with the ordinary 120s default timeout, breaking the
851
+ * one-dialog contract and making Cancel look inert (review mrf5doek finding
852
+ * 2). `readCode` throws instead, ending the OAuth attempt directly.
853
+ */
854
+ export async function loginWithManualInputCleanup<T>(
855
+ ctx: ExtensionContext,
856
+ run: (
857
+ readCode: (title: string, placeholder: string) => Promise<string>,
858
+ dismissManual: () => void,
859
+ ) => Promise<T>,
860
+ ): Promise<T> {
861
+ const controller = new AbortController();
862
+ const readCode = async (title: string, placeholder: string): Promise<string> => {
863
+ const result = await ctx.ui.input(title, placeholder, { timeout: OAUTH_MANUAL_INPUT_TIMEOUT_MS, signal: controller.signal });
864
+ if (result === undefined) throw new Error("OAuth login cancelled");
865
+ return result;
866
+ };
867
+ try {
868
+ return await run(readCode, () => controller.abort());
869
+ } finally {
870
+ controller.abort();
871
+ }
872
+ }
873
+
745
874
  async function loginProvider(providerId: ManagedProviderId, ctx: ExtensionContext) {
746
875
  if (providerId === ANTHROPIC_PROVIDER_ID) {
747
876
  let authorizeUrl: string | undefined;
748
- return loginAnthropic({
877
+ return loginWithManualInputCleanup(ctx, (readCode, dismissManual) =>
878
+ loginAnthropic({
879
+ onAuth: ({ url, instructions }) => {
880
+ authorizeUrl = url;
881
+ // Best-effort for the local attach case where a browser is reachable; on a
882
+ // headless remote box this opens nothing and the URL embedded in the
883
+ // manual-code dialog title below (rendered as a tappable link on web) is
884
+ // the real path.
885
+ openBrowser(url);
886
+ if (instructions) ctx.ui.notify(instructions, "info");
887
+ },
888
+ onPrompt: async (prompt) => (await ctx.ui.input(prompt.message, prompt.placeholder ?? "")) ?? "",
889
+ // Fires only once pi-ai already has a code (its exchange step) -- the
890
+ // race-winner boundary. Dismiss the losing manual dialog NOW, before the
891
+ // token exchange, rather than waiting on it (review mrf68z4h).
892
+ onProgress: (message) => {
893
+ dismissManual();
894
+ ctx.ui.notify(message, "info");
895
+ },
896
+ onManualCodeInput: () =>
897
+ readCode(
898
+ `Open this URL to authenticate Claude:\n${authorizeUrl}\n\nPaste the redirect URL or code`,
899
+ "http://localhost:53692/callback?...",
900
+ ),
901
+ }),
902
+ );
903
+ }
904
+
905
+ let authorizeUrl: string | undefined;
906
+ return loginWithManualInputCleanup(ctx, (readCode, dismissManual) =>
907
+ loginOpenAICodex({
749
908
  onAuth: ({ url, instructions }) => {
750
909
  authorizeUrl = url;
751
910
  // Best-effort for the local attach case where a browser is reachable; on a
752
- // headless remote box this opens nothing and the URL below is the real path.
911
+ // headless remote box this opens nothing and the URL embedded in the
912
+ // manual-code dialog title below (rendered as a tappable link on web) is
913
+ // the real path.
753
914
  openBrowser(url);
754
915
  if (instructions) ctx.ui.notify(instructions, "info");
755
916
  },
756
917
  onPrompt: async (prompt) => (await ctx.ui.input(prompt.message, prompt.placeholder ?? "")) ?? "",
757
- onProgress: (message) => ctx.ui.notify(message, "info"),
758
- onManualCodeInput: async () =>
759
- (await ctx.ui.input(
760
- `Open this URL to authenticate Claude:\n${authorizeUrl}\n\nPaste the redirect URL or code`,
761
- "http://localhost:53692/callback?...",
762
- { timeout: OAUTH_MANUAL_INPUT_TIMEOUT_MS },
763
- )) ?? "",
764
- });
765
- }
766
-
767
- let authorizeUrl: string | undefined;
768
- return loginOpenAICodex({
769
- onAuth: ({ url, instructions }) => {
770
- authorizeUrl = url;
771
- // Best-effort for the local attach case where a browser is reachable; on a
772
- // headless remote box this opens nothing and the URL below is the real path.
773
- openBrowser(url);
774
- if (instructions) ctx.ui.notify(instructions, "info");
775
- },
776
- onPrompt: async (prompt) => (await ctx.ui.input(prompt.message, prompt.placeholder ?? "")) ?? "",
777
- onProgress: (message) => ctx.ui.notify(message, "info"),
778
- onManualCodeInput: async () =>
779
- (await ctx.ui.input(
780
- `Open this URL to authenticate OpenAI Codex:\n${authorizeUrl}\n\nPaste the redirect URL or code`,
781
- "http://localhost:1455/auth/callback?...",
782
- { timeout: OAUTH_MANUAL_INPUT_TIMEOUT_MS },
783
- )) ?? "",
784
- });
918
+ // pi-ai's OpenAI browser path emits no progress between callback-win and
919
+ // exchange, so this dismisses at the exchange step when present; the
920
+ // finally-abort backstop covers the (bounded-by-dialog-timeout) gap.
921
+ onProgress: (message) => {
922
+ dismissManual();
923
+ ctx.ui.notify(message, "info");
924
+ },
925
+ onManualCodeInput: () =>
926
+ readCode(
927
+ `Open this URL to authenticate OpenAI Codex:\n${authorizeUrl}\n\nPaste the redirect URL or code`,
928
+ "http://localhost:1455/auth/callback?...",
929
+ ),
930
+ }),
931
+ );
785
932
  }
786
933
 
787
934
  export default async function (pi: ExtensionAPI): Promise<void> {
@@ -832,13 +979,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
832
979
  }
833
980
 
834
981
  const action = verb === "rm" ? "remove" : verb || "list";
835
- const pool = readSubscriptionPool(providerId);
836
982
 
837
983
  if (action === "status" || action === "list") {
984
+ const pool = readSubscriptionPool(providerId);
838
985
  const lines = [formatStatusLine(providerId), ""];
839
986
  for (const [index, entry] of pool.entries()) {
840
987
  const cooldown = entry.rateLimitedUntil > Date.now() ? ` cooldown=${Math.ceil((entry.rateLimitedUntil - Date.now()) / 1000)}s` : "";
841
- lines.push(`${index + 1}. ${entry.label}${cooldown}`);
988
+ const account = entry.accountId ? ` account=${shortAccountId(entry.accountId)}` : "";
989
+ lines.push(`${index + 1}. ${entry.label}${account}${cooldown}`);
842
990
  }
843
991
  ctx.ui.notify(lines.join("\n"), "info");
844
992
  return;
@@ -849,7 +997,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
849
997
  ctx.ui.notify(`${getProviderLabel(providerId)} login requires an interactive UI.`, "error");
850
998
  return;
851
999
  }
852
- const label = rest.join(" ") || (await ctx.ui.input(`${getProviderLabel(providerId)} subscription label`, providerId));
1000
+ const pool = readSubscriptionPool(providerId);
1001
+ const label = (rest.join(" ") || (await ctx.ui.input(`${getProviderLabel(providerId)} subscription label`, providerId)) || "").trim();
853
1002
  if (!label) return;
854
1003
  // The default label (`label === providerId`) is the OAuth-login-linked default slot,
855
1004
  // whose live value lives exclusively in auth.json (design §1/§3). Accepting it bypasses
@@ -863,18 +1012,32 @@ export default async function (pi: ExtensionAPI): Promise<void> {
863
1012
  }
864
1013
  try {
865
1014
  const cred = await activeLoginForProvider(providerId, ctx);
1015
+ const accountId = loginAccountId(cred);
866
1016
  if (isDefaultLabel) {
867
- // Same single-owner write as `reauthenticateCredential`'s default branch: persist
868
- // to auth.json through the same locked FileAuthStorageBackend seam; the
869
- // metadata-only pool entry is (re)created on next read.
870
- await persistDefaultSlotCredential(providerId, cred);
871
- markSubscriptionSuccess(providerId, providerId, Date.now());
1017
+ // Atomically commit the default login: validate account-uniqueness (excluding the
1018
+ // default slot), persist auth.json, then reflect identity and causally reconcile the
1019
+ // metadata-only cooldown under the pool lock. A collision rejects before auth.json
1020
+ // switches, and a newer same-account 429 recorded during OAuth remains authoritative.
1021
+ commitDefaultIdentity(providerId, {
1022
+ refresh: cred.refresh,
1023
+ access: cred.access,
1024
+ expires: cred.expires,
1025
+ accountId,
1026
+ lastRateLimitedAt: pool.find((entry) => entry.label === providerId)?.lastRateLimitedAt ?? 0,
1027
+ });
872
1028
  } else {
873
- upsertSubscription(providerId, {
1029
+ // Enforce label + Codex account uniqueness ATOMICALLY inside the pool lock: a sticky
1030
+ // browser session (or two brokers finishing OAuth together) must not land the same
1031
+ // quota under a second label. addSubscription throws DuplicateSubscriptionError,
1032
+ // surfaced by the catch below. The account check also catches a collision with the
1033
+ // default slot. Anthropic has no programmatic account identity, so its duplicate
1034
+ // protection stays a validation-procedure obligation, not a code check.
1035
+ addSubscription(providerId, {
874
1036
  label,
875
1037
  refresh: cred.refresh,
876
1038
  access: cred.access,
877
1039
  expires: cred.expires,
1040
+ ...(accountId ? { accountId } : {}),
878
1041
  rateLimitedUntil: 0,
879
1042
  lastAttemptAt: 0,
880
1043
  lastRateLimitedAt: 0,
@@ -913,7 +1076,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
913
1076
  return;
914
1077
  }
915
1078
  try {
916
- removeSubscription(providerId, ref);
1079
+ removeManagedAccount(providerId, ref);
917
1080
  logRotation(`removed ${getProviderLabel(providerId)} subscription "${ref}"`);
918
1081
  ctx.ui.notify(`Removed ${getProviderLabel(providerId)} subscription: ${ref}`, "info");
919
1082
  updateRotationStatus(ctx);
@@ -7,15 +7,8 @@
7
7
  * shown only when a name is set (via /name or
8
8
  * pi.setSessionName()).
9
9
  *
10
- * NOTE: this extension used to also render a "crtr subagent bar" by polling
11
- * `crtr agent session list` / `crtr agent session show`. Those subcommands were
12
- * removed in the canvas refactor (commit 9e141f0). Because crtr's front-door
13
- * treats any unrecognized first token as a prompt and boots a fresh root pi
14
- * (spawnSync('pi', ...)), each poll spawned a new pi that re-loaded this
15
- * extension and polled again — an unbounded fork bomb on startup. Canvas
16
- * navigation now lives in crtr's own `canvas-nav` extension (injected into every
17
- * node via `-e`), which reads the canvas in-process and navigates with
18
- * `crtr node focus`. The agent bar has been retired here accordingly.
10
+ * This extension owns only the statusline and session-name badge. Canvas
11
+ * navigation and interactive chrome live in crouter's attach viewer.
19
12
  */
20
13
 
21
14
  import { execFile } from "node:child_process";