@ilya-lesikov/pi-pi 0.5.0 → 0.7.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 (77) hide show
  1. package/3p/pi-ask-user/index.ts +65 -49
  2. package/3p/pi-subagents/src/agent-manager.ts +8 -0
  3. package/3p/pi-subagents/src/agent-runner.ts +112 -19
  4. package/3p/pi-subagents/src/index.ts +3 -0
  5. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
  6. package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
  7. package/extensions/orchestrator/agents/constraints.ts +55 -0
  8. package/extensions/orchestrator/agents/explore.ts +13 -13
  9. package/extensions/orchestrator/agents/librarian.ts +12 -9
  10. package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
  11. package/extensions/orchestrator/agents/planner.ts +28 -23
  12. package/extensions/orchestrator/agents/registry.ts +2 -1
  13. package/extensions/orchestrator/agents/repo-context.ts +11 -0
  14. package/extensions/orchestrator/agents/task.ts +14 -11
  15. package/extensions/orchestrator/agents/tool-routing.ts +17 -32
  16. package/extensions/orchestrator/ast-search.ts +2 -1
  17. package/extensions/orchestrator/cbm.test.ts +35 -0
  18. package/extensions/orchestrator/cbm.ts +43 -13
  19. package/extensions/orchestrator/command-handlers.test.ts +390 -19
  20. package/extensions/orchestrator/command-handlers.ts +68 -28
  21. package/extensions/orchestrator/commands.test.ts +255 -2
  22. package/extensions/orchestrator/commands.ts +108 -10
  23. package/extensions/orchestrator/config.test.ts +289 -68
  24. package/extensions/orchestrator/config.ts +631 -121
  25. package/extensions/orchestrator/context.test.ts +177 -10
  26. package/extensions/orchestrator/context.ts +115 -14
  27. package/extensions/orchestrator/custom-footer.ts +7 -3
  28. package/extensions/orchestrator/doctor.test.ts +561 -0
  29. package/extensions/orchestrator/doctor.ts +702 -0
  30. package/extensions/orchestrator/event-handlers.test.ts +84 -22
  31. package/extensions/orchestrator/event-handlers.ts +1220 -343
  32. package/extensions/orchestrator/exa.test.ts +46 -0
  33. package/extensions/orchestrator/exa.ts +16 -10
  34. package/extensions/orchestrator/flant-infra.test.ts +511 -0
  35. package/extensions/orchestrator/flant-infra.ts +390 -49
  36. package/extensions/orchestrator/index.ts +13 -2
  37. package/extensions/orchestrator/integration.test.ts +3065 -118
  38. package/extensions/orchestrator/log.test.ts +219 -0
  39. package/extensions/orchestrator/log.ts +153 -0
  40. package/extensions/orchestrator/model-registry.test.ts +302 -0
  41. package/extensions/orchestrator/model-registry.ts +298 -0
  42. package/extensions/orchestrator/model-version.test.ts +27 -0
  43. package/extensions/orchestrator/model-version.ts +19 -0
  44. package/extensions/orchestrator/orchestrator.test.ts +206 -56
  45. package/extensions/orchestrator/orchestrator.ts +298 -140
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -36
  48. package/extensions/orchestrator/phases/implementation.ts +7 -11
  49. package/extensions/orchestrator/phases/machine.test.ts +27 -8
  50. package/extensions/orchestrator/phases/machine.ts +13 -0
  51. package/extensions/orchestrator/phases/planning.ts +57 -31
  52. package/extensions/orchestrator/phases/review-task.ts +3 -7
  53. package/extensions/orchestrator/phases/review.test.ts +54 -0
  54. package/extensions/orchestrator/phases/review.ts +89 -48
  55. package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
  56. package/extensions/orchestrator/phases/verdict.test.ts +139 -0
  57. package/extensions/orchestrator/phases/verdict.ts +82 -0
  58. package/extensions/orchestrator/plannotator.test.ts +85 -0
  59. package/extensions/orchestrator/plannotator.ts +24 -6
  60. package/extensions/orchestrator/pp-menu.test.ts +207 -0
  61. package/extensions/orchestrator/pp-menu.ts +2741 -373
  62. package/extensions/orchestrator/repo-utils.test.ts +151 -0
  63. package/extensions/orchestrator/repo-utils.ts +67 -0
  64. package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
  65. package/extensions/orchestrator/spawn-cleanup.ts +35 -0
  66. package/extensions/orchestrator/state.test.ts +76 -6
  67. package/extensions/orchestrator/state.ts +128 -44
  68. package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
  69. package/extensions/orchestrator/test-helpers.ts +229 -0
  70. package/extensions/orchestrator/tracer.test.ts +132 -0
  71. package/extensions/orchestrator/tracer.ts +127 -0
  72. package/extensions/orchestrator/transition-controller.test.ts +207 -0
  73. package/extensions/orchestrator/transition-controller.ts +259 -0
  74. package/extensions/orchestrator/usage-tracker.test.ts +563 -0
  75. package/extensions/orchestrator/usage-tracker.ts +96 -12
  76. package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
  77. package/package.json +2 -1
@@ -1,8 +1,13 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import lockfile from "proper-lockfile";
5
+ import { refreshAnthropicToken } from "@earendil-works/pi-ai/oauth";
4
6
  import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
5
7
  import type { PiPiConfig } from "./config.js";
8
+ import { updateRegistryFromAvailableModels } from "./model-registry.js";
9
+ import { compareModelVersion } from "./model-version.js";
10
+ import { getLogger } from "./log.js";
6
11
 
7
12
  export interface OpenRouterModelData {
8
13
  name: string;
@@ -24,6 +29,12 @@ export interface FlantSettings {
24
29
  lastUpdated: string | null;
25
30
  cachedFlantModels: string[] | null;
26
31
  cachedOpenRouterData: Record<string, OpenRouterModelData> | null;
32
+ /**
33
+ * When true, additionally register the `pp-flant-anthropic-sub` provider,
34
+ * which routes Claude requests through the gateway using the user's personal
35
+ * Claude OAuth token (billed against their personal Claude subscription).
36
+ */
37
+ subscription: boolean;
27
38
  }
28
39
 
29
40
  const GEMINI_MAP: Record<string, string> = {
@@ -52,11 +63,153 @@ const DEFAULT_SETTINGS: FlantSettings = {
52
63
  lastUpdated: null,
53
64
  cachedFlantModels: null,
54
65
  cachedOpenRouterData: null,
66
+ subscription: false,
55
67
  };
56
68
 
69
+ /** Provider name for the personal-subscription Claude routing. */
70
+ export const SUB_PROVIDER = "pp-flant-anthropic-sub";
71
+
72
+ /** Prefix the gateway expects for personal-subscription Claude models. */
73
+ export const SUB_MODEL_PREFIX = "sub/";
74
+
75
+ /**
76
+ * Read the Claude OAuth access token persisted by pi's built-in `anthropic`
77
+ * OAuth provider. Returns null when absent or expired. The gateway uses this
78
+ * token (forwarded as `Authorization: Bearer ...`) to bill the user's personal
79
+ * Claude subscription; pi-ai automatically adds the Claude Code identity
80
+ * headers because the token has the `sk-ant-oat` prefix.
81
+ */
82
+ export function readClaudeOAuthToken(): string | null {
83
+ const authPath = join(resolveAgentDir(), "auth.json");
84
+ if (!existsSync(authPath)) return null;
85
+ try {
86
+ const raw = JSON.parse(readFileSync(authPath, "utf-8")) as {
87
+ anthropic?: { access?: unknown; expires?: unknown };
88
+ };
89
+ const anthropic = raw.anthropic;
90
+ if (!anthropic || typeof anthropic.access !== "string" || !anthropic.access) return null;
91
+ if (typeof anthropic.expires === "number" && anthropic.expires <= Date.now()) return null;
92
+ return anthropic.access;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ interface AnthropicOAuthCreds {
99
+ type?: unknown;
100
+ access?: unknown;
101
+ refresh?: unknown;
102
+ expires?: unknown;
103
+ }
104
+
105
+ /**
106
+ * Ensure the persisted Claude OAuth access token is fresh, refreshing it via
107
+ * the stored refresh token when it has expired (or is within its safety
108
+ * margin). The refreshed credentials are written back to `auth.json` under the
109
+ * `anthropic` provider id in pi's own `{ type: "oauth", ... }` format, so both
110
+ * this extension and pi's built-in `anthropic` provider observe the new token.
111
+ *
112
+ * Unlike readClaudeOAuthToken, this is async (a refresh performs a network
113
+ * call) and returns the valid access token, or null when no usable
114
+ * credentials exist / a refresh fails. Async entry points call this before the
115
+ * synchronous readClaudeOAuthToken so downstream reads see a fresh token.
116
+ */
117
+ export async function refreshClaudeOAuthToken(): Promise<string | null> {
118
+ const log = getLogger();
119
+ const authPath = join(resolveAgentDir(), "auth.json");
120
+ if (!existsSync(authPath)) return null;
121
+
122
+ let anthropic: AnthropicOAuthCreds | undefined;
123
+ try {
124
+ const raw = JSON.parse(readFileSync(authPath, "utf-8")) as { anthropic?: AnthropicOAuthCreds };
125
+ anthropic = raw.anthropic;
126
+ } catch {
127
+ return null;
128
+ }
129
+ if (!anthropic || typeof anthropic.access !== "string" || !anthropic.access) return null;
130
+
131
+ const expires = typeof anthropic.expires === "number" ? anthropic.expires : 0;
132
+ if (expires > Date.now()) return anthropic.access;
133
+
134
+ // Expired (or no expiry recorded): try to refresh.
135
+ if (typeof anthropic.refresh !== "string" || !anthropic.refresh) {
136
+ log.debug({ s: "flant" }, "claude oauth token expired and no refresh token available");
137
+ return null;
138
+ }
139
+
140
+ let refreshed: { refresh: string; access: string; expires: number };
141
+ try {
142
+ refreshed = await refreshAnthropicToken(anthropic.refresh);
143
+ } catch (err: any) {
144
+ log.debug({ s: "flant", err: err?.message }, "claude oauth token refresh failed");
145
+ return null;
146
+ }
147
+
148
+ // Persist refreshed credentials back under the `anthropic` provider id, using
149
+ // pi's { type: "oauth", ... } shape and the same file lock pi uses.
150
+ try {
151
+ const authDir = resolveAgentDir();
152
+ if (!existsSync(authDir)) mkdirSync(authDir, { recursive: true });
153
+ if (!existsSync(authPath)) writeFileSync(authPath, "{}\n", "utf-8");
154
+ const release = lockfile.lockSync(authPath, { stale: 10000 });
155
+ try {
156
+ let current: Record<string, unknown> = {};
157
+ try {
158
+ current = JSON.parse(readFileSync(authPath, "utf-8")) as Record<string, unknown>;
159
+ } catch {
160
+ current = {};
161
+ }
162
+ const existing = (current.anthropic && typeof current.anthropic === "object")
163
+ ? current.anthropic as Record<string, unknown>
164
+ : {};
165
+ // Another instance may have refreshed while we were waiting for the lock.
166
+ const existingExpires = typeof existing.expires === "number" ? existing.expires : 0;
167
+ if (existingExpires > Date.now() && typeof existing.access === "string" && existing.access) {
168
+ return existing.access;
169
+ }
170
+ current.anthropic = {
171
+ ...existing,
172
+ type: "oauth",
173
+ access: refreshed.access,
174
+ refresh: refreshed.refresh,
175
+ expires: refreshed.expires,
176
+ };
177
+ writeFileSync(authPath, JSON.stringify(current, null, 2) + "\n", "utf-8");
178
+ } finally {
179
+ release();
180
+ }
181
+ } catch (err: any) {
182
+ // If persistence fails we still return the freshly minted token so the
183
+ // current run can proceed; the next run will refresh again.
184
+ log.debug({ s: "flant", err: err?.message }, "failed to persist refreshed claude oauth token");
185
+ }
186
+
187
+ log.debug({ s: "flant" }, "refreshed claude oauth token");
188
+ return refreshed.access;
189
+ }
190
+
191
+ /** Resolve the gateway API key (LLM_API_KEY preferred, FLANT_API_KEY fallback). */
192
+ export function readGatewayApiKey(): string | null {
193
+ return process.env.LLM_API_KEY ?? process.env.FLANT_API_KEY ?? null;
194
+ }
195
+
57
196
  let piRef: ExtensionAPI | null = null;
58
197
  let generatedFlantConfig: Partial<PiPiConfig> | null = null;
59
198
 
199
+ /**
200
+ * Inputs needed to (re)register the personal-subscription Claude provider.
201
+ * Cached at the last registerFlantProviders call so refreshSubProvider can
202
+ * rebuild the provider with a freshly-read OAuth token without re-discovering
203
+ * models. Null when subscription routing is not active.
204
+ */
205
+ interface SubProviderContext {
206
+ anthropicModels: string[];
207
+ metadata: Record<string, OpenRouterModelData>;
208
+ }
209
+ let subProviderContext: SubProviderContext | null = null;
210
+ /** The OAuth access token the sub provider was last registered with. */
211
+ let lastSubToken: string | null = null;
212
+
60
213
  export function setPI(pi: ExtensionAPI): void {
61
214
  piRef = pi;
62
215
  }
@@ -70,6 +223,7 @@ export function unregisterFlantProviders(pi?: ExtensionAPI): void {
70
223
  if (!api) return;
71
224
  api.unregisterProvider("pp-flant-anthropic");
72
225
  api.unregisterProvider("pp-flant-openai");
226
+ api.unregisterProvider(SUB_PROVIDER);
73
227
  }
74
228
 
75
229
  function ensureSettingsDir(): void {
@@ -95,6 +249,7 @@ function normalizeSettings(raw: unknown): FlantSettings {
95
249
  enabled: !!value.enabled,
96
250
  autoUpdate: value.autoUpdate === undefined ? true : !!value.autoUpdate,
97
251
  cacheTTLDays,
252
+ subscription: !!value.subscription,
98
253
  lastUpdated: typeof value.lastUpdated === "string" ? value.lastUpdated : null,
99
254
  cachedFlantModels: Array.isArray(value.cachedFlantModels)
100
255
  ? value.cachedFlantModels.filter((m): m is string => typeof m === "string")
@@ -117,7 +272,13 @@ export function loadFlantSettings(): FlantSettings {
117
272
 
118
273
  export function saveFlantSettings(settings: FlantSettings): void {
119
274
  ensureSettingsDir();
120
- writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
275
+ if (!existsSync(SETTINGS_PATH)) writeFileSync(SETTINGS_PATH, "{}\n", "utf-8");
276
+ const release = lockfile.lockSync(SETTINGS_PATH, { stale: 10000 });
277
+ try {
278
+ writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
279
+ } finally {
280
+ release();
281
+ }
121
282
  }
122
283
 
123
284
  function toTitleCase(token: string): string {
@@ -252,9 +413,24 @@ export async function fetchOpenRouterMetadata(modelIds: string[]): Promise<Recor
252
413
  return out;
253
414
  }
254
415
 
255
- function modelSpec(modelId: string): string {
256
- const provider = modelId.startsWith("claude-") ? "pp-flant-anthropic" : "pp-flant-openai";
257
- return `${provider}/${modelId}`;
416
+ /**
417
+ * Returns true when the personal-subscription Claude path is fully usable:
418
+ * the setting is enabled AND both credentials (Claude OAuth token + gateway
419
+ * key) are present. Mirrors the gate in registerFlantProviders so we never
420
+ * generate `sub/` role assignments that cannot resolve to a real provider.
421
+ */
422
+ export function isSubscriptionActive(settings?: FlantSettings): boolean {
423
+ const s = settings ?? loadFlantSettings();
424
+ return s.subscription && !!readClaudeOAuthToken() && !!readGatewayApiKey();
425
+ }
426
+
427
+ function modelSpec(modelId: string, subscriptionActive = false): string {
428
+ if (modelId.startsWith("claude-")) {
429
+ return subscriptionActive
430
+ ? `${SUB_PROVIDER}/${SUB_MODEL_PREFIX}${modelId}`
431
+ : `pp-flant-anthropic/${modelId}`;
432
+ }
433
+ return `pp-flant-openai/${modelId}`;
258
434
  }
259
435
 
260
436
  function buildProviderModelConfig(
@@ -279,15 +455,24 @@ function buildProviderModelConfig(
279
455
  };
280
456
  }
281
457
 
458
+ export interface RegisterFlantOptions {
459
+ /** Whether to also register the personal-subscription provider. Defaults to loadFlantSettings().subscription. */
460
+ subscription?: boolean;
461
+ }
462
+
282
463
  export function registerFlantProviders(
283
464
  pi: ExtensionAPI,
284
465
  models: string[],
285
466
  metadata: Record<string, OpenRouterModelData>,
467
+ options: RegisterFlantOptions = {},
286
468
  ): void {
469
+ const log = getLogger();
287
470
  const uniqueModels = [...new Set(models)];
288
471
  const anthropicModels = uniqueModels.filter((m) => m.startsWith("claude-"));
289
472
  const openaiModels = uniqueModels.filter((m) => !m.startsWith("claude-"));
290
473
 
474
+ unregisterFlantProviders(pi);
475
+
291
476
  pi.registerProvider("pp-flant-anthropic", {
292
477
  api: "anthropic-messages",
293
478
  baseUrl: "https://llm-api.flant.ru",
@@ -301,18 +486,98 @@ export function registerFlantProviders(
301
486
  apiKey: "$FLANT_API_KEY",
302
487
  models: openaiModels.map((m) => buildProviderModelConfig(m, metadata)),
303
488
  });
489
+
490
+ const availableSpecs = [
491
+ ...anthropicModels.map((id) => `pp-flant-anthropic/${id}`),
492
+ ...openaiModels.map((id) => `pp-flant-openai/${id}`),
493
+ ];
494
+
495
+ const subscription = options.subscription ?? loadFlantSettings().subscription;
496
+ let subModels: string[] = [];
497
+ if (subscription) {
498
+ // Remember the models/metadata so refreshSubProvider can rebuild the
499
+ // provider with a fresh OAuth token on each turn (see below).
500
+ subProviderContext = { anthropicModels, metadata };
501
+ subModels = registerSubProvider(pi, anthropicModels, metadata);
502
+ availableSpecs.push(...subModels.map((id) => `${SUB_PROVIDER}/${id}`));
503
+ } else {
504
+ subProviderContext = null;
505
+ lastSubToken = null;
506
+ }
507
+
508
+ log.debug({ s: "flant", total: uniqueModels.length, anthropic: anthropicModels.length, openai: openaiModels.length, sub: subModels.length }, "registering flant providers");
509
+
510
+ updateRegistryFromAvailableModels(availableSpecs);
304
511
  }
305
512
 
306
- function compareModelVersion(a: string, b: string): number {
307
- const aParts = (a.match(/\d+/g) ?? []).map(Number);
308
- const bParts = (b.match(/\d+/g) ?? []).map(Number);
309
- const len = Math.max(aParts.length, bParts.length);
310
- for (let i = 0; i < len; i++) {
311
- const ai = aParts[i] ?? 0;
312
- const bi = bParts[i] ?? 0;
313
- if (ai !== bi) return ai - bi;
513
+ /**
514
+ * Register (or re-register) the personal-subscription Claude provider using the
515
+ * OAuth access token currently persisted in auth.json. Returns the list of
516
+ * `sub/`-prefixed model ids registered (empty when credentials are missing).
517
+ *
518
+ * The provider is registered with a literal `apiKey` (the resolved token), so
519
+ * the value is a static snapshot for the lifetime of the registration. Because
520
+ * the OAuth token expires within a few hours, refreshSubProvider must be called
521
+ * periodically (on each turn) to rebuild the provider with a fresh token;
522
+ * otherwise long-lived sessions send a stale token and the gateway returns 401.
523
+ */
524
+ function registerSubProvider(
525
+ pi: ExtensionAPI,
526
+ anthropicModels: string[],
527
+ metadata: Record<string, OpenRouterModelData>,
528
+ ): string[] {
529
+ const log = getLogger();
530
+ const oauthToken = readClaudeOAuthToken();
531
+ const gatewayKey = readGatewayApiKey();
532
+ if (!oauthToken || !gatewayKey) {
533
+ log.debug({ s: "flant", hasOAuth: !!oauthToken, hasGatewayKey: !!gatewayKey }, "subscription enabled but credentials missing; skipping sub provider");
534
+ lastSubToken = null;
535
+ return [];
314
536
  }
315
- return a.localeCompare(b);
537
+ pi.registerProvider(SUB_PROVIDER, {
538
+ name: "Flant (personal Claude subscription)",
539
+ api: "anthropic-messages",
540
+ baseUrl: "https://llm-api.flant.ru",
541
+ // The OAuth token (sk-ant-oat...) triggers pi-ai's Claude Code identity
542
+ // headers and is forwarded as `Authorization: Bearer ...`.
543
+ apiKey: oauthToken,
544
+ // Gateway key travels in a side header so it does not clobber the OAuth auth.
545
+ headers: { "x-litellm-api-key": `Bearer ${gatewayKey}` },
546
+ // Model id carries the `sub/` prefix the gateway expects, while pricing/
547
+ // metadata is looked up by the bare claude-* id.
548
+ models: anthropicModels.map((m) => {
549
+ const cfg = buildProviderModelConfig(m, metadata);
550
+ return { ...cfg, id: `${SUB_MODEL_PREFIX}${m}` };
551
+ }),
552
+ });
553
+ lastSubToken = oauthToken;
554
+ return anthropicModels.map((m) => `${SUB_MODEL_PREFIX}${m}`);
555
+ }
556
+
557
+ /**
558
+ * Ensure the personal-subscription Claude provider is registered with a
559
+ * non-expired OAuth token. Refreshes the token (persisting it to auth.json when
560
+ * needed) and, when it changed since the last registration, re-registers the
561
+ * provider so subsequent LLM calls pick up the fresh token.
562
+ *
563
+ * Called on each turn for the root session. No-op when subscription routing is
564
+ * not active. Cheap when the token is unchanged (only a token read + compare).
565
+ */
566
+ export async function refreshSubProvider(pi?: ExtensionAPI): Promise<void> {
567
+ const ctx = subProviderContext;
568
+ if (!ctx) return;
569
+ const api = pi ?? piRef;
570
+ if (!api) return;
571
+
572
+ await refreshClaudeOAuthToken();
573
+ const token = readClaudeOAuthToken();
574
+ // Only re-register when the token actually changed; registerProvider takes
575
+ // effect immediately, so re-registering every turn would be wasteful churn.
576
+ if (token && token === lastSubToken) return;
577
+
578
+ const log = getLogger();
579
+ registerSubProvider(api, ctx.anthropicModels, ctx.metadata);
580
+ log.debug({ s: "flant", changed: token !== lastSubToken }, "refreshed sub provider oauth token");
316
581
  }
317
582
 
318
583
  function pickLatest(models: string[]): string | null {
@@ -332,14 +597,32 @@ function pickCheapestFastModel(models: string[]): string | null {
332
597
  return pickLatest(models.filter((m) => /^claude-haiku-/.test(m)));
333
598
  }
334
599
 
335
- function makeVariant(modelId: string | null, fallbackModelId: string): { enabled: boolean; model: string; thinking: string } {
336
- if (!modelId) return { enabled: false, model: modelSpec(fallbackModelId), thinking: "high" };
337
- return { enabled: true, model: modelSpec(modelId), thinking: "high" };
600
+ function makeVariant(modelId: string | null, fallbackModelId: string, sub = false): { enabled: boolean; model: string; thinking: string } {
601
+ if (!modelId) return { enabled: false, model: modelSpec(fallbackModelId, sub), thinking: "high" };
602
+ return { enabled: true, model: modelSpec(modelId, sub), thinking: "high" };
338
603
  }
339
604
 
340
- export function generateFlantConfig(models: string[]): Partial<PiPiConfig> {
605
+ function makeVariantWithThinking(
606
+ modelId: string | null,
607
+ fallbackModelId: string,
608
+ thinking: string,
609
+ sub = false,
610
+ ): { enabled: boolean; model: string; thinking: string } {
611
+ if (!modelId) return { enabled: false, model: modelSpec(fallbackModelId, sub), thinking };
612
+ return { enabled: true, model: modelSpec(modelId, sub), thinking };
613
+ }
614
+
615
+ function buildPresetGroup(
616
+ presets: Record<string, { enabled?: boolean; agents: Record<string, { enabled: boolean; model: string; thinking: string }> }>,
617
+ defaultPreset = "regular",
618
+ ): { default: string; presets: typeof presets } {
619
+ return { default: defaultPreset, presets };
620
+ }
621
+
622
+ export function generateFlantConfig(models: string[], subscriptionActive = false): Partial<PiPiConfig> {
341
623
  const uniqueModels = [...new Set(models)];
342
624
  if (uniqueModels.length === 0) return {};
625
+ const sub = subscriptionActive;
343
626
 
344
627
  const latestOpus = pickLatest(uniqueModels.filter((m) => /^claude-opus-/.test(m)));
345
628
  const latestClaude = pickLatest(uniqueModels.filter((m) => /^claude-/.test(m)));
@@ -358,36 +641,81 @@ export function generateFlantConfig(models: string[]): Partial<PiPiConfig> {
358
641
  const fastModel = fastest ?? debugModel;
359
642
 
360
643
  return {
361
- mainModel: {
362
- implement: { model: modelSpec(implementModel), thinking: "high" },
363
- debug: { model: modelSpec(debugModel), thinking: "high" },
364
- brainstorm: { model: modelSpec(brainstormModel), thinking: "high" },
365
- review: { model: modelSpec(implementModel), thinking: "high" },
366
- },
367
- planners: {
368
- opus: makeVariant(latestOpus, fallback),
369
- gpt: makeVariant(latestGpt, fallback),
370
- gemini: makeVariant(latestGeminiPro, fallback),
371
- },
372
- planReviewers: {
373
- opus: makeVariant(latestOpus, fallback),
374
- gpt: makeVariant(latestGpt, fallback),
375
- gemini: makeVariant(latestGeminiPro, fallback),
376
- },
377
- codeReviewers: {
378
- opus: makeVariant(latestOpus, fallback),
379
- gpt: makeVariant(latestGpt, fallback),
380
- gemini: makeVariant(latestGeminiPro, fallback),
381
- },
382
- brainstormReviewers: {
383
- opus: makeVariant(latestOpus, fallback),
384
- gpt: makeVariant(latestGpt, fallback),
385
- gemini: makeVariant(latestGeminiPro, fallback),
386
- },
387
644
  agents: {
388
- explore: { model: modelSpec(fastModel), thinking: "low" },
389
- librarian: { model: modelSpec(fastModel), thinking: "medium" },
390
- task: { model: modelSpec(taskModel), thinking: "medium" },
645
+ orchestrators: {
646
+ implement: { model: modelSpec(implementModel, sub), thinking: "high" },
647
+ plan: { model: modelSpec(implementModel, sub), thinking: "high" },
648
+ debug: { model: modelSpec(debugModel, sub), thinking: "high" },
649
+ brainstorm: { model: modelSpec(brainstormModel, sub), thinking: "high" },
650
+ review: { model: modelSpec(implementModel, sub), thinking: "high" },
651
+ quick: { model: modelSpec(implementModel, sub), thinking: "high" },
652
+ },
653
+ subagents: {
654
+ simple: {
655
+ explore: { model: modelSpec(fastModel, sub), thinking: "low" },
656
+ librarian: { model: modelSpec(fastModel, sub), thinking: "medium" },
657
+ task: { model: modelSpec(taskModel, sub), thinking: "medium" },
658
+ },
659
+ presetGroups: {
660
+ planners: buildPresetGroup({
661
+ regular: {
662
+ agents: {
663
+ opus: makeVariant(latestOpus, fallback, sub),
664
+ gpt: makeVariant(latestGpt, fallback, sub),
665
+ gemini: makeVariant(latestGeminiPro, fallback, sub),
666
+ },
667
+ },
668
+ }),
669
+ planReviewers: buildPresetGroup({
670
+ regular: {
671
+ agents: {
672
+ opus: makeVariant(latestOpus, fallback, sub),
673
+ gpt: makeVariant(latestGpt, fallback, sub),
674
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
675
+ },
676
+ },
677
+ deep: {
678
+ agents: {
679
+ opus: makeVariantWithThinking(latestOpus, fallback, "xhigh", sub),
680
+ gpt: makeVariantWithThinking(latestGpt, fallback, "xhigh", sub),
681
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
682
+ },
683
+ },
684
+ }),
685
+ codeReviewers: buildPresetGroup({
686
+ regular: {
687
+ agents: {
688
+ opus: makeVariant(latestOpus, fallback, sub),
689
+ gpt: makeVariant(latestGpt, fallback, sub),
690
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
691
+ },
692
+ },
693
+ deep: {
694
+ agents: {
695
+ opus: makeVariantWithThinking(latestOpus, fallback, "xhigh", sub),
696
+ gpt: makeVariantWithThinking(latestGpt, fallback, "xhigh", sub),
697
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
698
+ },
699
+ },
700
+ }),
701
+ brainstormReviewers: buildPresetGroup({
702
+ regular: {
703
+ agents: {
704
+ opus: makeVariant(latestOpus, fallback, sub),
705
+ gpt: makeVariant(latestGpt, fallback, sub),
706
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
707
+ },
708
+ },
709
+ deep: {
710
+ agents: {
711
+ opus: makeVariantWithThinking(latestOpus, fallback, "xhigh", sub),
712
+ gpt: makeVariantWithThinking(latestGpt, fallback, "xhigh", sub),
713
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
714
+ },
715
+ },
716
+ }),
717
+ },
718
+ },
391
719
  },
392
720
  };
393
721
  }
@@ -412,6 +740,12 @@ export async function updateFlantInfra(
412
740
  setPI(pi);
413
741
  const settings = loadFlantSettings();
414
742
 
743
+ // Refresh the personal-subscription Claude OAuth token before (re)registering
744
+ // providers so the sub provider is built with a valid, non-expired token.
745
+ if (settings.subscription) {
746
+ await refreshClaudeOAuthToken();
747
+ }
748
+
415
749
  let models = isCacheValid(settings) ? settings.cachedFlantModels : null;
416
750
  let metadata = isCacheValid(settings) ? settings.cachedOpenRouterData : null;
417
751
  let refreshed = false;
@@ -455,7 +789,7 @@ export async function updateFlantInfra(
455
789
 
456
790
  try {
457
791
  registerFlantProviders(pi, models, metadata);
458
- generatedFlantConfig = generateFlantConfig(models);
792
+ generatedFlantConfig = generateFlantConfig(models, isSubscriptionActive(settings));
459
793
  if (!refreshed && settings.cachedFlantModels && settings.cachedOpenRouterData && !settings.lastUpdated) {
460
794
  settings.lastUpdated = new Date().toISOString();
461
795
  saveFlantSettings(settings);
@@ -469,13 +803,15 @@ export async function updateFlantInfra(
469
803
  export function initFlantSync(pi: ExtensionAPI): void {
470
804
  setPI(pi);
471
805
  const settings = loadFlantSettings();
806
+ const log = getLogger();
472
807
  if (!settings.enabled) {
808
+ log.debug({ s: "flant" }, "flant disabled");
473
809
  generatedFlantConfig = null;
474
810
  return;
475
811
  }
476
812
  if (settings.cachedFlantModels && settings.cachedOpenRouterData) {
477
813
  registerFlantProviders(pi, settings.cachedFlantModels, settings.cachedOpenRouterData);
478
- generatedFlantConfig = generateFlantConfig(settings.cachedFlantModels);
814
+ generatedFlantConfig = generateFlantConfig(settings.cachedFlantModels, isSubscriptionActive(settings));
479
815
  }
480
816
  }
481
817
 
@@ -486,6 +822,11 @@ export async function initFlantOnStartup(pi: ExtensionAPI): Promise<void> {
486
822
  generatedFlantConfig = null;
487
823
  return;
488
824
  }
489
- if (!settings.autoUpdate) return;
825
+ if (!settings.autoUpdate) {
826
+ // updateFlantInfra (which refreshes the token) is skipped when auto-update
827
+ // is off, but any registered sub provider still needs a fresh token.
828
+ if (settings.subscription) await refreshClaudeOAuthToken();
829
+ return;
830
+ }
490
831
  await updateFlantInfra(pi);
491
832
  }
@@ -11,15 +11,22 @@ import { validatePlan, validateArtifact } from "./validate-artifacts.js";
11
11
  import { initFlantSync } from "./flant-infra.js";
12
12
 
13
13
  const ORCHESTRATOR_KEY = Symbol.for("pi-pi:orchestrator-initialized");
14
+ const ORCHESTRATOR_CWD_KEY = Symbol.for("pi-pi:orchestrator-cwd");
15
+ // Shared with 3p/pi-subagents/src/agent-runner.ts: the value is a { depth: number }
16
+ // marking that this process runs as a subagent. The orchestrator only reads it for
17
+ // truthiness ("am I a subagent?"); agent-runner uses depth for nesting.
14
18
  export const SUBAGENT_SESSION_KEY = Symbol.for("pi-pi:subagent-session");
15
19
 
16
20
  export default function (pi: ExtensionAPI) {
17
21
  if ((globalThis as any)[ORCHESTRATOR_KEY]) {
18
- (globalThis as any)[SUBAGENT_SESSION_KEY] = true;
22
+ if (!(globalThis as any)[SUBAGENT_SESSION_KEY]) {
23
+ (globalThis as any)[SUBAGENT_SESSION_KEY] = { depth: 1 };
24
+ }
19
25
  registerSubagentTools(pi);
20
26
  return;
21
27
  }
22
28
  (globalThis as any)[ORCHESTRATOR_KEY] = true;
29
+ (globalThis as any)[ORCHESTRATOR_CWD_KEY] = process.cwd();
23
30
 
24
31
  initFlantSync(pi);
25
32
 
@@ -29,7 +36,11 @@ export default function (pi: ExtensionAPI) {
29
36
  }
30
37
 
31
38
  function registerSubagentTools(pi: ExtensionAPI): void {
32
- const cwd = process.cwd();
39
+ // Subagents run in-process; bind cbm/ast-search and plan validation to the
40
+ // orchestrator's project root (seeded to process.cwd() at init, then refreshed
41
+ // to ctx.cwd on session_start) rather than a raw process.cwd() captured here,
42
+ // which is the launch dir and wrong for worktree-isolated tasks.
43
+ const cwd = (globalThis as any)[ORCHESTRATOR_CWD_KEY] ?? process.cwd();
33
44
  registerCbmTools(pi, cwd);
34
45
  registerExaTools(pi);
35
46
  registerAstSearchTool(pi, cwd);