@ilya-lesikov/pi-pi 0.4.0 → 0.6.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 (76) 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 +73 -31
  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 +630 -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 +2 -1
  28. package/extensions/orchestrator/doctor.test.ts +559 -0
  29. package/extensions/orchestrator/doctor.ts +664 -0
  30. package/extensions/orchestrator/event-handlers.test.ts +84 -22
  31. package/extensions/orchestrator/event-handlers.ts +1191 -360
  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 +224 -0
  35. package/extensions/orchestrator/flant-infra.ts +110 -41
  36. package/extensions/orchestrator/index.ts +13 -2
  37. package/extensions/orchestrator/integration.test.ts +2866 -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 +238 -0
  41. package/extensions/orchestrator/model-registry.ts +282 -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 +295 -148
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -35
  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 -29
  52. package/extensions/orchestrator/phases/review-task.ts +3 -3
  53. package/extensions/orchestrator/phases/review.ts +38 -39
  54. package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
  55. package/extensions/orchestrator/phases/verdict.test.ts +139 -0
  56. package/extensions/orchestrator/phases/verdict.ts +82 -0
  57. package/extensions/orchestrator/plannotator.test.ts +85 -0
  58. package/extensions/orchestrator/plannotator.ts +24 -6
  59. package/extensions/orchestrator/pp-menu.test.ts +134 -0
  60. package/extensions/orchestrator/pp-menu.ts +2631 -392
  61. package/extensions/orchestrator/repo-utils.test.ts +151 -0
  62. package/extensions/orchestrator/repo-utils.ts +67 -0
  63. package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
  64. package/extensions/orchestrator/spawn-cleanup.ts +35 -0
  65. package/extensions/orchestrator/state.test.ts +76 -6
  66. package/extensions/orchestrator/state.ts +89 -26
  67. package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
  68. package/extensions/orchestrator/test-helpers.ts +217 -0
  69. package/extensions/orchestrator/tracer.test.ts +132 -0
  70. package/extensions/orchestrator/tracer.ts +127 -0
  71. package/extensions/orchestrator/transition-controller.test.ts +207 -0
  72. package/extensions/orchestrator/transition-controller.ts +259 -0
  73. package/extensions/orchestrator/usage-tracker.test.ts +435 -0
  74. package/extensions/orchestrator/usage-tracker.ts +23 -2
  75. package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
  76. package/package.json +2 -1
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { callExa } from "./exa.js";
3
+
4
+ function mockFetchText(text: string, init: { ok?: boolean; status?: number } = {}) {
5
+ return vi.spyOn(globalThis, "fetch").mockResolvedValue({
6
+ ok: init.ok ?? true,
7
+ status: init.status ?? 200,
8
+ text: async () => text,
9
+ } as Response);
10
+ }
11
+
12
+ afterEach(() => {
13
+ vi.restoreAllMocks();
14
+ });
15
+
16
+ describe("callExa error handling", () => {
17
+ it("throws on an SSE error payload even when the message lacks the word 'error'", async () => {
18
+ mockFetchText(`data: ${JSON.stringify({ error: { message: "rate limit exceeded" } })}\n`);
19
+ await expect(callExa("web_search_exa", {})).rejects.toThrow("rate limit exceeded");
20
+ });
21
+
22
+ it("returns text from a normal SSE success response", async () => {
23
+ mockFetchText(`data: ${JSON.stringify({ result: { content: [{ text: "hello world" }] } })}\n`);
24
+ await expect(callExa("web_search_exa", {})).resolves.toBe("hello world");
25
+ });
26
+
27
+ it("returns text from a non-SSE JSON success response", async () => {
28
+ mockFetchText(JSON.stringify({ result: { content: [{ text: "plain json" }] } }));
29
+ await expect(callExa("web_search_exa", {})).resolves.toBe("plain json");
30
+ });
31
+
32
+ it("throws on a non-SSE JSON error payload lacking the word 'error'", async () => {
33
+ mockFetchText(JSON.stringify({ error: { message: "quota reached" } }));
34
+ await expect(callExa("web_search_exa", {})).rejects.toThrow("quota reached");
35
+ });
36
+
37
+ it("falls through and returns the raw body for a non-JSON OK response", async () => {
38
+ mockFetchText("not json at all", { ok: true, status: 200 });
39
+ await expect(callExa("web_search_exa", {})).resolves.toBe("not json at all");
40
+ });
41
+
42
+ it("throws on a non-JSON body when the HTTP response is not ok", async () => {
43
+ mockFetchText("rate limited", { ok: false, status: 429 });
44
+ await expect(callExa("web_search_exa", {})).rejects.toThrow("Exa HTTP 429");
45
+ });
46
+ });
@@ -3,7 +3,7 @@ import { Type } from "@sinclair/typebox";
3
3
 
4
4
  const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
5
5
 
6
- async function callExa(toolName: string, args: Record<string, unknown>): Promise<string> {
6
+ export async function callExa(toolName: string, args: Record<string, unknown>): Promise<string> {
7
7
  const body = JSON.stringify({
8
8
  jsonrpc: "2.0",
9
9
  id: 1,
@@ -25,23 +25,29 @@ async function callExa(toolName: string, args: Record<string, unknown>): Promise
25
25
 
26
26
  for (const line of raw.split("\n")) {
27
27
  if (!line.startsWith("data:")) continue;
28
+ let json: any;
28
29
  try {
29
- const json = JSON.parse(line.slice(5).trim());
30
- if (json.error) throw new Error(json.error.message);
31
- const text = json.result?.content?.[0]?.text;
32
- if (text) return text;
33
- } catch (e: any) {
34
- if (e.message?.includes("error")) throw e;
30
+ json = JSON.parse(line.slice(5).trim());
31
+ } catch {
32
+ continue;
35
33
  }
34
+ if (json.error) throw new Error(json.error.message ?? JSON.stringify(json.error));
35
+ const text = json.result?.content?.[0]?.text;
36
+ if (text) return text;
36
37
  }
37
38
 
39
+ let json: any;
38
40
  try {
39
- const json = JSON.parse(raw);
40
- if (json.error) throw new Error(json.error.message);
41
- return json.result?.content?.[0]?.text ?? raw;
41
+ json = JSON.parse(raw);
42
42
  } catch {
43
+ if (!res.ok) throw new Error(`Exa HTTP ${res.status}: ${raw.slice(0, 200)}`);
43
44
  return raw;
44
45
  }
46
+ if (json.error) throw new Error(json.error.message ?? JSON.stringify(json.error));
47
+ const text = json.result?.content?.[0]?.text;
48
+ if (text != null) return text;
49
+ if (!res.ok) throw new Error(`Exa HTTP ${res.status}: ${raw.slice(0, 200)}`);
50
+ return raw;
45
51
  }
46
52
 
47
53
  function ok(text: string) {
@@ -0,0 +1,224 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ const tempDirs: string[] = [];
7
+
8
+ function makeTempDir(): string {
9
+ const dir = mkdtempSync(join(tmpdir(), "pi-pi-flant-infra-"));
10
+ tempDirs.push(dir);
11
+ return dir;
12
+ }
13
+
14
+ async function loadFlantInfraModule(agentDir: string) {
15
+ process.env.PI_CODING_AGENT_DIR = agentDir;
16
+ vi.resetModules();
17
+ return import("./flant-infra.js");
18
+ }
19
+
20
+ function collectModelSpecs(value: unknown): string[] {
21
+ if (!value || typeof value !== "object") return [];
22
+ const out: string[] = [];
23
+ const walk = (node: any) => {
24
+ if (!node || typeof node !== "object") return;
25
+ if (typeof node.model === "string") out.push(node.model);
26
+ for (const nested of Object.values(node)) {
27
+ walk(nested);
28
+ }
29
+ };
30
+ walk(value);
31
+ return out;
32
+ }
33
+
34
+ afterEach(() => {
35
+ delete process.env.PI_CODING_AGENT_DIR;
36
+ for (const dir of tempDirs.splice(0)) {
37
+ rmSync(dir, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ describe("flant-infra", () => {
42
+ it("registerFlantProviders is idempotent across repeated calls", async () => {
43
+ const dir = makeTempDir();
44
+ const mod = await loadFlantInfraModule(dir);
45
+
46
+ const registered = new Map<string, unknown>();
47
+ const pi = {
48
+ registerProvider: vi.fn((name: string, config: unknown) => registered.set(name, config)),
49
+ unregisterProvider: vi.fn((name: string) => registered.delete(name)),
50
+ } as any;
51
+
52
+ const models = ["claude-opus-4-6", "gpt-5"];
53
+ mod.registerFlantProviders(pi, models, {});
54
+ mod.registerFlantProviders(pi, models, {});
55
+
56
+ expect(registered.size).toBe(2);
57
+ expect([...registered.keys()].sort()).toEqual(["pp-flant-anthropic", "pp-flant-openai"]);
58
+ });
59
+
60
+ it("generateDisplayName formats model ids", async () => {
61
+ const dir = makeTempDir();
62
+ const mod = await loadFlantInfraModule(dir);
63
+
64
+ expect(mod.generateDisplayName("claude-opus-4-6")).toBe("Claude Opus 4.6");
65
+ expect(mod.generateDisplayName("gpt-5-4-mini")).toBe("GPT 5.4 Mini");
66
+ expect(mod.generateDisplayName("o3-mini")).toBe("O3 Mini");
67
+ expect(mod.generateDisplayName("qwen-3-coder")).toBe("Qwen 3 Coder");
68
+ expect(mod.generateDisplayName("api-ai-2")).toBe("API AI 2");
69
+ });
70
+
71
+ it("generateFlantConfig creates config with mapped model specs", async () => {
72
+ const dir = makeTempDir();
73
+ const mod = await loadFlantInfraModule(dir);
74
+
75
+ const config = mod.generateFlantConfig([
76
+ "claude-opus-4-6",
77
+ "gpt-5-4",
78
+ "gpt-5-4-mini",
79
+ "gemini-3-1-pro",
80
+ "gemini-3-1-flash",
81
+ "deepseek-v3",
82
+ "grok-4",
83
+ ]) as any;
84
+
85
+ expect(config.agents.orchestrators.implement.model).toBe("pp-flant-anthropic/claude-opus-4-6");
86
+ expect(config.agents.orchestrators.plan.model).toBe("pp-flant-anthropic/claude-opus-4-6");
87
+ expect(config.agents.orchestrators.debug.model).toBe("pp-flant-openai/gpt-5-4");
88
+ expect(config.agents.orchestrators.brainstorm.model).toBe("pp-flant-anthropic/claude-opus-4-6");
89
+ expect(config.agents.orchestrators.review.model).toBe("pp-flant-anthropic/claude-opus-4-6");
90
+ expect(config.agents.subagents.simple.explore.model).toBe("pp-flant-openai/gemini-3-1-flash");
91
+ expect(config.agents.subagents.simple.librarian.model).toBe("pp-flant-openai/gemini-3-1-flash");
92
+ expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.opus.model).toBe("pp-flant-anthropic/claude-opus-4-6");
93
+ expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.gpt.model).toBe("pp-flant-openai/gpt-5-4");
94
+ expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.gemini.model).toBe("pp-flant-openai/gemini-3-1-pro");
95
+ });
96
+
97
+ it("generateFlantConfig returns empty object for empty models", async () => {
98
+ const dir = makeTempDir();
99
+ const mod = await loadFlantInfraModule(dir);
100
+
101
+ expect(mod.generateFlantConfig([])).toEqual({});
102
+ });
103
+
104
+ it("generateFlantConfig with anthropic-only models uses anthropic specs", async () => {
105
+ const dir = makeTempDir();
106
+ const mod = await loadFlantInfraModule(dir);
107
+
108
+ const config = mod.generateFlantConfig(["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-3-5"]);
109
+ const specs = collectModelSpecs(config);
110
+
111
+ expect(specs.length).toBeGreaterThan(0);
112
+ expect(specs.every((spec) => spec.startsWith("pp-flant-anthropic/"))).toBe(true);
113
+ expect(specs.some((spec) => spec.startsWith("pp-flant-openai/"))).toBe(false);
114
+ });
115
+
116
+ it("generateFlantConfig picks latest models per family", async () => {
117
+ const dir = makeTempDir();
118
+ const mod = await loadFlantInfraModule(dir);
119
+
120
+ const config = mod.generateFlantConfig([
121
+ "claude-opus-4-5",
122
+ "claude-opus-4-7",
123
+ "gpt-5-3",
124
+ "gpt-5-4",
125
+ "gemini-3-0-pro",
126
+ "gemini-3-1-pro",
127
+ "gemini-3-1-flash",
128
+ "gemini-3-1-flash-lite",
129
+ ]) as any;
130
+
131
+ expect(config.agents.orchestrators.implement.model).toBe("pp-flant-anthropic/claude-opus-4-7");
132
+ expect(config.agents.orchestrators.plan.model).toBe("pp-flant-anthropic/claude-opus-4-7");
133
+ expect(config.agents.orchestrators.debug.model).toBe("pp-flant-openai/gpt-5-4");
134
+ expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.gemini.model).toBe("pp-flant-openai/gemini-3-1-pro");
135
+ expect(config.agents.subagents.simple.explore.model).toBe("pp-flant-openai/gemini-3-1-flash-lite");
136
+ });
137
+
138
+ it("loadFlantSettings returns defaults when file is missing", async () => {
139
+ const dir = makeTempDir();
140
+ const mod = await loadFlantInfraModule(dir);
141
+
142
+ expect(mod.loadFlantSettings()).toEqual({
143
+ enabled: false,
144
+ autoUpdate: true,
145
+ cacheTTLDays: 7,
146
+ lastUpdated: null,
147
+ cachedFlantModels: null,
148
+ cachedOpenRouterData: null,
149
+ });
150
+ });
151
+
152
+ it("loadFlantSettings parses valid settings file", async () => {
153
+ const dir = makeTempDir();
154
+ const mod = await loadFlantInfraModule(dir);
155
+ const settingsDir = join(dir, "extensions", "pp", "cache");
156
+ const settingsPath = join(settingsDir, "flant-models.json");
157
+ mkdirSync(settingsDir, { recursive: true });
158
+ writeFileSync(
159
+ settingsPath,
160
+ JSON.stringify({
161
+ enabled: true,
162
+ autoUpdate: false,
163
+ cacheTTLDays: "3",
164
+ lastUpdated: "2026-01-02T03:04:05.000Z",
165
+ cachedFlantModels: ["gpt-5-4", 123, "claude-opus-4-6"],
166
+ cachedOpenRouterData: {
167
+ "gpt-5-4": {
168
+ name: "GPT 5.4",
169
+ context_length: 123,
170
+ max_completion_tokens: 45,
171
+ pricing: { prompt: 1, completion: 2, cacheRead: 3, cacheWrite: 4 },
172
+ modality: "text",
173
+ },
174
+ },
175
+ }),
176
+ "utf-8",
177
+ );
178
+
179
+ expect(mod.loadFlantSettings()).toEqual({
180
+ enabled: true,
181
+ autoUpdate: false,
182
+ cacheTTLDays: 3,
183
+ lastUpdated: "2026-01-02T03:04:05.000Z",
184
+ cachedFlantModels: ["gpt-5-4", "claude-opus-4-6"],
185
+ cachedOpenRouterData: {
186
+ "gpt-5-4": {
187
+ name: "GPT 5.4",
188
+ context_length: 123,
189
+ max_completion_tokens: 45,
190
+ pricing: { prompt: 1, completion: 2, cacheRead: 3, cacheWrite: 4 },
191
+ modality: "text",
192
+ },
193
+ },
194
+ });
195
+ });
196
+
197
+ it("saveFlantSettings and loadFlantSettings round-trip", async () => {
198
+ const dir = makeTempDir();
199
+ const mod = await loadFlantInfraModule(dir);
200
+
201
+ const settings = {
202
+ enabled: true,
203
+ autoUpdate: true,
204
+ cacheTTLDays: 14,
205
+ lastUpdated: "2026-02-01T00:00:00.000Z",
206
+ cachedFlantModels: ["claude-opus-4-6", "gpt-5-4"],
207
+ cachedOpenRouterData: {
208
+ "claude-opus-4-6": {
209
+ name: "Claude Opus 4.6",
210
+ context_length: 200000,
211
+ max_completion_tokens: 32000,
212
+ pricing: { prompt: 0.01, completion: 0.02, cacheRead: 0.003, cacheWrite: 0.004 },
213
+ modality: "text",
214
+ },
215
+ },
216
+ };
217
+
218
+ mod.saveFlantSettings(settings);
219
+
220
+ const loaded = mod.loadFlantSettings();
221
+ expect(loaded).toEqual(settings);
222
+ expect(existsSync(join(dir, "extensions", "pp", "cache", "flant-models.json"))).toBe(true);
223
+ });
224
+ });
@@ -1,8 +1,12 @@
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";
4
5
  import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
5
6
  import type { PiPiConfig } from "./config.js";
7
+ import { updateRegistryFromAvailableModels } from "./model-registry.js";
8
+ import { compareModelVersion } from "./model-version.js";
9
+ import { getLogger } from "./log.js";
6
10
 
7
11
  export interface OpenRouterModelData {
8
12
  name: string;
@@ -117,7 +121,13 @@ export function loadFlantSettings(): FlantSettings {
117
121
 
118
122
  export function saveFlantSettings(settings: FlantSettings): void {
119
123
  ensureSettingsDir();
120
- writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
124
+ if (!existsSync(SETTINGS_PATH)) writeFileSync(SETTINGS_PATH, "{}\n", "utf-8");
125
+ const release = lockfile.lockSync(SETTINGS_PATH, { stale: 10000 });
126
+ try {
127
+ writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
128
+ } finally {
129
+ release();
130
+ }
121
131
  }
122
132
 
123
133
  function toTitleCase(token: string): string {
@@ -284,9 +294,13 @@ export function registerFlantProviders(
284
294
  models: string[],
285
295
  metadata: Record<string, OpenRouterModelData>,
286
296
  ): void {
297
+ const log = getLogger();
287
298
  const uniqueModels = [...new Set(models)];
288
299
  const anthropicModels = uniqueModels.filter((m) => m.startsWith("claude-"));
289
300
  const openaiModels = uniqueModels.filter((m) => !m.startsWith("claude-"));
301
+ log.debug({ s: "flant", total: uniqueModels.length, anthropic: anthropicModels.length, openai: openaiModels.length }, "registering flant providers");
302
+
303
+ unregisterFlantProviders(pi);
290
304
 
291
305
  pi.registerProvider("pp-flant-anthropic", {
292
306
  api: "anthropic-messages",
@@ -301,18 +315,11 @@ export function registerFlantProviders(
301
315
  apiKey: "$FLANT_API_KEY",
302
316
  models: openaiModels.map((m) => buildProviderModelConfig(m, metadata)),
303
317
  });
304
- }
305
318
 
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;
314
- }
315
- return a.localeCompare(b);
319
+ updateRegistryFromAvailableModels([
320
+ ...anthropicModels.map((id) => `pp-flant-anthropic/${id}`),
321
+ ...openaiModels.map((id) => `pp-flant-openai/${id}`),
322
+ ]);
316
323
  }
317
324
 
318
325
  function pickLatest(models: string[]): string | null {
@@ -337,6 +344,22 @@ function makeVariant(modelId: string | null, fallbackModelId: string): { enabled
337
344
  return { enabled: true, model: modelSpec(modelId), thinking: "high" };
338
345
  }
339
346
 
347
+ function makeVariantWithThinking(
348
+ modelId: string | null,
349
+ fallbackModelId: string,
350
+ thinking: string,
351
+ ): { enabled: boolean; model: string; thinking: string } {
352
+ if (!modelId) return { enabled: false, model: modelSpec(fallbackModelId), thinking };
353
+ return { enabled: true, model: modelSpec(modelId), thinking };
354
+ }
355
+
356
+ function buildPresetGroup(
357
+ presets: Record<string, { enabled?: boolean; agents: Record<string, { enabled: boolean; model: string; thinking: string }> }>,
358
+ defaultPreset = "regular",
359
+ ): { default: string; presets: typeof presets } {
360
+ return { default: defaultPreset, presets };
361
+ }
362
+
340
363
  export function generateFlantConfig(models: string[]): Partial<PiPiConfig> {
341
364
  const uniqueModels = [...new Set(models)];
342
365
  if (uniqueModels.length === 0) return {};
@@ -358,36 +381,80 @@ export function generateFlantConfig(models: string[]): Partial<PiPiConfig> {
358
381
  const fastModel = fastest ?? debugModel;
359
382
 
360
383
  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
384
  agents: {
388
- explore: { model: modelSpec(fastModel), thinking: "low" },
389
- librarian: { model: modelSpec(fastModel), thinking: "medium" },
390
- task: { model: modelSpec(taskModel), thinking: "medium" },
385
+ orchestrators: {
386
+ implement: { model: modelSpec(implementModel), thinking: "high" },
387
+ plan: { model: modelSpec(implementModel), thinking: "high" },
388
+ debug: { model: modelSpec(debugModel), thinking: "high" },
389
+ brainstorm: { model: modelSpec(brainstormModel), thinking: "high" },
390
+ review: { model: modelSpec(implementModel), thinking: "high" },
391
+ },
392
+ subagents: {
393
+ simple: {
394
+ explore: { model: modelSpec(fastModel), thinking: "low" },
395
+ librarian: { model: modelSpec(fastModel), thinking: "medium" },
396
+ task: { model: modelSpec(taskModel), thinking: "medium" },
397
+ },
398
+ presetGroups: {
399
+ planners: buildPresetGroup({
400
+ regular: {
401
+ agents: {
402
+ opus: makeVariant(latestOpus, fallback),
403
+ gpt: makeVariant(latestGpt, fallback),
404
+ gemini: makeVariant(latestGeminiPro, fallback),
405
+ },
406
+ },
407
+ }),
408
+ planReviewers: buildPresetGroup({
409
+ regular: {
410
+ agents: {
411
+ opus: makeVariant(latestOpus, fallback),
412
+ gpt: makeVariant(latestGpt, fallback),
413
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh"),
414
+ },
415
+ },
416
+ deep: {
417
+ agents: {
418
+ opus: makeVariantWithThinking(latestOpus, fallback, "xhigh"),
419
+ gpt: makeVariantWithThinking(latestGpt, fallback, "xhigh"),
420
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh"),
421
+ },
422
+ },
423
+ }),
424
+ codeReviewers: buildPresetGroup({
425
+ regular: {
426
+ agents: {
427
+ opus: makeVariant(latestOpus, fallback),
428
+ gpt: makeVariant(latestGpt, fallback),
429
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh"),
430
+ },
431
+ },
432
+ deep: {
433
+ agents: {
434
+ opus: makeVariantWithThinking(latestOpus, fallback, "xhigh"),
435
+ gpt: makeVariantWithThinking(latestGpt, fallback, "xhigh"),
436
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh"),
437
+ },
438
+ },
439
+ }),
440
+ brainstormReviewers: buildPresetGroup({
441
+ regular: {
442
+ agents: {
443
+ opus: makeVariant(latestOpus, fallback),
444
+ gpt: makeVariant(latestGpt, fallback),
445
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh"),
446
+ },
447
+ },
448
+ deep: {
449
+ agents: {
450
+ opus: makeVariantWithThinking(latestOpus, fallback, "xhigh"),
451
+ gpt: makeVariantWithThinking(latestGpt, fallback, "xhigh"),
452
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh"),
453
+ },
454
+ },
455
+ }),
456
+ },
457
+ },
391
458
  },
392
459
  };
393
460
  }
@@ -469,7 +536,9 @@ export async function updateFlantInfra(
469
536
  export function initFlantSync(pi: ExtensionAPI): void {
470
537
  setPI(pi);
471
538
  const settings = loadFlantSettings();
539
+ const log = getLogger();
472
540
  if (!settings.enabled) {
541
+ log.debug({ s: "flant" }, "flant disabled");
473
542
  generatedFlantConfig = null;
474
543
  return;
475
544
  }
@@ -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);