@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.
- package/README.md +1 -1
- package/dist/build-root.d.ts +12 -4
- package/dist/build-root.js +25 -6
- package/dist/builtin-memory/00-runtime-base.md +3 -10
- package/dist/builtin-memory/04-base-worker.md +18 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
- package/dist/builtin-memory/crouter-development/plugins.md +82 -5
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
- package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
- package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
- package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
- package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
- package/dist/builtin-views/canvas/tui.mjs +8 -9
- package/dist/builtin-views/chat/tui.mjs +12 -12
- package/dist/builtin-views/git-pr/tui.mjs +7 -8
- package/dist/builtin-views/inbox/tui.mjs +27 -41
- package/dist/builtin-views/linkedin/tui.mjs +23 -37
- package/dist/builtin-views/prompt-review/tui.mjs +11 -11
- package/dist/builtin-views/settings/tui.mjs +11 -11
- package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
- package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
- package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
- package/dist/clients/attach/attach-cmd.d.ts +9 -1
- package/dist/clients/attach/attach-cmd.js +841 -792
- package/dist/clients/attach/auth-pickers.d.ts +0 -12
- package/dist/clients/attach/auth-pickers.js +64 -15
- package/dist/clients/attach/canvas-panels.js +2 -3
- package/dist/clients/attach/chat-view.d.ts +7 -8
- package/dist/clients/attach/chat-view.js +119 -79
- package/dist/clients/attach/edit-diff-render.d.ts +6 -0
- package/dist/clients/attach/edit-diff-render.js +263 -0
- package/dist/clients/attach/graph-overlay.d.ts +12 -2
- package/dist/clients/attach/graph-overlay.js +83 -33
- package/dist/clients/attach/input-controller.d.ts +28 -2
- package/dist/clients/attach/input-controller.js +38 -5
- package/dist/clients/attach/mermaid-render.js +5 -2
- package/dist/clients/attach/pickers.d.ts +8 -7
- package/dist/clients/attach/pickers.js +11 -17
- package/dist/clients/attach/slash-commands.d.ts +9 -0
- package/dist/clients/attach/slash-commands.js +127 -7
- package/dist/clients/attach/titled-editor.d.ts +12 -1
- package/dist/clients/attach/titled-editor.js +103 -8
- package/dist/commands/canvas-browse.js +2 -2
- package/dist/commands/human/queue.js +3 -4
- package/dist/commands/memory/lint.js +39 -5
- package/dist/commands/memory/write.js +1 -0
- package/dist/commands/node.js +9 -2
- package/dist/commands/pkg/plugin-inspect.js +22 -1
- package/dist/commands/pkg/plugin-manage.js +31 -9
- package/dist/commands/surface-tmux-spread.js +1 -3
- package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
- package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
- package/dist/commands/sys/config.js +18 -21
- package/dist/commands/sys/doctor.js +42 -4
- package/dist/commands/sys/setup-core.d.ts +49 -1
- package/dist/commands/sys/setup-core.js +161 -4
- package/dist/commands/sys/setup.d.ts +88 -0
- package/dist/commands/sys/setup.js +943 -169
- package/dist/commands/view-pick.d.ts +4 -0
- package/dist/commands/view-pick.js +17 -7
- package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
- package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
- package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
- package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins.test.js +444 -0
- package/dist/core/__tests__/fault-classifier.test.js +15 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
- package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
- package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
- package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
- package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
- package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
- package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
- package/dist/core/__tests__/stream-watchdog.test.js +70 -0
- package/dist/core/__tests__/tmux-surface.test.js +72 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
- package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
- package/dist/core/canvas/browse/app.d.ts +6 -0
- package/dist/core/canvas/browse/app.js +230 -41
- package/dist/core/canvas/browse/model.d.ts +10 -6
- package/dist/core/canvas/browse/model.js +20 -12
- package/dist/core/canvas/browse/pins.d.ts +4 -0
- package/dist/core/canvas/browse/pins.js +29 -0
- package/dist/core/canvas/browse/render.d.ts +13 -1
- package/dist/core/canvas/browse/render.js +72 -15
- package/dist/core/canvas/nav-model.js +5 -11
- package/dist/core/canvas/paths.d.ts +5 -0
- package/dist/core/canvas/paths.js +7 -0
- package/dist/core/command-plugins/adapter.d.ts +15 -0
- package/dist/core/command-plugins/adapter.js +145 -0
- package/dist/core/command-plugins/compose.d.ts +5 -0
- package/dist/core/command-plugins/compose.js +56 -0
- package/dist/core/command-plugins/discovery.d.ts +104 -0
- package/dist/core/command-plugins/discovery.js +565 -0
- package/dist/core/config.d.ts +48 -0
- package/dist/core/config.js +228 -110
- package/dist/core/fault-classifier.js +1 -1
- package/dist/core/fs-utils.d.ts +1 -0
- package/dist/core/fs-utils.js +15 -1
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
- package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
- package/dist/core/keybindings/catalog.d.ts +14 -0
- package/dist/core/keybindings/catalog.js +257 -0
- package/dist/core/keybindings/index.d.ts +5 -0
- package/dist/core/keybindings/index.js +4 -0
- package/dist/core/keybindings/match.d.ts +29 -0
- package/dist/core/keybindings/match.js +99 -0
- package/dist/core/keybindings/persistence.d.ts +14 -0
- package/dist/core/keybindings/persistence.js +31 -0
- package/dist/core/keybindings/resolve.d.ts +14 -0
- package/dist/core/keybindings/resolve.js +305 -0
- package/dist/core/keybindings/types.d.ts +37 -0
- package/dist/core/keybindings/types.js +1 -0
- package/dist/core/memory-resolver.d.ts +1 -1
- package/dist/core/memory-resolver.js +10 -3
- package/dist/core/predicate.d.ts +5 -3
- package/dist/core/predicate.js +5 -3
- package/dist/core/profiles/select.d.ts +6 -0
- package/dist/core/profiles/select.js +86 -52
- package/dist/core/provider-management.d.ts +12 -0
- package/dist/core/provider-management.js +24 -0
- package/dist/core/runtime/broker-protocol.d.ts +33 -5
- package/dist/core/runtime/broker.js +164 -14
- package/dist/core/runtime/launch.d.ts +39 -6
- package/dist/core/runtime/launch.js +78 -20
- package/dist/core/runtime/naming.js +3 -3
- package/dist/core/runtime/pi-cli.d.ts +6 -0
- package/dist/core/runtime/pi-cli.js +16 -2
- package/dist/core/runtime/pi-vendored.d.ts +8 -0
- package/dist/core/runtime/pi-vendored.js +14 -0
- package/dist/core/runtime/placement.d.ts +2 -2
- package/dist/core/runtime/placement.js +4 -1
- package/dist/core/runtime/promote.js +4 -0
- package/dist/core/runtime/recap.d.ts +3 -3
- package/dist/core/runtime/recap.js +75 -47
- package/dist/core/runtime/recycle.js +6 -1
- package/dist/core/runtime/reset.js +5 -0
- package/dist/core/runtime/session-list-cache.d.ts +33 -0
- package/dist/core/runtime/session-list-cache.js +338 -0
- package/dist/core/runtime/spawn.js +52 -45
- package/dist/core/runtime/stream-watchdog.d.ts +26 -0
- package/dist/core/runtime/stream-watchdog.js +75 -0
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +2 -2
- package/dist/core/runtime/tmux.d.ts +18 -15
- package/dist/core/runtime/tmux.js +217 -110
- package/dist/core/scope.js +27 -4
- package/dist/core/subscription-state.d.ts +90 -0
- package/dist/core/subscription-state.js +762 -0
- package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
- package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
- package/dist/core/tui/host.d.ts +30 -1
- package/dist/core/tui/host.js +148 -34
- package/dist/core/view/contract.d.ts +17 -3
- package/dist/daemon/crtrd.js +253 -12
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
- package/dist/pi-extensions/canvas-recap.d.ts +4 -0
- package/dist/pi-extensions/canvas-recap.js +73 -46
- package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
- package/dist/pi-extensions/canvas-stophook.js +33 -4
- package/dist/types.d.ts +9 -26
- package/dist/types.js +1 -28
- package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +8 -4
- package/dist/pi-extensions/canvas-nav.d.ts +0 -43
- package/dist/pi-extensions/canvas-nav.js +0 -640
- package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
- 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
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
329
|
-
// auth.json
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
|
502
|
-
//
|
|
503
|
-
//
|
|
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
|
-
):
|
|
550
|
+
): FallbackResolution {
|
|
509
551
|
const target = resolveFallbackTarget(providerId, modelId, readRotationConfig(), extensionAPI?.getThinkingLevel?.());
|
|
510
|
-
if (!target
|
|
511
|
-
if (
|
|
512
|
-
if (
|
|
513
|
-
|
|
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
|
|
652
|
-
if (
|
|
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;
|
|
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
|
|
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
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
868
|
-
//
|
|
869
|
-
// metadata-only pool
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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";
|