@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
@@ -9,23 +9,30 @@ export function openPlannotator(
9
9
  const requestId = crypto.randomUUID();
10
10
  return new Promise((resolve) => {
11
11
  let handled = false;
12
+ const timer = setTimeout(() => {
13
+ if (!handled) resolve({ opened: false, reviewId: null });
14
+ }, 30000);
12
15
  pi.events.emit("plannotator:request", {
13
16
  requestId,
14
17
  action,
15
18
  payload,
16
19
  respond: (response: any) => {
17
20
  handled = true;
21
+ clearTimeout(timer);
18
22
  const reviewId = response?.result?.reviewId ?? null;
19
23
  resolve({ opened: response.status === "handled", reviewId });
20
24
  },
21
25
  });
22
- setTimeout(() => {
23
- if (!handled) resolve({ opened: false, reviewId: null });
24
- }, 30000);
25
26
  });
26
27
  }
27
28
 
29
+ const PLANNOTATOR_RESULT_TIMEOUT_MS = 30 * 60 * 1000;
30
+
28
31
  export function cancelPendingPlannotatorWait(orchestrator: Orchestrator): void {
32
+ if (orchestrator.plannotatorTimer) {
33
+ clearTimeout(orchestrator.plannotatorTimer);
34
+ orchestrator.plannotatorTimer = null;
35
+ }
29
36
  if (orchestrator.plannotatorUnsub) {
30
37
  orchestrator.plannotatorUnsub();
31
38
  orchestrator.plannotatorUnsub = null;
@@ -46,11 +53,22 @@ export function waitForPlannotatorResult(
46
53
  orchestrator.plannotatorReject = reject;
47
54
  const unsub = pi.events.on("plannotator:review-result", (data: any) => {
48
55
  if (reviewId && data?.reviewId && data.reviewId !== reviewId) return;
49
- unsub();
50
- orchestrator.plannotatorUnsub = null;
51
- orchestrator.plannotatorReject = null;
56
+ cleanup();
52
57
  resolve({ approved: !!data?.approved, feedback: data?.feedback });
53
58
  });
54
59
  orchestrator.plannotatorUnsub = unsub;
60
+ function cleanup() {
61
+ unsub();
62
+ orchestrator.plannotatorUnsub = null;
63
+ orchestrator.plannotatorReject = null;
64
+ if (orchestrator.plannotatorTimer) {
65
+ clearTimeout(orchestrator.plannotatorTimer);
66
+ orchestrator.plannotatorTimer = null;
67
+ }
68
+ }
69
+ orchestrator.plannotatorTimer = setTimeout(() => {
70
+ cleanup();
71
+ reject(new Error("Plannotator review timed out"));
72
+ }, PLANNOTATOR_RESULT_TIMEOUT_MS);
55
73
  });
56
74
  }
@@ -0,0 +1,207 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { join } from "path";
3
+ import { getDefaultConfig, GLOBAL_CONFIG_PATH, parseDuration } from "./config.js";
4
+ import * as configModule from "./config.js";
5
+ import * as flantInfra from "./flant-infra.js";
6
+ import { formatDuration, formatSourceTags, getConfigSourceInfo, pickMaxReviewPasses, showUsage } from "./pp-menu.js";
7
+ import { createUsageTracker } from "./usage-tracker.js";
8
+
9
+ const USAGE_TRACKER_SYMBOL = Symbol.for("pi-pi:usage-tracker");
10
+
11
+ afterEach(() => {
12
+ vi.restoreAllMocks();
13
+ delete (globalThis as any)[USAGE_TRACKER_SYMBOL];
14
+ });
15
+
16
+ function renderUsage(tracker: ReturnType<typeof createUsageTracker>): string {
17
+ (globalThis as any)[USAGE_TRACKER_SYMBOL] = tracker;
18
+ let captured = "";
19
+ showUsage({ ui: { notify: (text: string) => { captured = text; } } });
20
+ return captured;
21
+ }
22
+
23
+ describe("showUsage subscription rendering", () => {
24
+ it("labels subscription model and agent rows and excludes their dollars", () => {
25
+ const tracker = createUsageTracker();
26
+ tracker.recordTurn("openai/gpt-5", "openai", 100, 50, 0, 0, 0.4, false);
27
+ tracker.recordTurn("sub/claude-opus-4-6", "pp-flant-anthropic-sub", 200, 100, 0, 0, 9.9, false);
28
+ tracker.recordSubagentCompletion({ input: 30, output: 10 } as any, 5.0, {
29
+ description: "Explore", agentType: "explore", modelId: "sub/claude-haiku-4-5",
30
+ });
31
+
32
+ const out = renderUsage(tracker);
33
+
34
+ expect(out).toContain("sub/claude-opus-4-6:");
35
+ expect(out).toContain("subscription");
36
+ expect(out).toContain("explore");
37
+ expect(out).toContain("$0.40");
38
+ expect(out).not.toContain("$9.90");
39
+ expect(out).not.toContain("$5.00");
40
+ expect(out).toContain("Cost: $0.40");
41
+ });
42
+
43
+ it("shows the four-bucket input breakdown and processed-input totals", () => {
44
+ const tracker = createUsageTracker();
45
+ // uncached 84, output 27k, cacheRead 1000, cacheWrite 200 on a sub model.
46
+ tracker.recordTurn("sub/claude-opus-4-8", "pp-flant-anthropic-sub", 84, 27000, 1000, 200, 0, true);
47
+ // A cache-using subagent, to exercise the inline By-agent breakdown.
48
+ tracker.recordSubagentCompletion({ input: 10, output: 500, cacheRead: 300, cacheWrite: 90 } as any, 0, {
49
+ description: "Explore", agentType: "explore", modelId: "sub/claude-haiku-4-5",
50
+ });
51
+
52
+ const out = renderUsage(tracker);
53
+
54
+ // Total "Input" is the processed input across main + subagent:
55
+ // main 84+1000+200=1284, subagent 10+300+90=400 → 1684 → "1.7k".
56
+ expect(out).toContain("Input: 1.7k tokens");
57
+ expect(out).toContain("uncached: 94"); // 84 + 10
58
+ expect(out).toContain("cache read: 1.3k"); // 1000 + 300
59
+ expect(out).toContain("cache write: 290"); // 200 + 90
60
+ expect(out).toContain("Output: 28k tokens"); // 27000 + 500
61
+ // Hit rate = 1300 / 1684 = 77% (rounded).
62
+ expect(out).toContain("⚡77% hit rate");
63
+ // Cost is always shown, even at $0.00 for subscription sessions.
64
+ expect(out).toContain("Cost: $0.00");
65
+ // Per-model row is a one-liner: processed input (↑1.3k) with an inline
66
+ // uncached / cache read / cache write breakdown.
67
+ expect(out).toContain("sub/claude-opus-4-8: ↑1.3k (u84 r1.0k w200)");
68
+ // By-agent row carries the same inline breakdown (400 processed = 10+300+90).
69
+ expect(out).toContain("explore: ↑400 (u10 r300 w90)");
70
+ });
71
+
72
+ it("does not inflate paid model share when a subscription model is present", () => {
73
+ const tracker = createUsageTracker();
74
+ tracker.recordTurn("openai/gpt-5", "openai", 100, 0, 0, 0, 0.5, false);
75
+ tracker.recordTurn("sub/claude-opus-4-6", "pp-flant-anthropic-sub", 100, 0, 0, 0, 2.0, false);
76
+
77
+ const out = renderUsage(tracker);
78
+
79
+ expect(out).toContain("openai/gpt-5:");
80
+ expect(out).toContain("$0.50");
81
+ expect(out).not.toContain("$0.25");
82
+ });
83
+ });
84
+
85
+ describe("settings helpers", () => {
86
+ it("formatDuration renders compact human-readable values", () => {
87
+ expect(formatDuration(999)).toBe("999ms");
88
+ expect(formatDuration(30000)).toBe("30s");
89
+ expect(formatDuration(300000)).toBe("5m");
90
+ expect(formatDuration(3600000)).toBe("1h");
91
+ });
92
+
93
+ it("parseDuration accepts units and raw milliseconds", () => {
94
+ expect(parseDuration("30s")).toBe(30000);
95
+ expect(parseDuration("5m")).toBe(300000);
96
+ expect(parseDuration("1h")).toBe(3600000);
97
+ expect(parseDuration("1500")).toBe(1500);
98
+ expect(parseDuration("250ms")).toBe(250);
99
+ expect(parseDuration("bad")).toBeNull();
100
+ });
101
+
102
+ it("formatSourceTags combines matching source tags", () => {
103
+ const tags = formatSourceTags("info", {
104
+ activeValue: "info",
105
+ defaultValue: "debug",
106
+ flantValue: "info",
107
+ globalValue: "warn",
108
+ projectValue: "info",
109
+ source: "project",
110
+ });
111
+ expect(tags).toBe("(active, flant, project)");
112
+ });
113
+
114
+ it("getConfigSourceInfo resolves project-over-global-over-flant-over-default", () => {
115
+ const cwd = "/tmp/pp-menu-test";
116
+ const projectPath = join(cwd, ".pp", "config.json");
117
+
118
+ vi.spyOn(configModule, "readRawConfig").mockImplementation((path: string) => {
119
+ if (path === GLOBAL_CONFIG_PATH) return { general: { autoCommit: true } };
120
+ if (path === projectPath) return { general: { autoCommit: false } };
121
+ return {};
122
+ });
123
+ vi.spyOn(flantInfra, "getFlantGeneratedConfig").mockReturnValue({ general: { autoCommit: true } } as any);
124
+
125
+ const orchestrator = {
126
+ cwd,
127
+ config: {
128
+ ...getDefaultConfig(),
129
+ general: {
130
+ ...getDefaultConfig().general,
131
+ autoCommit: false,
132
+ },
133
+ },
134
+ } as any;
135
+
136
+ const info = getConfigSourceInfo(orchestrator, ["general", "autoCommit"]);
137
+ expect(info.source).toBe("project");
138
+ expect(info.activeValue).toBe(false);
139
+ expect(info.defaultValue).toBe(true);
140
+ expect(info.flantValue).toBe(true);
141
+ expect(info.globalValue).toBe(true);
142
+ expect(info.projectValue).toBe(false);
143
+ });
144
+
145
+ it("getConfigSourceInfo reports flant source when only flant overrides default", () => {
146
+ const cwd = "/tmp/pp-menu-test-flant";
147
+ const projectPath = join(cwd, ".pp", "config.json");
148
+
149
+ vi.spyOn(configModule, "readRawConfig").mockImplementation((path: string) => {
150
+ if (path === GLOBAL_CONFIG_PATH) return {};
151
+ if (path === projectPath) return {};
152
+ return {};
153
+ });
154
+ vi.spyOn(flantInfra, "getFlantGeneratedConfig").mockReturnValue({ general: { autoCommit: false } } as any);
155
+
156
+ const orchestrator = {
157
+ cwd,
158
+ config: {
159
+ ...getDefaultConfig(),
160
+ general: {
161
+ ...getDefaultConfig().general,
162
+ autoCommit: false,
163
+ },
164
+ },
165
+ } as any;
166
+
167
+ const info = getConfigSourceInfo(orchestrator, ["general", "autoCommit"]);
168
+ expect(info.source).toBe("flant");
169
+ expect(info.defaultValue).toBe(true);
170
+ expect(info.flantValue).toBe(false);
171
+ expect(info.globalValue).toBeUndefined();
172
+ expect(info.projectValue).toBeUndefined();
173
+ });
174
+ });
175
+
176
+ describe("pickMaxReviewPasses", () => {
177
+ function makeInputCtx(responses: (string | undefined)[]) {
178
+ let i = 0;
179
+ return {
180
+ ui: {
181
+ input: vi.fn(async () => responses[i++]),
182
+ notify: vi.fn(),
183
+ },
184
+ } as any;
185
+ }
186
+
187
+ it('returns 999 for "-" (unlimited)', async () => {
188
+ const ctx = makeInputCtx(["-"]);
189
+ expect(await pickMaxReviewPasses(ctx, 3)).toBe(999);
190
+ });
191
+
192
+ it("returns the entered positive integer", async () => {
193
+ const ctx = makeInputCtx(["5"]);
194
+ expect(await pickMaxReviewPasses(ctx, 3)).toBe(5);
195
+ });
196
+
197
+ it("re-prompts on non-integer junk then accepts a valid integer", async () => {
198
+ const ctx = makeInputCtx(["3abc", "1.5", "4"]);
199
+ expect(await pickMaxReviewPasses(ctx, 3)).toBe(4);
200
+ expect(ctx.ui.notify).toHaveBeenCalledTimes(2);
201
+ });
202
+
203
+ it("returns null when input is cancelled or empty", async () => {
204
+ expect(await pickMaxReviewPasses(makeInputCtx([undefined]), 3)).toBeNull();
205
+ expect(await pickMaxReviewPasses(makeInputCtx([""]), 3)).toBeNull();
206
+ });
207
+ });