pi-subagents 0.29.0 → 0.31.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 (48) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +125 -19
  3. package/agents/context-builder.md +3 -3
  4. package/agents/planner.md +1 -1
  5. package/agents/researcher.md +1 -1
  6. package/agents/scout.md +1 -1
  7. package/package.json +7 -7
  8. package/skills/pi-subagents/SKILL.md +30 -0
  9. package/src/agents/agent-management.ts +189 -8
  10. package/src/agents/agent-serializer.ts +35 -12
  11. package/src/agents/agents.ts +243 -24
  12. package/src/agents/frontmatter.ts +66 -2
  13. package/src/agents/proactive-skills.ts +191 -0
  14. package/src/agents/skills.ts +117 -20
  15. package/src/extension/doctor.ts +20 -0
  16. package/src/extension/fanout-child.ts +2 -1
  17. package/src/extension/index.ts +50 -5
  18. package/src/extension/schemas.ts +40 -79
  19. package/src/intercom/intercom-bridge.ts +2 -3
  20. package/src/runs/background/async-execution.ts +180 -67
  21. package/src/runs/background/async-job-tracker.ts +56 -11
  22. package/src/runs/background/async-resume.ts +53 -5
  23. package/src/runs/background/async-status.ts +4 -1
  24. package/src/runs/background/chain-append.ts +282 -0
  25. package/src/runs/background/chain-root-attachment.ts +161 -0
  26. package/src/runs/background/result-watcher.ts +11 -2
  27. package/src/runs/background/run-status.ts +1 -0
  28. package/src/runs/background/stale-run-reconciler.ts +9 -4
  29. package/src/runs/background/subagent-runner.ts +158 -11
  30. package/src/runs/foreground/chain-execution.ts +26 -2
  31. package/src/runs/foreground/execution.ts +114 -8
  32. package/src/runs/foreground/subagent-executor.ts +611 -87
  33. package/src/runs/shared/acceptance.ts +285 -34
  34. package/src/runs/shared/chain-outputs.ts +23 -8
  35. package/src/runs/shared/completion-guard.ts +1 -1
  36. package/src/runs/shared/dynamic-fanout.ts +5 -3
  37. package/src/runs/shared/mcp-direct-tool-allowlist.ts +2 -2
  38. package/src/runs/shared/parallel-utils.ts +13 -1
  39. package/src/runs/shared/pi-args.ts +12 -3
  40. package/src/runs/shared/single-output.ts +15 -1
  41. package/src/runs/shared/subagent-control.ts +8 -11
  42. package/src/shared/settings.ts +1 -0
  43. package/src/shared/types.ts +17 -2
  44. package/src/shared/utils.ts +19 -1
  45. package/src/slash/prompt-template-bridge.ts +26 -3
  46. package/src/slash/slash-bridge.ts +3 -1
  47. package/src/slash/slash-commands.ts +34 -4
  48. package/src/tui/render.ts +265 -13
@@ -39,6 +39,10 @@ const VALID_EVIDENCE = new Set<AcceptanceEvidenceKind>([
39
39
  "review-findings",
40
40
  "manual-notes",
41
41
  ]);
42
+ const ACCEPTANCE_CONFIG_KEYS = new Set(["level", "criteria", "evidence", "verify", "review", "stopRules", "reason"]);
43
+ const ACCEPTANCE_GATE_KEYS = new Set(["id", "must", "evidence", "severity"]);
44
+ const ACCEPTANCE_VERIFY_KEYS = new Set(["id", "command", "timeoutMs", "cwd", "env", "allowFailure"]);
45
+ const ACCEPTANCE_REVIEW_KEYS = new Set(["agent", "focus", "required"]);
42
46
 
43
47
  function normalizeLevel(level: AcceptanceLevel | undefined): Exclude<AcceptanceLevel, "auto"> | "auto" {
44
48
  return level ?? "auto";
@@ -144,13 +148,44 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
144
148
  return errors;
145
149
  }
146
150
  const value = input as Record<string, unknown>;
151
+ for (const key of Object.keys(value)) {
152
+ if (!ACCEPTANCE_CONFIG_KEYS.has(key)) errors.push(`${pathLabel}.${key} is not supported.`);
153
+ }
147
154
  if (value.level !== undefined && (typeof value.level !== "string" || !VALID_LEVELS.has(value.level as AcceptanceLevel))) {
148
155
  errors.push(`${pathLabel}.level must be one of auto, none, attested, checked, verified, reviewed.`);
149
156
  }
150
157
  if (value.level === "none" && (typeof value.reason !== "string" || !value.reason.trim())) {
151
158
  errors.push(`${pathLabel}.reason is required when level is none.`);
152
159
  }
160
+ if (value.reason !== undefined && typeof value.reason !== "string") errors.push(`${pathLabel}.reason must be a string.`);
153
161
  if (value.criteria !== undefined && !Array.isArray(value.criteria)) errors.push(`${pathLabel}.criteria must be an array.`);
162
+ if (Array.isArray(value.criteria)) {
163
+ for (const [index, criterion] of value.criteria.entries()) {
164
+ if (typeof criterion === "string") continue;
165
+ const criterionPath = `${pathLabel}.criteria[${index}]`;
166
+ if (!criterion || typeof criterion !== "object" || Array.isArray(criterion)) {
167
+ errors.push(`${criterionPath} must be a string or an object.`);
168
+ continue;
169
+ }
170
+ const gate = criterion as Record<string, unknown>;
171
+ for (const key of Object.keys(gate)) {
172
+ if (!ACCEPTANCE_GATE_KEYS.has(key)) errors.push(`${criterionPath}.${key} is not supported.`);
173
+ }
174
+ if (typeof gate.id !== "string" || !gate.id.trim()) errors.push(`${criterionPath}.id is required.`);
175
+ if (typeof gate.must !== "string" || !gate.must.trim()) errors.push(`${criterionPath}.must is required.`);
176
+ if (gate.evidence !== undefined && !Array.isArray(gate.evidence)) errors.push(`${criterionPath}.evidence must be an array.`);
177
+ if (Array.isArray(gate.evidence)) {
178
+ for (const [evidenceIndex, item] of gate.evidence.entries()) {
179
+ if (typeof item !== "string" || !VALID_EVIDENCE.has(item as AcceptanceEvidenceKind)) {
180
+ errors.push(`${criterionPath}.evidence[${evidenceIndex}] is not a supported evidence kind.`);
181
+ }
182
+ }
183
+ }
184
+ if (gate.severity !== undefined && gate.severity !== "required" && gate.severity !== "recommended") {
185
+ errors.push(`${criterionPath}.severity must be required or recommended.`);
186
+ }
187
+ }
188
+ }
154
189
  if (Array.isArray(value.evidence)) {
155
190
  for (const [index, item] of value.evidence.entries()) {
156
191
  if (typeof item !== "string" || !VALID_EVIDENCE.has(item as AcceptanceEvidenceKind)) {
@@ -168,11 +203,46 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
168
203
  continue;
169
204
  }
170
205
  const cmd = command as Record<string, unknown>;
206
+ for (const key of Object.keys(cmd)) {
207
+ if (!ACCEPTANCE_VERIFY_KEYS.has(key)) errors.push(`${pathLabel}.verify[${index}].${key} is not supported.`);
208
+ }
171
209
  if (typeof cmd.id !== "string" || !cmd.id.trim()) errors.push(`${pathLabel}.verify[${index}].id is required.`);
172
210
  if (typeof cmd.command !== "string" || !cmd.command.trim()) errors.push(`${pathLabel}.verify[${index}].command is required.`);
173
- if (cmd.timeoutMs !== undefined && (typeof cmd.timeoutMs !== "number" || cmd.timeoutMs <= 0)) {
174
- errors.push(`${pathLabel}.verify[${index}].timeoutMs must be a positive number.`);
211
+ if (cmd.timeoutMs !== undefined && (typeof cmd.timeoutMs !== "number" || !Number.isInteger(cmd.timeoutMs) || cmd.timeoutMs < 1)) {
212
+ errors.push(`${pathLabel}.verify[${index}].timeoutMs must be an integer >= 1.`);
175
213
  }
214
+ if (cmd.cwd !== undefined && typeof cmd.cwd !== "string") errors.push(`${pathLabel}.verify[${index}].cwd must be a string.`);
215
+ if (cmd.env !== undefined) {
216
+ if (!cmd.env || typeof cmd.env !== "object" || Array.isArray(cmd.env)) {
217
+ errors.push(`${pathLabel}.verify[${index}].env must be an object.`);
218
+ } else {
219
+ for (const [envKey, envValue] of Object.entries(cmd.env as Record<string, unknown>)) {
220
+ if (typeof envValue !== "string") errors.push(`${pathLabel}.verify[${index}].env.${envKey} must be a string.`);
221
+ }
222
+ }
223
+ }
224
+ if (cmd.allowFailure !== undefined && typeof cmd.allowFailure !== "boolean") {
225
+ errors.push(`${pathLabel}.verify[${index}].allowFailure must be a boolean.`);
226
+ }
227
+ }
228
+ }
229
+ if (value.review !== undefined && value.review !== false) {
230
+ if (!value.review || typeof value.review !== "object" || Array.isArray(value.review)) {
231
+ errors.push(`${pathLabel}.review must be false or an object.`);
232
+ } else {
233
+ const review = value.review as Record<string, unknown>;
234
+ for (const key of Object.keys(review)) {
235
+ if (!ACCEPTANCE_REVIEW_KEYS.has(key)) errors.push(`${pathLabel}.review.${key} is not supported.`);
236
+ }
237
+ if (review.agent !== undefined && typeof review.agent !== "string") errors.push(`${pathLabel}.review.agent must be a string.`);
238
+ if (review.focus !== undefined && typeof review.focus !== "string") errors.push(`${pathLabel}.review.focus must be a string.`);
239
+ if (review.required !== undefined && typeof review.required !== "boolean") errors.push(`${pathLabel}.review.required must be a boolean.`);
240
+ }
241
+ }
242
+ if (value.stopRules !== undefined && !Array.isArray(value.stopRules)) errors.push(`${pathLabel}.stopRules must be an array.`);
243
+ if (Array.isArray(value.stopRules)) {
244
+ for (const [index, item] of value.stopRules.entries()) {
245
+ if (typeof item !== "string") errors.push(`${pathLabel}.stopRules[${index}] must be a string.`);
176
246
  }
177
247
  }
178
248
  return errors;
@@ -258,16 +328,19 @@ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig): st
258
328
  lines.push(
259
329
  "",
260
330
  "Finish with a fenced JSON block tagged `acceptance-report` in this shape:",
331
+ "Use empty arrays when no items apply; array fields contain strings unless object entries are shown.",
261
332
  "```acceptance-report",
262
333
  JSON.stringify({
263
334
  criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "specific proof" }],
264
- changedFiles: [],
265
- testsAddedOrUpdated: [],
335
+ changedFiles: ["src/file.ts"],
336
+ testsAddedOrUpdated: ["test/file.test.ts"],
266
337
  commandsRun: [{ command: "command", result: "passed", summary: "short result" }],
267
- validationOutput: [],
268
- residualRisks: [],
338
+ validationOutput: ["validation output or concise summary"],
339
+ residualRisks: ["none"],
269
340
  noStagedFiles: true,
270
- notes: "anything else the parent should know",
341
+ diffSummary: "short description of the diff",
342
+ reviewFindings: ["blocker: file.ts:12 - issue found, or no blockers"],
343
+ manualNotes: "anything else the parent should know",
271
344
  }, null, 2),
272
345
  "```",
273
346
  );
@@ -299,43 +372,142 @@ function extractBalancedJson(text: string, start: number): string | undefined {
299
372
  return undefined;
300
373
  }
301
374
 
302
- export function parseAcceptanceReport(output: string): { report?: AcceptanceReport; error?: string } {
303
- const fenced = [...output.matchAll(/```acceptance-report\s*\n([\s\S]*?)```/gi)]
375
+ function unwrapAcceptanceReport(value: unknown): unknown {
376
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
377
+ const record = value as { acceptance?: unknown; "acceptance-report"?: unknown };
378
+ if ("acceptance" in record) return record.acceptance;
379
+ if ("acceptance-report" in record) return record["acceptance-report"];
380
+ return value;
381
+ }
382
+
383
+ function isCommandsRunArray(value: unknown): value is NonNullable<AcceptanceReport["commandsRun"]> {
384
+ return Array.isArray(value) && value.every((item) => {
385
+ if (!item || typeof item !== "object" || Array.isArray(item)) return false;
386
+ const command = item as { command?: unknown; result?: unknown; summary?: unknown };
387
+ return typeof command.command === "string"
388
+ && (command.result === "passed" || command.result === "failed" || command.result === "not-run")
389
+ && typeof command.summary === "string";
390
+ });
391
+ }
392
+
393
+ function hasGenericAcceptanceReportSignal(value: unknown): boolean {
394
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
395
+ const record = value as Record<string, unknown>;
396
+ return "criteriaSatisfied" in record && (
397
+ isStringArray(record.changedFiles)
398
+ || isStringArray(record.testsAddedOrUpdated)
399
+ || isCommandsRunArray(record.commandsRun)
400
+ || isStringArray(record.validationOutput)
401
+ || isStringArray(record.residualRisks)
402
+ || typeof record.noStagedFiles === "boolean"
403
+ || typeof record.diffSummary === "string"
404
+ || isStringArray(record.reviewFindings)
405
+ || typeof record.manualNotes === "string"
406
+ );
407
+ }
408
+
409
+ function parseReportJson(body: string): unknown {
410
+ const trimmed = body.trim();
411
+ try {
412
+ return JSON.parse(trimmed) as unknown;
413
+ } catch (error) {
414
+ const jsonStart = trimmed.indexOf("{");
415
+ if (jsonStart > 0) {
416
+ const json = extractBalancedJson(trimmed, jsonStart);
417
+ if (json) return JSON.parse(json) as unknown;
418
+ }
419
+ throw error;
420
+ }
421
+ }
422
+
423
+ function fencedBlocks(output: string, tag: string): string[] {
424
+ return [...output.matchAll(new RegExp(`\`\`\`${tag}\\s*\\n([\\s\\S]*?)\`\`\``, "gi"))]
304
425
  .map((match) => match[1]?.trim())
305
426
  .filter((value): value is string => Boolean(value));
427
+ }
428
+
429
+ function validationPathLabelForWrapper(value: unknown): string {
430
+ if (!value || typeof value !== "object" || Array.isArray(value)) return "";
431
+ const record = value as Record<string, unknown>;
432
+ if ("acceptance" in record) return "acceptance";
433
+ if ("acceptance-report" in record) return "acceptance-report";
434
+ return "";
435
+ }
436
+
437
+ function parseAcceptanceReportBody(body: string): { report?: AcceptanceReport; errors: string[] } {
438
+ const parsed = parseReportJson(body);
439
+ const report = unwrapAcceptanceReport(parsed);
440
+ return validateAcceptanceReport(report, validationPathLabelForWrapper(parsed));
441
+ }
442
+
443
+ function parseGenericJsonAcceptanceReportBody(body: string): AcceptanceReport | undefined {
444
+ const parsed = parseReportJson(body);
445
+ const report = unwrapAcceptanceReport(parsed);
446
+ const validation = validateAcceptanceReport(report);
447
+ if (!validation.report) return undefined;
448
+ return hasGenericAcceptanceReportSignal(validation.report) ? validation.report : undefined;
449
+ }
450
+
451
+ export function parseAcceptanceReport(output: string): { report?: AcceptanceReport; error?: string } {
452
+ const fenced = fencedBlocks(output, "acceptance-report");
306
453
  const parseErrors: string[] = [];
307
454
  for (const body of fenced) {
308
455
  try {
309
- const parsed = JSON.parse(body) as unknown;
310
- const report = (parsed && typeof parsed === "object" && "acceptance" in parsed)
311
- ? (parsed as { acceptance?: unknown }).acceptance
312
- : parsed;
313
- if (isAcceptanceReport(report)) return { report };
314
- parseErrors.push("acceptance-report block does not contain a valid acceptance report");
456
+ const validation = parseAcceptanceReportBody(body);
457
+ if (validation.report) return { report: validation.report };
458
+ parseErrors.push(`Invalid acceptance-report: ${validation.errors.join("; ")}`);
315
459
  } catch (error) {
316
460
  parseErrors.push(error instanceof Error ? error.message : String(error));
317
461
  }
318
462
  }
319
463
  if (parseErrors.length > 0) return { error: `Failed to parse acceptance-report: ${parseErrors.join("; ")}` };
464
+ for (const body of fencedBlocks(output, "(?:json|jsonc|json5)")) {
465
+ try {
466
+ const report = parseGenericJsonAcceptanceReportBody(body);
467
+ if (report) return { report };
468
+ } catch {
469
+ // Ignore unrelated or malformed generic JSON fences; only explicit
470
+ // acceptance-report fences should turn parse failures into blockers.
471
+ }
472
+ }
320
473
  const markerIndex = output.search(/ACCEPTANCE_REPORT\s*:/i);
321
474
  if (markerIndex !== -1) {
322
475
  const jsonStart = output.indexOf("{", markerIndex);
323
- if (jsonStart !== -1) {
324
- const json = extractBalancedJson(output, jsonStart);
325
- if (json) {
326
- try {
327
- const parsed = JSON.parse(json) as unknown;
328
- if (isAcceptanceReport(parsed)) return { report: parsed };
329
- } catch (error) {
330
- return { error: error instanceof Error ? error.message : String(error) };
476
+ if (jsonStart !== -1) {
477
+ const json = extractBalancedJson(output, jsonStart);
478
+ if (json) {
479
+ try {
480
+ const parsed = JSON.parse(json) as unknown;
481
+ const report = unwrapAcceptanceReport(parsed);
482
+ const validation = validateAcceptanceReport(report, validationPathLabelForWrapper(parsed));
483
+ if (validation.report) return { report: validation.report };
484
+ return { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}` };
485
+ } catch (error) {
486
+ return { error: error instanceof Error ? error.message : String(error) };
487
+ }
331
488
  }
332
489
  }
333
490
  }
334
- }
335
491
  return { error: "Structured acceptance report not found." };
336
492
  }
337
493
 
338
494
  export function stripAcceptanceReport(output: string): string {
495
+ const trailingFencePattern = /\n?```(acceptance-report|json|jsonc|json5)\s*\n([\s\S]*?)```\s*/gi;
496
+ let trailingFence: { index: number; tag: string; body: string } | undefined;
497
+ for (const match of output.matchAll(trailingFencePattern)) {
498
+ const end = (match.index ?? 0) + match[0].length;
499
+ if (output.slice(end).trim().length === 0 && match[1] && match[2]) {
500
+ trailingFence = { index: match.index ?? 0, tag: match[1].toLowerCase(), body: match[2] };
501
+ }
502
+ }
503
+ if (trailingFence) {
504
+ if (trailingFence.tag === "acceptance-report") return output.slice(0, trailingFence.index).trimEnd();
505
+ try {
506
+ if (parseGenericJsonAcceptanceReportBody(trailingFence.body)) return output.slice(0, trailingFence.index).trimEnd();
507
+ } catch {
508
+ // Leave unrelated or malformed generic JSON fences visible.
509
+ }
510
+ }
339
511
  return output
340
512
  .replace(/\n?```acceptance-report\s*\n[\s\S]*?```\s*$/i, "")
341
513
  .replace(/\n?ACCEPTANCE_REPORT\s*:\s*\{[\s\S]*\}\s*$/i, "")
@@ -346,26 +518,105 @@ function isStringArray(value: unknown): value is string[] {
346
518
  return Array.isArray(value) && value.every((item) => typeof item === "string");
347
519
  }
348
520
 
349
- function isAcceptanceReport(value: unknown): value is AcceptanceReport {
350
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
521
+ function pathFor(base: string, segment: string): string {
522
+ return base ? `${base}.${segment}` : segment;
523
+ }
524
+
525
+ function describeValidationValue(value: unknown): string {
526
+ if (value === undefined) return "missing";
527
+ if (value === null) return "null";
528
+ if (Array.isArray(value)) return "array";
529
+ if (typeof value === "object") return "object";
530
+ if (typeof value === "string") {
531
+ const short = value.length > 80 ? `${value.slice(0, 77)}...` : value;
532
+ return JSON.stringify(short);
533
+ }
534
+ return `${typeof value} ${String(value)}`;
535
+ }
536
+
537
+ function pushTypeError(errors: string[], pathLabel: string, expected: string, value: unknown): void {
538
+ errors.push(`${pathLabel}: expected ${expected}; got ${describeValidationValue(value)}`);
539
+ }
540
+
541
+ function validateStringArrayField(errors: string[], value: unknown, pathLabel: string): void {
542
+ if (!Array.isArray(value)) {
543
+ pushTypeError(errors, pathLabel, "string[]", value);
544
+ return;
545
+ }
546
+ for (const [index, item] of value.entries()) {
547
+ if (typeof item !== "string") pushTypeError(errors, `${pathLabel}[${index}]`, "string", item);
548
+ }
549
+ }
550
+
551
+ function validateAcceptanceReport(value: unknown, pathLabel = ""): { report?: AcceptanceReport; errors: string[] } {
552
+ const errors: string[] = [];
553
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
554
+ pushTypeError(errors, pathLabel || "acceptance-report", "object", value);
555
+ return { errors };
556
+ }
351
557
  const report = value as AcceptanceReport;
352
558
  if (report.criteriaSatisfied !== undefined) {
353
- if (!Array.isArray(report.criteriaSatisfied)) return false;
354
- for (const item of report.criteriaSatisfied) {
355
- if (!item || typeof item !== "object" || Array.isArray(item)) return false;
356
- const criterion = item as { id?: unknown; status?: unknown; evidence?: unknown };
357
- if (criterion.id !== undefined && typeof criterion.id !== "string") return false;
358
- if (criterion.status !== "satisfied" && criterion.status !== "not-satisfied" && criterion.status !== "not-applicable") return false;
359
- if (typeof criterion.evidence !== "string" || !criterion.evidence.trim()) return false;
559
+ if (!Array.isArray(report.criteriaSatisfied)) {
560
+ pushTypeError(errors, pathFor(pathLabel, "criteriaSatisfied"), "array", report.criteriaSatisfied);
561
+ } else {
562
+ for (const [index, item] of report.criteriaSatisfied.entries()) {
563
+ const itemPath = `${pathFor(pathLabel, "criteriaSatisfied")}[${index}]`;
564
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
565
+ pushTypeError(errors, itemPath, "object", item);
566
+ continue;
567
+ }
568
+ const criterion = item as { id?: unknown; status?: unknown; evidence?: unknown };
569
+ if (criterion.id !== undefined && typeof criterion.id !== "string") pushTypeError(errors, `${itemPath}.id`, "string", criterion.id);
570
+ if (criterion.status !== "satisfied" && criterion.status !== "not-satisfied" && criterion.status !== "not-applicable") {
571
+ pushTypeError(errors, `${itemPath}.status`, "one of \"satisfied\", \"not-satisfied\", \"not-applicable\"", criterion.status);
572
+ }
573
+ if (typeof criterion.evidence !== "string" || !criterion.evidence.trim()) pushTypeError(errors, `${itemPath}.evidence`, "non-empty string", criterion.evidence);
574
+ }
575
+ }
576
+ }
577
+ if (report.changedFiles !== undefined) validateStringArrayField(errors, report.changedFiles, pathFor(pathLabel, "changedFiles"));
578
+ if (report.testsAddedOrUpdated !== undefined) validateStringArrayField(errors, report.testsAddedOrUpdated, pathFor(pathLabel, "testsAddedOrUpdated"));
579
+ if (report.commandsRun !== undefined) {
580
+ if (!Array.isArray(report.commandsRun)) {
581
+ pushTypeError(errors, pathFor(pathLabel, "commandsRun"), "array", report.commandsRun);
582
+ } else {
583
+ for (const [index, item] of report.commandsRun.entries()) {
584
+ const itemPath = `${pathFor(pathLabel, "commandsRun")}[${index}]`;
585
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
586
+ pushTypeError(errors, itemPath, "object", item);
587
+ continue;
588
+ }
589
+ const command = item as { command?: unknown; result?: unknown; summary?: unknown };
590
+ if (typeof command.command !== "string" || !command.command.trim()) pushTypeError(errors, `${itemPath}.command`, "non-empty string", command.command);
591
+ if (command.result !== "passed" && command.result !== "failed" && command.result !== "not-run") {
592
+ pushTypeError(errors, `${itemPath}.result`, "one of \"passed\", \"failed\", \"not-run\"", command.result);
593
+ }
594
+ if (typeof command.summary !== "string") pushTypeError(errors, `${itemPath}.summary`, "string", command.summary);
595
+ }
360
596
  }
361
597
  }
362
- return report.criteriaSatisfied !== undefined
598
+ if (report.validationOutput !== undefined) validateStringArrayField(errors, report.validationOutput, pathFor(pathLabel, "validationOutput"));
599
+ if (report.residualRisks !== undefined) validateStringArrayField(errors, report.residualRisks, pathFor(pathLabel, "residualRisks"));
600
+ if (report.noStagedFiles !== undefined && typeof report.noStagedFiles !== "boolean") pushTypeError(errors, pathFor(pathLabel, "noStagedFiles"), "boolean", report.noStagedFiles);
601
+ if (report.diffSummary !== undefined && typeof report.diffSummary !== "string") pushTypeError(errors, pathFor(pathLabel, "diffSummary"), "string", report.diffSummary);
602
+ if (report.reviewFindings !== undefined) validateStringArrayField(errors, report.reviewFindings, pathFor(pathLabel, "reviewFindings"));
603
+ if (report.manualNotes !== undefined && typeof report.manualNotes !== "string") pushTypeError(errors, pathFor(pathLabel, "manualNotes"), "string", report.manualNotes);
604
+ if (report.notes !== undefined && typeof report.notes !== "string") pushTypeError(errors, pathFor(pathLabel, "notes"), "string", report.notes);
605
+ if (errors.length > 0) return { errors };
606
+ const hasReportField = report.criteriaSatisfied !== undefined
363
607
  || report.changedFiles !== undefined
364
608
  || report.testsAddedOrUpdated !== undefined
365
609
  || report.commandsRun !== undefined
610
+ || report.validationOutput !== undefined
366
611
  || report.residualRisks !== undefined
612
+ || report.noStagedFiles !== undefined
613
+ || report.diffSummary !== undefined
367
614
  || report.manualNotes !== undefined
615
+ || report.notes !== undefined
368
616
  || report.reviewFindings !== undefined;
617
+ return hasReportField
618
+ ? { report, errors }
619
+ : { errors: [`${pathLabel || "acceptance-report"}: expected at least one acceptance report field`] };
369
620
  }
370
621
 
371
622
  function checkCriteriaSatisfied(criteria: ResolvedAcceptanceGate[], report: AcceptanceReport): AcceptanceRuntimeCheck[] {
@@ -8,6 +8,11 @@ const SAFE_OUTPUT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
8
8
 
9
9
  export class ChainOutputValidationError extends Error {}
10
10
 
11
+ export interface ChainOutputValidationContext {
12
+ priorOutputNames?: Iterable<string>;
13
+ startStepIndex?: number;
14
+ }
15
+
11
16
  function outputNamesForStep(step: ChainStep): string[] {
12
17
  if (isParallelStep(step)) return step.parallel.map((task) => task.as).filter((name): name is string => Boolean(name));
13
18
  if (isDynamicParallelStep(step)) return [step.collect.as];
@@ -22,27 +27,37 @@ function taskTemplatesForStep(step: ChainStep): string[] {
22
27
  }
23
28
 
24
29
  export function validateChainOutputBindings(steps: ChainStep[], dynamicFanoutConfig: DynamicFanoutConfig = {}): void {
25
- const available = new Set<string>();
26
- const seen = new Set<string>();
30
+ validateChainOutputBindingsWithContext(steps, dynamicFanoutConfig);
31
+ }
32
+
33
+ export function validateChainOutputBindingsWithContext(
34
+ steps: ChainStep[],
35
+ dynamicFanoutConfig: DynamicFanoutConfig = {},
36
+ context: ChainOutputValidationContext = {},
37
+ ): void {
38
+ const priorOutputNames = [...(context.priorOutputNames ?? [])];
39
+ const available = new Set<string>(priorOutputNames);
40
+ const seen = new Set<string>(priorOutputNames);
27
41
  for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
42
+ const displayStepIndex = (context.startStepIndex ?? 0) + stepIndex + 1;
28
43
  const step = steps[stepIndex]!;
29
44
  if (hasDynamicFanoutFields(step)) {
30
45
  if (!isDynamicParallelStep(step)) {
31
- throw new ChainOutputValidationError(`Dynamic chain step ${stepIndex + 1} requires expand, a single parallel template object, and collect; dynamic expand/collect cannot be mixed with static parallel arrays.`);
46
+ throw new ChainOutputValidationError(`Dynamic chain step ${displayStepIndex} requires expand, a single parallel template object, and collect; dynamic expand/collect cannot be mixed with static parallel arrays.`);
32
47
  }
33
48
  try {
34
- validateDynamicStepShape(step, stepIndex, dynamicFanoutConfig);
49
+ validateDynamicStepShape(step, displayStepIndex - 1, dynamicFanoutConfig);
35
50
  } catch (error) {
36
51
  if (error instanceof DynamicFanoutError) throw new ChainOutputValidationError(error.message);
37
52
  throw error;
38
53
  }
39
54
  if (!available.has(step.expand.from.output)) {
40
- throw new ChainOutputValidationError(`Dynamic chain step ${stepIndex + 1} references unknown output '${step.expand.from.output}'. Named outputs are only available after producing step/group completes.`);
55
+ throw new ChainOutputValidationError(`Dynamic chain step ${displayStepIndex} references unknown output '${step.expand.from.output}'. Named outputs are only available after producing step/group completes.`);
41
56
  }
42
57
  }
43
58
  for (const name of outputNamesForStep(step)) {
44
59
  if (!SAFE_OUTPUT_NAME_PATTERN.test(name)) {
45
- throw new ChainOutputValidationError(`Invalid chain output name '${name}' at step ${stepIndex + 1}. Use /^[A-Za-z_][A-Za-z0-9_]*$/.`);
60
+ throw new ChainOutputValidationError(`Invalid chain output name '${name}' at step ${displayStepIndex}. Use /^[A-Za-z_][A-Za-z0-9_]*$/.`);
46
61
  }
47
62
  if (seen.has(name)) {
48
63
  throw new ChainOutputValidationError(`Duplicate chain output name '${name}'. Each as name must be unique.`);
@@ -54,10 +69,10 @@ export function validateChainOutputBindings(steps: ChainStep[], dynamicFanoutCon
54
69
  const rawReference = match[0];
55
70
  const name = match[1]!;
56
71
  if (!SAFE_OUTPUT_NAME_PATTERN.test(name)) {
57
- throw new ChainOutputValidationError(`Invalid chain output reference '${rawReference}' at step ${stepIndex + 1}. Use {outputs.name} with /^[A-Za-z_][A-Za-z0-9_]*$/ names.`);
72
+ throw new ChainOutputValidationError(`Invalid chain output reference '${rawReference}' at step ${displayStepIndex}. Use {outputs.name} with /^[A-Za-z_][A-Za-z0-9_]*$/ names.`);
58
73
  }
59
74
  if (!available.has(name)) {
60
- throw new ChainOutputValidationError(`Unknown chain output reference '${rawReference}' at step ${stepIndex + 1}. Named outputs are only available after producing step/group completes.`);
75
+ throw new ChainOutputValidationError(`Unknown chain output reference '${rawReference}' at step ${displayStepIndex}. Named outputs are only available after producing step/group completes.`);
61
76
  }
62
77
  }
63
78
  }
@@ -84,7 +84,7 @@ function stripFrameworkInstructions(task: string): string {
84
84
  return task
85
85
  .split("\n")
86
86
  .filter((line) => !/^\s*\[(?:Write to|Read from):/i.test(line))
87
- .filter((line) => !/^\s*(?:Create and maintain progress at:|Update progress at:|Write your findings to:)/i.test(line))
87
+ .filter((line) => !/^\s*(?:Create and maintain progress at:|Update progress at:|\*\*Output:\*\*|Write your findings to(?: exactly this path)?:|This path is authoritative for this run\.|Ignore any other output filename or output path mentioned elsewhere)/i.test(line))
88
88
  .join("\n");
89
89
  }
90
90
 
@@ -26,6 +26,7 @@ export interface DynamicCollectedResult {
26
26
  text: string;
27
27
  structured?: unknown;
28
28
  error?: string;
29
+ timedOut?: boolean;
29
30
  outputPath?: string;
30
31
  artifactPaths?: ArtifactPaths;
31
32
  }
@@ -48,8 +49,8 @@ const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outpu
48
49
  const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
49
50
  ...DYNAMIC_PARALLEL_KEYS,
50
51
  "outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "maxSubagentDepth",
51
- "structuredOutput", "structuredOutputSchema", "tools", "extensions", "mcpDirectTools", "completionGuard", "systemPrompt",
52
- "systemPromptMode", "thinking", "modelCandidates", "sessionFile", "effectiveAcceptance",
52
+ "structuredOutput", "structuredOutputSchema", "tools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "completionGuard", "systemPrompt",
53
+ "systemPromptMode", "thinking", "modelCandidates", "sessionFile", "effectiveAcceptance", "parentSessionId",
53
54
  ]);
54
55
  const DYNAMIC_COLLECT_KEYS = new Set(["as", "outputSchema"]);
55
56
 
@@ -262,7 +263,7 @@ export function materializeDynamicParallelStep(step: DynamicParallelStep, output
262
263
  export function collectDynamicResults(
263
264
  step: DynamicParallelStep,
264
265
  items: DynamicMaterializedItem[],
265
- results: Array<Pick<SingleResult, "agent" | "exitCode" | "error" | "structuredOutput" | "artifactPaths" | "savedOutputPath"> & { output?: string; finalOutput?: string }>,
266
+ results: Array<Pick<SingleResult, "agent" | "exitCode" | "error" | "timedOut" | "structuredOutput" | "artifactPaths" | "savedOutputPath"> & { output?: string; finalOutput?: string }>,
266
267
  ): DynamicCollectedResult[] {
267
268
  return items.map((entry, index) => {
268
269
  const result = results[index];
@@ -278,6 +279,7 @@ export function collectDynamicResults(
278
279
  text,
279
280
  ...(result?.structuredOutput !== undefined ? { structured: result.structuredOutput } : {}),
280
281
  ...(result?.error ? { error: result.error } : {}),
282
+ ...(result?.timedOut ? { timedOut: true } : {}),
281
283
  ...(result?.savedOutputPath ? { outputPath: result.savedOutputPath } : {}),
282
284
  ...(result?.artifactPaths ? { artifactPaths: result.artifactPaths } : {}),
283
285
  };
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
- import { getAgentDir } from "../../shared/utils.ts";
5
+ import { getAgentDir, getProjectConfigDir } from "../../shared/utils.ts";
6
6
 
7
7
  const CACHE_VERSION = 1;
8
8
  const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -112,7 +112,7 @@ function loadMcpConfig(cwd: string): McpConfig {
112
112
  function getConfigPaths(cwd: string): string[] {
113
113
  const piGlobalPath = path.join(getAgentDir(), "mcp.json");
114
114
  const projectPath = path.resolve(cwd, ".mcp.json");
115
- const projectPiPath = path.resolve(cwd, ".pi", "mcp.json");
115
+ const projectPiPath = path.resolve(getProjectConfigDir(cwd), "mcp.json");
116
116
  const sources: string[] = [];
117
117
  if (GENERIC_GLOBAL_CONFIG_PATH !== piGlobalPath) sources.push(GENERIC_GLOBAL_CONFIG_PATH);
118
118
  sources.push(piGlobalPath);
@@ -1,6 +1,14 @@
1
1
  export interface RunnerSubagentStep {
2
+ /** Session id of the direct parent session for permission-system ask forwarding. */
3
+ parentSessionId?: string;
2
4
  agent: string;
3
5
  task: string;
6
+ importAsyncRoot?: {
7
+ runId: string;
8
+ asyncDir: string;
9
+ resultPath: string;
10
+ index: number;
11
+ };
4
12
  phase?: string;
5
13
  label?: string;
6
14
  outputName?: string;
@@ -11,6 +19,7 @@ export interface RunnerSubagentStep {
11
19
  modelCandidates?: string[];
12
20
  tools?: string[];
13
21
  extensions?: string[];
22
+ subagentOnlyExtensions?: string[];
14
23
  mcpDirectTools?: string[];
15
24
  completionGuard?: boolean;
16
25
  systemPrompt?: string | null;
@@ -101,6 +110,7 @@ export interface ParallelTaskResult {
101
110
  output: string;
102
111
  exitCode: number | null;
103
112
  error?: string;
113
+ timedOut?: boolean;
104
114
  model?: string;
105
115
  attemptedModels?: string[];
106
116
  outputTargetPath?: string;
@@ -117,7 +127,9 @@ export function aggregateParallelOutputs(
117
127
  const header = headerFormat(r.taskIndex ?? i, r.agent);
118
128
  const hasOutput = Boolean(r.output?.trim());
119
129
  const status =
120
- r.exitCode === -1
130
+ r.timedOut
131
+ ? `TIMED OUT${r.error ? `: ${r.error}` : ""}`
132
+ : r.exitCode === -1
121
133
  ? "SKIPPED"
122
134
  : r.exitCode !== 0 && r.exitCode !== null
123
135
  ? `FAILED (exit code ${r.exitCode})${r.error ? `: ${r.error}` : ""}`
@@ -25,8 +25,10 @@ export const SUBAGENT_PARENT_CHILD_INDEX_ENV = "PI_SUBAGENT_PARENT_CHILD_INDEX";
25
25
  export const SUBAGENT_PARENT_DEPTH_ENV = "PI_SUBAGENT_PARENT_DEPTH";
26
26
  export const SUBAGENT_PARENT_PATH_ENV = "PI_SUBAGENT_PARENT_PATH";
27
27
  export const SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV = "PI_SUBAGENT_PARENT_CAPABILITY_TOKEN";
28
+ export const SUBAGENT_PARENT_SESSION_ENV = "PI_SUBAGENT_PARENT_SESSION";
28
29
 
29
30
  interface BuildPiArgsInput {
31
+ parentSessionId?: string;
30
32
  baseArgs: string[];
31
33
  task: string;
32
34
  sessionEnabled: boolean;
@@ -37,8 +39,10 @@ interface BuildPiArgsInput {
37
39
  systemPromptMode?: "append" | "replace";
38
40
  inheritProjectContext: boolean;
39
41
  inheritSkills: boolean;
42
+ requireReadTool?: boolean;
40
43
  tools?: string[];
41
44
  extensions?: string[];
45
+ subagentOnlyExtensions?: string[];
42
46
  systemPrompt?: string | null;
43
47
  mcpDirectTools?: string[];
44
48
  cwd?: string;
@@ -97,7 +101,10 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
97
101
  args.push("--model", modelArg);
98
102
  }
99
103
 
100
- const declaredBuiltinTools = input.tools?.filter((tool) => !(tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js"))) ?? [];
104
+ const declaredBuiltinToolsBase = input.tools?.filter((tool) => !(tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js"))) ?? [];
105
+ const declaredBuiltinTools = input.requireReadTool && input.tools?.length && !declaredBuiltinToolsBase.includes("read")
106
+ ? ["read", ...declaredBuiltinToolsBase]
107
+ : declaredBuiltinToolsBase;
101
108
  const fanoutAuthorized = declaredBuiltinTools.includes("subagent");
102
109
  const toolExtensionPaths: string[] = [];
103
110
  if (input.tools?.length) {
@@ -120,11 +127,11 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
120
127
  : [PROMPT_RUNTIME_EXTENSION_PATH];
121
128
  if (input.extensions !== undefined) {
122
129
  args.push("--no-extensions");
123
- for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths, ...input.extensions])]) {
130
+ for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths, ...input.extensions, ...(input.subagentOnlyExtensions ?? [])])]) {
124
131
  args.push("--extension", extPath);
125
132
  }
126
133
  } else {
127
- for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths])]) {
134
+ for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths, ...(input.subagentOnlyExtensions ?? [])])]) {
128
135
  args.push("--extension", extPath);
129
136
  }
130
137
  }
@@ -216,6 +223,8 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
216
223
  env[STRUCTURED_OUTPUT_SCHEMA_ENV] = input.structuredOutput.schemaPath;
217
224
  }
218
225
 
226
+ env[SUBAGENT_PARENT_SESSION_ENV] = input.parentSessionId ?? process.env[SUBAGENT_PARENT_SESSION_ENV] ?? "";
227
+
219
228
  return { args, env, tempDir };
220
229
  }
221
230