pi-typesafe-router 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,1118 @@
1
+ import { join } from "node:path";
2
+ import {
3
+ buildSessionContext,
4
+ convertToLlm,
5
+ getAgentDir,
6
+ type ExtensionAPI,
7
+ type InputEvent,
8
+ type SessionBeforeSwitchEvent,
9
+ type SessionBeforeForkEvent,
10
+ type SessionBeforeTreeEvent,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import type { ImageContent } from "@earendil-works/pi-ai";
13
+ import { z } from "zod";
14
+ import { matchesKey } from "@earendil-works/pi-tui";
15
+ import type { RouterAPI, RouterContext } from "./host.ts";
16
+
17
+ export type { RouterAPI, RouterContext, RouterEvents } from "./host.ts";
18
+
19
+ import { classify as defaultClassify } from "./classifier.ts";
20
+ import { parseConfig } from "./config.ts";
21
+ import { contextInputTokens, projectState } from "./context.ts";
22
+ import { candidateChecks, chooseRoute } from "./routing.ts";
23
+ import { probeGeneration as defaultProbeGeneration } from "./generation-probe.ts";
24
+ import {
25
+ configuredTargets,
26
+ verificationFingerprint,
27
+ type VerifiedGeneration,
28
+ } from "./verification.ts";
29
+ import { classifierLines, routeLines, runtimeLines, type EvaluationResult } from "./diagnostics.ts";
30
+ import { abortable, createConfig, loadConfig } from "./settings.ts";
31
+ import {
32
+ ClassifierError,
33
+ targetKey,
34
+ type Classification,
35
+ type Classify,
36
+ type Eligibility,
37
+ type Mode,
38
+ type Route,
39
+ type RouterConfig,
40
+ type Target,
41
+ } from "./types.ts";
42
+
43
+ const NAME = "typesafe-router";
44
+
45
+ const sessionModeSchema = z.object({ mode: z.enum(["off", "auto", "shadow"]) });
46
+
47
+ const DISCLOSURE =
48
+ "Classification sends your request and bounded recent user/assistant text to the configured backend. Text can contain private code or secrets. Shadow mode also sends data and may incur charges. No automatic generation replay or classifier-backend failover.";
49
+
50
+ const HELP =
51
+ "/typesafe-router setup [typesafe|cloudflare|vercel] | doctor | status | on | shadow | off";
52
+
53
+ interface Decision {
54
+ route: Route;
55
+ target?: Target;
56
+ reason: string;
57
+ skipped: string[];
58
+ backend: string;
59
+ milliseconds: number;
60
+ classification?: Classification;
61
+ shadow: boolean;
62
+ }
63
+
64
+ interface Operation {
65
+ controller: AbortController;
66
+ epoch: number;
67
+ phase: "classifying" | "selecting" | "loading" | "probing";
68
+ purpose: "routing" | "doctor" | "config";
69
+ done: Promise<void>;
70
+ }
71
+
72
+ export interface Dependencies {
73
+ configPath?: string;
74
+ classify?: Classify;
75
+ probeGeneration?: typeof defaultProbeGeneration;
76
+ load?: (path: string) => Promise<RouterConfig | undefined>;
77
+ }
78
+
79
+ /** Register the extension; dependency overrides are for offline integration tests. */
80
+ export function registerRouter(pi: RouterAPI, dependencies: Dependencies = {}): void {
81
+ const path = dependencies.configPath ?? join(getAgentDir(), "typesafe-router.json");
82
+ const classify = dependencies.classify ?? defaultClassify;
83
+ const readConfig = dependencies.load ?? loadConfig;
84
+ const probeGeneration = dependencies.probeGeneration ?? defaultProbeGeneration;
85
+ let verified: VerifiedGeneration | undefined;
86
+ let config: RouterConfig | undefined;
87
+ let configError = false;
88
+ let mode: Mode = "off";
89
+ let epoch = 0;
90
+ let active: Operation | undefined;
91
+ let selecting: string | undefined;
92
+ let last: Decision | undefined;
93
+ let generationFailed = false;
94
+ let shuttingDown = false;
95
+
96
+ function notify(ctx: RouterContext, text: string, type: "info" | "warning" | "error" = "info") {
97
+ if (ctx.hasUI) ctx.ui.notify(text, type);
98
+ else process.stderr.write(`[${NAME}] ${text}\n`);
99
+ }
100
+
101
+ function status(ctx: RouterContext) {
102
+ if (ctx.hasUI)
103
+ ctx.ui.setStatus(
104
+ NAME,
105
+ mode === "off"
106
+ ? undefined
107
+ : `Jev ${mode}${active ? `: ${active.phase}` : !verified ? ": doctor required" : last?.target ? `: ${targetKey(last.target)}` : ""}`,
108
+ );
109
+ }
110
+
111
+ function cancel() {
112
+ epoch++;
113
+ active?.controller.abort();
114
+ }
115
+
116
+ function isCurrent(op: Operation) {
117
+ return op.epoch === epoch && !op.controller.signal.aborted;
118
+ }
119
+
120
+ function setMode(next: Mode, ctx: RouterContext) {
121
+ cancel();
122
+ mode = next;
123
+ pi.appendEntry(`${NAME}-mode`, { mode });
124
+ status(ctx);
125
+ }
126
+
127
+ async function reload(ctx: RouterContext): Promise<boolean> {
128
+ verified = undefined;
129
+ const { op, cleanup } = begin(ctx, "config");
130
+ op.phase = "loading";
131
+ status(ctx);
132
+
133
+ try {
134
+ const loaded = await abortable(() => readConfig(path), op.controller.signal);
135
+
136
+ if (!isCurrent(op) || shuttingDown) return false;
137
+ config = loaded;
138
+ configError = false;
139
+ mode = loaded?.mode ?? "off";
140
+ last = undefined;
141
+ generationFailed = false;
142
+
143
+ return true;
144
+ } catch {
145
+ if (!isCurrent(op) || shuttingDown) return false;
146
+ config = undefined;
147
+ configError = true;
148
+ mode = "off";
149
+ last = undefined;
150
+ generationFailed = false;
151
+ notify(
152
+ ctx,
153
+ `Invalid router configuration at ${path}. Routing is blocked. Repair the file and run /typesafe-router doctor, or use /typesafe-router off to proceed without routing.`,
154
+ "error",
155
+ );
156
+
157
+ return true;
158
+ } finally {
159
+ cleanup();
160
+ }
161
+ }
162
+
163
+ function contextMessages(ctx: RouterContext) {
164
+ return buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId())
165
+ .messages;
166
+ }
167
+
168
+ function eligibility(
169
+ ctx: RouterContext,
170
+ text = "",
171
+ images: readonly ImageContent[] = [],
172
+ ): Eligibility {
173
+ const messages = contextMessages(ctx);
174
+
175
+ const historyHasImages = convertToLlm(messages).some(
176
+ (message) =>
177
+ Array.isArray(message.content) && message.content.some((block) => block.type === "image"),
178
+ );
179
+
180
+ return {
181
+ models: ctx.modelRegistry.getAll(),
182
+ available: ctx.modelRegistry.getAvailable(),
183
+ scope: ctx.scopedModels.map(({ model }) => ({ provider: model.provider, model: model.id })),
184
+ hasImages: images.length > 0 || historyHasImages,
185
+ inputTokens: contextInputTokens(
186
+ ctx.getContextUsage(),
187
+ messages,
188
+ text || images.length
189
+ ? { role: "user", content: [{ type: "text", text }, ...images], timestamp: Date.now() }
190
+ : undefined,
191
+ ),
192
+ outputReserveTokens: config!.outputReserveTokens,
193
+ };
194
+ }
195
+
196
+ function verificationStatus(ctx: RouterContext) {
197
+ if (!verified || !config) return "not verified; run /typesafe-router doctor before routing";
198
+
199
+ if (verified.fingerprint !== verificationFingerprint(config, ctx.modelRegistry))
200
+ return "stale; model or credential references changed; run /typesafe-router doctor";
201
+
202
+ return `verified at ${verified.checkedAt}; availability is a snapshot, not a guarantee`;
203
+ }
204
+
205
+ async function requireVerification(ctx: RouterContext, cfg: RouterConfig, op: Operation) {
206
+ try {
207
+ if (!verified) {
208
+ notify(
209
+ ctx,
210
+ "Routing blocked: run /typesafe-router doctor successfully before routing. Every route needs a verified generation model.",
211
+ "warning",
212
+ );
213
+
214
+ return false;
215
+ }
216
+
217
+ const fresh = await abortable(() => readConfig(path), op.controller.signal);
218
+
219
+ if (!isCurrent(op)) return false;
220
+
221
+ if (
222
+ !fresh ||
223
+ JSON.stringify(fresh) !== JSON.stringify(cfg) ||
224
+ verified.fingerprint !== verificationFingerprint(cfg, ctx.modelRegistry)
225
+ ) {
226
+ verified = undefined;
227
+ notify(
228
+ ctx,
229
+ "Routing blocked: configuration, model mappings, or credential references changed. Run /typesafe-router doctor again.",
230
+ "warning",
231
+ );
232
+
233
+ return false;
234
+ }
235
+
236
+ const current = eligibility(ctx);
237
+
238
+ const blocked = Object.entries(cfg.routes).flatMap(([route, targets]) =>
239
+ candidateChecks(targets, current).some(
240
+ (candidate) => candidate.eligible && verified?.passed.has(targetKey(candidate.target)),
241
+ )
242
+ ? []
243
+ : [route],
244
+ );
245
+
246
+ if (blocked.length) {
247
+ notify(
248
+ ctx,
249
+ `Routing blocked: no currently eligible verified model for ${blocked.join(", ")}. Check model scope, credentials, and context size with /typesafe-router doctor.`,
250
+ "warning",
251
+ );
252
+
253
+ return false;
254
+ }
255
+
256
+ return true;
257
+ } catch {
258
+ if (isCurrent(op)) {
259
+ verified = undefined;
260
+ notify(
261
+ ctx,
262
+ "Routing blocked: configuration or verification could not be checked. Run /typesafe-router doctor.",
263
+ "error",
264
+ );
265
+ }
266
+
267
+ return false;
268
+ }
269
+ }
270
+
271
+ async function credential(
272
+ ctx: RouterContext,
273
+ cfg: RouterConfig,
274
+ signal: AbortSignal,
275
+ ): Promise<string> {
276
+ const auth = cfg.backend.auth;
277
+
278
+ const key =
279
+ auth.source === "env"
280
+ ? process.env[auth.variable]
281
+ : (await abortable(() => ctx.modelRegistry.getProviderAuth(auth.provider), signal))?.auth
282
+ .apiKey;
283
+
284
+ if (!key?.trim()) throw new ClassifierError("credentials");
285
+
286
+ return key.trim();
287
+ }
288
+
289
+ async function evaluate(
290
+ ctx: RouterContext,
291
+ cfg: RouterConfig,
292
+ text: string,
293
+ op: Operation,
294
+ synthetic = false,
295
+ ): Promise<EvaluationResult> {
296
+ const history = synthetic
297
+ ? []
298
+ : ctx.sessionManager
299
+ .buildContextEntries()
300
+ .flatMap((entry) =>
301
+ entry.type === "message" &&
302
+ (entry.message.role === "user" || entry.message.role === "assistant")
303
+ ? [entry.message]
304
+ : [],
305
+ );
306
+
307
+ const state = projectState(text, history, cfg.maxContextChars, cfg.historyMessages);
308
+
309
+ if (!state || (!synthetic && text.trimStart().startsWith("/")))
310
+ return { reason: "insufficient-context" };
311
+ const timeout = new AbortController();
312
+ const timer = setTimeout(() => timeout.abort(), cfg.timeoutMs);
313
+ const signal = AbortSignal.any([timeout.signal, op.controller.signal]);
314
+
315
+ try {
316
+ const apiKey = await credential(ctx, cfg, signal);
317
+
318
+ const classification = await abortable(
319
+ () => classify(cfg.backend, state, { signal, apiKey }),
320
+ signal,
321
+ );
322
+
323
+ return {
324
+ classification,
325
+ reason:
326
+ classification.choice === "uncertain" ||
327
+ classification.confidence === undefined ||
328
+ classification.confidence < cfg.minConfidence
329
+ ? "uncertain"
330
+ : "classified",
331
+ };
332
+ } catch (error) {
333
+ if (op.controller.signal.aborted) throw error;
334
+
335
+ return {
336
+ failure: timeout.signal.aborted
337
+ ? { code: "timeout" }
338
+ : error instanceof ClassifierError
339
+ ? { code: error.code, status: error.status }
340
+ : { code: "unavailable" },
341
+ reason: timeout.signal.aborted
342
+ ? "classifier-timeout"
343
+ : error instanceof ClassifierError
344
+ ? new ClassifierError(error.code, error.status).message
345
+ : "classifier-unavailable",
346
+ };
347
+ } finally {
348
+ clearTimeout(timer);
349
+ }
350
+ }
351
+
352
+ async function select(
353
+ ctx: RouterContext,
354
+ targets: readonly Target[],
355
+ checks: Eligibility,
356
+ op: Operation,
357
+ ): Promise<{ target?: Target; skipped: string[] }> {
358
+ const skipped: string[] = [];
359
+
360
+ for (const check of candidateChecks(targets, checks)) {
361
+ if (!isCurrent(op)) return { skipped };
362
+
363
+ if (!verified?.passed.has(targetKey(check.target))) {
364
+ skipped.push(`${targetKey(check.target)}: generation probe not passed`);
365
+ continue;
366
+ }
367
+
368
+ if (!check.eligible) {
369
+ skipped.push(`${targetKey(check.target)}: ${check.reason}`);
370
+ continue;
371
+ }
372
+
373
+ const model = ctx.modelRegistry.find(check.target.provider, check.target.model);
374
+
375
+ if (!model) {
376
+ skipped.push(`${targetKey(check.target)}: disappeared`);
377
+ continue;
378
+ }
379
+
380
+ op.phase = "selecting";
381
+ status(ctx);
382
+ selecting = targetKey(check.target);
383
+
384
+ try {
385
+ // Pi's setter is not cancellable. Await it; never race another selection against it.
386
+ const selected = await pi.setModel(model);
387
+
388
+ if (!isCurrent(op)) return { skipped };
389
+
390
+ if (selected) return { target: check.target, skipped };
391
+ skipped.push(`${targetKey(check.target)}: auth-not-configured`);
392
+ } catch {
393
+ skipped.push(`${targetKey(check.target)}: selection-failed`);
394
+ } finally {
395
+ selecting = undefined;
396
+ }
397
+ }
398
+
399
+ return { skipped };
400
+ }
401
+
402
+ function persist(ctx: RouterContext, decision: Decision) {
403
+ last = decision;
404
+ pi.appendEntry(`${NAME}-decision`, decision);
405
+ status(ctx);
406
+ }
407
+
408
+ function begin(ctx: RouterContext, purpose: Operation["purpose"] = "routing") {
409
+ if (active || shuttingDown || !ctx.isIdle())
410
+ throw new Error("Router operation is no longer permitted");
411
+ epoch++;
412
+ let settle!: () => void;
413
+
414
+ const done = new Promise<void>((resolve) => {
415
+ settle = resolve;
416
+ });
417
+
418
+ const op: Operation = {
419
+ controller: new AbortController(),
420
+ epoch,
421
+ phase: "classifying",
422
+ purpose,
423
+ done,
424
+ };
425
+
426
+ active = op;
427
+
428
+ const unsubscribe =
429
+ ctx.mode === "tui"
430
+ ? ctx.ui.onTerminalInput((data) => {
431
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
432
+ cancel();
433
+
434
+ return { consume: true };
435
+ }
436
+
437
+ return undefined;
438
+ })
439
+ : () => {};
440
+
441
+ status(ctx);
442
+
443
+ return {
444
+ op,
445
+ cleanup: () => {
446
+ try {
447
+ unsubscribe();
448
+
449
+ if (active === op) active = undefined;
450
+
451
+ if (!shuttingDown) status(ctx);
452
+ } finally {
453
+ settle();
454
+ }
455
+ },
456
+ };
457
+ }
458
+
459
+ async function onInput(
460
+ event: InputEvent,
461
+ ctx: RouterContext,
462
+ ): Promise<{ action: "continue" | "handled" }> {
463
+ if (active) {
464
+ notify(
465
+ ctx,
466
+ "Routing is already in progress. This submission was not queued; submit it again after routing settles.",
467
+ "warning",
468
+ );
469
+
470
+ return { action: "handled" };
471
+ }
472
+
473
+ if (event.source === "extension" || event.streamingBehavior || !ctx.isIdle())
474
+ return { action: "continue" };
475
+
476
+ if (configError) {
477
+ notify(
478
+ ctx,
479
+ "Router config is invalid. Repair it and run /typesafe-router doctor, or /typesafe-router off to proceed without routing.",
480
+ "error",
481
+ );
482
+
483
+ return { action: "handled" };
484
+ }
485
+
486
+ if (!config || mode === "off" || (ctx.mode !== "tui" && !config.allowHeadless))
487
+ return { action: "continue" };
488
+ const cfg = config;
489
+ const started = Date.now();
490
+ const { op, cleanup } = begin(ctx);
491
+
492
+ try {
493
+ if (!(await requireVerification(ctx, cfg, op))) return { action: "handled" };
494
+ const result = await evaluate(ctx, cfg, event.text, op);
495
+
496
+ if (!isCurrent(op)) return { action: "handled" };
497
+
498
+ const route =
499
+ result.reason === "insufficient-context"
500
+ ? cfg.uncertainRoute
501
+ : chooseRoute(result.classification, cfg);
502
+
503
+ const checks = eligibility(ctx, event.text, event.images);
504
+ checks.available = checks.available.filter((model) =>
505
+ verified?.passed.has(`${model.provider}/${model.id}`),
506
+ );
507
+
508
+ const selected =
509
+ mode === "shadow"
510
+ ? {
511
+ target: candidateChecks(cfg.routes[route], checks).find((check) => check.eligible)
512
+ ?.target,
513
+ skipped: candidateChecks(cfg.routes[route], checks)
514
+ .filter((check) => !check.eligible)
515
+ .map((check) => `${targetKey(check.target)}: ${check.reason}`),
516
+ }
517
+ : await select(ctx, cfg.routes[route], checks, op);
518
+
519
+ if (!isCurrent(op)) return { action: "handled" };
520
+
521
+ if (!(await requireVerification(ctx, cfg, op))) return { action: "handled" };
522
+ persist(ctx, {
523
+ ...selected,
524
+ ...result,
525
+ route,
526
+ backend: cfg.backend.type,
527
+ milliseconds: Date.now() - started,
528
+ shadow: mode === "shadow",
529
+ });
530
+ generationFailed = false;
531
+
532
+ if (mode === "shadow") return { action: "continue" };
533
+
534
+ if (!selected.target) {
535
+ notify(
536
+ ctx,
537
+ "No eligible model in the selected route. Prompt was not submitted. Run /typesafe-router doctor; select a model manually or repair the mapping.",
538
+ "error",
539
+ );
540
+
541
+ return { action: "handled" };
542
+ }
543
+
544
+ if (result.reason !== "classified" || selected.skipped.length)
545
+ notify(
546
+ ctx,
547
+ `${route} → ${targetKey(selected.target)} (${result.reason}${selected.skipped.length ? "; preflight fallback" : ""}).`,
548
+ "warning",
549
+ );
550
+
551
+ return { action: "continue" };
552
+ } catch {
553
+ if (isCurrent(op))
554
+ notify(
555
+ ctx,
556
+ "Routing failed safely; prompt was not submitted. Check configuration or turn routing off.",
557
+ "error",
558
+ );
559
+
560
+ return { action: "handled" };
561
+ } finally {
562
+ const cancelled = !isCurrent(op);
563
+ cleanup();
564
+
565
+ if (cancelled && !shuttingDown) {
566
+ // A replacement session invalidates ctx. Never touch it after shutdown.
567
+ try {
568
+ status(ctx);
569
+ notify(
570
+ ctx,
571
+ op.phase === "selecting"
572
+ ? "Routing cancelled; prompt was not submitted. Pi authentication finished settling; verify /model before resubmitting."
573
+ : "Routing cancelled; prompt was not submitted. Submit it again when ready.",
574
+ "warning",
575
+ );
576
+ } catch {
577
+ /* session disposed */
578
+ }
579
+ }
580
+ }
581
+ }
582
+
583
+ function activity(ctx: RouterContext) {
584
+ return active ? `${active.purpose}: ${active.phase}` : ctx.isIdle() ? "idle" : "Pi is running";
585
+ }
586
+
587
+ function currentModel(ctx: RouterContext) {
588
+ return `current model: ${ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none selected"}`;
589
+ }
590
+
591
+ async function showStatus(ctx: RouterContext) {
592
+ if (shuttingDown) return;
593
+ const snapshotEpoch = epoch;
594
+ let disk = "not compared while an operation is running; showing active configuration";
595
+
596
+ if (!active) {
597
+ try {
598
+ const fresh = await readConfig(path);
599
+ disk = !fresh
600
+ ? "missing; run /typesafe-router doctor"
601
+ : JSON.stringify(fresh) === JSON.stringify(config)
602
+ ? "matches active configuration"
603
+ : "unapplied changes; run /typesafe-router doctor";
604
+ } catch {
605
+ disk = "invalid or unreadable; run /typesafe-router doctor";
606
+ }
607
+ }
608
+
609
+ if (shuttingDown) return;
610
+
611
+ if (snapshotEpoch !== epoch)
612
+ disk = "state changed during this status check; run status again to compare disk";
613
+
614
+ const lines = [
615
+ "pi-typesafe-router: status",
616
+ ...runtimeLines(mode, activity(ctx), path, config),
617
+ `config: ${configError ? "invalid; automatic routing blocked" : config ? "active in memory" : "not loaded"}`,
618
+ `disk config: ${disk}`,
619
+ currentModel(ctx),
620
+ `generation verification: ${disk.startsWith("unapplied") || disk.startsWith("invalid") || disk.startsWith("missing") ? "stale; run /typesafe-router doctor" : verificationStatus(ctx)}`,
621
+ ];
622
+
623
+ if (config) {
624
+ for (const [route, targets] of Object.entries(config.routes))
625
+ lines.push(`route ${route}: ${targets.map(targetKey).join(" → ")}`);
626
+ }
627
+
628
+ if (last)
629
+ lines.push(
630
+ `last decision (historical): ${last.route} → ${last.target ? targetKey(last.target) : "no eligible model"}; ${last.reason}; ${last.milliseconds}ms${last.shadow ? " (shadow)" : ""}`,
631
+ );
632
+ else lines.push("last decision: none in this session");
633
+
634
+ lines.push(HELP);
635
+ notify(ctx, lines.join("\n"));
636
+ }
637
+
638
+ async function doctor(ctx: RouterContext) {
639
+ const { op, cleanup } = begin(ctx, "doctor");
640
+ verified = undefined;
641
+ op.phase = "loading";
642
+
643
+ function progress(message: string) {
644
+ if (!isCurrent(op)) return;
645
+
646
+ if (ctx.hasUI && ctx.mode === "tui")
647
+ ctx.ui.setWidget(`${NAME}-doctor-progress`, [message], { placement: "aboveEditor" });
648
+ else notify(ctx, message);
649
+ }
650
+
651
+ progress("Checking configuration…");
652
+ notify(
653
+ ctx,
654
+ "Checks use synthetic requests and may incur charges; no conversation history or tools.",
655
+ );
656
+ let applied = false;
657
+
658
+ try {
659
+ let loaded: RouterConfig | undefined;
660
+ let invalidConfig = false;
661
+
662
+ try {
663
+ loaded = await abortable(() => readConfig(path), op.controller.signal);
664
+ } catch {
665
+ if (!isCurrent(op)) return;
666
+ invalidConfig = true;
667
+ }
668
+
669
+ if (!isCurrent(op)) return;
670
+ config = loaded;
671
+ configError = invalidConfig;
672
+ last = undefined;
673
+ generationFailed = false;
674
+
675
+ if (!loaded) {
676
+ mode = "off";
677
+ pi.appendEntry(`${NAME}-mode`, { mode });
678
+ notify(
679
+ ctx,
680
+ [
681
+ "pi-typesafe-router: ❌",
682
+ ...runtimeLines(mode, "idle", path),
683
+ `config: ${invalidConfig ? "invalid or unreadable; not applied" : "missing"}`,
684
+ currentModel(ctx),
685
+ "classifier check: skipped; no valid configuration",
686
+ invalidConfig
687
+ ? "next: fix the configuration JSON, fields, or file permissions, then run /typesafe-router doctor"
688
+ : "next: /typesafe-router setup typesafe (or cloudflare/vercel), then /typesafe-router doctor",
689
+ ].join("\n"),
690
+ "error",
691
+ );
692
+
693
+ return;
694
+ }
695
+
696
+ applied = true;
697
+ const checks = eligibility(ctx);
698
+
699
+ const checkedRoutes = {
700
+ quick: candidateChecks(loaded.routes.quick, checks),
701
+ standard: candidateChecks(loaded.routes.standard, checks),
702
+ deep: candidateChecks(loaded.routes.deep, checks),
703
+ };
704
+
705
+ const blockedRoutes = Object.values(checkedRoutes).some(
706
+ (candidates) => !candidates.some((candidate) => candidate.eligible),
707
+ );
708
+
709
+ op.phase = "probing";
710
+ const fingerprint = verificationFingerprint(loaded, ctx.modelRegistry);
711
+ const targets = configuredTargets(loaded);
712
+ const total = targets.length + 1;
713
+ let completed = 0;
714
+ progress(`Checking model access… (0/${total} complete)`);
715
+
716
+ function completedProbe() {
717
+ completed++;
718
+ progress(`Checking model access… (${completed}/${total} complete)`);
719
+ }
720
+
721
+ async function checkClassifier(cfg: RouterConfig) {
722
+ const started = Date.now();
723
+
724
+ const result = await evaluate(
725
+ ctx,
726
+ cfg,
727
+ "Hello. Explain what a variable is in one sentence.",
728
+ op,
729
+ true,
730
+ );
731
+
732
+ const milliseconds = Date.now() - started;
733
+ completedProbe();
734
+
735
+ return { result, milliseconds };
736
+ }
737
+
738
+ const generationTimeoutMs = loaded.generationProbeTimeoutMs;
739
+
740
+ const [classifier, probes] = await Promise.all([
741
+ checkClassifier(loaded),
742
+ Promise.all(
743
+ targets.map(async (target) => {
744
+ const probe = await abortable(
745
+ () =>
746
+ probeGeneration(
747
+ ctx.modelRegistry,
748
+ target,
749
+ op.controller.signal,
750
+ generationTimeoutMs,
751
+ ),
752
+ op.controller.signal,
753
+ );
754
+
755
+ completedProbe();
756
+
757
+ return probe;
758
+ }),
759
+ ),
760
+ ]);
761
+
762
+ const { result, milliseconds: classifierElapsed } = classifier;
763
+
764
+ if (!isCurrent(op)) return;
765
+
766
+ const passed = new Set(
767
+ probes.flatMap((probe) => (probe.passed ? [targetKey(probe.target)] : [])),
768
+ );
769
+
770
+ const currentChecks = eligibility(ctx);
771
+
772
+ const missingRoutes = Object.entries(loaded.routes).flatMap(([route, targets]) =>
773
+ candidateChecks(targets, currentChecks).some(
774
+ (candidate) => candidate.eligible && passed.has(targetKey(candidate.target)),
775
+ )
776
+ ? []
777
+ : [route],
778
+ );
779
+
780
+ const latest = await abortable(() => readConfig(path), op.controller.signal);
781
+
782
+ if (!isCurrent(op)) return;
783
+
784
+ const unchanged =
785
+ !!latest &&
786
+ JSON.stringify(latest) === JSON.stringify(loaded) &&
787
+ fingerprint === verificationFingerprint(loaded, ctx.modelRegistry);
788
+
789
+ const ready = !!result.classification && missingRoutes.length === 0 && unchanged;
790
+
791
+ if (ready) verified = { fingerprint, passed, checkedAt: new Date().toISOString() };
792
+
793
+ const lines = [
794
+ `pi-typesafe-router: ${ready ? "✅" : "❌"}`,
795
+ ...runtimeLines(mode, "idle", path, loaded, []),
796
+ currentModel(ctx),
797
+ `context usage: ${checks.inputTokens === null ? "unknown after compaction; size check deferred to Pi" : `${checks.inputTokens} tokens`}`,
798
+ ...classifierLines(result, classifierElapsed, loaded),
799
+ ...routeLines(checkedRoutes, probes),
800
+ ];
801
+
802
+ if (!unchanged)
803
+ lines.push(
804
+ "next: configuration or credential references changed during doctor; run /typesafe-router doctor again",
805
+ );
806
+ else if (missingRoutes.length)
807
+ lines.push(
808
+ `next: no verified eligible model for ${missingRoutes.join(", ")}; fix model IDs, provider credentials, or access and run /typesafe-router doctor`,
809
+ );
810
+ else if (blockedRoutes)
811
+ lines.push(
812
+ "next: fix the blocked route mappings or generation credentials, then run /typesafe-router doctor",
813
+ );
814
+ else if (result.classification && ctx.mode !== "tui" && !loaded.allowHeadless)
815
+ lines.push("routing outside TUI: disabled (allowHeadless is false)");
816
+
817
+ progress("Checks complete");
818
+ notify(ctx, lines.join("\n"), ready ? "info" : "warning");
819
+ } catch {
820
+ if (isCurrent(op))
821
+ notify(
822
+ ctx,
823
+ `pi-typesafe-router: ❌\nconfig: ${applied ? "applied" : "not applied"}\nrouting: ${mode}\nchecks: incomplete; routing verification was not granted\nnext: inspect the configuration and Pi model catalogue, then run /typesafe-router doctor again`,
824
+ "error",
825
+ );
826
+ } finally {
827
+ const cancelled = !isCurrent(op);
828
+ op.controller.abort();
829
+
830
+ if (ctx.hasUI && ctx.mode === "tui") ctx.ui.setWidget(`${NAME}-doctor-progress`, undefined);
831
+ cleanup();
832
+
833
+ if (cancelled && !shuttingDown)
834
+ notify(
835
+ ctx,
836
+ `pi-typesafe-router: doctor cancelled\nconfig: ${applied ? "applied before cancellation" : "not applied"}\nrouting: ${mode}\nchecks: cancelled; routing verification was not granted`,
837
+ "warning",
838
+ );
839
+ }
840
+ }
841
+
842
+ pi.on("session_start", async (_event, ctx) => {
843
+ shuttingDown = false;
844
+
845
+ if (!(await reload(ctx))) return;
846
+
847
+ for (const entry of ctx.sessionManager.getBranch()) {
848
+ if (entry.type === "custom" && entry.customType === `${NAME}-mode`) {
849
+ const parsed = sessionModeSchema.safeParse(entry.data);
850
+
851
+ if (parsed.success && config && !configError) mode = parsed.data.mode;
852
+ }
853
+ }
854
+
855
+ status(ctx);
856
+ });
857
+ pi.on("session_shutdown", async () => {
858
+ shuttingDown = true;
859
+ verified = undefined;
860
+ cancel();
861
+ // A late auth result must settle before Pi tears down this extension runtime.
862
+ await active?.done;
863
+ last = undefined;
864
+ generationFailed = false;
865
+ });
866
+
867
+ const beforeNavigation = (
868
+ _event: SessionBeforeSwitchEvent | SessionBeforeForkEvent | SessionBeforeTreeEvent,
869
+ ctx: RouterContext,
870
+ ) => {
871
+ if (!active) return;
872
+ cancel();
873
+ notify(
874
+ ctx,
875
+ "Operation cancelled. Wait for it to settle, then repeat session navigation.",
876
+ "warning",
877
+ );
878
+
879
+ return { cancel: true };
880
+ };
881
+
882
+ pi.on("session_before_switch", beforeNavigation);
883
+ pi.on("session_before_fork", beforeNavigation);
884
+ pi.on("session_before_tree", beforeNavigation);
885
+ pi.on("session_tree", (_event, ctx) => {
886
+ setMode("off", ctx);
887
+ last = undefined;
888
+ generationFailed = false;
889
+ });
890
+ pi.on("input", async (event, ctx) => {
891
+ try {
892
+ return await onInput(event, ctx);
893
+ } catch {
894
+ // Pi catches thrown hook errors and continues. Return handled, never throw.
895
+ try {
896
+ notify(ctx, "Router preflight failed; prompt was not submitted.", "error");
897
+ } catch {
898
+ /* disposed UI */
899
+ }
900
+
901
+ return { action: "handled" };
902
+ }
903
+ });
904
+ pi.on("model_select", (event, ctx) => {
905
+ if (selecting === `${event.model.provider}/${event.model.id}` && event.source === "set") return;
906
+ last = undefined;
907
+ generationFailed = false;
908
+
909
+ if (mode !== "off" || active) {
910
+ setMode("off", ctx);
911
+ notify(ctx, "External model selection: automatic routing is now off.");
912
+ } else cancel(); // Also invalidate pending enable dialogs while already off.
913
+ });
914
+ pi.on("message_end", (event) => {
915
+ if (event.message.role === "assistant")
916
+ generationFailed =
917
+ event.message.stopReason === "error" &&
918
+ !last?.shadow &&
919
+ last?.target !== undefined &&
920
+ last.target.provider === event.message.provider &&
921
+ last.target.model === event.message.model;
922
+ });
923
+ pi.on("agent_settled", (_event, ctx) => {
924
+ if (generationFailed && last?.target && !last.shadow)
925
+ notify(
926
+ ctx,
927
+ "Generation failed after Pi's retries. The router did not switch models or replay the task. Use /model to select another model (this turns routing off), inspect completed tool effects, then explicitly continue.",
928
+ "warning",
929
+ );
930
+ });
931
+
932
+ pi.registerCommand(NAME, {
933
+ description: "Configure routing, run doctor diagnostics, or view active settings",
934
+ handler: async (args, ctx) => {
935
+ const [command = "status", option, ...extra] = args.trim().split(/\s+/).filter(Boolean);
936
+
937
+ if (extra.length || (option && command !== "setup")) {
938
+ notify(ctx, HELP, "warning");
939
+
940
+ return;
941
+ }
942
+
943
+ if (command === "off") {
944
+ configError = false;
945
+ setMode("off", ctx);
946
+ notify(
947
+ ctx,
948
+ `routing: off${active ? "; cancellation requested; wait for the current operation to settle" : "; no automatic classifier requests will be sent"}`,
949
+ );
950
+
951
+ return;
952
+ }
953
+
954
+ if (["validate", "check", "reload", "cancel", "recover"].includes(command)) {
955
+ notify(
956
+ ctx,
957
+ command === "recover"
958
+ ? "Recovery command removed. Use /model to select a model, inspect completed tool effects, then explicitly continue. No message was sent."
959
+ : command === "cancel"
960
+ ? "Cancel command removed. Use Escape or /typesafe-router off to stop pending routing."
961
+ : "Command removed. Use /typesafe-router doctor to refresh configuration and run local checks plus an automatic synthetic classifier test (may incur charges).",
962
+ "warning",
963
+ );
964
+
965
+ return;
966
+ }
967
+
968
+ if (command === "status") {
969
+ await showStatus(ctx);
970
+
971
+ return;
972
+ }
973
+
974
+ if (active || shuttingDown || !ctx.isIdle()) {
975
+ notify(
976
+ ctx,
977
+ "An operation is still running. Wait for it to finish, or use Escape /typesafe-router off to stop it.",
978
+ "warning",
979
+ );
980
+
981
+ return;
982
+ }
983
+
984
+ if (command === "doctor") {
985
+ await doctor(ctx);
986
+
987
+ return;
988
+ }
989
+
990
+ const commandEpoch = ++epoch;
991
+ const permitted = () => commandEpoch === epoch && !active && !shuttingDown && ctx.isIdle();
992
+
993
+ if (command === "setup") {
994
+ if (!ctx.hasUI) {
995
+ notify(
996
+ ctx,
997
+ `Copy an example to ${path} and configure model IDs. Setup requires UI.`,
998
+ "warning",
999
+ );
1000
+
1001
+ return;
1002
+ }
1003
+
1004
+ const backend =
1005
+ option ??
1006
+ (await ctx.ui.select("Classification backend", ["typesafe", "cloudflare", "vercel"]));
1007
+
1008
+ if (!backend || !permitted()) return;
1009
+
1010
+ if (!["typesafe", "cloudflare", "vercel"].includes(backend)) {
1011
+ notify(ctx, HELP, "warning");
1012
+
1013
+ return;
1014
+ }
1015
+
1016
+ const accountId =
1017
+ backend === "cloudflare"
1018
+ ? await ctx.ui.input("Cloudflare account ID (not an API token)")
1019
+ : undefined;
1020
+
1021
+ if (!permitted() || (backend === "cloudflare" && !accountId)) return;
1022
+
1023
+ const models = ctx.modelRegistry
1024
+ .getAvailable()
1025
+ .filter((model) => !["auto", "smart-router", "typesafe-router"].includes(model.provider));
1026
+
1027
+ const names = models.map((model) => `${model.provider}/${model.id}`);
1028
+
1029
+ const selected = await ctx.ui.select(
1030
+ "Initial model for all routes (edit ordered mappings in the config later)",
1031
+ names,
1032
+ );
1033
+
1034
+ if (!permitted()) return;
1035
+ const model = models[names.indexOf(selected ?? "")];
1036
+
1037
+ if (!model) return;
1038
+
1039
+ try {
1040
+ const target = { provider: model.provider, model: model.id };
1041
+
1042
+ const initial = parseConfig({
1043
+ version: 1,
1044
+ backend: backend === "cloudflare" ? { type: backend, accountId } : { type: backend },
1045
+ routes: { quick: [target], standard: [target], deep: [target] },
1046
+ });
1047
+
1048
+ await createConfig(path, initial);
1049
+
1050
+ if (!permitted() || !(await reload(ctx))) return;
1051
+ notify(
1052
+ ctx,
1053
+ `Created ${path}; routing is off. Set the backend credential environment variable, edit model mappings, then run /typesafe-router doctor and /typesafe-router on. Existing files are never overwritten.`,
1054
+ );
1055
+ } catch {
1056
+ notify(
1057
+ ctx,
1058
+ "Setup could not create config. It may already exist, the account ID may be invalid, or the directory may not be writable. No existing file was overwritten.",
1059
+ "error",
1060
+ );
1061
+ }
1062
+
1063
+ return;
1064
+ }
1065
+
1066
+ if (!config || configError) {
1067
+ notify(
1068
+ ctx,
1069
+ `Run /typesafe-router doctor to load and diagnose ${path}; use setup if no file exists.`,
1070
+ "error",
1071
+ );
1072
+
1073
+ return;
1074
+ }
1075
+
1076
+ if (command === "on" || command === "shadow") {
1077
+ if (ctx.mode !== "tui" && !config.allowHeadless) {
1078
+ notify(
1079
+ ctx,
1080
+ "Automatic routing is disabled in this interface. Set allowHeadless: true in config and run /typesafe-router doctor before enabling it.",
1081
+ "warning",
1082
+ );
1083
+
1084
+ return;
1085
+ }
1086
+
1087
+ if (
1088
+ ctx.hasUI &&
1089
+ !(await ctx.ui.confirm(
1090
+ `Enable ${command === "on" ? "automatic" : "shadow"} routing?`,
1091
+ DISCLOSURE,
1092
+ ))
1093
+ )
1094
+ return;
1095
+
1096
+ if (!permitted()) return;
1097
+
1098
+ const { op, cleanup } = begin(ctx, "config");
1099
+
1100
+ try {
1101
+ if (!(await requireVerification(ctx, config, op)) || !isCurrent(op)) return;
1102
+ setMode(command === "on" ? "auto" : "shadow", ctx);
1103
+ notify(ctx, `${mode} routing enabled for this session. ${DISCLOSURE}`);
1104
+ } finally {
1105
+ cleanup();
1106
+ }
1107
+
1108
+ return;
1109
+ }
1110
+
1111
+ notify(ctx, HELP, "warning");
1112
+ },
1113
+ });
1114
+ }
1115
+
1116
+ export default function typesafeRouter(pi: ExtensionAPI): void {
1117
+ registerRouter(pi);
1118
+ }