@oh-my-pi/pi-coding-agent 15.11.6 → 15.11.8

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 (102) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/dist/cli.js +431 -381
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/cli/bench-cli.d.ts +78 -0
  5. package/dist/types/collab/crypto.d.ts +12 -0
  6. package/dist/types/collab/guest.d.ts +21 -0
  7. package/dist/types/collab/host.d.ts +13 -0
  8. package/dist/types/collab/protocol.d.ts +100 -0
  9. package/dist/types/collab/relay-client.d.ts +22 -0
  10. package/dist/types/commands/bench.d.ts +29 -0
  11. package/dist/types/commands/join.d.ts +12 -0
  12. package/dist/types/config/model-resolver.d.ts +3 -2
  13. package/dist/types/config/settings-schema.d.ts +93 -1
  14. package/dist/types/edit/renderer.d.ts +1 -0
  15. package/dist/types/extensibility/slash-commands.d.ts +1 -11
  16. package/dist/types/modes/components/agent-hub.d.ts +13 -0
  17. package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
  18. package/dist/types/modes/components/hook-selector.d.ts +4 -6
  19. package/dist/types/modes/components/oauth-selector.d.ts +10 -1
  20. package/dist/types/modes/components/segment-track.d.ts +11 -6
  21. package/dist/types/modes/components/settings-selector.d.ts +8 -1
  22. package/dist/types/modes/components/snapcompact-shape-preview.d.ts +31 -0
  23. package/dist/types/modes/components/status-line/component.d.ts +4 -1
  24. package/dist/types/modes/components/status-line/types.d.ts +9 -0
  25. package/dist/types/modes/components/tool-execution.d.ts +13 -9
  26. package/dist/types/modes/interactive-mode.d.ts +7 -0
  27. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +3 -0
  28. package/dist/types/modes/setup-wizard/scenes/types.d.ts +10 -1
  29. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +3 -0
  30. package/dist/types/modes/types.d.ts +8 -0
  31. package/dist/types/session/agent-session.d.ts +11 -0
  32. package/dist/types/session/session-manager.d.ts +21 -0
  33. package/dist/types/session/snapcompact-inline.d.ts +8 -3
  34. package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
  35. package/dist/types/tools/bash.d.ts +2 -0
  36. package/dist/types/tools/eval-render.d.ts +1 -0
  37. package/dist/types/tools/renderers.d.ts +13 -0
  38. package/dist/types/tools/ssh.d.ts +1 -0
  39. package/package.json +14 -12
  40. package/scripts/bench-guard.ts +71 -0
  41. package/src/cli/args.ts +2 -0
  42. package/src/cli/bench-cli.ts +437 -0
  43. package/src/cli-commands.ts +2 -0
  44. package/src/collab/crypto.ts +57 -0
  45. package/src/collab/guest.ts +421 -0
  46. package/src/collab/host.ts +494 -0
  47. package/src/collab/protocol.ts +191 -0
  48. package/src/collab/relay-client.ts +216 -0
  49. package/src/commands/bench.ts +42 -0
  50. package/src/commands/join.ts +39 -0
  51. package/src/config/model-registry.ts +74 -19
  52. package/src/config/model-resolver.ts +36 -5
  53. package/src/config/settings-schema.ts +119 -1
  54. package/src/edit/renderer.ts +5 -0
  55. package/src/extensibility/slash-commands.ts +1 -97
  56. package/src/hindsight/client.ts +26 -1
  57. package/src/hindsight/state.ts +6 -2
  58. package/src/internal-urls/docs-index.generated.ts +4 -3
  59. package/src/main.ts +11 -2
  60. package/src/mcp/transports/stdio.ts +81 -7
  61. package/src/modes/components/agent-hub.ts +119 -22
  62. package/src/modes/components/assistant-message.ts +126 -6
  63. package/src/modes/components/collab-prompt-message.ts +30 -0
  64. package/src/modes/components/hook-selector.ts +4 -5
  65. package/src/modes/components/oauth-selector.ts +67 -7
  66. package/src/modes/components/segment-track.ts +44 -7
  67. package/src/modes/components/settings-selector.ts +27 -0
  68. package/src/modes/components/snapcompact-shape-preview-doc.md +11 -0
  69. package/src/modes/components/snapcompact-shape-preview.ts +192 -0
  70. package/src/modes/components/status-line/component.ts +21 -1
  71. package/src/modes/components/status-line/presets.ts +1 -1
  72. package/src/modes/components/status-line/segments.ts +13 -0
  73. package/src/modes/components/status-line/types.ts +10 -0
  74. package/src/modes/components/tips.txt +2 -1
  75. package/src/modes/components/tool-execution.ts +18 -10
  76. package/src/modes/controllers/input-controller.ts +80 -12
  77. package/src/modes/controllers/selector-controller.ts +6 -2
  78. package/src/modes/controllers/streaming-reveal.ts +7 -0
  79. package/src/modes/interactive-mode.ts +36 -4
  80. package/src/modes/setup-wizard/index.ts +1 -0
  81. package/src/modes/setup-wizard/scenes/glyph.ts +24 -6
  82. package/src/modes/setup-wizard/scenes/providers.ts +36 -2
  83. package/src/modes/setup-wizard/scenes/sign-in.ts +10 -1
  84. package/src/modes/setup-wizard/scenes/theme.ts +28 -1
  85. package/src/modes/setup-wizard/scenes/types.ts +10 -1
  86. package/src/modes/setup-wizard/scenes/web-search.ts +22 -6
  87. package/src/modes/setup-wizard/wizard-overlay.ts +38 -1
  88. package/src/modes/types.ts +8 -0
  89. package/src/modes/utils/context-usage.ts +1 -1
  90. package/src/modes/utils/ui-helpers.ts +7 -0
  91. package/src/prompts/bench.md +7 -0
  92. package/src/sdk.ts +240 -36
  93. package/src/session/agent-session.ts +22 -0
  94. package/src/session/session-manager.ts +44 -0
  95. package/src/session/snapcompact-inline.ts +20 -22
  96. package/src/slash-commands/builtin-registry.ts +210 -0
  97. package/src/tools/bash.ts +3 -0
  98. package/src/tools/eval-render.ts +4 -0
  99. package/src/tools/read.ts +38 -5
  100. package/src/tools/renderers.ts +13 -0
  101. package/src/tools/ssh.ts +3 -0
  102. package/src/tools/write.ts +13 -42
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Client-side WebSocket wrapper for collab live-session sharing.
3
+ *
4
+ * Connects to a relay room, seals/opens AES-GCM frames, and reconnects with
5
+ * exponential backoff on transient drops. Fatal relay close codes (room gone,
6
+ * host conflict, room full) and decryption failures never reconnect.
7
+ */
8
+ import { logger } from "@oh-my-pi/pi-utils";
9
+ import { open, seal } from "./crypto";
10
+ import type { CollabFrame, RelayControlMessage } from "./protocol";
11
+ import { packEnvelope, unpackEnvelope } from "./protocol";
12
+
13
+ const FATAL_CLOSE_REASONS: Record<number, string> = {
14
+ 4001: "room closed",
15
+ 4004: "no such room",
16
+ 4009: "a host is already connected for this room",
17
+ 4029: "room is full",
18
+ };
19
+
20
+ const BACKOFF_BASE_MS = 1_000;
21
+ const BACKOFF_MAX_MS = 30_000;
22
+ /** Max enveloped frames buffered while a reconnect is pending; overflow is dropped. */
23
+ const MAX_PENDING_SENDS = 256;
24
+
25
+ export interface CollabSocketOptions {
26
+ /** wss://host[:port]/r/<roomId> — no query string. */
27
+ wsUrl: string;
28
+ role: "host" | "guest";
29
+ key: CryptoKey;
30
+ }
31
+
32
+ export class CollabSocket {
33
+ /** Fires after every successful (re)connect. */
34
+ onOpen?: () => void;
35
+ onFrame?: (frame: CollabFrame, fromPeer: number) => void;
36
+ onControl?: (msg: RelayControlMessage) => void;
37
+ /** Fires once per terminal close (intentional, fatal code, or bad key). willReconnect=true for transient drops that will retry. */
38
+ onClose?: (reason: string, willReconnect: boolean) => void;
39
+
40
+ readonly #opts: CollabSocketOptions;
41
+ #ws: WebSocket | null = null;
42
+ #retryTimer: NodeJS.Timeout | undefined;
43
+ #attempt = 0;
44
+ /** Terminal state: intentional close or fatal failure. Cleared by connect(). */
45
+ #closed = false;
46
+ /** Serializes seal() so frames hit the wire in send() order. */
47
+ #sendChain: Promise<void> = Promise.resolve();
48
+ /** Serializes open() so frames are delivered in arrival order. */
49
+ #recvChain: Promise<void> = Promise.resolve();
50
+ /** Envelopes sealed while disconnected, flushed on the next open. */
51
+ #pendingSends: Uint8Array[] = [];
52
+
53
+ constructor(opts: CollabSocketOptions) {
54
+ this.#opts = opts;
55
+ }
56
+
57
+ get isOpen(): boolean {
58
+ return this.#ws?.readyState === WebSocket.OPEN;
59
+ }
60
+
61
+ connect(): void {
62
+ if (this.#ws || this.#retryTimer) return;
63
+ this.#closed = false;
64
+ this.#attempt = 0;
65
+ this.#openSocket();
66
+ }
67
+
68
+ send(frame: CollabFrame, targetPeer = 0): void {
69
+ this.#sendChain = this.#sendChain
70
+ .then(async () => {
71
+ if (this.#closed) {
72
+ logger.debug("collab: dropping frame, socket closed", { t: frame.t });
73
+ return;
74
+ }
75
+ const sealed = await seal(this.#opts.key, frame);
76
+ const envelope = packEnvelope(targetPeer, sealed);
77
+ const ws = this.#ws;
78
+ if (ws && ws.readyState === WebSocket.OPEN) {
79
+ ws.send(envelope);
80
+ return;
81
+ }
82
+ if (this.#pendingSends.length >= MAX_PENDING_SENDS) {
83
+ logger.debug("collab: dropping frame, reconnect buffer full", { t: frame.t });
84
+ return;
85
+ }
86
+ this.#pendingSends.push(envelope);
87
+ })
88
+ .catch((err: unknown) => {
89
+ logger.debug("collab: send failed", { error: String(err) });
90
+ });
91
+ }
92
+
93
+ /** Intentional close: clears any retry timer, suppresses reconnect. A later connect() starts fresh. */
94
+ close(): void {
95
+ const hadActivity = this.#ws !== null || this.#retryTimer !== undefined;
96
+ this.#clearRetry();
97
+ const wasClosed = this.#closed;
98
+ this.#closed = true;
99
+ this.#pendingSends.length = 0;
100
+ const ws = this.#ws;
101
+ this.#ws = null;
102
+ if (ws) {
103
+ try {
104
+ ws.close(1000);
105
+ } catch {
106
+ // already closing/closed
107
+ }
108
+ }
109
+ if (hadActivity && !wasClosed) this.onClose?.("closed", false);
110
+ }
111
+
112
+ #openSocket(): void {
113
+ const ws = new WebSocket(`${this.#opts.wsUrl}?role=${this.#opts.role}`);
114
+ ws.binaryType = "arraybuffer";
115
+ this.#ws = ws;
116
+ ws.onopen = () => {
117
+ if (this.#ws !== ws) return;
118
+ this.#attempt = 0;
119
+ for (const envelope of this.#pendingSends) ws.send(envelope);
120
+ this.#pendingSends.length = 0;
121
+ this.onOpen?.();
122
+ };
123
+ ws.onmessage = (event: MessageEvent) => {
124
+ if (this.#ws !== ws) return;
125
+ this.#handleMessage(ws, event.data);
126
+ };
127
+ ws.onerror = () => {
128
+ // The paired close event carries the actionable state; nothing to do here.
129
+ };
130
+ ws.onclose = (event: CloseEvent) => {
131
+ if (this.#ws !== ws) return;
132
+ this.#ws = null;
133
+ this.#handleClose(event.code, event.reason);
134
+ };
135
+ }
136
+
137
+ #handleMessage(ws: WebSocket, data: unknown): void {
138
+ if (typeof data === "string") {
139
+ try {
140
+ this.onControl?.(JSON.parse(data) as RelayControlMessage);
141
+ } catch {
142
+ logger.debug("collab: ignoring malformed control message");
143
+ }
144
+ return;
145
+ }
146
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data instanceof Uint8Array ? data : null;
147
+ if (!bytes) return;
148
+ const envelope = unpackEnvelope(bytes);
149
+ if (!envelope) return;
150
+ this.#recvChain = this.#recvChain
151
+ .then(async () => {
152
+ if (this.#ws !== ws) return;
153
+ let frame: CollabFrame;
154
+ try {
155
+ frame = await open(this.#opts.key, envelope.payload);
156
+ } catch {
157
+ this.#failFatal("bad key or corrupted frame");
158
+ return;
159
+ }
160
+ if (this.#ws !== ws) return;
161
+ this.onFrame?.(frame, envelope.peerId);
162
+ })
163
+ .catch((err: unknown) => {
164
+ logger.debug("collab: frame handler failed", { error: String(err) });
165
+ });
166
+ }
167
+
168
+ #handleClose(code: number, reason: string): void {
169
+ if (this.#closed) return;
170
+ const fatalReason = FATAL_CLOSE_REASONS[code];
171
+ if (fatalReason !== undefined) {
172
+ this.#closed = true;
173
+ this.#pendingSends.length = 0;
174
+ this.onClose?.(fatalReason, false);
175
+ return;
176
+ }
177
+ this.onClose?.(reason || `connection lost (code ${code})`, true);
178
+ this.#scheduleRetry();
179
+ }
180
+
181
+ /** Decryption failure: wrong key or corrupted frame. Never reconnect. */
182
+ #failFatal(reason: string): void {
183
+ if (this.#closed) return;
184
+ this.#closed = true;
185
+ this.#clearRetry();
186
+ this.#pendingSends.length = 0;
187
+ const ws = this.#ws;
188
+ this.#ws = null;
189
+ if (ws) {
190
+ try {
191
+ ws.close(1000);
192
+ } catch {
193
+ // already closing/closed
194
+ }
195
+ }
196
+ this.onClose?.(reason, false);
197
+ }
198
+
199
+ #scheduleRetry(): void {
200
+ const base = Math.min(BACKOFF_BASE_MS * 2 ** this.#attempt, BACKOFF_MAX_MS);
201
+ this.#attempt++;
202
+ const delay = base * (0.75 + Math.random() * 0.5);
203
+ this.#retryTimer = setTimeout(() => {
204
+ this.#retryTimer = undefined;
205
+ if (this.#closed) return;
206
+ this.#openSocket();
207
+ }, delay);
208
+ }
209
+
210
+ #clearRetry(): void {
211
+ if (this.#retryTimer !== undefined) {
212
+ clearTimeout(this.#retryTimer);
213
+ this.#retryTimer = undefined;
214
+ }
215
+ }
216
+ }
@@ -0,0 +1,42 @@
1
+ import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
2
+ import { runBenchCommand } from "../cli/bench-cli";
3
+
4
+ export default class Bench extends Command {
5
+ static description =
6
+ "Benchmark models with the same prompt: time-to-first-token and generation throughput (tokens/s)";
7
+
8
+ static args = {
9
+ models: Args.string({
10
+ description: "Model selectors (provider/model or fuzzy id, e.g. opus)",
11
+ required: true,
12
+ multiple: true,
13
+ }),
14
+ };
15
+
16
+ static flags = {
17
+ runs: Flags.integer({ description: "Requests per model (results are averaged)", default: 1 }),
18
+ "max-tokens": Flags.integer({ description: "Max output tokens per request", default: 512 }),
19
+ prompt: Flags.string({ description: "Custom prompt text (default: bundled bench prompt)" }),
20
+ json: Flags.boolean({ description: "Output JSON" }),
21
+ };
22
+
23
+ static examples = [
24
+ "# Compare two models\n omp bench anthropic/claude-opus-4-5 openai/gpt-5.2",
25
+ "# Fuzzy selectors work\n omp bench opus sonnet",
26
+ "# Average over 3 runs each\n omp bench opus gpt-5.2 --runs 3",
27
+ "# Machine-readable output\n omp bench opus --json",
28
+ ];
29
+
30
+ async run(): Promise<void> {
31
+ const { args, flags } = await this.parse(Bench);
32
+ await runBenchCommand({
33
+ models: args.models ?? [],
34
+ flags: {
35
+ runs: flags.runs,
36
+ maxTokens: flags["max-tokens"],
37
+ prompt: flags.prompt,
38
+ json: flags.json,
39
+ },
40
+ });
41
+ }
42
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Join a shared collab session from the CLI: launches the interactive TUI and
3
+ * immediately runs `/join <link>`.
4
+ */
5
+ import { APP_NAME } from "@oh-my-pi/pi-utils";
6
+ import { Args, Command } from "@oh-my-pi/pi-utils/cli";
7
+ import { parseArgs } from "../cli/args";
8
+ import { runRootCommand } from "../main";
9
+
10
+ export default class Join extends Command {
11
+ static description = "Join a shared collab session (same as /join)";
12
+
13
+ static args = {
14
+ link: Args.string({
15
+ description: "Collab link shared by the host (/collab)",
16
+ required: true,
17
+ }),
18
+ };
19
+
20
+ static examples = [`${APP_NAME} join wss://relay.omp.sh/s/abc123#key`];
21
+
22
+ async run(): Promise<void> {
23
+ const { args } = await this.parse(Join);
24
+ const link = args.link?.trim();
25
+ if (!link) {
26
+ process.stderr.write(`Usage: ${APP_NAME} join <link>\n`);
27
+ process.exitCode = 1;
28
+ return;
29
+ }
30
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
31
+ process.stderr.write(`${APP_NAME} join requires an interactive terminal\n`);
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+ const parsed = parseArgs([]);
36
+ parsed.join = link;
37
+ await runRootCommand(parsed, []);
38
+ }
39
+ }
@@ -20,6 +20,11 @@ import {
20
20
  UNK_CONTEXT_WINDOW,
21
21
  UNK_MAX_TOKENS,
22
22
  } from "@oh-my-pi/pi-catalog/provider-models";
23
+ import {
24
+ collapseBuiltModelVariants,
25
+ getVariantAliasSources,
26
+ resolveVariantAlias,
27
+ } from "@oh-my-pi/pi-catalog/variant-collapse";
23
28
 
24
29
  // Sentinel for local-only OAuth token (LM Studio, vLLM) — declared inline to avoid loading
25
30
  // any provider module at startup. Must match `DEFAULT_LOCAL_TOKEN` in oauth/lm-studio.ts.
@@ -542,7 +547,37 @@ function normalizeSuppressedSelector(selector: string): string {
542
547
  if (!trimmed) return trimmed;
543
548
  const parsed = parseModelString(trimmed);
544
549
  if (!parsed) return trimmed;
545
- return `${parsed.provider}/${parsed.id}`;
550
+ // Retired effort-tier variant ids normalize to their collapsed logical id
551
+ // so persisted suppressions keyed by raw member ids still bind.
552
+ const aliasId = resolveVariantAlias(parsed.provider, parsed.id);
553
+ return `${parsed.provider}/${aliasId ?? parsed.id}`;
554
+ }
555
+
556
+ /**
557
+ * Look up a model's override, falling back to entries keyed by retired
558
+ * effort-tier variant ids (models.yml authored before collapsing). A raw key
559
+ * only re-binds when no live model holds that id.
560
+ */
561
+ function resolveModelOverrideWithAliases(
562
+ overrides: Map<string, ModelOverride>,
563
+ model: Model<Api>,
564
+ hasLiveModel: (provider: string, id: string) => boolean,
565
+ ): ModelOverride | undefined {
566
+ const direct = overrides.get(model.id);
567
+ if (direct) return direct;
568
+ for (const rawId of getVariantAliasSources(model.provider, model.id)) {
569
+ if (hasLiveModel(model.provider, rawId)) continue;
570
+ const remapped = overrides.get(rawId);
571
+ if (remapped) {
572
+ logger.debug("model override re-keyed through variant alias", {
573
+ provider: model.provider,
574
+ from: rawId,
575
+ to: model.id,
576
+ });
577
+ return remapped;
578
+ }
579
+ }
580
+ return undefined;
546
581
  }
547
582
 
548
583
  function getDisabledProviderIdsFromSettings(): Set<string> {
@@ -567,6 +602,7 @@ function getConfiguredProviderOrderFromSettings(): string[] {
567
602
  export class ModelRegistry {
568
603
  #models: Model<Api>[] = [];
569
604
  #canonicalIndex: CanonicalModelIndex = { records: [], byId: new Map(), bySelector: new Map() };
605
+ #canonicalIndexDirty: boolean = true;
570
606
  #customProviderApiKeys: Map<string, string> = new Map();
571
607
  #keylessProviders: Set<string> = new Set();
572
608
  #discoverableProviders: DiscoveryProviderConfig[] = [];
@@ -799,7 +835,9 @@ export class ModelRegistry {
799
835
  const withConfigModels = this.#mergeCustomModels(resolvedDefaults, this.#customModelOverlays);
800
836
  // Merge runtime extension models so they survive refresh() cycles
801
837
  const combined = this.#mergeCustomModels(withConfigModels, this.#runtimeModelOverlays);
802
- const withModelOverrides = this.#applyModelOverrides(combined, this.#modelOverrides);
838
+ // Custom/config providers bypass the model-manager merge point —
839
+ // collapse effort-tier variants here so X/X-thinking twins fold.
840
+ const withModelOverrides = this.#applyModelOverrides(collapseBuiltModelVariants(combined), this.#modelOverrides);
803
841
  this.#models = this.#applyRuntimeProviderOverrides(withModelOverrides);
804
842
  this.#rebuildCanonicalIndex();
805
843
  this.#lastStaticLoadMtime = this.#modelsConfigFile.getMtimeMs();
@@ -1152,7 +1190,7 @@ export class ModelRegistry {
1152
1190
  const withConfigModels = this.#mergeCustomModels(resolved, this.#customModelOverlays);
1153
1191
  // Merge runtime extension models so they survive online discovery completion
1154
1192
  const combined = this.#mergeCustomModels(withConfigModels, this.#runtimeModelOverlays);
1155
- const withModelOverrides = this.#applyModelOverrides(combined, this.#modelOverrides);
1193
+ const withModelOverrides = this.#applyModelOverrides(collapseBuiltModelVariants(combined), this.#modelOverrides);
1156
1194
  this.#models = this.#applyRuntimeProviderOverrides(withModelOverrides);
1157
1195
  this.#rebuildCanonicalIndex();
1158
1196
  }
@@ -1398,8 +1436,13 @@ export class ModelRegistry {
1398
1436
  #applyProviderModelOverrides(provider: string, models: Model<Api>[]): Model<Api>[] {
1399
1437
  const overrides = this.#modelOverrides.get(provider);
1400
1438
  if (!overrides || overrides.size === 0) return models;
1439
+ let liveIds: Set<string> | null = null;
1440
+ const hasLiveModel = (_provider: string, id: string) => {
1441
+ liveIds ??= new Set(models.map(m => m.id));
1442
+ return liveIds.has(id);
1443
+ };
1401
1444
  return models.map(model => {
1402
- const override = overrides.get(model.id);
1445
+ const override = resolveModelOverrideWithAliases(overrides, model, hasLiveModel);
1403
1446
  if (!override) return model;
1404
1447
  return applyModelOverride(model, override);
1405
1448
  });
@@ -1443,10 +1486,15 @@ export class ModelRegistry {
1443
1486
  }
1444
1487
  #applyModelOverrides(models: Model<Api>[], overrides: Map<string, Map<string, ModelOverride>>): Model<Api>[] {
1445
1488
  if (overrides.size === 0) return models;
1489
+ let liveKeys: Set<string> | null = null;
1490
+ const hasLiveModel = (provider: string, id: string) => {
1491
+ liveKeys ??= new Set(models.map(m => `${m.provider}\u0000${m.id}`));
1492
+ return liveKeys.has(`${provider}\u0000${id}`);
1493
+ };
1446
1494
  return models.map(model => {
1447
1495
  const providerOverrides = overrides.get(model.provider);
1448
1496
  if (!providerOverrides) return model;
1449
- const override = providerOverrides.get(model.id);
1497
+ const override = resolveModelOverrideWithAliases(providerOverrides, model, hasLiveModel);
1450
1498
  if (!override) return model;
1451
1499
  return applyModelOverride(model, override);
1452
1500
  });
@@ -1472,14 +1520,25 @@ export class ModelRegistry {
1472
1520
  this.#rebuildPending = true;
1473
1521
  return;
1474
1522
  }
1475
- this.#canonicalIndex = buildCanonicalModelIndex(
1476
- this.#models,
1477
- getBundledCanonicalReferenceData(),
1478
- this.#equivalenceConfig,
1479
- );
1523
+ // Defer the catalog-wide index build to first read. Boot model
1524
+ // resolution reads it only when enabledModels or a default-role pattern
1525
+ // is configured; the empty interactive launch never reads it pre-paint,
1526
+ // so the ~200ms build over the full catalog moves off the first-paint
1527
+ // critical path.
1528
+ this.#canonicalIndexDirty = true;
1480
1529
  this.#rebuildPending = false;
1481
1530
  }
1482
1531
 
1532
+ #ensureCanonicalIndex(): CanonicalModelIndex {
1533
+ if (this.#canonicalIndexDirty) {
1534
+ this.#canonicalIndex = logger.time("buildCanonicalModelIndex", () =>
1535
+ buildCanonicalModelIndex(this.#models, getBundledCanonicalReferenceData(), this.#equivalenceConfig),
1536
+ );
1537
+ this.#canonicalIndexDirty = false;
1538
+ }
1539
+ return this.#canonicalIndex;
1540
+ }
1541
+
1483
1542
  #suspendRebuild(): void {
1484
1543
  this.#rebuildSuspended += 1;
1485
1544
  }
@@ -1490,11 +1549,7 @@ export class ModelRegistry {
1490
1549
  }
1491
1550
  if (this.#rebuildSuspended === 0 && this.#rebuildPending) {
1492
1551
  this.#rebuildPending = false;
1493
- this.#canonicalIndex = buildCanonicalModelIndex(
1494
- this.#models,
1495
- getBundledCanonicalReferenceData(),
1496
- this.#equivalenceConfig,
1497
- );
1552
+ this.#canonicalIndexDirty = true;
1498
1553
  }
1499
1554
  }
1500
1555
 
@@ -1603,7 +1658,7 @@ export class ModelRegistry {
1603
1658
  getCanonicalModels(options?: CanonicalModelQueryOptions): CanonicalModelRecord[] {
1604
1659
  const { candidateKeys, isAvailable } = this.#canonicalQueryFilters(options);
1605
1660
  const records: CanonicalModelRecord[] = [];
1606
- for (const record of this.#canonicalIndex.records) {
1661
+ for (const record of this.#ensureCanonicalIndex().records) {
1607
1662
  const variants = this.#filterCanonicalVariants(record, candidateKeys, isAvailable);
1608
1663
  if (variants.length === 0) {
1609
1664
  continue;
@@ -1629,7 +1684,7 @@ export class ModelRegistry {
1629
1684
  const candidates = options?.candidates ?? (options?.availableOnly ? this.getAvailable() : this.getAll());
1630
1685
  const preferences = this.#variantPreferences(candidates);
1631
1686
  const selections: CanonicalModelSelection[] = [];
1632
- for (const record of this.#canonicalIndex.records) {
1687
+ for (const record of this.#ensureCanonicalIndex().records) {
1633
1688
  const variants = this.#filterCanonicalVariants(record, candidateKeys, isAvailable);
1634
1689
  if (variants.length === 0) {
1635
1690
  continue;
@@ -1647,7 +1702,7 @@ export class ModelRegistry {
1647
1702
  }
1648
1703
 
1649
1704
  getCanonicalVariants(canonicalId: string, options?: CanonicalModelQueryOptions): CanonicalModelVariant[] {
1650
- const record = this.#canonicalIndex.byId.get(canonicalId.trim().toLowerCase());
1705
+ const record = this.#ensureCanonicalIndex().byId.get(canonicalId.trim().toLowerCase());
1651
1706
  if (!record) {
1652
1707
  return [];
1653
1708
  }
@@ -1665,7 +1720,7 @@ export class ModelRegistry {
1665
1720
  }
1666
1721
 
1667
1722
  getCanonicalId(model: Model<Api>): string | undefined {
1668
- return this.#canonicalIndex.bySelector.get(formatCanonicalVariantSelector(model).toLowerCase());
1723
+ return this.#ensureCanonicalIndex().bySelector.get(formatCanonicalVariantSelector(model).toLowerCase());
1669
1724
  }
1670
1725
 
1671
1726
  /**
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Layering:
5
5
  * - `matchModel` is the single matching engine. Order: exact `provider/id`
6
- * reference (with OpenRouter routed/date fallbacks) → exact canonical id →
7
- * exact bare id → provider-scoped fuzzysubstring with alias-vs-dated pick.
6
+ * reference (with variant-alias and OpenRouter routed/date fallbacks) →
7
+ * exact canonical id → exact bare id retired variant alias
8
+ * provider-scoped fuzzy → substring with alias-vs-dated pick.
8
9
  * - `parseModelPatternWithContext`/`parseModelPattern` layer the selector
9
10
  * grammar on top: trailing `:level` thinking suffixes (`splitThinkingSuffix`)
10
11
  * and `@upstream` provider routing (`splitUpstreamRouting`).
@@ -19,9 +20,11 @@ import type { Api, Effort, KnownProvider, Model, ModelSpec } from "@oh-my-pi/pi-
19
20
  import { buildModel } from "@oh-my-pi/pi-catalog/build";
20
21
  import { modelMatchesHost } from "@oh-my-pi/pi-catalog/hosts";
21
22
  import { buildModelProviderPriorityRank } from "@oh-my-pi/pi-catalog/identity";
23
+ import { stripThinkingVariantToken } from "@oh-my-pi/pi-catalog/identity/family";
22
24
  import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
23
25
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
24
26
  import { DEFAULT_MODEL_PER_PROVIDER } from "@oh-my-pi/pi-catalog/provider-models";
27
+ import { resolveBareVariantAlias, resolveVariantAlias } from "@oh-my-pi/pi-catalog/variant-collapse";
25
28
  import { fuzzyMatch } from "@oh-my-pi/pi-tui";
26
29
  import { logger } from "@oh-my-pi/pi-utils";
27
30
  import chalk from "chalk";
@@ -228,6 +231,18 @@ export function resolveProviderModelReference(
228
231
  return exact;
229
232
  }
230
233
 
234
+ // Retired effort-tier variant ids resolve to their collapsed logical
235
+ // model: hand-table aliases first, then the `X-thinking` → `X` grammar
236
+ // for auto-derived pairs. Exact lookup above always wins while raw is live.
237
+ const variantAliasId =
238
+ resolveVariantAlias(normalizedProvider, normalizedModelId) ?? stripThinkingVariantToken(normalizedModelId);
239
+ if (variantAliasId) {
240
+ const aliased = index.get(`${normalizedProvider}\u0000${variantAliasId.toLowerCase()}`);
241
+ if (aliased) {
242
+ return aliased;
243
+ }
244
+ }
245
+
231
246
  if (normalizedProvider !== "openrouter") {
232
247
  return undefined;
233
248
  }
@@ -407,11 +422,13 @@ function findExactCanonicalModelMatch(
407
422
 
408
423
  /**
409
424
  * The single model-matching engine. Tries, in order:
410
- * 1. exact `provider/id` reference (OpenRouter routed/date fallbacks included),
425
+ * 1. exact `provider/id` reference (variant-alias and OpenRouter routed/date
426
+ * fallbacks included),
411
427
  * 2. exact canonical id (coalesces provider variants),
412
428
  * 3. exact bare id (preference-ranked),
413
- * 4. provider-scoped fuzzy match,
414
- * 5. substring match with the alias-vs-dated pick.
429
+ * 4. retired effort-tier variant alias (collapsed catalog entries),
430
+ * 5. provider-scoped fuzzy match,
431
+ * 6. substring match with the alias-vs-dated pick.
415
432
  * Returns the matched model or undefined if no match found.
416
433
  */
417
434
  function matchModel(
@@ -440,6 +457,20 @@ function matchModel(
440
457
  if (exactMatches.length > 0) {
441
458
  return pickPreferredModel(exactMatches, context);
442
459
  }
460
+
461
+ // Retired effort-tier variant ids (bare, no provider prefix) resolve to
462
+ // their collapsed logical model; models from the providers whose table
463
+ // declared the alias win ties. Auto-derived `X-thinking` pairs resolve
464
+ // through the grammar fallback.
465
+ const bareAlias = resolveBareVariantAlias(modelPattern);
466
+ const bareAliasTargetId = bareAlias?.id ?? stripThinkingVariantToken(modelPattern);
467
+ if (bareAliasTargetId) {
468
+ const aliasMatches = availableModels.filter(m => m.id.toLowerCase() === bareAliasTargetId.toLowerCase());
469
+ if (aliasMatches.length > 0) {
470
+ const preferred = bareAlias ? aliasMatches.filter(m => bareAlias.providers.includes(m.provider)) : [];
471
+ return pickPreferredModel(preferred.length > 0 ? preferred : aliasMatches, context);
472
+ }
473
+ }
443
474
  // Check for provider/modelId format — fuzzy match within provider only.
444
475
  const slashIndex = modelPattern.indexOf("/");
445
476
  if (slashIndex !== -1) {