pi-crew 0.5.2 → 0.5.6

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 (137) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/README.md +17 -1
  3. package/docs/architecture.md +2 -0
  4. package/docs/bugs/cross-session-notification-leakage.md +82 -0
  5. package/docs/coding-agent-optimization.md +268 -0
  6. package/docs/deep-review-report.md +384 -0
  7. package/docs/distillation/cybersecurity-patterns.md +294 -0
  8. package/docs/migration-v0.4-v0.5.md +208 -0
  9. package/docs/optimization-plan.md +642 -0
  10. package/docs/pi-crew-v0.5.5-audit-fix-plan.md +133 -0
  11. package/docs/pi-mono-opportunities.md +969 -0
  12. package/docs/pi-mono-review.md +291 -0
  13. package/docs/skills/REFERENCE.md +144 -0
  14. package/package.json +12 -9
  15. package/skills/artifact-analysis-loop/SKILL.md +302 -0
  16. package/skills/async-worker-recovery/SKILL.md +19 -1
  17. package/skills/child-pi-spawning/SKILL.md +19 -6
  18. package/skills/context-artifact-hygiene/SKILL.md +19 -2
  19. package/skills/delegation-patterns/SKILL.md +68 -3
  20. package/skills/detection-pipeline-design/SKILL.md +285 -0
  21. package/skills/event-log-tracing/SKILL.md +20 -6
  22. package/skills/git-master/SKILL.md +20 -6
  23. package/skills/hunting-investigation-loop/SKILL.md +401 -0
  24. package/skills/incident-playbook-construction/SKILL.md +383 -0
  25. package/skills/live-agent-lifecycle/SKILL.md +20 -6
  26. package/skills/mailbox-interactive/SKILL.md +19 -6
  27. package/skills/model-routing-context/SKILL.md +19 -1
  28. package/skills/multi-perspective-review/SKILL.md +19 -4
  29. package/skills/observability-reliability/SKILL.md +19 -2
  30. package/skills/orchestration/SKILL.md +20 -2
  31. package/skills/ownership-session-security/SKILL.md +20 -2
  32. package/skills/pi-extension-lifecycle/SKILL.md +20 -2
  33. package/skills/post-mortem/SKILL.md +7 -2
  34. package/skills/read-only-explorer/SKILL.md +20 -6
  35. package/skills/requirements-to-task-packet/SKILL.md +23 -3
  36. package/skills/resource-discovery-config/SKILL.md +20 -2
  37. package/skills/runtime-state-reader/SKILL.md +20 -2
  38. package/skills/safe-bash/SKILL.md +21 -6
  39. package/skills/scrutinize/SKILL.md +20 -2
  40. package/skills/secure-agent-orchestration-review/SKILL.md +29 -2
  41. package/skills/security-review/SKILL.md +560 -0
  42. package/skills/state-mutation-locking/SKILL.md +22 -2
  43. package/skills/systematic-debugging/SKILL.md +8 -6
  44. package/skills/threat-hypothesis-framework/SKILL.md +175 -0
  45. package/skills/ui-render-performance/SKILL.md +20 -2
  46. package/skills/verification-before-done/SKILL.md +17 -2
  47. package/skills/widget-rendering/SKILL.md +21 -6
  48. package/skills/workspace-isolation/SKILL.md +20 -6
  49. package/skills/worktree-isolation/SKILL.md +20 -6
  50. package/src/agents/agent-config.ts +40 -1
  51. package/src/benchmark/benchmark-runner.ts +45 -0
  52. package/src/benchmark/feedback-loop.ts +5 -0
  53. package/src/config/config.ts +32 -5
  54. package/src/config/role-tools.ts +82 -0
  55. package/src/config/suggestions.ts +8 -0
  56. package/src/config/types.ts +4 -0
  57. package/src/extension/async-notifier.ts +10 -1
  58. package/src/extension/crew-cleanup.ts +114 -0
  59. package/src/extension/cross-extension-rpc.ts +1 -1
  60. package/src/extension/notification-router.ts +18 -0
  61. package/src/extension/register.ts +27 -19
  62. package/src/extension/registration/subagent-tools.ts +1 -1
  63. package/src/extension/team-tool/anchor.ts +201 -0
  64. package/src/extension/team-tool/api.ts +2 -1
  65. package/src/extension/team-tool/auto-summarize.ts +154 -0
  66. package/src/extension/team-tool/run.ts +42 -7
  67. package/src/extension/team-tool.ts +44 -2
  68. package/src/hooks/registry.ts +1 -3
  69. package/src/observability/event-bus.ts +69 -0
  70. package/src/observability/event-to-metric.ts +0 -2
  71. package/src/runtime/anchor-manager.ts +473 -0
  72. package/src/runtime/async-runner.ts +8 -4
  73. package/src/runtime/auto-summarize.ts +350 -0
  74. package/src/runtime/background-runner.ts +10 -3
  75. package/src/runtime/budget-tracker.ts +354 -0
  76. package/src/runtime/chain-runner.ts +507 -0
  77. package/src/runtime/child-pi.ts +123 -35
  78. package/src/runtime/crash-recovery.ts +5 -4
  79. package/src/runtime/crew-agent-runtime.ts +1 -0
  80. package/src/runtime/custom-tools/irc-tool.ts +13 -0
  81. package/src/runtime/custom-tools/submit-result-tool.ts +3 -2
  82. package/src/runtime/delivery-coordinator.ts +10 -3
  83. package/src/runtime/dynamic-script-runner.ts +482 -0
  84. package/src/runtime/foreground-control.ts +87 -17
  85. package/src/runtime/handoff-manager.ts +589 -0
  86. package/src/runtime/hidden-handoff.ts +424 -0
  87. package/src/runtime/live-agent-manager.ts +20 -4
  88. package/src/runtime/live-session-runtime.ts +39 -4
  89. package/src/runtime/manifest-cache.ts +2 -1
  90. package/src/runtime/model-resolver.ts +16 -4
  91. package/src/runtime/phase-tracker.ts +373 -0
  92. package/src/runtime/pi-args.ts +11 -1
  93. package/src/runtime/pi-json-output.ts +31 -0
  94. package/src/runtime/pipeline-runner.ts +514 -0
  95. package/src/runtime/progress-tracker.ts +124 -0
  96. package/src/runtime/retry-runner.ts +354 -0
  97. package/src/runtime/sandbox.ts +252 -0
  98. package/src/runtime/scheduler.ts +7 -2
  99. package/src/runtime/skill-effectiveness.ts +473 -0
  100. package/src/runtime/skill-instructions.ts +37 -3
  101. package/src/runtime/subagent-manager.ts +1 -1
  102. package/src/runtime/task-graph.ts +11 -1
  103. package/src/runtime/task-runner.ts +92 -18
  104. package/src/runtime/team-runner.ts +13 -12
  105. package/src/runtime/tool-progress.ts +10 -3
  106. package/src/runtime/verification-gates.ts +367 -0
  107. package/src/schema/team-tool-schema.ts +37 -0
  108. package/src/skills/discover-skills.ts +5 -0
  109. package/src/state/active-run-registry.ts +9 -2
  110. package/src/state/contracts.ts +9 -0
  111. package/src/state/crew-init.ts +3 -3
  112. package/src/state/decision-ledger.ts +98 -55
  113. package/src/state/event-log-rotation.ts +2 -2
  114. package/src/state/event-log.ts +144 -10
  115. package/src/state/hook-instinct-bridge.ts +5 -5
  116. package/src/state/mailbox.ts +10 -0
  117. package/src/state/run-cache.ts +18 -8
  118. package/src/state/state-store.ts +3 -1
  119. package/src/state/types.ts +4 -0
  120. package/src/tools/safe-bash-extension.ts +1 -0
  121. package/src/tools/safe-bash.ts +152 -20
  122. package/src/types/new-api-types.ts +34 -0
  123. package/src/ui/agent-management-overlay.ts +5 -1
  124. package/src/ui/crew-widget.ts +29 -15
  125. package/src/ui/overlays/mailbox-detail-overlay.ts +13 -2
  126. package/src/ui/powerbar-publisher.ts +101 -7
  127. package/src/ui/tool-render.ts +15 -15
  128. package/src/ui/transcript-cache.ts +13 -0
  129. package/src/utils/bm25-search.ts +16 -8
  130. package/src/utils/env-filter.ts +8 -5
  131. package/src/utils/redaction.ts +169 -15
  132. package/src/utils/session-utils.ts +52 -0
  133. package/src/utils/sse-parser.ts +10 -1
  134. package/src/worktree/cleanup.ts +6 -1
  135. package/src/worktree/worktree-manager.ts +32 -13
  136. package/workflows/chain.workflow.md +252 -0
  137. package/workflows/pipeline.workflow.md +27 -0
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Phase Tracker — marks phase transitions with timestamps and metrics.
3
+ *
4
+ * Tracks workflow phases (assessment, implementation, verification, etc.)
5
+ * with start/complete/skip lifecycle and phase-level metrics.
6
+ *
7
+ * @file src/runtime/phase-tracker.ts
8
+ */
9
+
10
+ import { EventEmitter } from "node:events";
11
+
12
+ /** Phase status. */
13
+ export type PhaseStatus = "active" | "completed" | "skipped" | "failed";
14
+
15
+ /** Metrics collected for a phase. */
16
+ export interface PhaseMetrics {
17
+ /** Number of tasks completed in this phase. */
18
+ tasksCompleted?: number;
19
+ /** Number of tasks failed in this phase. */
20
+ tasksFailed?: number;
21
+ /** Total tokens used in this phase. */
22
+ tokensUsed?: number;
23
+ /** Number of subagents spawned in this phase. */
24
+ subagentsSpawned?: number;
25
+ /** Custom metadata key-value pairs. */
26
+ custom?: Record<string, unknown>;
27
+ }
28
+
29
+ /** A tracked phase. */
30
+ export interface Phase {
31
+ /** Unique phase name/identifier. */
32
+ name: string;
33
+ /** ISO timestamp when phase started. */
34
+ startTime: string;
35
+ /** ISO timestamp when phase ended (if ended). */
36
+ endTime?: string;
37
+ /** Duration in milliseconds (if ended). */
38
+ durationMs?: number;
39
+ /** Current phase status. */
40
+ status: PhaseStatus;
41
+ /** Collected metrics for this phase. */
42
+ metrics?: PhaseMetrics;
43
+ /** Order index (0-based). */
44
+ index: number;
45
+ }
46
+
47
+ /** Event emitted on phase lifecycle changes. */
48
+ export interface PhaseLifecycleEvent {
49
+ type: "phase:started" | "phase:completed" | "phase:skipped" | "phase:failed";
50
+ phase: Phase;
51
+ }
52
+
53
+ /** Default empty metrics. */
54
+ function emptyMetrics(): PhaseMetrics {
55
+ return {
56
+ tasksCompleted: 0,
57
+ tasksFailed: 0,
58
+ tokensUsed: 0,
59
+ subagentsSpawned: 0,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * PhaseTracker manages workflow phase lifecycle.
65
+ *
66
+ * @example
67
+ * ```typescript
68
+ * const tracker = new PhaseTracker();
69
+ * tracker.start("assessment");
70
+ * // ... do work ...
71
+ * tracker.complete("assessment", { tasksCompleted: 5, tokensUsed: 12000 });
72
+ * tracker.start("implementation");
73
+ * ```
74
+ */
75
+ export class PhaseTracker extends EventEmitter {
76
+ private phases: Phase[] = [];
77
+ private currentPhaseName: string | null = null;
78
+ private phaseMetrics: Map<string, PhaseMetrics> = new Map();
79
+
80
+ /**
81
+ * Start a new phase, completing the previous one if any.
82
+ *
83
+ * @param name - Phase name (e.g., "assessment", "implementation").
84
+ * @param metrics - Optional initial metrics for the phase.
85
+ * @returns The started Phase object.
86
+ */
87
+ start(name: string, metrics?: PhaseMetrics): Phase {
88
+ // HIGH-8: Prevent duplicate phases - check if phase already exists
89
+ if (this.phases.some((p) => p.name === name)) {
90
+ throw new Error(`Phase "${name}" already exists. Duplicate phases are not allowed.`);
91
+ }
92
+
93
+ // Complete previous phase before starting new one (only if active)
94
+ if (this.currentPhaseName !== null) {
95
+ this.completeIfActive(this.currentPhaseName);
96
+ }
97
+
98
+ const phase: Phase = {
99
+ name,
100
+ startTime: new Date().toISOString(),
101
+ status: "active",
102
+ index: this.phases.length,
103
+ metrics: metrics ?? emptyMetrics(),
104
+ };
105
+
106
+ this.phases.push(phase);
107
+ this.currentPhaseName = name;
108
+ this.phaseMetrics.set(name, phase.metrics!);
109
+
110
+ const event: PhaseLifecycleEvent = { type: "phase:started", phase };
111
+ this.emit("phase:started", event);
112
+ return phase;
113
+ }
114
+
115
+ /**
116
+ * Complete a phase with optional metrics update.
117
+ *
118
+ * @param name - Phase name to complete.
119
+ * @param metrics - Optional metrics to merge/update.
120
+ */
121
+ complete(name: string, metrics?: Partial<PhaseMetrics>): void {
122
+ const phase = this.phases.find((p) => p.name === name);
123
+ if (!phase) {
124
+ throw new Error(`Phase "${name}" not found`);
125
+ }
126
+ if (phase.status !== "active") {
127
+ throw new Error(`Phase "${name}" is not active (status: ${phase.status})`);
128
+ }
129
+
130
+ const now = new Date();
131
+ const startMs = new Date(phase.startTime).getTime();
132
+ const endMs = now.getTime();
133
+
134
+ phase.endTime = now.toISOString();
135
+ phase.durationMs = endMs - startMs;
136
+ phase.status = "completed";
137
+
138
+ // Merge provided metrics with existing
139
+ if (metrics) {
140
+ const existing = this.phaseMetrics.get(name) ?? emptyMetrics();
141
+ phase.metrics = {
142
+ tasksCompleted: metrics.tasksCompleted ?? existing.tasksCompleted,
143
+ tasksFailed: metrics.tasksFailed ?? existing.tasksFailed,
144
+ tokensUsed: metrics.tokensUsed ?? existing.tokensUsed,
145
+ subagentsSpawned: metrics.subagentsSpawned ?? existing.subagentsSpawned,
146
+ custom: { ...existing.custom, ...metrics.custom },
147
+ };
148
+ this.phaseMetrics.set(name, phase.metrics);
149
+ }
150
+
151
+ this.emit("phase:completed", { type: "phase:completed", phase } as PhaseLifecycleEvent);
152
+ }
153
+
154
+ /**
155
+ * Skip the current active phase without metrics.
156
+ *
157
+ * @param name - Phase name to skip.
158
+ * @param reason - Optional reason for skipping.
159
+ */
160
+ skip(name: string, reason?: string): void {
161
+ const phase = this.phases.find((p) => p.name === name);
162
+ if (!phase) {
163
+ throw new Error(`Phase "${name}" not found`);
164
+ }
165
+ if (phase.status !== "active") {
166
+ throw new Error(`Phase "${name}" is not active (status: ${phase.status})`);
167
+ }
168
+
169
+ const now = new Date();
170
+ const startMs = new Date(phase.startTime).getTime();
171
+ const endMs = now.getTime();
172
+
173
+ phase.endTime = now.toISOString();
174
+ phase.durationMs = endMs - startMs;
175
+ phase.status = "skipped";
176
+
177
+ // Clear current phase since we're done with it
178
+ if (this.currentPhaseName === name) {
179
+ this.currentPhaseName = null;
180
+ }
181
+
182
+ this.emit("phase:skipped", { type: "phase:skipped", phase } as PhaseLifecycleEvent);
183
+ }
184
+
185
+ /**
186
+ * Mark a phase as failed.
187
+ *
188
+ * @param name - Phase name to fail.
189
+ * @param error - Optional error information.
190
+ */
191
+ fail(name: string, error?: string): void {
192
+ const phase = this.phases.find((p) => p.name === name);
193
+ if (!phase) {
194
+ throw new Error(`Phase "${name}" not found`);
195
+ }
196
+ if (phase.status !== "active") {
197
+ throw new Error(`Phase "${name}" is not active (status: ${phase.status})`);
198
+ }
199
+
200
+ const now = new Date();
201
+ const startMs = new Date(phase.startTime).getTime();
202
+ const endMs = now.getTime();
203
+
204
+ phase.endTime = now.toISOString();
205
+ phase.durationMs = endMs - startMs;
206
+ phase.status = "failed";
207
+
208
+ if (error) {
209
+ const existing = this.phaseMetrics.get(name) ?? emptyMetrics();
210
+ phase.metrics = { ...existing, custom: { ...existing.custom, error } };
211
+ this.phaseMetrics.set(name, phase.metrics);
212
+ }
213
+
214
+ // Clear current phase since we're done with it
215
+ if (this.currentPhaseName === name) {
216
+ this.currentPhaseName = null;
217
+ }
218
+
219
+ this.emit("phase:failed", { type: "phase:failed", phase } as PhaseLifecycleEvent);
220
+ }
221
+
222
+ /**
223
+ * Complete a phase only if it is currently active. Does not throw if the
224
+ * phase is already completed, skipped, or failed.
225
+ *
226
+ * @param name - Phase name to complete.
227
+ * @param metrics - Optional metrics to merge/update.
228
+ */
229
+ completeIfActive(name: string, metrics?: Partial<PhaseMetrics>): void {
230
+ const phase = this.phases.find((p) => p.name === name);
231
+ if (phase && phase.status === "active") {
232
+ this.complete(name, metrics);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Get all phases.
238
+ * @returns Copy of phases array.
239
+ */
240
+ getPhases(): Phase[] {
241
+ return [...this.phases];
242
+ }
243
+
244
+ /**
245
+ * Get phases filtered by status.
246
+ * @param status - Status to filter by.
247
+ * @returns Filtered phases.
248
+ */
249
+ getPhasesByStatus(status: PhaseStatus): Phase[] {
250
+ return this.phases.filter((p) => p.status === status);
251
+ }
252
+
253
+ /**
254
+ * Get the current active phase.
255
+ * @returns Current phase or null if none active.
256
+ */
257
+ getCurrentPhase(): Phase | null {
258
+ return this.phases.find((p) => p.status === "active") ?? null;
259
+ }
260
+
261
+ /**
262
+ * Get a specific phase by name.
263
+ * @param name - Phase name.
264
+ * @returns Phase or undefined.
265
+ */
266
+ getPhase(name: string): Phase | undefined {
267
+ return this.phases.find((p) => p.name === name);
268
+ }
269
+
270
+ /**
271
+ * Get metrics for a phase.
272
+ * @param name - Phase name.
273
+ * @returns Metrics or undefined.
274
+ */
275
+ getMetrics(name: string): PhaseMetrics | undefined {
276
+ return this.phaseMetrics.get(name);
277
+ }
278
+
279
+ /**
280
+ * Update metrics for the current phase.
281
+ * @param updates - Partial metrics to merge.
282
+ */
283
+ updateCurrentMetrics(updates: Partial<PhaseMetrics>): void {
284
+ if (!this.currentPhaseName) return;
285
+ const existing = this.phaseMetrics.get(this.currentPhaseName) ?? emptyMetrics();
286
+ const updated: PhaseMetrics = {
287
+ tasksCompleted: updates.tasksCompleted ?? existing.tasksCompleted,
288
+ tasksFailed: updates.tasksFailed ?? existing.tasksFailed,
289
+ tokensUsed: updates.tokensUsed ?? existing.tokensUsed,
290
+ subagentsSpawned: updates.subagentsSpawned ?? existing.subagentsSpawned,
291
+ custom: { ...existing.custom, ...updates.custom },
292
+ };
293
+ this.phaseMetrics.set(this.currentPhaseName, updated);
294
+ const phase = this.getPhase(this.currentPhaseName);
295
+ if (phase) {
296
+ phase.metrics = updated;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Add tokens to the current phase's metrics.
302
+ * @param tokens - Number of tokens to add.
303
+ */
304
+ addTokensToCurrent(tokens: number): void {
305
+ if (!this.currentPhaseName) return;
306
+ const existing = this.phaseMetrics.get(this.currentPhaseName) ?? emptyMetrics();
307
+ existing.tokensUsed = (existing.tokensUsed ?? 0) + tokens;
308
+ this.phaseMetrics.set(this.currentPhaseName, existing);
309
+ const phase = this.getPhase(this.currentPhaseName);
310
+ if (phase) {
311
+ phase.metrics = existing;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Get total duration across all completed phases.
317
+ * @returns Total milliseconds or 0.
318
+ */
319
+ totalDuration(): number {
320
+ return this.phases.reduce((sum, p) => sum + (p.durationMs ?? 0), 0);
321
+ }
322
+
323
+ /**
324
+ * Get summary statistics for all phases.
325
+ * @returns Summary object.
326
+ */
327
+ summary(): {
328
+ totalPhases: number;
329
+ active: number;
330
+ completed: number;
331
+ skipped: number;
332
+ failed: number;
333
+ totalDurationMs: number;
334
+ } {
335
+ return {
336
+ totalPhases: this.phases.length,
337
+ active: this.phases.filter((p) => p.status === "active").length,
338
+ completed: this.phases.filter((p) => p.status === "completed").length,
339
+ skipped: this.phases.filter((p) => p.status === "skipped").length,
340
+ failed: this.phases.filter((p) => p.status === "failed").length,
341
+ totalDurationMs: this.totalDuration(),
342
+ };
343
+ }
344
+
345
+ /**
346
+ * Check if a phase exists.
347
+ * @param name - Phase name.
348
+ * @returns True if phase exists.
349
+ */
350
+ hasPhase(name: string): boolean {
351
+ return this.phases.some((p) => p.name === name);
352
+ }
353
+
354
+ /**
355
+ * Reset all phases (for testing or recovery).
356
+ */
357
+ reset(): void {
358
+ this.phases = [];
359
+ this.currentPhaseName = null;
360
+ this.phaseMetrics.clear();
361
+ }
362
+
363
+ /**
364
+ * Dispose of resources (EventEmitter listeners).
365
+ * Call this when the tracker is no longer needed.
366
+ */
367
+ dispose(): void {
368
+ this.removeAllListeners();
369
+ this.phases = [];
370
+ this.currentPhaseName = null;
371
+ this.phaseMetrics.clear();
372
+ }
373
+ }
@@ -3,6 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import type { AgentConfig } from "../agents/agent-config.ts";
6
+ import { getAgentSessionOptions } from "../agents/agent-config.ts";
6
7
 
7
8
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
8
9
  const PROMPT_RUNTIME_EXTENSION_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "prompt", "prompt-runtime.ts");
@@ -17,6 +18,8 @@ export interface BuildPiWorkerArgsInput {
17
18
  maxDepth?: number;
18
19
  skillPaths?: string[];
19
20
  env?: NodeJS.ProcessEnv;
21
+ /** Role for tool restrictions (uses role-tools.ts config) */
22
+ role?: string;
20
23
  }
21
24
 
22
25
  export interface BuildPiWorkerArgsResult {
@@ -99,7 +102,14 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
99
102
  args.push("--thinking", input.agent.thinking);
100
103
  }
101
104
 
102
- if (input.agent.tools?.length) args.push("--tools", input.agent.tools.join(","));
105
+ // Apply role-based tool restrictions (from role-tools.ts)
106
+ // Role-specific config takes precedence over agent-defined tools
107
+ const toolConfig = input.role ? getAgentSessionOptions(input.role) : {};
108
+ const explicitTools = toolConfig.tools ?? input.agent.tools;
109
+ const excludeTools = toolConfig.excludeTools;
110
+
111
+ if (explicitTools?.length) args.push("--tools", explicitTools.join(","));
112
+ if (excludeTools?.length) args.push("--exclude-tools", excludeTools.join(","));
103
113
  // Always add --no-extensions before --extension to prevent user extensions from being auto-loaded.
104
114
  // User extensions in ~/.pi/agent/extensions/ may fail due to missing dependencies.
105
115
  args.push("--no-extensions");
@@ -12,6 +12,8 @@ export interface ParsedPiJsonOutput {
12
12
  textEvents: string[];
13
13
  finalText?: string;
14
14
  usage?: ParsedPiUsage;
15
+ /** Unified patches extracted from tool_result events (edit tool patch field) */
16
+ patches?: string[];
15
17
  }
16
18
 
17
19
  function asRecord(value: unknown): Record<string, unknown> | undefined {
@@ -87,6 +89,7 @@ function extractText(value: unknown): string[] {
87
89
  export function parsePiJsonOutput(stdout: string): ParsedPiJsonOutput {
88
90
  let jsonEvents = 0;
89
91
  const textEvents: string[] = [];
92
+ const patches: string[] = [];
90
93
  let usage: ParsedPiUsage | undefined;
91
94
  for (const line of stdout.split("\n")) {
92
95
  const trimmed = line.trim();
@@ -99,6 +102,8 @@ export function parsePiJsonOutput(stdout: string): ParsedPiJsonOutput {
99
102
  }
100
103
  jsonEvents++;
101
104
  textEvents.push(...extractText(event));
105
+ // Extract unified patches from tool_result events
106
+ extractPatch(event, patches);
102
107
  const eventUsage = extractUsage(event);
103
108
  if (eventUsage) usage = mergeUsage(usage ?? {}, eventUsage);
104
109
  }
@@ -107,5 +112,31 @@ export function parsePiJsonOutput(stdout: string): ParsedPiJsonOutput {
107
112
  textEvents,
108
113
  finalText: textEvents.length > 0 ? textEvents[textEvents.length - 1] : undefined,
109
114
  usage,
115
+ patches: patches.length > 0 ? patches : undefined,
110
116
  };
111
117
  }
118
+
119
+ /**
120
+ * Extract unified patches from a tool_result event.
121
+ * pi's edit tool now includes a `patch` field (standard unified diff format).
122
+ * We detect it by looking for lines starting with "---" or "+++" which indicate
123
+ * unified diff format.
124
+ */
125
+ function extractPatch(event: unknown, patches: string[]): void {
126
+ const obj = asRecord(event);
127
+ if (!obj || obj.type !== "tool_result") return;
128
+
129
+ const content = obj.content;
130
+ if (!Array.isArray(content)) return;
131
+
132
+ for (const item of content) {
133
+ const part = asRecord(item);
134
+ if (!part || part.type !== "text") continue;
135
+ const text = typeof part.text === "string" ? part.text : "";
136
+
137
+ // Check if this looks like a unified patch (starts with "---" or "+++")
138
+ if (text.includes("--- a/") || text.includes("diff ---")) {
139
+ patches.push(text);
140
+ }
141
+ }
142
+ }