@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
@@ -111,15 +111,35 @@ type AskResponse =
111
111
  text: string;
112
112
  };
113
113
 
114
+ // Reason a question was cancelled. Only "user" (a deliberate top-level ESC)
115
+ // should abort the LLM turn; "timeout" and "signal" are programmatic and must not.
116
+ type CancelReason = "user" | "timeout" | "signal";
117
+
118
+ // Sentinel returned through the UI/askUser boundary to carry a cancel reason.
119
+ // Distinct from a plain AskResponse so callers can disambiguate cancel vs answer.
120
+ interface AskCancel {
121
+ __cancel: true;
122
+ reason: CancelReason;
123
+ }
124
+
125
+ function makeCancel(reason: CancelReason): AskCancel {
126
+ return { __cancel: true, reason };
127
+ }
128
+
129
+ export function isCancel(value: unknown): value is AskCancel {
130
+ return typeof value === "object" && value !== null && (value as AskCancel).__cancel === true;
131
+ }
132
+
114
133
  interface AskToolDetails {
115
134
  question: string;
116
135
  context?: string;
117
136
  options: QuestionOption[];
118
137
  response: AskResponse | null;
119
138
  cancelled: boolean;
139
+ cancelReason?: CancelReason;
120
140
  }
121
141
 
122
- type AskUIResult = AskResponse;
142
+ type AskUIResult = AskResponse | AskCancel;
123
143
 
124
144
  function normalizeOptions(options: AskOptionInput[]): QuestionOption[] {
125
145
  return options
@@ -983,7 +1003,6 @@ class WrappedSingleSelectList implements Component {
983
1003
  */
984
1004
  class AskComponent extends Container {
985
1005
  private question: string;
986
- private context?: string;
987
1006
  private options: QuestionOption[];
988
1007
  private allowMultiple: boolean;
989
1008
  private allowFreeform: boolean;
@@ -1003,7 +1022,6 @@ class AskComponent extends Container {
1003
1022
  // Static layout components
1004
1023
  private titleText: Text;
1005
1024
  private questionText: Text;
1006
- private contextComponent?: Component;
1007
1025
  private modeContainer: Container;
1008
1026
  private helpText: Text;
1009
1027
 
@@ -1026,7 +1044,7 @@ class AskComponent extends Container {
1026
1044
 
1027
1045
  constructor(
1028
1046
  question: string,
1029
- context: string | undefined,
1047
+ _context: string | undefined,
1030
1048
  options: QuestionOption[],
1031
1049
  allowMultiple: boolean,
1032
1050
  allowFreeform: boolean,
@@ -1041,7 +1059,6 @@ class AskComponent extends Container {
1041
1059
  super();
1042
1060
 
1043
1061
  this.question = question;
1044
- this.context = context;
1045
1062
  this.options = options;
1046
1063
  this.allowMultiple = allowMultiple;
1047
1064
  this.allowFreeform = allowFreeform;
@@ -1067,17 +1084,9 @@ class AskComponent extends Container {
1067
1084
 
1068
1085
  this.questionText = new Text("", 1, 0);
1069
1086
  this.addChild(this.questionText);
1070
-
1071
- if (this.context) {
1072
- this.addChild(new Spacer(1));
1073
- const mdTheme = safeMarkdownTheme();
1074
- if (mdTheme) {
1075
- this.contextComponent = new Markdown("", 1, 0, mdTheme);
1076
- } else {
1077
- this.contextComponent = new Text("", 1, 0);
1078
- }
1079
- this.addChild(this.contextComponent);
1080
- }
1087
+ // The interactive dialogue intentionally omits a separate context block:
1088
+ // the model output rendered above carries the detail. Only a short, dimmed
1089
+ // question line is shown here to keep the prompt scannable (see LOCKED #2).
1081
1090
 
1082
1091
  this.addChild(new Spacer(1));
1083
1092
 
@@ -1140,29 +1149,19 @@ class AskComponent extends Container {
1140
1149
  private countStaticLines(width: number): number {
1141
1150
  const titleLines = 1;
1142
1151
  const questionLines = this.countWrappedLines(this.question, width);
1143
- const contextLines = this.context ? 1 + this.countWrappedLines(this.context, width) : 0;
1144
1152
  const helpLines = 1;
1145
1153
  const borderLines = 2;
1146
- const spacerLines = this.context ? 6 : 5;
1147
- return borderLines + spacerLines + titleLines + questionLines + contextLines + helpLines;
1154
+ const spacerLines = 5;
1155
+ return borderLines + spacerLines + titleLines + questionLines + helpLines;
1148
1156
  }
1149
1157
 
1150
1158
  private updateStaticText(): void {
1151
1159
  const theme = this.theme;
1152
1160
  const title = this.mode === "comment" ? "Optional comment" : "Question";
1153
1161
  this.titleText.setText(theme.fg("accent", theme.bold(title)));
1154
- this.questionText.setText(theme.fg("text", theme.bold(this.question)));
1155
- if (this.contextComponent && this.context) {
1156
- if (this.contextComponent instanceof Markdown) {
1157
- (this.contextComponent as Markdown).setText(
1158
- `**Context:**\n${this.context}`,
1159
- );
1160
- } else {
1161
- (this.contextComponent as Text).setText(
1162
- `${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`,
1163
- );
1164
- }
1165
- }
1162
+ // Dimmed, non-bold so it stays visually subordinate to the "Question" title
1163
+ // (accent+bold) above and the richer model output rendered before the dialogue.
1164
+ this.questionText.setText(theme.fg("dim", this.question));
1166
1165
  }
1167
1166
 
1168
1167
  private updateHelpText(): void {
@@ -1171,7 +1170,7 @@ class AskComponent extends Container {
1171
1170
  ? literalHint(theme, this.shortcuts.overlayToggle.spec, "hide")
1172
1171
  : null;
1173
1172
  const commentHint = this.allowComment && !this.shortcuts.commentToggle.disabled
1174
- ? literalHint(theme, this.shortcuts.commentToggle.spec, "toggle context")
1173
+ ? literalHint(theme, this.shortcuts.commentToggle.spec, "add text")
1175
1174
  : null;
1176
1175
  if (this.mode === "freeform" || this.mode === "comment") {
1177
1176
  const alternateCancelKeys = this.keybindings
@@ -1236,7 +1235,7 @@ class AskComponent extends Container {
1236
1235
  this.shortcuts.commentToggle,
1237
1236
  );
1238
1237
  list.onSubmit = (result) => this.handleSelectionSubmit([result], list.isCommentEnabled());
1239
- list.onCancel = () => this.onDone(null);
1238
+ list.onCancel = () => this.onDone(makeCancel("user"));
1240
1239
  list.onEnterFreeform = () => this.showFreeformMode();
1241
1240
 
1242
1241
  this.singleSelectList = list;
@@ -1254,7 +1253,7 @@ class AskComponent extends Container {
1254
1253
  this.keybindings,
1255
1254
  this.shortcuts.commentToggle,
1256
1255
  );
1257
- list.onCancel = () => this.onDone(null);
1256
+ list.onCancel = () => this.onDone(makeCancel("user"));
1258
1257
  list.onSubmit = (result) => this.handleSelectionSubmit(result, list.isCommentEnabled());
1259
1258
  list.onEnterFreeform = () => this.showFreeformMode();
1260
1259
 
@@ -1389,7 +1388,7 @@ class AskComponent extends Container {
1389
1388
  }
1390
1389
 
1391
1390
  if (this.keybindings.matches(data, "tui.select.cancel")) {
1392
- this.onDone(null);
1391
+ this.onDone(makeCancel("user"));
1393
1392
  return;
1394
1393
  }
1395
1394
 
@@ -1490,14 +1489,14 @@ export async function askUser(
1490
1489
  overlayToggleKey?: string | null;
1491
1490
  commentToggleKey?: string | null;
1492
1491
  },
1493
- ): Promise<AskResponse | null> {
1492
+ ): Promise<AskResponse | AskCancel | null> {
1494
1493
  const {
1495
1494
  question,
1496
1495
  context,
1497
1496
  options: rawOptions = [],
1498
1497
  allowMultiple = false,
1499
1498
  allowFreeform = true,
1500
- allowComment = false,
1499
+ allowComment = true,
1501
1500
  timeout,
1502
1501
  signal,
1503
1502
  overlay,
@@ -1544,11 +1543,11 @@ export async function askUser(
1544
1543
  try {
1545
1544
  const customFactory = (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: AskUIResult | null) => void) => {
1546
1545
  if (signal) {
1547
- const onAbort = () => done(null);
1546
+ const onAbort = () => done(makeCancel("signal"));
1548
1547
  signal.addEventListener("abort", onAbort, { once: true });
1549
1548
  }
1550
1549
  if (timeout && timeout > 0) {
1551
- setTimeout(() => done(null), timeout);
1550
+ setTimeout(() => done(makeCancel("timeout")), timeout);
1552
1551
  }
1553
1552
  return new AskComponent(
1554
1553
  question,
@@ -1604,11 +1603,12 @@ export default function(pi: ExtensionAPI) {
1604
1603
  name: "ask_user",
1605
1604
  label: "Ask User",
1606
1605
  description:
1607
- "Ask the user a question with optional multiple-choice answers. Use this to gather information interactively. Ask exactly one focused question per call. Before calling, gather context with tools (read/web/ref) and pass a short summary via the context field.",
1606
+ "Ask the user a question with optional multiple-choice answers. Use this to gather information interactively. Ask exactly one focused question per call. Keep the `question` SHORT — one scannable line; put any reasoning, findings, or detail in your message BEFORE the call, not in the question itself.",
1608
1607
  promptSnippet:
1609
- "Ask the user one focused question with optional multiple-choice answers to gather information interactively",
1608
+ "Ask the user one short, focused question with optional multiple-choice answers to gather information interactively",
1610
1609
  promptGuidelines: [
1611
- "Before calling ask_user, gather context with tools (read/web/ref) and pass a short summary via the context field.",
1610
+ "Keep the `question` field SHORT a single scannable line (ideally under ~100 chars). The dialogue de-emphasizes it; the user reads your detail from the message rendered above the dialogue.",
1611
+ "Put context, reasoning, and findings in your assistant message BEFORE calling ask_user — do NOT pack them into the `question` field.",
1612
1612
  "Use ask_user when the user's intent is ambiguous, when a decision requires explicit user input, or when multiple valid options exist.",
1613
1613
  "Ask exactly one focused question per ask_user call.",
1614
1614
  "Do not combine multiple numbered, multipart, or unrelated questions into one ask_user prompt.",
@@ -1618,7 +1618,7 @@ export default function(pi: ExtensionAPI) {
1618
1618
  // (potentially with side effects) before the user sees the prompt.
1619
1619
  executionMode: "sequential",
1620
1620
  parameters: Type.Object({
1621
- question: Type.String({ description: "The question to ask the user" }),
1621
+ question: Type.String({ description: "The question to ask the user. Keep it SHORT — one scannable line. Put detail/reasoning in your message above the dialogue, not here." }),
1622
1622
  context: Type.Optional(
1623
1623
  Type.String({
1624
1624
  description: "Relevant context to show before the question (summary of findings)",
@@ -1645,7 +1645,7 @@ export default function(pi: ExtensionAPI) {
1645
1645
  Type.Boolean({ description: "Add a freeform text option. Default: true" }),
1646
1646
  ),
1647
1647
  allowComment: Type.Optional(
1648
- Type.Boolean({ description: "Collect an optional comment after selecting one or more options. Default: false" }),
1648
+ Type.Boolean({ description: "Collect an optional comment to append after selecting one or more options. Default: true" }),
1649
1649
  ),
1650
1650
  displayMode: Type.Optional(
1651
1651
  StringEnum(["overlay", "inline"] as const, {
@@ -1683,7 +1683,7 @@ export default function(pi: ExtensionAPI) {
1683
1683
  options: rawOptions = [],
1684
1684
  allowMultiple = false,
1685
1685
  allowFreeform = true,
1686
- allowComment = false,
1686
+ allowComment = true,
1687
1687
  displayMode,
1688
1688
  overlayToggleKey,
1689
1689
  commentToggleKey,
@@ -1712,7 +1712,7 @@ export default function(pi: ExtensionAPI) {
1712
1712
  });
1713
1713
  }
1714
1714
 
1715
- let result: AskResponse | null;
1715
+ let result: AskResponse | AskCancel | null;
1716
1716
  try {
1717
1717
  result = await askUser(ctx, {
1718
1718
  question,
@@ -1737,11 +1737,27 @@ export default function(pi: ExtensionAPI) {
1737
1737
  };
1738
1738
  }
1739
1739
 
1740
- if (result === null) {
1741
- pi.events.emit("ask:cancelled", { question, context: normalizedContext, options });
1740
+ if (result === null || isCancel(result)) {
1741
+ // Only a deliberate top-level user ESC carries reason "user"; timeout and
1742
+ // programmatic signal aborts must NOT abort the LLM turn. A bare null
1743
+ // (e.g. empty submit) is treated as non-user.
1744
+ const cancelReason: CancelReason | undefined = isCancel(result) ? result.reason : undefined;
1745
+ pi.events.emit("ask:cancelled", {
1746
+ question,
1747
+ context: normalizedContext,
1748
+ options,
1749
+ reason: cancelReason,
1750
+ });
1742
1751
  return {
1743
1752
  content: [{ type: "text" as const, text: "User cancelled the question" }],
1744
- details: { question, context: normalizedContext, options, response: null, cancelled: true } as AskToolDetails,
1753
+ details: {
1754
+ question,
1755
+ context: normalizedContext,
1756
+ options,
1757
+ response: null,
1758
+ cancelled: true,
1759
+ cancelReason,
1760
+ } as AskToolDetails,
1745
1761
  };
1746
1762
  }
1747
1763
 
@@ -47,6 +47,8 @@ interface SpawnOptions {
47
47
  onTurnEnd?: (turnCount: number) => void;
48
48
  validateCompletion?: () => string | undefined;
49
49
  maxValidationRetries?: number;
50
+ /** toolCallId of the parent tool call that spawned this subagent (for trace correlation). */
51
+ toolCallId?: string;
50
52
  }
51
53
 
52
54
  export class AgentManager {
@@ -120,6 +122,7 @@ export class AgentManager {
120
122
  private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
121
123
  record.status = "running";
122
124
  record.startedAt = Date.now();
125
+ if (options.toolCallId && !record.toolCallId) record.toolCallId = options.toolCallId;
123
126
  if (options.isBackground) this.runningBackground++;
124
127
  this.onStart?.(record);
125
128
 
@@ -156,6 +159,10 @@ export class AgentManager {
156
159
  onTextDelta: options.onTextDelta,
157
160
  validateCompletion: options.validateCompletion,
158
161
  maxValidationRetries: options.maxValidationRetries,
162
+ subagentId: id,
163
+ subagentType: type,
164
+ subagentDescription: options.description,
165
+ parentToolCallId: options.toolCallId ?? record.toolCallId,
159
166
  onSessionCreated: (session) => {
160
167
  record.session = session;
161
168
  // Flush any steers that arrived before the session was ready
@@ -283,6 +290,7 @@ export class AgentManager {
283
290
  if (activity.type === "end") record.toolUses++;
284
291
  },
285
292
  signal,
293
+ subagentId: id,
286
294
  });
287
295
  record.status = "completed";
288
296
  record.result = responseText;
@@ -24,6 +24,72 @@ import type { SubagentType, ThinkingLevel } from "./types.js";
24
24
  /** Names of tools registered by this extension that subagents must NOT inherit. */
25
25
  const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
26
26
 
27
+ const TRACER_KEY = Symbol.for("pi-pi:tracer");
28
+ const SUBAGENT_SESSION_KEY = Symbol.for("pi-pi:subagent-session");
29
+
30
+ interface PiPiTracer {
31
+ openSubagent(meta: {
32
+ subagentId: string;
33
+ type?: string;
34
+ description?: string;
35
+ parentToolCallId?: string;
36
+ parentSubagentId?: string;
37
+ depth: number;
38
+ systemPrompt?: string;
39
+ effectivePrompt?: string;
40
+ }): void;
41
+ traceSubagent(subagentId: string, kind: string, payload: Record<string, unknown>): void;
42
+ }
43
+
44
+ function getTracer(): PiPiTracer | undefined {
45
+ return (globalThis as any)[TRACER_KEY];
46
+ }
47
+
48
+ function subagentDepth(): number {
49
+ const marker = (globalThis as any)[SUBAGENT_SESSION_KEY];
50
+ return typeof marker?.depth === "number" ? marker.depth : 1;
51
+ }
52
+
53
+ function traceSubagentEvent(subagentId: string | undefined, event: AgentSessionEvent, turnIndex: number): void {
54
+ if (!subagentId) return;
55
+ const tracer = getTracer();
56
+ if (!tracer) return;
57
+ try {
58
+ switch (event.type) {
59
+ case "agent_start":
60
+ tracer.traceSubagent(subagentId, "agent_start", {});
61
+ break;
62
+ case "agent_end":
63
+ tracer.traceSubagent(subagentId, "agent_end", { messages: (event as any).messages, willRetry: (event as any).willRetry });
64
+ break;
65
+ case "turn_start":
66
+ tracer.traceSubagent(subagentId, "turn_start", { turnIndex });
67
+ break;
68
+ case "turn_end":
69
+ tracer.traceSubagent(subagentId, "turn_end", { turnIndex, message: (event as any).message, toolResults: (event as any).toolResults });
70
+ break;
71
+ case "message_start":
72
+ tracer.traceSubagent(subagentId, "message_start", { turnIndex, message: (event as any).message });
73
+ break;
74
+ case "message_update":
75
+ tracer.traceSubagent(subagentId, "message_update", { turnIndex, assistantMessageEvent: (event as any).assistantMessageEvent });
76
+ break;
77
+ case "message_end":
78
+ tracer.traceSubagent(subagentId, "message_end", { turnIndex, message: (event as any).message });
79
+ break;
80
+ case "tool_execution_start":
81
+ tracer.traceSubagent(subagentId, "tool_execution_start", { turnIndex, toolCallId: (event as any).toolCallId, toolName: (event as any).toolName, args: (event as any).args });
82
+ break;
83
+ case "tool_execution_update":
84
+ tracer.traceSubagent(subagentId, "tool_execution_update", { turnIndex, toolCallId: (event as any).toolCallId, toolName: (event as any).toolName, args: (event as any).args, partialResult: (event as any).partialResult });
85
+ break;
86
+ case "tool_execution_end":
87
+ tracer.traceSubagent(subagentId, "tool_execution_end", { turnIndex, toolCallId: (event as any).toolCallId, toolName: (event as any).toolName, result: (event as any).result, isError: (event as any).isError });
88
+ break;
89
+ }
90
+ } catch {}
91
+ }
92
+
27
93
  /** Default max turns. undefined = unlimited (no turn limit). */
28
94
  let defaultMaxTurns: number | undefined;
29
95
 
@@ -109,6 +175,11 @@ export interface RunOptions {
109
175
  */
110
176
  validateCompletion?: () => string | undefined;
111
177
  maxValidationRetries?: number;
178
+ /** Correlation IDs for session tracing. */
179
+ subagentId?: string;
180
+ subagentType?: string;
181
+ subagentDescription?: string;
182
+ parentToolCallId?: string;
112
183
  }
113
184
 
114
185
  export interface RunResult {
@@ -171,6 +242,8 @@ export async function runAgent(
171
242
 
172
243
  // Resolve working directory: worktree override > parent cwd
173
244
  const effectiveCwd = options.cwd ?? ctx.cwd;
245
+ // Shared marker with extensions/orchestrator/index.ts (SUBAGENT_SESSION_KEY).
246
+ // Canonical shape is { depth: number }; a legacy boolean true is tolerated as depth 1.
174
247
  const subagentSessionKey = Symbol.for("pi-pi:subagent-session");
175
248
  const previousSubagentSession = (globalThis as any)[subagentSessionKey];
176
249
  const previousDepth = typeof previousSubagentSession === "object" && previousSubagentSession !== null
@@ -178,7 +251,10 @@ export async function runAgent(
178
251
  : previousSubagentSession
179
252
  ? 1
180
253
  : 0;
181
- const subagentSessionState = { depth: previousDepth + 1 };
254
+ const parentSubagentId = typeof previousSubagentSession === "object" && previousSubagentSession !== null
255
+ ? (previousSubagentSession as { subagentId?: string }).subagentId
256
+ : undefined;
257
+ const subagentSessionState = { depth: previousDepth + 1, subagentId: options.subagentId };
182
258
  (globalThis as any)[subagentSessionKey] = subagentSessionState;
183
259
 
184
260
  try {
@@ -332,16 +408,7 @@ export async function runAgent(
332
408
 
333
409
  let currentMessageText = "";
334
410
  const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
335
- if (event.type === "session_shutdown") {
336
- }
337
- if (event.type === "agent_start") {
338
- }
339
- if (event.type === "turn_start") {
340
- }
341
- if (event.type === "message_start") {
342
- }
343
- if (event.type === "tool_execution_start") {
344
- }
411
+ traceSubagentEvent(options.subagentId, event, turnCount);
345
412
  if (event.type === "turn_end") {
346
413
  turnCount++;
347
414
  const msg = (event as any).message;
@@ -385,6 +452,21 @@ export async function runAgent(
385
452
  }
386
453
  }
387
454
 
455
+ if (options.subagentId) {
456
+ try {
457
+ getTracer()?.openSubagent({
458
+ subagentId: options.subagentId,
459
+ type: options.subagentType ?? type,
460
+ description: options.subagentDescription,
461
+ parentToolCallId: options.parentToolCallId,
462
+ parentSubagentId,
463
+ depth: subagentDepth(),
464
+ systemPrompt,
465
+ effectivePrompt,
466
+ });
467
+ } catch {}
468
+ }
469
+
388
470
  try {
389
471
  await session.prompt(effectivePrompt);
390
472
 
@@ -436,23 +518,34 @@ export async function runAgent(
436
518
  export async function resumeAgent(
437
519
  session: AgentSession,
438
520
  prompt: string,
439
- options: { onToolActivity?: (activity: ToolActivity) => void; signal?: AbortSignal } = {},
521
+ options: { onToolActivity?: (activity: ToolActivity) => void; signal?: AbortSignal; subagentId?: string } = {},
440
522
  ): Promise<string> {
441
523
  const collector = collectResponseText(session);
442
524
  const cleanupAbort = forwardAbortSignal(session, options.signal);
443
525
 
444
- const unsubToolUse = options.onToolActivity
445
- ? session.subscribe((event: AgentSessionEvent) => {
446
- if (event.type === "tool_execution_start") options.onToolActivity!({ type: "start", toolName: event.toolName });
447
- if (event.type === "tool_execution_end") options.onToolActivity!({ type: "end", toolName: event.toolName });
448
- })
449
- : () => {};
526
+ // Seed from prior assistant turns so resumed turn indexes don't collide with
527
+ // the turns already written to this subagent's trace file.
528
+ let resumeTurnCount = session.messages.filter((m) => m.role === "assistant").length;
529
+ const unsubTrace = session.subscribe((event: AgentSessionEvent) => {
530
+ traceSubagentEvent(options.subagentId, event, resumeTurnCount);
531
+ if (event.type === "turn_end") resumeTurnCount++;
532
+ if (options.onToolActivity) {
533
+ if (event.type === "tool_execution_start") options.onToolActivity({ type: "start", toolName: event.toolName });
534
+ if (event.type === "tool_execution_end") options.onToolActivity({ type: "end", toolName: event.toolName });
535
+ }
536
+ });
537
+
538
+ if (options.subagentId) {
539
+ try {
540
+ getTracer()?.traceSubagent(options.subagentId, "resume_start", { prompt });
541
+ } catch {}
542
+ }
450
543
 
451
544
  try {
452
545
  await session.prompt(prompt);
453
546
  } finally {
454
547
  collector.unsubscribe();
455
- unsubToolUse();
548
+ unsubTrace();
456
549
  cleanupAbort();
457
550
  }
458
551
 
@@ -397,6 +397,7 @@ Use get_subagent_result for full output.`,
397
397
  durationMs,
398
398
  modelId,
399
399
  tokens,
400
+ toolCallId: record.toolCallId,
400
401
  };
401
402
  }
402
403
 
@@ -960,6 +961,7 @@ Guidelines:
960
961
  thinkingLevel: thinking,
961
962
  isBackground: true,
962
963
  isolation,
964
+ toolCallId,
963
965
  ...bgCallbacks,
964
966
  });
965
967
 
@@ -1069,6 +1071,7 @@ Guidelines:
1069
1071
  inheritContext,
1070
1072
  thinkingLevel: thinking,
1071
1073
  isolation,
1074
+ toolCallId,
1072
1075
  ...fgCallbacks,
1073
1076
  });
1074
1077
 
@@ -1,38 +1,51 @@
1
- import type { PiPiConfig } from "../config.js";
2
- import { TOOL_ROUTING, ALL_CBM_TOOLS, EXA_TOOLS, WORKING_PRINCIPLES_READONLY, COMMUNICATION } from "./tool-routing.js";
1
+ import type { VariantConfig } from "../config.js";
2
+ import { loadAllContextFiles } from "../context.js";
3
+ import { resolveModel, getModelInfo } from "../model-registry.js";
4
+ import type { RepoInfo } from "../repo-utils.js";
5
+ import { buildRepoContext } from "./repo-context.js";
6
+ import { TOOLS_BLOCK, ALL_CBM_TOOLS, EXA_TOOLS, PRINCIPLES_BLOCK } from "./tool-routing.js";
3
7
 
4
8
  export function createBrainstormReviewerAgent(
5
9
  variant: string,
6
- config: PiPiConfig,
10
+ variants: Record<string, VariantConfig>,
7
11
  taskArtifacts: { userRequest: string; research: string; artifacts?: { name: string; content: string }[] },
8
12
  outputPath: string,
13
+ contextDirs: string[],
14
+ phase?: string,
15
+ repos: RepoInfo[] = [],
9
16
  ) {
10
- const variantConfig = config.brainstormReviewers[variant];
17
+ const variantConfig = variants[variant];
11
18
  if (!variantConfig) {
12
19
  throw new Error(`Unknown brainstorm-reviewer variant: ${variant}`);
13
20
  }
21
+ const contextFiles = loadAllContextFiles(contextDirs, "brainstormReviewer", "system", phase, getModelInfo(variantConfig.model));
22
+ const contextBlock = contextFiles.map((f) => f.content).join("\n\n");
23
+ const repoContext = buildRepoContext(repos);
14
24
 
15
25
  return {
16
26
  frontmatter: {
17
27
  description: `Brainstorm reviewer (${variant} variant, pi-pi)`,
18
28
  tools: `read, grep, find, bash, write, lsp, ast_search, ${ALL_CBM_TOOLS}, ${EXA_TOOLS}`,
19
- model: variantConfig.model,
29
+ model: resolveModel(variantConfig.model),
20
30
  thinking: variantConfig.thinking,
21
- max_turns: variantConfig.maxTurns ?? 120,
31
+ max_turns: 120,
22
32
  prompt_mode: "replace",
23
33
  },
24
34
  prompt: [
25
- "You are a research reviewer. Your job is to verify the thoroughness and accuracy of brainstorm research artifacts.",
35
+ // --- static prefix (cacheable) ---
36
+ "<constraints>",
37
+ "You are a research reviewer. You verify the thoroughness and accuracy of brainstorm research artifacts. You are a GAP-FINDER, not a perfectionist — approve by default, reject only for critical gaps (maximum 3).",
38
+ "These rules override your default helpfulness. Strict compliance is required.",
39
+ "You are READ-ONLY: you MUST NOT implement or modify any file except the single review .md file named below. You MUST write it before finishing.",
40
+ "Your review MUST begin with the verdict on the VERY FIRST LINE: `VERDICT: APPROVE` or `VERDICT: NEEDS_WORK`.",
41
+ "</constraints>",
26
42
  "",
27
- "You are a GAP-FINDER, not a perfectionist.",
28
- "Approve by default. Reject only for critical gaps — maximum 3.",
43
+ PRINCIPLES_BLOCK,
29
44
  "",
30
- WORKING_PRINCIPLES_READONLY,
31
- "",
32
- COMMUNICATION,
33
- "",
34
- TOOL_ROUTING,
45
+ TOOLS_BLOCK,
35
46
  "",
47
+ ...(contextBlock ? ["<project_context>", contextBlock, "</project_context>", ""] : []),
48
+ "<task>",
36
49
  "# Your job:",
37
50
  "1. Read USER_REQUEST.md and RESEARCH.md provided below",
38
51
  "2. INDEPENDENTLY investigate the codebase to verify claims and find gaps",
@@ -45,23 +58,22 @@ export function createBrainstormReviewerAgent(
45
58
  "- Separation: does USER_REQUEST.md contain only user-stated info (no agent findings leaked in)?",
46
59
  "- Structure: do both files follow the required format?",
47
60
  "",
48
- "# Format your review as:",
61
+ "# Format your review with the verdict on the VERY FIRST LINE, then findings:",
62
+ "VERDICT: APPROVE | NEEDS_WORK",
49
63
  "- GAPS: (missing information that would block planning)",
50
64
  "- INACCURACIES: (claims that don't match the code)",
51
65
  "- SUGGESTIONS: (improvements, not required)",
52
- "- VERDICT: APPROVE or NEEDS_WORK (with reason)",
53
66
  "",
54
67
  "subagent_type is REQUIRED when spawning subagents — calls without it are rejected:",
55
68
  '- Agent(subagent_type="Explore", ...) — codebase research. Prefer this for most lookups. Fast and cheap.',
56
69
  '- Agent(subagent_type="Librarian", ...) — external docs, library APIs, web research.',
57
70
  "Spawn multiple Explore agents in parallel for broad searches.",
71
+ "</task>",
58
72
  "",
59
73
  "# MANDATORY: Write your review to this exact file using the write tool:",
60
74
  ` ${outputPath}`,
61
75
  "",
62
- "Your task is NOT complete until this file exists. Do NOT finish without writing it.",
63
- "You MUST NOT write to any other file. Only write .md files inside .pp/state/.",
64
- "Do NOT implement, fix, or modify any source code — you are a reviewer, not an implementer.",
76
+ "You MUST write only to the review file above. Do NOT write to any other file.",
65
77
  "",
66
78
  "=== USER REQUEST ===",
67
79
  taskArtifacts.userRequest,
@@ -74,6 +86,7 @@ export function createBrainstormReviewerAgent(
74
86
  ...taskArtifacts.artifacts.flatMap((a) => [`=== ${a.name} ===`, a.content, ""]),
75
87
  ]
76
88
  : []),
89
+ ...(repoContext ? [repoContext] : []),
77
90
  "",
78
91
  "The artifacts above are already in your context. Do NOT re-read them from disk.",
79
92
  ].join("\n"),