pi-subagents 0.30.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 (39) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +116 -17
  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 +5 -0
  9. package/src/agents/agent-management.ts +170 -6
  10. package/src/agents/agent-serializer.ts +31 -13
  11. package/src/agents/agents.ts +207 -23
  12. package/src/agents/frontmatter.ts +66 -2
  13. package/src/agents/skills.ts +117 -20
  14. package/src/extension/doctor.ts +20 -0
  15. package/src/extension/fanout-child.ts +1 -0
  16. package/src/extension/index.ts +47 -4
  17. package/src/extension/schemas.ts +10 -76
  18. package/src/intercom/intercom-bridge.ts +2 -3
  19. package/src/runs/background/async-execution.ts +14 -4
  20. package/src/runs/background/async-job-tracker.ts +56 -11
  21. package/src/runs/background/result-watcher.ts +11 -2
  22. package/src/runs/background/stale-run-reconciler.ts +9 -4
  23. package/src/runs/background/subagent-runner.ts +79 -3
  24. package/src/runs/foreground/chain-execution.ts +26 -2
  25. package/src/runs/foreground/execution.ts +113 -8
  26. package/src/runs/foreground/subagent-executor.ts +325 -77
  27. package/src/runs/shared/acceptance.ts +285 -34
  28. package/src/runs/shared/completion-guard.ts +1 -1
  29. package/src/runs/shared/dynamic-fanout.ts +4 -2
  30. package/src/runs/shared/mcp-direct-tool-allowlist.ts +2 -2
  31. package/src/runs/shared/parallel-utils.ts +6 -1
  32. package/src/runs/shared/pi-args.ts +9 -1
  33. package/src/runs/shared/single-output.ts +15 -1
  34. package/src/shared/settings.ts +1 -0
  35. package/src/shared/types.ts +8 -2
  36. package/src/shared/utils.ts +19 -1
  37. package/src/slash/prompt-template-bridge.ts +26 -3
  38. package/src/slash/slash-commands.ts +33 -3
  39. 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[] {
@@ -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
  }
@@ -49,7 +50,7 @@ const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
49
50
  ...DYNAMIC_PARALLEL_KEYS,
50
51
  "outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "maxSubagentDepth",
51
52
  "structuredOutput", "structuredOutputSchema", "tools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "completionGuard", "systemPrompt",
52
- "systemPromptMode", "thinking", "modelCandidates", "sessionFile", "effectiveAcceptance",
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,4 +1,6 @@
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;
4
6
  importAsyncRoot?: {
@@ -108,6 +110,7 @@ export interface ParallelTaskResult {
108
110
  output: string;
109
111
  exitCode: number | null;
110
112
  error?: string;
113
+ timedOut?: boolean;
111
114
  model?: string;
112
115
  attemptedModels?: string[];
113
116
  outputTargetPath?: string;
@@ -124,7 +127,9 @@ export function aggregateParallelOutputs(
124
127
  const header = headerFormat(r.taskIndex ?? i, r.agent);
125
128
  const hasOutput = Boolean(r.output?.trim());
126
129
  const status =
127
- r.exitCode === -1
130
+ r.timedOut
131
+ ? `TIMED OUT${r.error ? `: ${r.error}` : ""}`
132
+ : r.exitCode === -1
128
133
  ? "SKIPPED"
129
134
  : r.exitCode !== 0 && r.exitCode !== null
130
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,6 +39,7 @@ 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[];
42
45
  subagentOnlyExtensions?: string[];
@@ -98,7 +101,10 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
98
101
  args.push("--model", modelArg);
99
102
  }
100
103
 
101
- 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;
102
108
  const fanoutAuthorized = declaredBuiltinTools.includes("subagent");
103
109
  const toolExtensionPaths: string[] = [];
104
110
  if (input.tools?.length) {
@@ -217,6 +223,8 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
217
223
  env[STRUCTURED_OUTPUT_SCHEMA_ENV] = input.structuredOutput.schemaPath;
218
224
  }
219
225
 
226
+ env[SUBAGENT_PARENT_SESSION_ENV] = input.parentSessionId ?? process.env[SUBAGENT_PARENT_SESSION_ENV] ?? "";
227
+
220
228
  return { args, env, tempDir };
221
229
  }
222
230
 
@@ -31,9 +31,23 @@ export function resolveSingleOutputPath(
31
31
  return path.resolve(baseCwd, output);
32
32
  }
33
33
 
34
+ function formatOutputPathInstruction(outputPath: string): string {
35
+ return [
36
+ `Write your findings to exactly this path: ${outputPath}`,
37
+ "This path is authoritative for this run.",
38
+ "Ignore any other output filename or output path mentioned elsewhere, including output destinations in the base agent prompt, system prompt, or task instructions.",
39
+ ].join("\n");
40
+ }
41
+
34
42
  export function injectSingleOutputInstruction(task: string, outputPath: string | undefined): string {
35
43
  if (!outputPath) return task;
36
- return `${task}\n\n---\n**Output:** Write your findings to: ${outputPath}`;
44
+ return `${task}\n\n---\n**Output:**\n${formatOutputPathInstruction(outputPath)}`;
45
+ }
46
+
47
+ export function injectOutputPathSystemPrompt(systemPrompt: string, outputPath: string | undefined): string {
48
+ if (!outputPath) return systemPrompt;
49
+ const instruction = `Runtime output path override:\n${formatOutputPathInstruction(outputPath)}`;
50
+ return systemPrompt ? `${systemPrompt}\n\n${instruction}` : instruction;
37
51
  }
38
52
 
39
53
  function countLines(text: string): number {
@@ -305,6 +305,7 @@ function resolveChainPath(filePath: string, chainDir: string): string {
305
305
  * These are appended to the task to tell the agent what to read/write.
306
306
  */
307
307
  export function writeInitialProgressFile(progressDir: string): void {
308
+ fs.mkdirSync(progressDir, { recursive: true });
308
309
  fs.writeFileSync(path.join(progressDir, "progress.md"), INITIAL_PROGRESS_CONTENT);
309
310
  }
310
311
 
@@ -391,6 +391,7 @@ export interface SingleResult {
391
391
  detached?: boolean;
392
392
  detachedReason?: string;
393
393
  interrupted?: boolean;
394
+ timedOut?: boolean;
394
395
  messages?: Message[];
395
396
  usage: Usage;
396
397
  model?: string;
@@ -681,6 +682,7 @@ export interface ForegroundResumeRun {
681
682
  export interface SubagentState {
682
683
  baseCwd: string;
683
684
  currentSessionId: string | null;
685
+ subagentInProgress?: boolean;
684
686
  asyncJobs: Map<string, AsyncJobState>;
685
687
  foregroundRuns?: Map<string, ForegroundResumeRun>;
686
688
  foregroundControls: Map<string, {
@@ -754,9 +756,13 @@ export const SUBAGENT_RESULT_INTERCOM_DELIVERY_EVENT = "subagent:result-intercom
754
756
  // ============================================================================
755
757
 
756
758
  export interface RunSyncOptions {
759
+ /** Session id of the direct parent session for permission-system ask forwarding. */
760
+ parentSessionId?: string;
757
761
  cwd?: string;
758
762
  signal?: AbortSignal;
759
763
  interruptSignal?: AbortSignal;
764
+ timeoutMs?: number;
765
+ deadlineAt?: number;
760
766
  allowIntercomDetach?: boolean;
761
767
  intercomEvents?: IntercomEventBus;
762
768
  onUpdate?: (r: import("@earendil-works/pi-agent-core").AgentToolResult<Details>) => void;
@@ -782,7 +788,7 @@ export interface RunSyncOptions {
782
788
  availableModels?: Array<{ provider: string; id: string; fullId: string }>;
783
789
  /** Current parent-session provider to prefer for ambiguous bare model ids */
784
790
  preferredModelProvider?: string;
785
- /** Skills to inject (overrides agent default if provided) */
791
+ /** Skills to make available (overrides agent default if provided) */
786
792
  skills?: string[];
787
793
  structuredOutput?: {
788
794
  schema: JsonSchemaObject;
@@ -925,7 +931,7 @@ export const SLASH_SUBAGENT_CANCEL_EVENT = "subagent:slash:cancel";
925
931
  export const POLL_INTERVAL_MS = 250;
926
932
  export const MAX_WIDGET_JOBS = 4;
927
933
  export const DEFAULT_SUBAGENT_MAX_DEPTH = 2;
928
- export const SUBAGENT_ACTIONS = ["list", "get", "create", "update", "delete", "status", "interrupt", "resume", "append-step", "doctor"] as const;
934
+ export const SUBAGENT_ACTIONS = ["list", "get", "models", "create", "update", "delete", "status", "interrupt", "resume", "append-step", "doctor"] as const;
929
935
 
930
936
  export const DEFAULT_FORK_PREAMBLE =
931
937
  "You are a delegated subagent running from a fork of the parent session. " +
@@ -5,6 +5,7 @@
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
+ import * as piCodingAgent from "@earendil-works/pi-coding-agent";
8
9
  import type { Message } from "@earendil-works/pi-ai";
9
10
  import { formatToolCall } from "./formatters.ts";
10
11
  import type { AgentProgress, AsyncStatus, Details, DisplayItem, ErrorInfo, SingleResult, ToolCallSummary } from "./types.ts";
@@ -13,11 +14,28 @@ import type { AgentProgress, AsyncStatus, Details, DisplayItem, ErrorInfo, Singl
13
14
  // File System Utilities
14
15
  // ============================================================================
15
16
 
17
+ const DEFAULT_CONFIG_DIR_NAME = ".pi";
18
+
19
+ export function resolveConfigDirName(codingAgentModule: unknown = piCodingAgent): string {
20
+ const value = codingAgentModule && typeof codingAgentModule === "object"
21
+ ? (codingAgentModule as { CONFIG_DIR_NAME?: unknown }).CONFIG_DIR_NAME
22
+ : undefined;
23
+ return typeof value === "string" && value.trim() ? value : DEFAULT_CONFIG_DIR_NAME;
24
+ }
25
+
26
+ export function getConfigDirName(): string {
27
+ return resolveConfigDirName();
28
+ }
29
+
30
+ export function getProjectConfigDir(projectRoot: string): string {
31
+ return path.join(projectRoot, getConfigDirName());
32
+ }
33
+
16
34
  export function getAgentDir(): string {
17
35
  const configured = process.env.PI_CODING_AGENT_DIR;
18
36
  if (configured === "~") return os.homedir();
19
37
  if (configured?.startsWith("~/")) return path.join(os.homedir(), configured.slice(2));
20
- return configured || path.join(os.homedir(), ".pi", "agent");
38
+ return configured || path.join(os.homedir(), getConfigDirName(), "agent");
21
39
  }
22
40
 
23
41
  const statusCache = new Map<string, { mtime: number; status: AsyncStatus }>();