@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
@@ -0,0 +1,563 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { createUsageTracker, dumpUsageSummary, loadUsageSummary } from "./usage-tracker.js";
6
+
7
+ const tempDirs: string[] = [];
8
+
9
+ function makeTempDir(): string {
10
+ const dir = mkdtempSync(join(tmpdir(), "pi-pi-usage-tracker-"));
11
+ tempDirs.push(dir);
12
+ return dir;
13
+ }
14
+
15
+ afterEach(() => {
16
+ delete process.env.PI_CODING_AGENT_DIR;
17
+ for (const dir of tempDirs.splice(0)) {
18
+ rmSync(dir, { recursive: true, force: true });
19
+ }
20
+ });
21
+
22
+ describe("usage-tracker", () => {
23
+ it("createUsageTracker starts with zero counts", () => {
24
+ const tracker = createUsageTracker();
25
+
26
+ expect(tracker.getMainInputTokens()).toBe(0);
27
+ expect(tracker.getMainOutputTokens()).toBe(0);
28
+ expect(tracker.getMainCacheReadTokens()).toBe(0);
29
+ expect(tracker.getMainCacheWriteTokens()).toBe(0);
30
+ expect(tracker.getMainCost()).toBe(0);
31
+ expect(tracker.getTotalInputTokens()).toBe(0);
32
+ expect(tracker.getTotalOutputTokens()).toBe(0);
33
+ expect(tracker.getTotalCost()).toBe(0);
34
+ });
35
+
36
+ it("loadFromSummary is idempotent and does not double-count subagents", () => {
37
+ const tracker = createUsageTracker();
38
+ const summary = {
39
+ subagents: [
40
+ { description: "a", agentType: "Explore", modelId: "m", inputTokens: 100, outputTokens: 50, cost: 0.5, toolUses: 1, durationMs: 10 },
41
+ ],
42
+ };
43
+
44
+ tracker.loadFromSummary(summary);
45
+ tracker.loadFromSummary(summary);
46
+
47
+ expect(tracker.getSubagentList()).toHaveLength(1);
48
+ const totals = tracker.getSubagentTotals();
49
+ expect(totals.inputTokens).toBe(100);
50
+ expect(totals.outputTokens).toBe(50);
51
+ expect(totals.cost).toBeCloseTo(0.5);
52
+ });
53
+
54
+ it("recordTurn accumulates input output and cache tokens", () => {
55
+ const tracker = createUsageTracker();
56
+
57
+ tracker.recordTurn("openai/gpt-5", "openai", 10, 20, 3, 4, 0.1, true);
58
+ tracker.recordTurn("openai/gpt-5", "openai", 5, 7, 2, 1, 0.2, true);
59
+
60
+ expect(tracker.getMainInputTokens()).toBe(15);
61
+ expect(tracker.getMainOutputTokens()).toBe(27);
62
+ expect(tracker.getMainCacheReadTokens()).toBe(5);
63
+ expect(tracker.getMainCacheWriteTokens()).toBe(5);
64
+ });
65
+
66
+ it("recordTurn accumulates cost", () => {
67
+ const tracker = createUsageTracker();
68
+
69
+ tracker.recordTurn("openai/gpt-5", "openai", 1, 1, 0, 0, 0.25, false);
70
+ tracker.recordTurn("openai/gpt-5", "openai", 1, 1, 0, 0, 0.5, false);
71
+
72
+ expect(tracker.getMainCost()).toBe(0.75);
73
+ });
74
+
75
+ it("recordTurn handles non-finite numeric input as zero", () => {
76
+ const tracker = createUsageTracker();
77
+
78
+ tracker.recordTurn("openai/gpt-5", "openai", Number.NaN, Number.POSITIVE_INFINITY, 0, 0, Number.NaN, false);
79
+
80
+ expect(tracker.getMainInputTokens()).toBe(0);
81
+ expect(tracker.getMainOutputTokens()).toBe(0);
82
+ expect(tracker.getMainCost()).toBe(0);
83
+ });
84
+
85
+ it("recordTurn tracks per-model usage", () => {
86
+ const tracker = createUsageTracker();
87
+
88
+ tracker.recordTurn("openai/gpt-5", "openai", 10, 5, 1, 0, 0.2, false);
89
+ tracker.recordTurn("anthropic/claude-opus-4-6", "anthropic", 7, 3, 0, 0, 0.3, false);
90
+
91
+ expect(tracker.getPerModelUsage()).toEqual({
92
+ "openai/gpt-5": {
93
+ inputTokens: 10,
94
+ outputTokens: 5,
95
+ cacheReadTokens: 1,
96
+ cacheWriteTokens: 0,
97
+ cacheSupported: false,
98
+ turns: 1,
99
+ subscription: false,
100
+ },
101
+ "anthropic/claude-opus-4-6": {
102
+ inputTokens: 7,
103
+ outputTokens: 3,
104
+ cacheReadTokens: 0,
105
+ cacheWriteTokens: 0,
106
+ cacheSupported: false,
107
+ turns: 1,
108
+ subscription: false,
109
+ },
110
+ });
111
+ });
112
+
113
+ it("recordTurn cacheSupported sets model cache support", () => {
114
+ const tracker = createUsageTracker();
115
+
116
+ tracker.recordTurn("openai/gpt-5", "openai", 1, 1, 0, 0, 0, false);
117
+ tracker.recordTurn("openai/gpt-5", "openai", 1, 1, 0, 0, 0, true);
118
+
119
+ expect(tracker.getPerModelUsage()["openai/gpt-5"]?.cacheSupported).toBe(true);
120
+ });
121
+
122
+ it("recordTurn uses unknown-model key when model id is empty", () => {
123
+ const tracker = createUsageTracker();
124
+
125
+ tracker.recordTurn("", "openai", 3, 2, 0, 0, 0.1, false);
126
+
127
+ expect(tracker.getPerModelUsage()["unknown-model"]).toMatchObject({
128
+ inputTokens: 3,
129
+ outputTokens: 2,
130
+ turns: 1,
131
+ });
132
+ });
133
+
134
+ it("recordSubagentCompletion accumulates subagent tokens", () => {
135
+ const tracker = createUsageTracker();
136
+
137
+ tracker.recordSubagentCompletion({ input: 11, output: 4 } as any, 0.15);
138
+ tracker.recordSubagentCompletion({ input: 9, output: 6 }, 0.25);
139
+
140
+ expect(tracker.getSubagentTotals()).toEqual({
141
+ inputTokens: 20,
142
+ outputTokens: 10,
143
+ cost: 0.4,
144
+ });
145
+ });
146
+
147
+ it("recordSubagentCompletion defaults metadata fields when missing", () => {
148
+ const tracker = createUsageTracker();
149
+
150
+ tracker.recordSubagentCompletion({ input: 1, output: 2 } as any);
151
+
152
+ expect(tracker.getSubagentList()[0]).toMatchObject({
153
+ description: "unknown",
154
+ agentType: "unknown",
155
+ modelId: "unknown",
156
+ durationMs: 0,
157
+ toolUses: 0,
158
+ });
159
+ });
160
+
161
+ it("recordSubagentCompletion uses tokens.cost over fallback cost", () => {
162
+ const tracker = createUsageTracker();
163
+
164
+ tracker.recordSubagentCompletion({ input: 1, output: 1, cost: 0.6 } as any, 0.2);
165
+
166
+ expect(tracker.getSubagentTotals().cost).toBe(0.6);
167
+ });
168
+
169
+ it("recordSubagentCompletion stores subagent metadata", () => {
170
+ const tracker = createUsageTracker();
171
+
172
+ tracker.recordSubagentCompletion(
173
+ { input: 5, output: 2, cacheRead: 8, cacheWrite: 1 } as any,
174
+ 0.2,
175
+ { description: "Planner", agentType: "planner", modelId: "openai/gpt-5", durationMs: 900, toolUses: 3 },
176
+ );
177
+
178
+ expect(tracker.getSubagentList()).toEqual([
179
+ {
180
+ description: "Planner",
181
+ agentType: "planner",
182
+ modelId: "openai/gpt-5",
183
+ inputTokens: 5,
184
+ outputTokens: 2,
185
+ cacheReadTokens: 8,
186
+ cacheWriteTokens: 1,
187
+ cacheSupported: true,
188
+ cost: 0.2,
189
+ durationMs: 900,
190
+ toolUses: 3,
191
+ subscription: false,
192
+ },
193
+ ]);
194
+ });
195
+
196
+ it("recordSubagentCompletion uses total when input and output are zero", () => {
197
+ const tracker = createUsageTracker();
198
+
199
+ tracker.recordSubagentCompletion({ total: 77, input: 0, output: 0 });
200
+
201
+ expect(tracker.getSubagentTotals().inputTokens).toBe(77);
202
+ expect(tracker.getSubagentTotals().outputTokens).toBe(0);
203
+ });
204
+
205
+ it("getTotalInputTokens includes main and subagent input", () => {
206
+ const tracker = createUsageTracker();
207
+
208
+ tracker.recordTurn("openai/gpt-5", "openai", 10, 0, 0, 0, 0, false);
209
+ tracker.recordSubagentCompletion({ input: 4, output: 0 });
210
+
211
+ expect(tracker.getTotalInputTokens()).toBe(14);
212
+ });
213
+
214
+ it("getTotalOutputTokens includes main and subagent output", () => {
215
+ const tracker = createUsageTracker();
216
+
217
+ tracker.recordTurn("openai/gpt-5", "openai", 0, 7, 0, 0, 0, false);
218
+ tracker.recordSubagentCompletion({ input: 0, output: 3 } as any);
219
+
220
+ expect(tracker.getTotalOutputTokens()).toBe(10);
221
+ });
222
+
223
+ it("getTotalCost includes main and subagent cost", () => {
224
+ const tracker = createUsageTracker();
225
+
226
+ tracker.recordTurn("openai/gpt-5", "openai", 0, 0, 0, 0, 1.2, false);
227
+ tracker.recordSubagentCompletion({ input: 0, output: 0 }, 0.8);
228
+
229
+ expect(tracker.getTotalCost()).toBe(2);
230
+ });
231
+
232
+ it("getCacheHitRate is cacheRead over processed input (uncached + read + write)", () => {
233
+ const tracker = createUsageTracker();
234
+
235
+ // uncached 30, cacheRead 10, cacheWrite 10 → 10 / (30+10+10) = 0.2
236
+ tracker.recordTurn("openai/gpt-5", "openai", 30, 0, 10, 10, 0, true);
237
+
238
+ expect(tracker.getCacheHitRate()).toBeCloseTo(10 / 50);
239
+ });
240
+
241
+ it("getCacheHitRate includes subagent cache tokens", () => {
242
+ const tracker = createUsageTracker();
243
+ tracker.recordTurn("openai/gpt-5", "openai", 10, 0, 0, 0, 0, true);
244
+ tracker.recordSubagentCompletion({ input: 0, output: 0, cacheRead: 30, cacheWrite: 10 } as any);
245
+ // cacheRead 30 / processed (10 + 30 + 10) = 0.6
246
+ expect(tracker.getCacheHitRate()).toBeCloseTo(30 / 50);
247
+ });
248
+
249
+ it("getCacheHitRate returns zero when denominator is zero", () => {
250
+ const tracker = createUsageTracker();
251
+ expect(tracker.getCacheHitRate()).toBe(0);
252
+ });
253
+
254
+ it("getTotalProcessedInputTokens sums uncached + cache read + cache write across main and subagents", () => {
255
+ const tracker = createUsageTracker();
256
+ tracker.recordTurn("openai/gpt-5", "openai", 10, 0, 20, 5, 0, true);
257
+ tracker.recordSubagentCompletion({ input: 3, output: 0, cacheRead: 7, cacheWrite: 2 } as any);
258
+ // main: 10+20+5=35, subagent: 3+7+2=12 → 47
259
+ expect(tracker.getTotalProcessedInputTokens()).toBe(47);
260
+ });
261
+
262
+ it("getTotalCacheReadTokens and getTotalCacheWriteTokens include subagents", () => {
263
+ const tracker = createUsageTracker();
264
+ tracker.recordTurn("openai/gpt-5", "openai", 0, 0, 20, 5, 0, true);
265
+ tracker.recordSubagentCompletion({ input: 0, output: 0, cacheRead: 7, cacheWrite: 2 } as any);
266
+ expect(tracker.getTotalCacheReadTokens()).toBe(27);
267
+ expect(tracker.getTotalCacheWriteTokens()).toBe(7);
268
+ });
269
+
270
+ it("isCacheSupported true when any model or subagent has cache", () => {
271
+ const tracker = createUsageTracker();
272
+ expect(tracker.isCacheSupported()).toBe(false);
273
+
274
+ tracker.recordTurn("openai/gpt-5", "openai", 1, 1, 0, 0, 0, true);
275
+ expect(tracker.isCacheSupported()).toBe(true);
276
+
277
+ tracker.reset();
278
+ tracker.recordSubagentCompletion({ input: 1, output: 1, cacheRead: 1 } as any);
279
+ expect(tracker.isCacheSupported()).toBe(true);
280
+ });
281
+
282
+ it("getPerModelUsage returns a copy", () => {
283
+ const tracker = createUsageTracker();
284
+ tracker.recordTurn("openai/gpt-5", "openai", 2, 3, 0, 0, 0, false);
285
+
286
+ const usage = tracker.getPerModelUsage();
287
+ usage["openai/gpt-5"]!.inputTokens = 999;
288
+
289
+ expect(tracker.getPerModelUsage()["openai/gpt-5"]!.inputTokens).toBe(2);
290
+ });
291
+
292
+ it("toSummary returns complete summary", () => {
293
+ const tracker = createUsageTracker();
294
+ tracker.recordTurn("openai/gpt-5", "openai", 10, 7, 3, 2, 0.1234567, true);
295
+ tracker.recordSubagentCompletion({ input: 4, output: 1 } as any, 0.2, { description: "Explore", agentType: "explore", modelId: "google/gemini-3.1-pro" });
296
+
297
+ const summary = tracker.toSummary() as any;
298
+
299
+ expect(summary.startedAt).toBeTypeOf("string");
300
+ expect(summary.endedAt).toBeTypeOf("string");
301
+ expect(summary.totals).toEqual({
302
+ inputTokens: 10,
303
+ outputTokens: 7,
304
+ cacheReadTokens: 3,
305
+ cacheWriteTokens: 2,
306
+ cost: 0.123457,
307
+ turns: 1,
308
+ });
309
+ expect(summary.subagents).toHaveLength(1);
310
+ expect(summary.models["openai/gpt-5"]).toMatchObject({ inputTokens: 10, outputTokens: 7, turns: 1 });
311
+ });
312
+
313
+ it("loadFromSummary restores state", () => {
314
+ const tracker = createUsageTracker();
315
+ tracker.loadFromSummary({
316
+ startedAt: "2026-01-01T00:00:00.000Z",
317
+ totals: {
318
+ inputTokens: 100,
319
+ outputTokens: 50,
320
+ cacheReadTokens: 20,
321
+ cacheWriteTokens: 5,
322
+ cost: 1.5,
323
+ turns: 3,
324
+ },
325
+ models: {
326
+ "openai/gpt-5": {
327
+ inputTokens: 100,
328
+ outputTokens: 50,
329
+ cacheReadTokens: 20,
330
+ cacheWriteTokens: 5,
331
+ cacheSupported: true,
332
+ turns: 3,
333
+ },
334
+ },
335
+ subagents: [
336
+ {
337
+ description: "Reviewer",
338
+ agentType: "reviewer",
339
+ modelId: "anthropic/claude-opus-4-6",
340
+ inputTokens: 7,
341
+ outputTokens: 2,
342
+ cacheReadTokens: 0,
343
+ cacheWriteTokens: 0,
344
+ cacheSupported: false,
345
+ cost: 0.4,
346
+ durationMs: 200,
347
+ toolUses: 2,
348
+ },
349
+ ],
350
+ });
351
+
352
+ expect(tracker.getMainInputTokens()).toBe(100);
353
+ expect(tracker.getMainOutputTokens()).toBe(50);
354
+ expect(tracker.getMainCacheReadTokens()).toBe(20);
355
+ expect(tracker.getMainCacheWriteTokens()).toBe(5);
356
+ expect(tracker.getMainCost()).toBe(1.5);
357
+ expect(tracker.getSubagentTotals()).toEqual({ inputTokens: 7, outputTokens: 2, cost: 0.4 });
358
+ expect(tracker.getPerModelUsage()["openai/gpt-5"]?.turns).toBe(3);
359
+ });
360
+
361
+ it("loadFromSummary handles malformed summary entries", () => {
362
+ const tracker = createUsageTracker();
363
+
364
+ tracker.loadFromSummary({
365
+ startedAt: 123,
366
+ totals: "bad",
367
+ models: {
368
+ "openai/gpt-5": {
369
+ inputTokens: "x",
370
+ outputTokens: 2,
371
+ cacheSupported: "yes",
372
+ turns: "z",
373
+ },
374
+ },
375
+ subagents: [{ inputTokens: "bad", outputTokens: 4 }],
376
+ } as any);
377
+
378
+ expect(tracker.getMainInputTokens()).toBe(0);
379
+ expect(tracker.getMainOutputTokens()).toBe(0);
380
+ expect(tracker.getPerModelUsage()["openai/gpt-5"]).toMatchObject({
381
+ inputTokens: 0,
382
+ outputTokens: 2,
383
+ cacheSupported: false,
384
+ turns: 0,
385
+ });
386
+ expect(tracker.getSubagentList()[0]).toMatchObject({
387
+ inputTokens: 0,
388
+ outputTokens: 4,
389
+ });
390
+ });
391
+
392
+ it("loadFromSummary and toSummary preserve data round-trip", () => {
393
+ const source = createUsageTracker();
394
+ source.recordTurn("openai/gpt-5", "openai", 13, 8, 2, 1, 0.5, true);
395
+ source.recordSubagentCompletion({ input: 3, output: 1, cacheRead: 1 } as any, 0.1, {
396
+ description: "Librarian",
397
+ agentType: "librarian",
398
+ modelId: "google/gemini-3.1-pro",
399
+ durationMs: 321,
400
+ toolUses: 4,
401
+ });
402
+
403
+ const mid = source.toSummary() as Record<string, unknown>;
404
+ const restored = createUsageTracker();
405
+ restored.loadFromSummary(mid);
406
+ const roundTrip = restored.toSummary() as any;
407
+
408
+ expect(roundTrip.totals).toEqual((mid as any).totals);
409
+ expect(roundTrip.models).toEqual((mid as any).models);
410
+ expect(roundTrip.subagents).toEqual((mid as any).subagents);
411
+ });
412
+
413
+ it("subscription main turns count tokens but contribute zero cost", () => {
414
+ const tracker = createUsageTracker();
415
+
416
+ // Detected via the sub/ model id prefix...
417
+ tracker.recordTurn("sub/claude-opus-4-6", "anthropic", 100, 50, 10, 5, 1.23, true);
418
+ // ...or via the subscription provider with a bare id (keyed under sub/).
419
+ tracker.recordTurn("claude-opus-4-6", "pp-flant-anthropic-sub", 20, 10, 0, 0, 0.5, true);
420
+
421
+ expect(tracker.getMainInputTokens()).toBe(120);
422
+ expect(tracker.getMainOutputTokens()).toBe(60);
423
+ expect(tracker.getMainCacheReadTokens()).toBe(10);
424
+ expect(tracker.getMainCost()).toBe(0);
425
+ expect(tracker.getTotalCost()).toBe(0);
426
+ expect(tracker.getPerModelUsage()["sub/claude-opus-4-6"]?.subscription).toBe(true);
427
+ });
428
+
429
+ it("provider-detected subscription turns key under sub/ so paid rows stay separate", () => {
430
+ const tracker = createUsageTracker();
431
+
432
+ // Same underlying model id: one paid, one subscription (provider-only).
433
+ tracker.recordTurn("claude-opus-4-6", "pp-flant-anthropic", 10, 5, 0, 0, 0.4, false);
434
+ tracker.recordTurn("claude-opus-4-6", "pp-flant-anthropic-sub", 20, 10, 0, 0, 2.0, false);
435
+
436
+ const usage = tracker.getPerModelUsage();
437
+ expect(usage["claude-opus-4-6"]).toMatchObject({ inputTokens: 10, outputTokens: 5, subscription: false });
438
+ expect(usage["sub/claude-opus-4-6"]).toMatchObject({ inputTokens: 20, outputTokens: 10, subscription: true });
439
+ expect(tracker.getMainCost()).toBeCloseTo(0.4);
440
+ });
441
+
442
+ it("subscription subagents count tokens but contribute zero cost", () => {
443
+ const tracker = createUsageTracker();
444
+
445
+ tracker.recordSubagentCompletion({ input: 30, output: 15 } as any, 0.9, {
446
+ description: "Explore", agentType: "explore", modelId: "sub/claude-haiku-4-5",
447
+ });
448
+
449
+ expect(tracker.getSubagentTotals()).toEqual({ inputTokens: 30, outputTokens: 15, cost: 0 });
450
+ expect(tracker.getSubagentList()[0]?.subscription).toBe(true);
451
+ expect(tracker.getSubagentList()[0]?.cost).toBe(0);
452
+ });
453
+
454
+ it("mixed paid and subscription session totals only the paid portion", () => {
455
+ const tracker = createUsageTracker();
456
+
457
+ tracker.recordTurn("openai/gpt-5", "openai", 10, 5, 0, 0, 0.4, false);
458
+ tracker.recordTurn("sub/claude-opus-4-6", "pp-flant-anthropic-sub", 10, 5, 0, 0, 2.0, false);
459
+ tracker.recordSubagentCompletion({ input: 5, output: 2 } as any, 0.1, { modelId: "openai/gpt-5" });
460
+ tracker.recordSubagentCompletion({ input: 5, output: 2 } as any, 3.0, { modelId: "sub/claude-haiku-4-5" });
461
+
462
+ expect(tracker.getMainCost()).toBeCloseTo(0.4);
463
+ expect(tracker.getSubagentTotals().cost).toBeCloseTo(0.1);
464
+ expect(tracker.getTotalCost()).toBeCloseTo(0.5);
465
+ });
466
+
467
+ it("subscription marker survives JSON round-trip and stays paid-only", () => {
468
+ const source = createUsageTracker();
469
+ source.recordTurn("sub/claude-opus-4-6", "pp-flant-anthropic-sub", 10, 5, 0, 0, 2.0, true);
470
+ source.recordSubagentCompletion({ input: 5, output: 2 } as any, 3.0, { modelId: "sub/claude-haiku-4-5" });
471
+
472
+ const mid = source.toSummary() as Record<string, unknown>;
473
+ const restored = createUsageTracker();
474
+ restored.loadFromSummary(mid);
475
+
476
+ const roundTrip = restored.toSummary() as any;
477
+ expect(restored.getTotalCost()).toBe(0);
478
+ expect(restored.getPerModelUsage()["sub/claude-opus-4-6"]?.subscription).toBe(true);
479
+ expect(restored.getSubagentList()[0]?.subscription).toBe(true);
480
+ expect(roundTrip.totals).toEqual((mid as any).totals);
481
+ expect(roundTrip.models).toEqual((mid as any).models);
482
+ expect(roundTrip.subagents).toEqual((mid as any).subagents);
483
+ });
484
+
485
+ it("legacy summaries without subscription field default to non-subscription", () => {
486
+ const tracker = createUsageTracker();
487
+ tracker.loadFromSummary({
488
+ totals: { inputTokens: 10, outputTokens: 5, cost: 1.5, turns: 1 },
489
+ models: { "openai/gpt-5": { inputTokens: 10, outputTokens: 5, turns: 1 } },
490
+ subagents: [{ description: "a", agentType: "x", modelId: "m", inputTokens: 3, outputTokens: 1, cost: 0.2 }],
491
+ });
492
+
493
+ expect(tracker.getPerModelUsage()["openai/gpt-5"]?.subscription).toBe(false);
494
+ expect(tracker.getSubagentList()[0]?.subscription).toBe(false);
495
+ expect(tracker.getMainCost()).toBe(1.5);
496
+ expect(tracker.getSubagentTotals().cost).toBeCloseTo(0.2);
497
+ });
498
+
499
+ it("loadFromSummary recovers subscription from sub/ prefix when flag is absent", () => {
500
+ const tracker = createUsageTracker();
501
+ tracker.loadFromSummary({
502
+ totals: { inputTokens: 10, outputTokens: 5, cost: 0, turns: 1 },
503
+ models: { "sub/claude-opus-4-6": { inputTokens: 10, outputTokens: 5, turns: 1 } },
504
+ subagents: [{ description: "a", agentType: "x", modelId: "sub/claude-haiku-4-5", inputTokens: 3, outputTokens: 1, cost: 2.5 }],
505
+ });
506
+
507
+ expect(tracker.getPerModelUsage()["sub/claude-opus-4-6"]?.subscription).toBe(true);
508
+ expect(tracker.getSubagentList()[0]?.subscription).toBe(true);
509
+ expect(tracker.getSubagentList()[0]?.cost).toBe(0);
510
+ expect(tracker.getSubagentTotals().cost).toBe(0);
511
+ });
512
+
513
+ it("reset clears all state", () => {
514
+ const tracker = createUsageTracker();
515
+ tracker.recordTurn("openai/gpt-5", "openai", 1, 2, 3, 4, 0.2, true);
516
+ tracker.recordSubagentCompletion({ input: 5, output: 6 } as any, 0.1);
517
+
518
+ tracker.reset();
519
+
520
+ expect(tracker.getMainInputTokens()).toBe(0);
521
+ expect(tracker.getMainOutputTokens()).toBe(0);
522
+ expect(tracker.getMainCacheReadTokens()).toBe(0);
523
+ expect(tracker.getMainCacheWriteTokens()).toBe(0);
524
+ expect(tracker.getMainCost()).toBe(0);
525
+ expect(tracker.getSubagentTotals()).toEqual({ inputTokens: 0, outputTokens: 0, cost: 0 });
526
+ expect(tracker.getPerModelUsage()).toEqual({});
527
+ expect(tracker.getSubagentList()).toEqual([]);
528
+ });
529
+
530
+ it("dumpUsageSummary and loadUsageSummary write and read JSON", () => {
531
+ const dir = makeTempDir();
532
+ process.env.PI_CODING_AGENT_DIR = dir;
533
+ const tracker = createUsageTracker();
534
+ tracker.recordTurn("openai/gpt-5", "openai", 11, 12, 1, 0, 0.9, true);
535
+
536
+ dumpUsageSummary(tracker, "session-1");
537
+ const loaded = loadUsageSummary("session-1") as any;
538
+
539
+ expect(loaded.sessionId).toBe("session-1");
540
+ expect(loaded.totals.inputTokens).toBe(11);
541
+ expect(loaded.totals.outputTokens).toBe(12);
542
+ expect(loaded.models["openai/gpt-5"].turns).toBe(1);
543
+
544
+ const filePath = join(dir, "extensions", "pp", "usage", "session-1.json");
545
+ const raw = readFileSync(filePath, "utf-8");
546
+ expect(raw.endsWith("\n")).toBe(true);
547
+ });
548
+
549
+ it("loadUsageSummary returns null for missing and invalid files", () => {
550
+ const dir = makeTempDir();
551
+ process.env.PI_CODING_AGENT_DIR = dir;
552
+
553
+ expect(loadUsageSummary("missing")).toBeNull();
554
+
555
+ const usageDir = join(dir, "extensions", "pp", "usage");
556
+ const invalidPath = join(usageDir, "broken.json");
557
+ rmSync(usageDir, { recursive: true, force: true });
558
+ mkdirSync(usageDir, { recursive: true });
559
+ writeFileSync(invalidPath, "{broken", "utf-8");
560
+
561
+ expect(loadUsageSummary("broken")).toBeNull();
562
+ });
563
+ });