@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,36 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ // Documents the shared contract for Symbol.for("pi-pi:subagent-session") between
4
+ // extensions/orchestrator/index.ts (writer) and 3p/pi-subagents/src/agent-runner.ts
5
+ // (depth tracker). The canonical shape is { depth: number }; a legacy boolean true
6
+ // must still be tolerated as depth 1. This mirrors agent-runner's read so a future
7
+ // shape change on either side is caught.
8
+ function readDepth(marker: unknown): number {
9
+ return typeof marker === "object" && marker !== null
10
+ ? ((marker as { depth?: number }).depth ?? 0)
11
+ : marker
12
+ ? 1
13
+ : 0;
14
+ }
15
+
16
+ describe("subagent-session marker contract", () => {
17
+ it("treats the orchestrator's { depth: 1 } write as depth 1 (truthy, not reset to 0)", () => {
18
+ const marker = { depth: 1 };
19
+ expect(readDepth(marker)).toBe(1);
20
+ expect(Boolean(marker)).toBe(true);
21
+ });
22
+
23
+ it("still tolerates a legacy boolean marker as depth 1", () => {
24
+ expect(readDepth(true)).toBe(1);
25
+ });
26
+
27
+ it("reports depth 0 when no marker is set", () => {
28
+ expect(readDepth(undefined)).toBe(0);
29
+ });
30
+
31
+ it("increments nesting from an existing object marker", () => {
32
+ const previous = { depth: 2 };
33
+ const next = { depth: readDepth(previous) + 1 };
34
+ expect(next.depth).toBe(3);
35
+ });
36
+ });
@@ -0,0 +1,217 @@
1
+ import { expect } from "vitest";
2
+
3
+ export type TextMatch = string | RegExp | ((value: string) => boolean);
4
+
5
+ export interface MenuExpectation {
6
+ question?: TextMatch;
7
+ options?: {
8
+ exact?: TextMatch[];
9
+ include?: TextMatch[];
10
+ exclude?: TextMatch[];
11
+ };
12
+ choose: TextMatch | ((options: string[]) => string);
13
+ }
14
+
15
+ export interface MenuTranscriptEntry {
16
+ question: string;
17
+ options: Array<{ title: string; description?: string }>;
18
+ chosen: string;
19
+ }
20
+
21
+ export interface AskUserHarness {
22
+ transcript: MenuTranscriptEntry[];
23
+ expect(step: MenuExpectation): AskUserHarness;
24
+ handle(opts: any): Promise<{ kind: "selection"; selections: [string] }>;
25
+ assertDone(): void;
26
+ }
27
+
28
+ interface MenuOption {
29
+ title: string;
30
+ description?: string;
31
+ }
32
+
33
+ function escapeRegExp(value: string): string {
34
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35
+ }
36
+
37
+ function stringifyMatcher(matcher: TextMatch | undefined): string {
38
+ if (matcher === undefined) return "<none>";
39
+ if (typeof matcher === "string") return JSON.stringify(matcher);
40
+ if (matcher instanceof RegExp) return matcher.toString();
41
+ return "<function>";
42
+ }
43
+
44
+ function matchText(matcher: TextMatch, value: string): boolean {
45
+ if (typeof matcher === "string") return value === matcher;
46
+ if (matcher instanceof RegExp) return matcher.test(value);
47
+ return matcher(value);
48
+ }
49
+
50
+ function renderMenu(question: string, options: MenuOption[]): string {
51
+ const lines = options.map((option) => {
52
+ if (!option.description) return `- ${option.title}`;
53
+ return `- ${option.title} — ${option.description}`;
54
+ });
55
+ return `question=${JSON.stringify(question)}\noptions:\n${lines.join("\n")}`;
56
+ }
57
+
58
+ export function createAskUserHarness(): AskUserHarness {
59
+ const queue: MenuExpectation[] = [];
60
+ const transcript: MenuTranscriptEntry[] = [];
61
+
62
+ return {
63
+ transcript,
64
+ expect(step: MenuExpectation) {
65
+ queue.push(step);
66
+ return this;
67
+ },
68
+ async handle(opts: any) {
69
+ const question = String(opts?.question ?? "");
70
+ const options: MenuOption[] = (Array.isArray(opts?.options) ? opts.options : []).map((option: any): MenuOption => {
71
+ if (typeof option === "string") return { title: option };
72
+ return {
73
+ title: String(option?.title ?? ""),
74
+ description: option?.description === undefined ? undefined : String(option.description),
75
+ };
76
+ });
77
+ const titles: string[] = options.map((option: MenuOption) => option.title);
78
+
79
+ if (queue.length === 0) {
80
+ throw new Error(`Unexpected menu: ${renderMenu(question, options)}`);
81
+ }
82
+
83
+ const expected = queue.shift()!;
84
+
85
+ if (expected.question && !matchText(expected.question, question)) {
86
+ throw new Error(
87
+ `Question mismatch: expected ${stringifyMatcher(expected.question)}, actual ${JSON.stringify(question)}\n${renderMenu(question, options)}`,
88
+ );
89
+ }
90
+
91
+ if (expected.options?.include) {
92
+ for (const include of expected.options.include) {
93
+ const found = titles.some((title: string) => matchText(include, title));
94
+ if (!found) {
95
+ throw new Error(
96
+ `Missing expected option ${stringifyMatcher(include)}\n${renderMenu(question, options)}`,
97
+ );
98
+ }
99
+ }
100
+ }
101
+
102
+ if (expected.options?.exclude) {
103
+ for (const exclude of expected.options.exclude) {
104
+ const found = titles.some((title: string) => matchText(exclude, title));
105
+ if (found) {
106
+ throw new Error(
107
+ `Found excluded option ${stringifyMatcher(exclude)}\n${renderMenu(question, options)}`,
108
+ );
109
+ }
110
+ }
111
+ }
112
+
113
+ if (expected.options?.exact) {
114
+ expect(titles.length).toBe(expected.options.exact.length);
115
+ for (let i = 0; i < expected.options.exact.length; i += 1) {
116
+ const matcher = expected.options.exact[i]!;
117
+ const actual = titles[i] ?? "";
118
+ if (!matchText(matcher, actual)) {
119
+ throw new Error(
120
+ `Exact options mismatch at index ${i}: expected ${stringifyMatcher(matcher)}, actual ${JSON.stringify(actual)}\n${renderMenu(question, options)}`,
121
+ );
122
+ }
123
+ }
124
+ }
125
+
126
+ let chosen: string;
127
+ if (typeof expected.choose === "function") {
128
+ const maybeChoice = (expected.choose as (options: string[]) => unknown)(titles);
129
+ if (typeof maybeChoice === "string") {
130
+ chosen = maybeChoice;
131
+ } else {
132
+ const matcher = expected.choose as (value: string) => boolean;
133
+ const picked = titles.find((title: string) => matcher(title));
134
+ if (!picked) {
135
+ throw new Error(`No option matched chooser function\n${renderMenu(question, options)}`);
136
+ }
137
+ chosen = picked;
138
+ }
139
+ } else {
140
+ const picked = titles.find((title: string) => matchText(expected.choose as TextMatch, title));
141
+ if (!picked) {
142
+ throw new Error(
143
+ `No option matched chooser ${stringifyMatcher(expected.choose as TextMatch)}\n${renderMenu(question, options)}`,
144
+ );
145
+ }
146
+ chosen = picked;
147
+ }
148
+
149
+ if (!titles.includes(chosen)) {
150
+ throw new Error(`Chosen option not found: ${JSON.stringify(chosen)}\n${renderMenu(question, options)}`);
151
+ }
152
+
153
+ transcript.push({ question, options, chosen });
154
+
155
+ return { kind: "selection", selections: [chosen] };
156
+ },
157
+ assertDone() {
158
+ if (queue.length > 0) {
159
+ const next = queue[0]!;
160
+ throw new Error(
161
+ `Unconsumed menu expectations: ${queue.length}. Next expected question=${stringifyMatcher(next.question)} choose=${stringifyMatcher(
162
+ typeof next.choose === "function" ? undefined : next.choose,
163
+ )}`,
164
+ );
165
+ }
166
+ },
167
+ };
168
+ }
169
+
170
+ export const m = {
171
+ taskMenu: (type: string, phase: string) =>
172
+ (q: string) => q.includes(`Task: ${type}`) && q.includes(`Phase: ${phase}`),
173
+ anyTaskMenu: (q: string) => q.startsWith("/pp"),
174
+ preset: (name: string) => new RegExp(`^${escapeRegExp(name)}(?: \\[default\\])?$`),
175
+ autoReview: /^Auto review(?: \(pass \d+\))?$/,
176
+ };
177
+
178
+ export function expectActiveTaskNext(menu: AskUserHarness, choose: string): void {
179
+ menu.expect({ question: m.anyTaskMenu, options: { include: ["Next"] }, choose: "Next" });
180
+ menu.expect({ question: "Next", options: { include: [choose] }, choose });
181
+ }
182
+
183
+ export function expectBrainstormToPlan(menu: AskUserHarness): void {
184
+ expectActiveTaskNext(menu, "Continue to plan & implement");
185
+ menu.expect({ question: "Planner preset", options: { include: [m.preset("regular"), "Back"] }, choose: m.preset("regular") });
186
+ }
187
+
188
+ export function expectPlanToImplement(menu: AskUserHarness): void {
189
+ expectActiveTaskNext(menu, "Continue to implement");
190
+ }
191
+
192
+ export function expectImplementToDone(menu: AskUserHarness): void {
193
+ expectActiveTaskNext(menu, "Complete");
194
+ }
195
+
196
+ export function expectQuickMenu(menu: AskUserHarness, choose: string): void {
197
+ menu.expect({ question: m.anyTaskMenu, options: { include: [choose] }, choose });
198
+ }
199
+
200
+ export function expectReviewAuto(menu: AskUserHarness, preset = "regular"): void {
201
+ menu.expect({ question: m.anyTaskMenu, options: { include: ["Review"] }, choose: "Review" });
202
+ menu.expect({ question: "Review", options: { include: [m.autoReview] }, choose: m.autoReview });
203
+ menu.expect({ question: "Review preset", options: { include: [m.preset(preset), "Back"] }, choose: m.preset(preset) });
204
+ }
205
+
206
+ export function expectReviewOnMyOwn(menu: AskUserHarness): void {
207
+ menu.expect({ question: m.anyTaskMenu, options: { include: ["Review"] }, choose: "Review" });
208
+ menu.expect({ question: "Review", options: { include: ["Review on my own"] }, choose: "Review on my own" });
209
+ }
210
+
211
+ export function expectPpStartImplementAutonomous(menu: AskUserHarness): void {
212
+ menu.expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" });
213
+ menu.expect({ question: "Task", options: { include: ["Implement"] }, choose: "Implement" });
214
+ menu.expect({ question: "Implement", options: { include: ["New"] }, choose: "New" });
215
+ menu.expect({ question: "Mode", options: { include: ["Guided", "Autonomous", "Back"] }, choose: "Autonomous" });
216
+ menu.expect({ question: "Autonomous", options: { include: ["Start", "Back"] }, choose: "Start" });
217
+ }
@@ -0,0 +1,132 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync, utimesSync } from "fs";
2
+ import { tmpdir } from "os";
3
+ import { join } from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { initTracer, finalizeTracer, getTracer } from "./tracer.js";
6
+
7
+ const tempDirs: string[] = [];
8
+
9
+ function makeTempDir(): string {
10
+ const dir = mkdtempSync(join(tmpdir(), "pi-pi-tracer-test-"));
11
+ tempDirs.push(dir);
12
+ return dir;
13
+ }
14
+
15
+ function readLines(file: string): Record<string, any>[] {
16
+ if (!existsSync(file)) return [];
17
+ return readFileSync(file, "utf-8")
18
+ .split("\n")
19
+ .filter((l) => l.trim().length > 0)
20
+ .map((l) => JSON.parse(l));
21
+ }
22
+
23
+ afterEach(() => {
24
+ finalizeTracer();
25
+ for (const dir of tempDirs.splice(0)) {
26
+ try {
27
+ rmSync(dir, { recursive: true, force: true });
28
+ } catch {}
29
+ }
30
+ });
31
+
32
+ describe("tracer", () => {
33
+ it("is a no-op when not initialized", () => {
34
+ expect(getTracer()).toBeUndefined();
35
+ });
36
+
37
+ it("creates per-session dir with main.jsonl and writes main events", () => {
38
+ const ppDir = join(makeTempDir(), ".pp");
39
+ initTracer(ppDir, "sess-abc");
40
+ const tracer = getTracer();
41
+ expect(tracer).toBeDefined();
42
+
43
+ tracer!.traceMain("before_agent_start", { prompt: "hello", systemPrompt: "sys" });
44
+ tracer!.traceMain("tool_execution_end", { toolCallId: "t1", result: { ok: true }, isError: false });
45
+
46
+ const dir = join(ppDir, "logs", "traces", "sess-abc");
47
+ expect(existsSync(join(dir, "main.jsonl"))).toBe(true);
48
+
49
+ const lines = readLines(join(dir, "main.jsonl"));
50
+ expect(lines[0].kind).toBe("before_agent_start");
51
+ expect(lines[0].prompt).toBe("hello");
52
+ expect(lines[0].systemPrompt).toBe("sys");
53
+ expect(lines[1].kind).toBe("tool_execution_end");
54
+ expect(lines[1].result).toEqual({ ok: true });
55
+ });
56
+
57
+ it("writes one file per subagent keyed by id, with prompts and full payloads", () => {
58
+ const ppDir = join(makeTempDir(), ".pp");
59
+ initTracer(ppDir, "sess-1");
60
+ const tracer = getTracer()!;
61
+
62
+ tracer.openSubagent({
63
+ subagentId: "agent-1",
64
+ type: "Explore",
65
+ description: "find stuff",
66
+ parentToolCallId: "call-9",
67
+ parentSubagentId: "root-agent",
68
+ depth: 2,
69
+ systemPrompt: "sub-sys",
70
+ effectivePrompt: "do the thing",
71
+ });
72
+ tracer.traceSubagent("agent-1", "tool_execution_start", { toolCallId: "x", toolName: "grep", args: { pattern: "foo" } });
73
+
74
+ const dir = join(ppDir, "logs", "traces", "sess-1");
75
+ const subLines = readLines(join(dir, "agent-1.jsonl"));
76
+ expect(subLines[0].kind).toBe("subagent_open");
77
+ expect(subLines[0].systemPrompt).toBe("sub-sys");
78
+ expect(subLines[0].effectivePrompt).toBe("do the thing");
79
+ expect(subLines[0].parentToolCallId).toBe("call-9");
80
+ expect(subLines[0].parentSubagentId).toBe("root-agent");
81
+ expect(subLines[0].depth).toBe(2);
82
+ expect(subLines[1].kind).toBe("tool_execution_start");
83
+ expect(subLines[1].args).toEqual({ pattern: "foo" });
84
+
85
+ const mainLines = readLines(join(dir, "main.jsonl"));
86
+ const spawned = mainLines.find((l) => l.kind === "subagent_spawned" && l.subagentId === "agent-1");
87
+ expect(spawned).toBeDefined();
88
+ expect(spawned!.parentToolCallId).toBe("call-9");
89
+ expect(spawned!.parentSubagentId).toBe("root-agent");
90
+ });
91
+
92
+ it("keeps concurrent subagents in separate files", () => {
93
+ const ppDir = join(makeTempDir(), ".pp");
94
+ initTracer(ppDir, "sess-2");
95
+ const tracer = getTracer()!;
96
+
97
+ tracer.openSubagent({ subagentId: "a", depth: 1 });
98
+ tracer.openSubagent({ subagentId: "b", depth: 1 });
99
+ tracer.traceSubagent("a", "turn_end", { turnIndex: 0 });
100
+ tracer.traceSubagent("b", "turn_end", { turnIndex: 0 });
101
+
102
+ const dir = join(ppDir, "logs", "traces", "sess-2");
103
+ expect(readLines(join(dir, "a.jsonl")).some((l) => l.kind === "turn_end")).toBe(true);
104
+ expect(readLines(join(dir, "b.jsonl")).some((l) => l.kind === "turn_end")).toBe(true);
105
+ expect(readLines(join(dir, "a.jsonl")).some((l) => l.subagentId === "b")).toBe(false);
106
+ });
107
+
108
+ it("finalize writes session_finalized and clears the tracer", () => {
109
+ const ppDir = join(makeTempDir(), ".pp");
110
+ initTracer(ppDir, "sess-3");
111
+ const dir = join(ppDir, "logs", "traces", "sess-3");
112
+ finalizeTracer();
113
+ expect(getTracer()).toBeUndefined();
114
+ expect(readLines(join(dir, "main.jsonl")).some((l) => l.kind === "session_finalized")).toBe(true);
115
+ });
116
+
117
+ it("cleans up trace dirs older than 7 days", () => {
118
+ const ppDir = join(makeTempDir(), ".pp");
119
+ initTracer(ppDir, "old-session");
120
+ finalizeTracer();
121
+ const tracesRoot = join(ppDir, "logs", "traces");
122
+ const oldDir = join(tracesRoot, "old-session");
123
+ expect(existsSync(oldDir)).toBe(true);
124
+
125
+ const past = (Date.now() - 8 * 24 * 60 * 60 * 1000) / 1000;
126
+ utimesSync(oldDir, past, past);
127
+
128
+ initTracer(ppDir, "new-session");
129
+ expect(existsSync(oldDir)).toBe(false);
130
+ expect(existsSync(join(tracesRoot, "new-session"))).toBe(true);
131
+ });
132
+ });
@@ -0,0 +1,127 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ const TRACER_KEY = Symbol.for("pi-pi:tracer");
5
+
6
+ export interface SubagentMeta {
7
+ subagentId: string;
8
+ type?: string;
9
+ description?: string;
10
+ parentToolCallId?: string;
11
+ parentSubagentId?: string;
12
+ depth: number;
13
+ systemPrompt?: string;
14
+ effectivePrompt?: string;
15
+ }
16
+
17
+ interface Tracer {
18
+ sessionId: string;
19
+ dir: string;
20
+ turnIndex: number;
21
+ traceMain(kind: string, payload: Record<string, unknown>): void;
22
+ openSubagent(meta: SubagentMeta): void;
23
+ traceSubagent(subagentId: string, kind: string, payload: Record<string, unknown>): void;
24
+ finalize(): void;
25
+ }
26
+
27
+ function sanitizeSessionId(sessionId: string): string {
28
+ return sessionId.replace(/[^a-zA-Z0-9_.-]/g, "_") || `session-${Date.now()}`;
29
+ }
30
+
31
+ function appendLine(file: string, obj: Record<string, unknown>): void {
32
+ try {
33
+ appendFileSync(file, JSON.stringify(obj) + "\n");
34
+ } catch {}
35
+ }
36
+
37
+ function cleanOldTraceDirs(tracesRoot: string, maxAgeDays: number): void {
38
+ try {
39
+ if (!existsSync(tracesRoot)) return;
40
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
41
+ for (const entry of readdirSync(tracesRoot, { withFileTypes: true })) {
42
+ if (!entry.isDirectory()) continue;
43
+ const dirPath = join(tracesRoot, entry.name);
44
+ try {
45
+ if (statSync(dirPath).mtimeMs < cutoff) {
46
+ rmSync(dirPath, { recursive: true, force: true });
47
+ }
48
+ } catch {}
49
+ }
50
+ } catch {}
51
+ }
52
+
53
+ export function initTracer(ppDir: string, sessionId: string): void {
54
+ try {
55
+ const tracesRoot = join(ppDir, "logs", "traces");
56
+ mkdirSync(tracesRoot, { recursive: true });
57
+ cleanOldTraceDirs(tracesRoot, 7);
58
+
59
+ const safeId = sanitizeSessionId(sessionId);
60
+ const dir = join(tracesRoot, safeId);
61
+ mkdirSync(dir, { recursive: true });
62
+
63
+ const mainFile = join(dir, "main.jsonl");
64
+
65
+ const tracer: Tracer = {
66
+ sessionId,
67
+ dir,
68
+ turnIndex: -1,
69
+ traceMain(kind, payload) {
70
+ appendLine(mainFile, { ts: Date.now(), scope: "main", sessionId, kind, ...payload });
71
+ },
72
+ openSubagent(meta) {
73
+ const subFile = join(dir, `${sanitizeSessionId(meta.subagentId)}.jsonl`);
74
+ appendLine(subFile, {
75
+ ts: Date.now(),
76
+ scope: "subagent",
77
+ sessionId,
78
+ subagentId: meta.subagentId,
79
+ parentSubagentId: meta.parentSubagentId,
80
+ parentToolCallId: meta.parentToolCallId,
81
+ depth: meta.depth,
82
+ kind: "subagent_open",
83
+ type: meta.type,
84
+ description: meta.description,
85
+ systemPrompt: meta.systemPrompt,
86
+ effectivePrompt: meta.effectivePrompt,
87
+ });
88
+ appendLine(mainFile, {
89
+ ts: Date.now(),
90
+ scope: "main",
91
+ sessionId,
92
+ kind: "subagent_spawned",
93
+ subagentId: meta.subagentId,
94
+ parentSubagentId: meta.parentSubagentId,
95
+ parentToolCallId: meta.parentToolCallId,
96
+ depth: meta.depth,
97
+ type: meta.type,
98
+ description: meta.description,
99
+ });
100
+ },
101
+ traceSubagent(subagentId, kind, payload) {
102
+ const subFile = join(dir, `${sanitizeSessionId(subagentId)}.jsonl`);
103
+ appendLine(subFile, { ts: Date.now(), scope: "subagent", sessionId, subagentId, kind, ...payload });
104
+ },
105
+ finalize() {
106
+ appendLine(mainFile, { ts: Date.now(), scope: "main", sessionId, kind: "session_finalized" });
107
+ },
108
+ };
109
+
110
+ (globalThis as any)[TRACER_KEY] = tracer;
111
+ } catch {
112
+ (globalThis as any)[TRACER_KEY] = undefined;
113
+ }
114
+ }
115
+
116
+ export function getTracer(): Tracer | undefined {
117
+ return (globalThis as any)[TRACER_KEY];
118
+ }
119
+
120
+ export function finalizeTracer(): void {
121
+ const tracer = getTracer();
122
+ if (!tracer) return;
123
+ try {
124
+ tracer.finalize();
125
+ } catch {}
126
+ (globalThis as any)[TRACER_KEY] = undefined;
127
+ }