@ilya-lesikov/pi-pi 0.6.0 → 0.8.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.
Files changed (66) hide show
  1. package/3p/pi-ask-user/index.ts +1 -1
  2. package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
  3. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
  4. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
  5. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
  6. package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
  7. package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
  8. package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
  9. package/3p/pi-subagents/src/agent-manager.ts +34 -1
  10. package/3p/pi-subagents/src/agent-runner.ts +66 -33
  11. package/3p/pi-subagents/src/index.ts +3 -38
  12. package/3p/pi-subagents/src/types.ts +4 -0
  13. package/extensions/orchestrator/agents/advisor.ts +35 -0
  14. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
  15. package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
  16. package/extensions/orchestrator/agents/constraints.test.ts +44 -0
  17. package/extensions/orchestrator/agents/constraints.ts +3 -0
  18. package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
  19. package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
  20. package/extensions/orchestrator/agents/planner.ts +9 -8
  21. package/extensions/orchestrator/agents/prompts.test.ts +120 -0
  22. package/extensions/orchestrator/agents/reviewer.ts +48 -0
  23. package/extensions/orchestrator/agents/task.ts +6 -20
  24. package/extensions/orchestrator/agents/tool-routing.ts +23 -1
  25. package/extensions/orchestrator/command-handlers.ts +1 -1
  26. package/extensions/orchestrator/config.ts +8 -4
  27. package/extensions/orchestrator/context.test.ts +54 -0
  28. package/extensions/orchestrator/context.ts +65 -2
  29. package/extensions/orchestrator/custom-footer.ts +5 -2
  30. package/extensions/orchestrator/doctor.test.ts +3 -1
  31. package/extensions/orchestrator/doctor.ts +40 -2
  32. package/extensions/orchestrator/event-handlers.test.ts +97 -1
  33. package/extensions/orchestrator/event-handlers.ts +222 -48
  34. package/extensions/orchestrator/flant-infra.test.ts +312 -0
  35. package/extensions/orchestrator/flant-infra.ts +407 -44
  36. package/extensions/orchestrator/index.ts +1 -1
  37. package/extensions/orchestrator/integration.test.ts +312 -18
  38. package/extensions/orchestrator/messages.test.ts +30 -0
  39. package/extensions/orchestrator/messages.ts +6 -0
  40. package/extensions/orchestrator/model-registry.test.ts +124 -13
  41. package/extensions/orchestrator/model-registry.ts +91 -33
  42. package/extensions/orchestrator/orchestrator.test.ts +113 -0
  43. package/extensions/orchestrator/orchestrator.ts +163 -3
  44. package/extensions/orchestrator/phases/brainstorm.ts +9 -20
  45. package/extensions/orchestrator/phases/implementation.test.ts +11 -0
  46. package/extensions/orchestrator/phases/implementation.ts +4 -6
  47. package/extensions/orchestrator/phases/planning.test.ts +16 -0
  48. package/extensions/orchestrator/phases/planning.ts +11 -4
  49. package/extensions/orchestrator/phases/review-task.ts +1 -4
  50. package/extensions/orchestrator/phases/review.test.ts +62 -0
  51. package/extensions/orchestrator/phases/review.ts +58 -15
  52. package/extensions/orchestrator/plannotator.ts +9 -6
  53. package/extensions/orchestrator/pp-menu.test.ts +74 -1
  54. package/extensions/orchestrator/pp-menu.ts +366 -94
  55. package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
  56. package/extensions/orchestrator/pp-state-tools.ts +249 -0
  57. package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
  58. package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
  59. package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
  60. package/extensions/orchestrator/state.ts +41 -20
  61. package/extensions/orchestrator/test-helpers.ts +18 -3
  62. package/extensions/orchestrator/usage-tracker.test.ts +131 -3
  63. package/extensions/orchestrator/usage-tracker.ts +78 -11
  64. package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
  65. package/extensions/orchestrator/validate-artifacts.ts +2 -2
  66. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  vi.mock("./log.js", () => ({
4
4
  getLogger: () => ({
@@ -7,10 +7,14 @@ vi.mock("./log.js", () => ({
7
7
  }));
8
8
 
9
9
  import {
10
+ findLatestFamilyMatch,
10
11
  getAllAliases,
11
12
  getModelFamilies,
12
13
  getModelInfo,
14
+ isSubscriptionFallbackActive,
13
15
  resolveModel,
16
+ setSubscriptionFallbackActive,
17
+ toNonSubSpec,
14
18
  updateRegistryFromAvailableModels,
15
19
  } from "./model-registry.js";
16
20
 
@@ -19,20 +23,43 @@ describe("model-registry", () => {
19
23
  updateRegistryFromAvailableModels([]);
20
24
  });
21
25
 
22
- it("resolveModel resolves known aliases", () => {
23
- expect(resolveModel("anthropic/claude-sonnet-latest")).toBe("anthropic/claude-sonnet-4-6");
24
- expect(resolveModel("openai/gpt-mini-latest")).toBe("openai/gpt-5.4-mini");
26
+ it("resolveModel passes through native-latest aliases", () => {
27
+ expect(resolveModel("anthropic/claude-sonnet-latest")).toBe("anthropic/claude-sonnet-latest");
28
+ expect(resolveModel("anthropic/claude-opus-latest")).toBe("anthropic/claude-opus-latest");
29
+ expect(resolveModel("anthropic/claude-haiku-latest")).toBe("anthropic/claude-haiku-latest");
30
+ });
31
+
32
+ it("resolveModel returns input unchanged for non-native aliases without available models", () => {
33
+ expect(resolveModel("openai/gpt-mini-latest")).toBe("openai/gpt-mini-latest");
34
+ expect(resolveModel("pp-flant-anthropic/claude-opus-latest")).toBe("pp-flant-anthropic/claude-opus-latest");
25
35
  });
26
36
 
27
37
  it("resolveModel passes through unknown aliases", () => {
28
38
  expect(resolveModel("custom/provider-model")).toBe("custom/provider-model");
29
39
  });
30
40
 
31
- it("resolveModel resolves flant aliases", () => {
41
+ it("resolveModel resolves flant aliases after updateRegistry", () => {
42
+ updateRegistryFromAvailableModels(["claude-opus-4-6", "gemini-3.1-pro"]);
32
43
  expect(resolveModel("pp-flant-anthropic/claude-opus-latest")).toBe("pp-flant-anthropic/claude-opus-4-6");
33
44
  expect(resolveModel("pp-flant-openai/gemini-pro-latest")).toBe("pp-flant-openai/gemini-3.1-pro");
34
45
  });
35
46
 
47
+ it("getModelInfo detects personal-subscription Claude models", () => {
48
+ expect(getModelInfo("pp-flant-anthropic-sub/sub/claude-opus-4-8")).toMatchObject({ vendor: "anthropic", family: "opus", tier: "smart" });
49
+ expect(getModelInfo("pp-flant-anthropic-sub/sub/claude-sonnet-4-6")).toMatchObject({ vendor: "anthropic", family: "sonnet", tier: "regular" });
50
+ expect(getModelInfo("pp-flant-anthropic-sub/sub/claude-haiku-4-5")).toMatchObject({ vendor: "anthropic", family: "haiku", tier: "stupid" });
51
+ });
52
+
53
+ it("resolveModel resolves subscription aliases to sub/ specs after updateRegistry", () => {
54
+ updateRegistryFromAvailableModels([
55
+ "pp-flant-anthropic-sub/sub/claude-opus-4-7",
56
+ "pp-flant-anthropic-sub/sub/claude-opus-4-8",
57
+ "pp-flant-anthropic-sub/sub/claude-haiku-4-5",
58
+ ]);
59
+ expect(resolveModel("pp-flant-anthropic-sub/claude-opus-latest")).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
60
+ expect(resolveModel("pp-flant-anthropic-sub/claude-haiku-latest")).toBe("pp-flant-anthropic-sub/sub/claude-haiku-4-5");
61
+ });
62
+
36
63
  it("getModelInfo detects all configured families", () => {
37
64
  expect(getModelInfo("anthropic/claude-opus-4-6")).toMatchObject({ vendor: "anthropic", family: "opus", tier: "smart" });
38
65
  expect(getModelInfo("anthropic/claude-sonnet-4-6")).toMatchObject({ vendor: "anthropic", family: "sonnet", tier: "regular" });
@@ -153,11 +180,11 @@ describe("model-registry", () => {
153
180
  expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.6");
154
181
  });
155
182
 
156
- it("updateRegistryFromAvailableModels keeps defaults for missing families", () => {
183
+ it("updateRegistryFromAvailableModels keeps native-latest for missing families", () => {
157
184
  updateRegistryFromAvailableModels(["openai/gpt-9.1"]);
158
185
 
159
186
  expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-9.1");
160
- expect(resolveModel("anthropic/claude-opus-latest")).toBe("anthropic/claude-opus-4-6");
187
+ expect(resolveModel("anthropic/claude-opus-latest")).toBe("anthropic/claude-opus-latest");
161
188
  });
162
189
 
163
190
  it("updateRegistryFromAvailableModels ignores aliases ending with -latest", () => {
@@ -168,7 +195,7 @@ describe("model-registry", () => {
168
195
  ]);
169
196
 
170
197
  expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.4");
171
- expect(resolveModel("pp-flant-anthropic/claude-opus-latest")).toBe("pp-flant-anthropic/claude-opus-4-6");
198
+ expect(resolveModel("pp-flant-anthropic/claude-opus-latest")).toBe("pp-flant-anthropic/claude-opus-latest");
172
199
  });
173
200
 
174
201
  it("updateRegistryFromAvailableModels handles empty input", () => {
@@ -176,14 +203,14 @@ describe("model-registry", () => {
176
203
  expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-9.9");
177
204
 
178
205
  updateRegistryFromAvailableModels([]);
179
- expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.4");
206
+ expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-latest");
180
207
  });
181
208
 
182
209
  it("updateRegistryFromAvailableModels ignores unknown bare ids", () => {
183
210
  updateRegistryFromAvailableModels(["custom-model-1", "another-custom-model"]);
184
211
 
185
- expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.4");
186
- expect(resolveModel("anthropic/claude-opus-latest")).toBe("anthropic/claude-opus-4-6");
212
+ expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-latest");
213
+ expect(resolveModel("anthropic/claude-opus-latest")).toBe("anthropic/claude-opus-latest");
187
214
  });
188
215
 
189
216
  it("updateRegistryFromAvailableModels chooses highest among flant openai versions", () => {
@@ -197,16 +224,25 @@ describe("model-registry", () => {
197
224
  });
198
225
 
199
226
  it("getAllAliases returns a copy", () => {
227
+ updateRegistryFromAvailableModels(["openai/gpt-5.4"]);
200
228
  const aliases = getAllAliases();
201
229
  aliases["openai/gpt-latest"] = "openai/gpt-0.0";
202
230
 
203
231
  expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.4");
204
232
  });
205
233
 
206
- it("getAllAliases contains known defaults", () => {
234
+ it("getAllAliases contains native-latest identity mappings", () => {
235
+ const aliases = getAllAliases();
236
+
237
+ expect(aliases["anthropic/claude-opus-latest"]).toBe("anthropic/claude-opus-latest");
238
+ expect(aliases["anthropic/claude-sonnet-latest"]).toBe("anthropic/claude-sonnet-latest");
239
+ expect(aliases["anthropic/claude-haiku-latest"]).toBe("anthropic/claude-haiku-latest");
240
+ });
241
+
242
+ it("getAllAliases contains resolved aliases after updateRegistry", () => {
243
+ updateRegistryFromAvailableModels(["grok-4"]);
207
244
  const aliases = getAllAliases();
208
245
 
209
- expect(aliases["anthropic/claude-opus-latest"]).toBe("anthropic/claude-opus-4-6");
210
246
  expect(aliases["pp-flant-openai/grok-latest"]).toBe("pp-flant-openai/grok-4");
211
247
  });
212
248
 
@@ -235,4 +271,79 @@ describe("model-registry", () => {
235
271
  expect(families.find((f) => f.family === "haiku")).toMatchObject({ vendor: "anthropic", tier: "stupid" });
236
272
  expect(families.find((f) => f.family === "gemini-pro")).toMatchObject({ vendor: "google", tier: "regular" });
237
273
  });
274
+
275
+ describe("findLatestFamilyMatch", () => {
276
+ it("returns latest versioned model for a native-latest alias", () => {
277
+ const available = [
278
+ "anthropic/claude-opus-4-0-20250514",
279
+ "anthropic/claude-opus-4-6",
280
+ "anthropic/claude-sonnet-4-6",
281
+ ];
282
+ expect(findLatestFamilyMatch("anthropic/claude-opus-latest", available)).toBe("anthropic/claude-opus-4-6");
283
+ });
284
+
285
+ it("returns null for unknown model spec", () => {
286
+ expect(findLatestFamilyMatch("custom/unknown-model", ["anthropic/claude-opus-4-6"])).toBeNull();
287
+ });
288
+
289
+ it("returns null when no candidates match the provider", () => {
290
+ expect(findLatestFamilyMatch("anthropic/claude-opus-latest", ["openai/gpt-5.4"])).toBeNull();
291
+ });
292
+
293
+ it("returns null for spec without provider", () => {
294
+ expect(findLatestFamilyMatch("claude-opus-latest", ["anthropic/claude-opus-4-6"])).toBeNull();
295
+ });
296
+
297
+ it("returns the only candidate when there is one", () => {
298
+ expect(findLatestFamilyMatch("openai/gpt-latest", ["openai/gpt-5.4"])).toBe("openai/gpt-5.4");
299
+ });
300
+
301
+ it("returns null for empty available list", () => {
302
+ expect(findLatestFamilyMatch("anthropic/claude-opus-latest", [])).toBeNull();
303
+ });
304
+ });
305
+
306
+ describe("subscription fallback rewrite", () => {
307
+ beforeEach(() => setSubscriptionFallbackActive(false));
308
+ afterEach(() => setSubscriptionFallbackActive(false));
309
+
310
+ it("toNonSubSpec rewrites provider-prefixed sub specs", () => {
311
+ expect(toNonSubSpec("pp-flant-anthropic-sub/sub/claude-opus-4-8")).toBe("pp-flant-anthropic/claude-opus-4-8");
312
+ });
313
+
314
+ it("toNonSubSpec rewrites bare sub/ ids", () => {
315
+ expect(toNonSubSpec("sub/claude-haiku-4-5")).toBe("pp-flant-anthropic/claude-haiku-4-5");
316
+ });
317
+
318
+ it("toNonSubSpec leaves non-subscription specs unchanged", () => {
319
+ expect(toNonSubSpec("pp-flant-anthropic/claude-opus-4-8")).toBe("pp-flant-anthropic/claude-opus-4-8");
320
+ expect(toNonSubSpec("openai/gpt-5.4")).toBe("openai/gpt-5.4");
321
+ });
322
+
323
+ it("resolveModel leaves sub specs unchanged while fallback inactive", () => {
324
+ expect(isSubscriptionFallbackActive()).toBe(false);
325
+ expect(resolveModel("pp-flant-anthropic-sub/sub/claude-opus-4-8")).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
326
+ });
327
+
328
+ it("resolveModel rewrites sub specs to non-sub while fallback active", () => {
329
+ setSubscriptionFallbackActive(true);
330
+ expect(isSubscriptionFallbackActive()).toBe(true);
331
+ expect(resolveModel("pp-flant-anthropic-sub/sub/claude-opus-4-8")).toBe("pp-flant-anthropic/claude-opus-4-8");
332
+ expect(resolveModel("sub/claude-haiku-4-5")).toBe("pp-flant-anthropic/claude-haiku-4-5");
333
+ });
334
+
335
+ it("resolveModel does not touch non-sub specs while fallback active", () => {
336
+ setSubscriptionFallbackActive(true);
337
+ expect(resolveModel("pp-flant-openai/gpt-5-4")).toBe("pp-flant-openai/gpt-5-4");
338
+ });
339
+
340
+ it("resolveModel rewrites a subscription alias after registry update while fallback active", () => {
341
+ updateRegistryFromAvailableModels([
342
+ "pp-flant-anthropic-sub/sub/claude-opus-4-7",
343
+ "pp-flant-anthropic-sub/sub/claude-opus-4-8",
344
+ ]);
345
+ setSubscriptionFallbackActive(true);
346
+ expect(resolveModel("pp-flant-anthropic-sub/claude-opus-latest")).toBe("pp-flant-anthropic/claude-opus-4-8");
347
+ });
348
+ });
238
349
  });
@@ -11,7 +11,7 @@ export interface ModelInfo {
11
11
  displayName: string;
12
12
  }
13
13
 
14
- type ProviderPrefix = "anthropic" | "openai" | "google" | "deepseek" | "x-ai" | "qwen" | "pp-flant-anthropic" | "pp-flant-openai";
14
+ type ProviderPrefix = "anthropic" | "openai" | "google" | "deepseek" | "x-ai" | "qwen" | "pp-flant-anthropic" | "pp-flant-anthropic-sub" | "pp-flant-openai";
15
15
  type KnownVendor = "anthropic" | "openai" | "google" | "deepseek" | "xai" | "qwen";
16
16
  type KnownFamily = "opus" | "sonnet" | "haiku" | "gpt" | "gpt-mini" | "gemini-pro" | "gemini-flash" | "deepseek" | "grok" | "qwen";
17
17
  type KnownTier = "stupid" | "regular" | "smart" | "xsmart";
@@ -24,6 +24,7 @@ export interface ModelFamilyDefinition {
24
24
  patterns: RegExp[];
25
25
  aliasTemplate: string;
26
26
  providers: ProviderPrefix[];
27
+ nativeLatestProviders?: ProviderPrefix[];
27
28
  }
28
29
 
29
30
  export interface ModelFamilyInfo {
@@ -41,27 +42,30 @@ export const MODEL_FAMILIES: ModelFamilyDefinition[] = [
41
42
  family: "opus",
42
43
  tier: "smart",
43
44
  displayName: "Claude Opus",
44
- patterns: [/^(anthropic|pp-flant-anthropic)\/claude-opus-[a-z0-9.-]+$/],
45
+ patterns: [/^(anthropic|pp-flant-anthropic)\/claude-opus-[a-z0-9.-]+$/, /^pp-flant-anthropic-sub\/sub\/claude-opus-[a-z0-9.-]+$/],
45
46
  aliasTemplate: "claude-opus-latest",
46
- providers: ["anthropic", "pp-flant-anthropic"],
47
+ providers: ["anthropic", "pp-flant-anthropic", "pp-flant-anthropic-sub"],
48
+ nativeLatestProviders: ["anthropic"],
47
49
  },
48
50
  {
49
51
  vendor: "anthropic",
50
52
  family: "sonnet",
51
53
  tier: "regular",
52
54
  displayName: "Claude Sonnet",
53
- patterns: [/^(anthropic|pp-flant-anthropic)\/claude-sonnet-[a-z0-9.-]+$/],
55
+ patterns: [/^(anthropic|pp-flant-anthropic)\/claude-sonnet-[a-z0-9.-]+$/, /^pp-flant-anthropic-sub\/sub\/claude-sonnet-[a-z0-9.-]+$/],
54
56
  aliasTemplate: "claude-sonnet-latest",
55
- providers: ["anthropic", "pp-flant-anthropic"],
57
+ providers: ["anthropic", "pp-flant-anthropic", "pp-flant-anthropic-sub"],
58
+ nativeLatestProviders: ["anthropic"],
56
59
  },
57
60
  {
58
61
  vendor: "anthropic",
59
62
  family: "haiku",
60
63
  tier: "stupid",
61
64
  displayName: "Claude Haiku",
62
- patterns: [/^(anthropic|pp-flant-anthropic)\/claude-haiku-[a-z0-9.-]+$/],
65
+ patterns: [/^(anthropic|pp-flant-anthropic)\/claude-haiku-[a-z0-9.-]+$/, /^pp-flant-anthropic-sub\/sub\/claude-haiku-[a-z0-9.-]+$/],
63
66
  aliasTemplate: "claude-haiku-latest",
64
- providers: ["anthropic", "pp-flant-anthropic"],
67
+ providers: ["anthropic", "pp-flant-anthropic", "pp-flant-anthropic-sub"],
68
+ nativeLatestProviders: ["anthropic"],
65
69
  },
66
70
  {
67
71
  vendor: "openai",
@@ -137,30 +141,20 @@ export const MODEL_FAMILIES: ModelFamilyDefinition[] = [
137
141
  },
138
142
  ];
139
143
 
140
- const DEFAULT_ALIAS_MAP: Record<string, string> = {
141
- "anthropic/claude-opus-latest": "anthropic/claude-opus-4-6",
142
- "anthropic/claude-sonnet-latest": "anthropic/claude-sonnet-4-6",
143
- "anthropic/claude-haiku-latest": "anthropic/claude-haiku-3-5",
144
- "openai/gpt-latest": "openai/gpt-5.4",
145
- "openai/gpt-mini-latest": "openai/gpt-5.4-mini",
146
- "google/gemini-pro-latest": "google/gemini-3.1-pro",
147
- "google/gemini-flash-latest": "google/gemini-3.1-flash",
148
- "deepseek/deepseek-latest": "deepseek/deepseek-v3",
149
- "x-ai/grok-latest": "x-ai/grok-4",
150
- "qwen/qwen-latest": "qwen/qwen3-coder",
151
- "pp-flant-anthropic/claude-opus-latest": "pp-flant-anthropic/claude-opus-4-6",
152
- "pp-flant-anthropic/claude-sonnet-latest": "pp-flant-anthropic/claude-sonnet-4-6",
153
- "pp-flant-anthropic/claude-haiku-latest": "pp-flant-anthropic/claude-haiku-3-5",
154
- "pp-flant-openai/gpt-latest": "pp-flant-openai/gpt-5.4",
155
- "pp-flant-openai/gpt-mini-latest": "pp-flant-openai/gpt-5.4-mini",
156
- "pp-flant-openai/gemini-pro-latest": "pp-flant-openai/gemini-3.1-pro",
157
- "pp-flant-openai/gemini-flash-latest": "pp-flant-openai/gemini-3.1-flash",
158
- "pp-flant-openai/deepseek-latest": "pp-flant-openai/deepseek-v3",
159
- "pp-flant-openai/grok-latest": "pp-flant-openai/grok-4",
160
- "pp-flant-openai/qwen-latest": "pp-flant-openai/qwen3-coder",
161
- };
144
+ function buildNativeLatestAliases(): Record<string, string> {
145
+ const map: Record<string, string> = {};
146
+ for (const family of MODEL_FAMILIES) {
147
+ const nativeSet = new Set(family.nativeLatestProviders ?? []);
148
+ for (const provider of family.providers) {
149
+ if (!nativeSet.has(provider)) continue;
150
+ const alias = toAlias(provider, family.aliasTemplate);
151
+ map[alias] = alias;
152
+ }
153
+ }
154
+ return map;
155
+ }
162
156
 
163
- let aliasMap: Record<string, string> = { ...DEFAULT_ALIAS_MAP };
157
+ let aliasMap: Record<string, string> = buildNativeLatestAliases();
164
158
 
165
159
  function pickLatest(models: string[]): string | null {
166
160
  if (models.length === 0) return null;
@@ -213,12 +207,54 @@ function findFamily(modelId: string): ModelFamilyDefinition | null {
213
207
  }
214
208
 
215
209
  import { getLogger } from "./log.js";
210
+ import { SUB_MODEL_PREFIX, SUB_PROVIDER } from "./flant-infra.js";
211
+
212
+ // Session-scoped subscription fallback. When active (set after the user accepts
213
+ // a sub-429 fallback), every model spec resolved through resolveModel that is
214
+ // subscription-routed (`pp-flant-anthropic-sub/sub/<m>` or a bare `sub/<m>`) is
215
+ // rewritten to the regular per-token form `pp-flant-anthropic/<m>`. This is the
216
+ // single lever every resolution site funnels through (main phase switchModel,
217
+ // subagent tool_call input.model, planner/reviewer specs, registerAgents), so a
218
+ // one-shot switch cannot be undone by a later phase/subagent re-resolving a
219
+ // `sub/` spec. Cleared when the user switches back or the task ends.
220
+ let subscriptionFallbackActive = false;
221
+
222
+ export function setSubscriptionFallbackActive(active: boolean): void {
223
+ subscriptionFallbackActive = active;
224
+ }
225
+
226
+ export function isSubscriptionFallbackActive(): boolean {
227
+ return subscriptionFallbackActive;
228
+ }
229
+
230
+ // Rewrite a subscription-routed spec to its regular per-token equivalent.
231
+ // Handles both `pp-flant-anthropic-sub/sub/<m>` and a bare `sub/<m>` id.
232
+ // Non-subscription specs pass through unchanged.
233
+ export function toNonSubSpec(spec: string): string {
234
+ if (spec.startsWith(`${SUB_PROVIDER}/${SUB_MODEL_PREFIX}`)) {
235
+ return `pp-flant-anthropic/${spec.slice(`${SUB_PROVIDER}/${SUB_MODEL_PREFIX}`.length)}`;
236
+ }
237
+ if (spec.startsWith(`${SUB_PROVIDER}/`)) {
238
+ return `pp-flant-anthropic/${spec.slice(`${SUB_PROVIDER}/`.length)}`;
239
+ }
240
+ if (spec.startsWith(SUB_MODEL_PREFIX)) {
241
+ return `pp-flant-anthropic/${spec.slice(SUB_MODEL_PREFIX.length)}`;
242
+ }
243
+ return spec;
244
+ }
216
245
 
217
246
  export function resolveModel(aliasOrId: string): string {
218
- const resolved = aliasMap[aliasOrId] ?? aliasOrId;
247
+ let resolved = aliasMap[aliasOrId] ?? aliasOrId;
219
248
  if (resolved !== aliasOrId) {
220
249
  getLogger().debug({ s: "model", alias: aliasOrId, resolved }, "resolved model alias");
221
250
  }
251
+ if (subscriptionFallbackActive) {
252
+ const rewritten = toNonSubSpec(resolved);
253
+ if (rewritten !== resolved) {
254
+ getLogger().debug({ s: "model", from: resolved, to: rewritten }, "subscription fallback rewrite");
255
+ resolved = rewritten;
256
+ }
257
+ }
222
258
  return resolved;
223
259
  }
224
260
 
@@ -244,11 +280,13 @@ export function getModelInfo(modelId: string): ModelInfo {
244
280
  export function updateRegistryFromAvailableModels(availableModels: string[]): void {
245
281
  const log = getLogger();
246
282
  const normalizedModels = collectNormalizedModels(availableModels).filter((modelId) => !modelId.endsWith("-latest"));
247
- const nextAliasMap: Record<string, string> = { ...DEFAULT_ALIAS_MAP };
283
+ const nextAliasMap: Record<string, string> = buildNativeLatestAliases();
248
284
 
249
285
  let updatedCount = 0;
250
286
  for (const family of MODEL_FAMILIES) {
287
+ const nativeSet = new Set(family.nativeLatestProviders ?? []);
251
288
  for (const provider of family.providers) {
289
+ if (nativeSet.has(provider)) continue;
252
290
  const alias = toAlias(provider, family.aliasTemplate);
253
291
  const candidates = normalizedModels.filter((modelId) => {
254
292
  if (!modelId.startsWith(`${provider}/`)) return false;
@@ -257,7 +295,7 @@ export function updateRegistryFromAvailableModels(availableModels: string[]): vo
257
295
  const latest = pickLatest(candidates);
258
296
  if (latest) {
259
297
  nextAliasMap[alias] = latest;
260
- if (latest !== DEFAULT_ALIAS_MAP[alias]) updatedCount++;
298
+ updatedCount++;
261
299
  }
262
300
  }
263
301
  }
@@ -270,6 +308,26 @@ export function getAllAliases(): Record<string, string> {
270
308
  return { ...aliasMap };
271
309
  }
272
310
 
311
+ export function findLatestFamilyMatch(
312
+ modelSpec: string,
313
+ availableSpecs: string[],
314
+ ): string | null {
315
+ const family = findFamily(modelSpec);
316
+ if (!family) return null;
317
+
318
+ const slashIdx = modelSpec.indexOf("/");
319
+ if (slashIdx === -1) return null;
320
+ const requestedProvider = modelSpec.substring(0, slashIdx).trim().toLowerCase();
321
+
322
+ const candidates = availableSpecs.filter((spec) => {
323
+ const specLower = spec.toLowerCase();
324
+ if (!specLower.startsWith(`${requestedProvider}/`)) return false;
325
+ return family.patterns.some((p) => p.test(specLower));
326
+ });
327
+
328
+ return pickLatest(candidates);
329
+ }
330
+
273
331
  export function getModelFamilies(): ModelFamilyInfo[] {
274
332
  return MODEL_FAMILIES.map((family) => ({
275
333
  vendor: family.vendor,
@@ -123,6 +123,119 @@ describe("Orchestrator.safeSendUserMessage", () => {
123
123
  });
124
124
  });
125
125
 
126
+ describe("Orchestrator.cancelPendingRetry", () => {
127
+ it("clears the pending timer, disarms the ESC interrupt, and resets the counter", () => {
128
+ vi.useFakeTimers();
129
+ const send = vi.fn();
130
+ const orchestrator = new Orchestrator(makePi({ sendUserMessage: send }));
131
+ const unsub = vi.fn();
132
+ orchestrator.errorRetryCount = 3;
133
+ orchestrator.pendingRetryEscUnsub = unsub;
134
+ orchestrator.pendingRetryTimer = setTimeout(() => send("fired"), 1000) as any;
135
+
136
+ orchestrator.cancelPendingRetry();
137
+
138
+ expect(orchestrator.pendingRetryTimer).toBeNull();
139
+ expect(orchestrator.pendingRetryEscUnsub).toBeNull();
140
+ expect(orchestrator.errorRetryCount).toBe(0);
141
+ expect(unsub).toHaveBeenCalledTimes(1);
142
+ vi.advanceTimersByTime(2000);
143
+ expect(send).not.toHaveBeenCalled();
144
+ vi.useRealTimers();
145
+ });
146
+ });
147
+
148
+ describe("Orchestrator.armRetryEscInterrupt", () => {
149
+ function makeCtxWithTerminal() {
150
+ let handler: ((data: string) => any) | null = null;
151
+ const notify = vi.fn();
152
+ const ctx = {
153
+ ui: {
154
+ notify,
155
+ onTerminalInput: (h: (data: string) => any) => {
156
+ handler = h;
157
+ return () => { handler = null; };
158
+ },
159
+ },
160
+ };
161
+ return { ctx, notify, feed: (data: string) => handler?.(data) };
162
+ }
163
+
164
+ it("cancels the pending retry on a bare ESC only", () => {
165
+ vi.useFakeTimers();
166
+ const orchestrator = new Orchestrator(makePi());
167
+ const { ctx, notify, feed } = makeCtxWithTerminal();
168
+ orchestrator.pendingRetryTimer = setTimeout(() => {}, 10000) as any;
169
+ orchestrator.armRetryEscInterrupt(ctx as any);
170
+
171
+ // Arrow key (ESC-prefixed sequence) must NOT cancel and must NOT be consumed.
172
+ const arrow = feed("\x1b[A");
173
+ expect(arrow).toBeUndefined();
174
+ expect(orchestrator.pendingRetryTimer).not.toBeNull();
175
+
176
+ // Bare ESC cancels and consumes.
177
+ const esc = feed("\x1b");
178
+ expect(esc).toEqual({ consume: true });
179
+ expect(orchestrator.pendingRetryTimer).toBeNull();
180
+ expect(notify).toHaveBeenCalled();
181
+ vi.useRealTimers();
182
+ });
183
+
184
+ it("is a no-op when no retry timer is armed", () => {
185
+ const orchestrator = new Orchestrator(makePi());
186
+ const { ctx, feed } = makeCtxWithTerminal();
187
+ orchestrator.armRetryEscInterrupt(ctx as any);
188
+ expect(feed("\x1b")).toBeUndefined();
189
+ });
190
+ });
191
+
192
+ describe("Orchestrator.sendUserMessageWhenIdle", () => {
193
+ it("sends immediately when idle", () => {
194
+ const send = vi.fn();
195
+ const orchestrator = new Orchestrator(makePi({ sendUserMessage: send }));
196
+ orchestrator.active = makeActiveTask(null);
197
+ orchestrator.activeTaskToken = 7;
198
+ orchestrator.lastCtx = { isIdle: () => true };
199
+
200
+ orchestrator.sendUserMessageWhenIdle("[PI-PI] go", 7);
201
+ expect(send).toHaveBeenCalledWith("[PI-PI] go", { deliverAs: "followUp" });
202
+ });
203
+
204
+ it("defers while busy, then sends once idle", async () => {
205
+ vi.useFakeTimers();
206
+ const send = vi.fn();
207
+ const orchestrator = new Orchestrator(makePi({ sendUserMessage: send }));
208
+ orchestrator.active = makeActiveTask(null);
209
+ orchestrator.activeTaskToken = 1;
210
+ let idle = false;
211
+ orchestrator.lastCtx = { isIdle: () => idle };
212
+
213
+ orchestrator.sendUserMessageWhenIdle("[PI-PI] go", 1);
214
+ expect(send).not.toHaveBeenCalled();
215
+ await vi.advanceTimersByTimeAsync(3000);
216
+ expect(send).not.toHaveBeenCalled();
217
+ idle = true;
218
+ await vi.advanceTimersByTimeAsync(1000);
219
+ expect(send).toHaveBeenCalledWith("[PI-PI] go", { deliverAs: "followUp" });
220
+ vi.useRealTimers();
221
+ });
222
+
223
+ it("drops (does not send) when the task token changes", async () => {
224
+ vi.useFakeTimers();
225
+ const send = vi.fn();
226
+ const orchestrator = new Orchestrator(makePi({ sendUserMessage: send }));
227
+ orchestrator.active = makeActiveTask(null);
228
+ orchestrator.activeTaskToken = 1;
229
+ orchestrator.lastCtx = { isIdle: () => false };
230
+
231
+ orchestrator.sendUserMessageWhenIdle("[PI-PI] go", 1);
232
+ orchestrator.activeTaskToken = 2; // task switched
233
+ await vi.advanceTimersByTimeAsync(2000);
234
+ expect(send).not.toHaveBeenCalled();
235
+ vi.useRealTimers();
236
+ });
237
+ });
238
+
126
239
  describe("Orchestrator.switchModel thinking level", () => {
127
240
  function makeCtx() {
128
241
  return {