@mediadatafusion/pi-workflow-suite 0.0.11 → 0.0.12

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 (62) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +26 -17
  3. package/VERSION +1 -1
  4. package/agents/codebase-research.md +7 -5
  5. package/agents/general-worker.md +9 -7
  6. package/agents/implementation-planning.md +5 -3
  7. package/agents/quality-validation.md +9 -8
  8. package/agents/workflow-orchestrator.md +9 -7
  9. package/config/prompts/execute-approved-plan.md +12 -2
  10. package/config/prompts/mission-final-validation.md +38 -5
  11. package/config/prompts/mission-plan.md +17 -1
  12. package/config/prompts/mission-repair.md +16 -2
  13. package/config/prompts/mission-review-prompt.md +19 -6
  14. package/config/prompts/mission-run.md +18 -5
  15. package/config/prompts/validate-approved-plan.md +57 -3
  16. package/config/prompts/workflow-plan-prompt.md +11 -1
  17. package/config/prompts/workflow-repair.md +18 -2
  18. package/config/prompts/workflow-reviewer-prompt.md +25 -9
  19. package/config/prompts/workflow-summary.md +1 -4
  20. package/config/workflow-settings.example.json +13 -11
  21. package/docs/assets/mediadatafusion-logo.png +0 -0
  22. package/docs/assets/pi-workflow-suite-demo.gif +0 -0
  23. package/docs/assets/pi-workflow-suite-demo.mp4 +0 -0
  24. package/docs/assets/pi-workflow-suite-header.png +0 -0
  25. package/docs/assets/pi-workflow-suite-video-thumb.png +0 -0
  26. package/docs/assets/readme-link-commands.svg +10 -0
  27. package/docs/assets/readme-link-install.svg +10 -0
  28. package/docs/assets/readme-link-quick-start.svg +10 -0
  29. package/docs/assets/readme-link-settings.svg +10 -0
  30. package/docs/assets/screenshots/.gitkeep +1 -0
  31. package/docs/assets/screenshots/00-mission-home.png +0 -0
  32. package/docs/assets/screenshots/01-startup-Logo.png +0 -0
  33. package/docs/assets/screenshots/02-theme-settings.png +0 -0
  34. package/docs/assets/screenshots/03-GlobalSafetySettings.png +0 -0
  35. package/docs/assets/screenshots/04-SharedSubAgentsSettings.png +0 -0
  36. package/docs/assets/screenshots/05-mission-mode.png +0 -0
  37. package/docs/assets/screenshots/06-diagram-mermaid.png +0 -0
  38. package/extensions/subagent/index.ts +41 -18
  39. package/extensions/subagent/repolock-guard.ts +224 -4
  40. package/extensions/subagent/runner.ts +136 -12
  41. package/extensions/workflow-model-router.ts +124 -41
  42. package/extensions/workflow-modes.ts +3791 -967
  43. package/extensions/workflow-settings-capabilities.ts +10 -0
  44. package/extensions/workflow-state.ts +77 -10
  45. package/extensions/workflow-subagent-policy.ts +13 -1
  46. package/extensions/workflow-summary.ts +8 -19
  47. package/extensions/workflow-tool-guard.ts +326 -35
  48. package/extensions/workflow-validation-classifier.ts +46 -4
  49. package/extensions/workflow-web-tools.ts +361 -1
  50. package/package.json +9 -5
  51. package/scripts/audit-live.sh +1 -1
  52. package/scripts/build-package-export.mjs +8 -13
  53. package/scripts/check-clean-release-tree.sh +3 -2
  54. package/scripts/check-package-media.mjs +78 -0
  55. package/scripts/install-to-live.sh +2 -0
  56. package/scripts/package-media-config.mjs +28 -0
  57. package/scripts/prepare-package-readme.mjs +19 -18
  58. package/scripts/quarantine-live-junk.sh +1 -1
  59. package/scripts/verify-live.sh +9 -1
  60. package/skills/implementation-planning/SKILL.md +1 -1
  61. package/skills/safe-execution/SKILL.md +1 -1
  62. package/skills/validation-review/SKILL.md +1 -1
@@ -1,6 +1,8 @@
1
1
  import { existsSync, realpathSync } from "node:fs";
2
- import { isAbsolute, resolve } from "node:path";
2
+ import { isAbsolute, resolve, join, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { loadWorkflowSettings } from "../workflow-model-router.js";
4
6
 
5
7
  const PATH_SCOPED_TOOLS = new Set(["read", "grep", "find", "ls", "edit", "write"]);
6
8
 
@@ -40,18 +42,151 @@ function piRuntimeInstructionPath(candidate: string): boolean {
40
42
  || rel === "themes" || rel.startsWith("themes/");
41
43
  }
42
44
 
45
+ function packageInstructionPath(candidate: string): boolean {
46
+ const root = safeRealpath(join(dirname(fileURLToPath(import.meta.url)), ".."));
47
+ if (!pathInsideRoot(candidate, root)) return false;
48
+ const rel = candidate === root ? "" : candidate.slice(root.length + 1);
49
+ return rel === "skills" || rel.startsWith("skills/")
50
+ || rel === "agents" || rel.startsWith("agents/")
51
+ || rel === "config/prompts" || rel.startsWith("config/prompts/")
52
+ || rel === "prompts" || rel.startsWith("prompts/")
53
+ || rel === "themes" || rel.startsWith("themes/");
54
+ }
55
+
56
+ function piClipboardImageTempFile(candidate: string): boolean {
57
+ const base = candidate.split(/[\\/]/).pop() ?? "";
58
+ return /^pi-clipboard-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(?:png|jpg|jpeg|gif|webp|bmp|tiff|heic)$/i.test(base);
59
+ }
60
+
61
+ function piCodingAgentPackageRoot(): string | undefined {
62
+ try {
63
+ const resolver = (import.meta as ImportMeta & { resolve?: (specifier: string) => string }).resolve;
64
+ if (!resolver) return undefined;
65
+ const entry = fileURLToPath(resolver("@earendil-works/pi-coding-agent"));
66
+ return safeRealpath(resolve(dirname(entry), ".."));
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ function piCodingAgentDocsPath(candidate: string): boolean {
73
+ const root = piCodingAgentPackageRoot();
74
+ if (!root || !pathInsideRoot(candidate, root)) return false;
75
+ const rel = candidate === root ? "" : candidate.slice(root.length + 1);
76
+ return rel === "docs" || rel.startsWith("docs/");
77
+ }
78
+
79
+ function userInstalledSkillPath(candidate: string): boolean {
80
+ const home = process.env.HOME;
81
+ if (!home) return false;
82
+ const root = safeRealpath(join(home, ".agents", "skills"));
83
+ if (!pathInsideRoot(candidate, root)) return false;
84
+ const rel = candidate === root ? "" : candidate.slice(root.length + 1);
85
+ const skillName = rel.split(/[\\/]/)[0];
86
+ return Boolean(skillName && skillName !== "." && skillName !== ".." && !skillName.startsWith("."));
87
+ }
88
+
89
+ function workflowStateReadPath(candidate: string): boolean {
90
+ const root = safeRealpath(getAgentDir());
91
+ if (!pathInsideRoot(candidate, root)) return false;
92
+ const rel = candidate === root ? "" : candidate.slice(root.length + 1);
93
+ return rel === "workflows/active.json"
94
+ || rel === "workflows/plans/latest.json"
95
+ || rel === "workflows/missions/latest.json";
96
+ }
97
+
98
+ const BLOCKED_EXECUTE_BASH: RegExp[] = [
99
+ /\brm\s+-[^\n;|&]*r[^\n;|&]*f\b/i,
100
+ /\bsudo\b/i,
101
+ /\bchmod\s+-R\b/i,
102
+ /\bchown\s+-R\b/i,
103
+ /\bgit\s+reset\b/i,
104
+ /\bgit\s+clean\b/i,
105
+ /\bgit\s+push\b/i,
106
+ /\bgit\s+checkout\b/i,
107
+ /\bgit\s+switch\b/i,
108
+ /\bnpm\s+install\b/i,
109
+ /\bpnpm\s+add\b/i,
110
+ /\byarn\s+add\b/i,
111
+ /\bpip\s+install\b/i,
112
+ /\bpip3?\s+install\b/i,
113
+ /\bbundle\s+install\b/i,
114
+ /\bgem\s+install\b/i,
115
+ /\bcargo\s+install\b/i,
116
+ /\bgo\s+(?:get|install)\b/i,
117
+ /\bdeno\s+(?:install|add|cache)\b/i,
118
+ /\bcomposer\s+(?:install|require|update)\b/i,
119
+ /\bmix\s+(?:deps\.get|deps\.compile)\b/i,
120
+ /\bbrew\s+install\b/i,
121
+ /\bapt\s+(?:install|get\s+install)\b/i,
122
+ /\byum\s+install\b/i,
123
+ /\bdnf\s+install\b/i,
124
+ /\bapk\s+add\b/i,
125
+ /\bnuget\s+install\b/i,
126
+ /\bdotnet\s+(?:add\s+package|tool\s+install|restore)\b/i,
127
+ /\bcabal\s+(?:install|update)\b/i,
128
+ /\bstack\s+(?:install|update)\b/i,
129
+ /\bconan\s+install\b/i,
130
+ /\bvcpkg\s+install\b/i,
131
+ /\bcoursier\s+(?:install|fetch)\b/i,
132
+ /\bcurl\b[^\n]*\|\s*sh\b/i,
133
+ /\bwget\b[^\n]*\|\s*sh\b/i,
134
+ /\bvercel\s+deploy\b/i,
135
+ /\bdeploy\b/i,
136
+ /\bsupabase\s+db\s+push\b/i,
137
+ /\bsupabase\s+migration\s+up\b/i,
138
+ /\bmigration\b[^\n]*(run|up|execute)/i,
139
+ ];
140
+
141
+ const PACKAGE_INSTALL_RE = /\b(?:npm\s+install|pnpm\s+add|yarn\s+add|pip3?\s+install|bundle\s+install|gem\s+install|cargo\s+install|go\s+(?:get|install)|deno\s+(?:install|add|cache)|composer\s+(?:install|require|update)|mix\s+deps\.(?:get|compile)|brew\s+install|apt(?:-get)?\s+install|yum\s+install|dnf\s+install|apk\s+add|nuget\s+install|dotnet\s+(?:add\s+package|tool\s+install|restore)|cabal\s+(?:install|update)|stack\s+(?:install|update)|conan\s+install|vcpkg\s+install|coursier\s+(?:install|fetch))\b/i;
142
+
143
+ function isBlockedExecuteCommand(command: string): boolean {
144
+ return BLOCKED_EXECUTE_BASH.some((pattern) => pattern.test(command));
145
+ }
146
+
147
+ function isPackageInstallCommand(command: string): boolean {
148
+ return PACKAGE_INSTALL_RE.test(command);
149
+ }
150
+
151
+ function commandBlocked(command: string, cwd?: string): boolean {
152
+ const settings = loadWorkflowSettings(cwd);
153
+ if (settings.safety.blockDestructiveCommands === false) return false;
154
+ if (isPackageInstallCommand(command) && settings.safety.allowPackageInstallInExecution !== false) return false;
155
+ return isBlockedExecuteCommand(command);
156
+ }
157
+
43
158
  function repoLockPathBlock(pathValue: unknown, cwd: string, tool: string): string | undefined {
44
159
  if (process.env.PI_WORKFLOW_REPO_LOCK_ENABLED !== "1") return undefined;
45
160
  const root = safeRealpath(process.env.PI_WORKFLOW_REPO_LOCK_ROOT || cwd);
46
161
  const candidate = resolveCandidatePath(typeof pathValue === "string" && pathValue.trim() ? pathValue.trim() : ".", cwd);
47
162
  if (!pathInsideRoot(candidate, root)) {
48
- if ((tool === "read" || tool === "grep" || tool === "find" || tool === "ls") && piRuntimeInstructionPath(candidate)) return undefined;
163
+ if ((tool === "read" || tool === "grep" || tool === "find" || tool === "ls") && (piRuntimeInstructionPath(candidate) || packageInstructionPath(candidate) || piCodingAgentDocsPath(candidate) || userInstalledSkillPath(candidate) || workflowStateReadPath(candidate) || piClipboardImageTempFile(candidate))) return undefined;
164
+ if (candidate.startsWith("/private/tmp/") || candidate.startsWith("/tmp/") || candidate.startsWith("/var/tmp/")) return undefined;
49
165
  return `Repo Lock blocked sub-agent path outside current repository: ${candidate} (repo root: ${root})`;
50
166
  }
51
167
  if ((tool === "edit" || tool === "write") && protectedRepoPath(candidate, root)) return `Repo Lock blocked sub-agent ${tool} for protected project control path: ${candidate}`;
52
168
  return undefined;
53
169
  }
54
170
 
171
+ function stripQuotedSlashes(command: string): string {
172
+ return command
173
+ .replace(/'([^']*)'/g, (_full, content: string) => {
174
+ // If content starts with /regex/ or /regex/flags (awk/sed address pattern), mask slashes
175
+ if (/^\/[^/]+\/(?:[gimp]*$|\s)/.test(content)) return "'" + content.replace(/\//g, " ") + "'";
176
+ // If content starts with /, it's a quoted absolute path — preserve
177
+ if (content.startsWith('/')) return _full;
178
+ // Content contains / but is not a path — mask (sed expression, prose, etc.)
179
+ if (content.includes('/')) return "'" + content.replace(/\//g, " ") + "'";
180
+ return _full;
181
+ })
182
+ .replace(/"([^"]*)"/g, (_full, content: string) => {
183
+ if (/^\/[^/]+\/(?:[gimp]*$|\s)/.test(content)) return '"' + content.replace(/\//g, " ") + '"';
184
+ if (content.startsWith('/')) return _full;
185
+ if (content.includes('/')) return '"' + content.replace(/\//g, " ") + '"';
186
+ return _full;
187
+ });
188
+ }
189
+
55
190
  function stripHereDocBodies(command: string): string {
56
191
  const lines = command.split("\n");
57
192
  const kept: string[] = [];
@@ -72,21 +207,104 @@ function stripUriTokens(command: string): string {
72
207
  }
73
208
 
74
209
  function bashPathCandidates(command: string): string[] {
75
- const trimmed = stripUriTokens(stripHereDocBodies(command)).trim();
210
+ const trimmed = stripUriTokens(stripHereDocBodies(stripQuotedSlashes(command))).trim();
76
211
  if (!trimmed) return [];
77
212
  return Array.from(trimmed.matchAll(/(?:^|[\s=:'"`])((?:\.{1,2}|~|\/)[^\s'"`;&|)]*)/g)).map((match) => match[1]).filter(Boolean);
78
213
  }
79
214
 
215
+ function hasShellControlOperator(command: string): boolean {
216
+ let quote: "'" | '"' | undefined;
217
+ for (let i = 0; i < command.length; i += 1) {
218
+ const char = command[i];
219
+ if (char === "\\") { i += 1; continue; }
220
+ if (quote) {
221
+ if (char === quote) quote = undefined;
222
+ continue;
223
+ }
224
+ if (char === "'" || char === '"') { quote = char; continue; }
225
+ if (char === ";" || char === "|" || char === "&" || char === "<" || char === ">" || char === "\n") return true;
226
+ }
227
+ return quote !== undefined;
228
+ }
229
+
230
+ function shellWords(command: string): string[] | undefined {
231
+ const words: string[] = [];
232
+ let current = "";
233
+ let quote: "'" | '"' | undefined;
234
+ for (let i = 0; i < command.length; i += 1) {
235
+ const char = command[i];
236
+ if (char === "\\") {
237
+ i += 1;
238
+ current += command[i] ?? "";
239
+ continue;
240
+ }
241
+ if (quote) {
242
+ if (char === quote) quote = undefined;
243
+ else current += char;
244
+ continue;
245
+ }
246
+ if (char === "'" || char === '"') { quote = char; continue; }
247
+ if (/\s/.test(char)) {
248
+ if (current) { words.push(current); current = ""; }
249
+ continue;
250
+ }
251
+ current += char;
252
+ }
253
+ if (quote) return undefined;
254
+ if (current) words.push(current);
255
+ return words;
256
+ }
257
+
258
+ function simpleCpSourceOperands(command: string): Set<string> | undefined {
259
+ if (hasShellControlOperator(command)) return undefined;
260
+ const words = shellWords(command);
261
+ if (!words || words.length < 3) return undefined;
262
+ const commandName = words[0].split(/[\\/]/).pop();
263
+ if (commandName !== "cp") return undefined;
264
+ const operands: string[] = [];
265
+ let endOfOptions = false;
266
+ for (const word of words.slice(1)) {
267
+ if (!endOfOptions && word === "--") { endOfOptions = true; continue; }
268
+ if (!endOfOptions && word.startsWith("-")) continue;
269
+ operands.push(word);
270
+ }
271
+ if (operands.length < 2) return undefined;
272
+ return new Set(operands.slice(0, -1));
273
+ }
274
+
275
+ function simpleReadOnlyBashAllowed(command: string): boolean {
276
+ if (hasShellControlOperator(command)) return false;
277
+ const words = shellWords(command);
278
+ if (!words?.length) return false;
279
+ const commandName = words[0].split(/[\\/]/).pop();
280
+ if (commandName === "cat" || commandName === "ls" || commandName === "grep" || commandName === "rg") return true;
281
+ if (commandName !== "find") return false;
282
+ return !words.some((word) => word === "-delete" || word === "-exec" || word === "-execdir" || word === "-ok" || word === "-okdir" || word === "-fprint" || word === "-fprintf");
283
+ }
284
+
285
+ function piCodingAgentDocsBashReadAllowed(command: string): boolean {
286
+ return simpleReadOnlyBashAllowed(command);
287
+ }
288
+
80
289
  function repoLockBashBlock(command: string, cwd: string): string | undefined {
81
290
  if (process.env.PI_WORKFLOW_REPO_LOCK_ENABLED !== "1") return undefined;
82
291
  const root = safeRealpath(process.env.PI_WORKFLOW_REPO_LOCK_ROOT || cwd);
83
292
  const candidates = bashPathCandidates(command);
293
+ const cpSourceOperands = simpleCpSourceOperands(command);
84
294
  for (const raw of candidates) {
85
295
  if (raw === "." || raw === "./" || raw === "/") continue;
86
296
  const cleaned = raw.replace(/[),]+$/, "");
87
297
  if (!cleaned || cleaned.startsWith("./node_modules/.bin")) continue;
298
+ if (cleaned.startsWith("/dev/")) continue;
299
+ if (cleaned.startsWith("/tmp/") || cleaned.startsWith("/private/tmp/") || cleaned.startsWith("/var/tmp/")) continue;
88
300
  const candidate = resolveCandidatePath(cleaned, cwd);
89
- if (!pathInsideRoot(candidate, root)) return `Repo Lock blocked sub-agent bash path outside current repository: ${cleaned} -> ${candidate} (repo root: ${root})`;
301
+ if (!pathInsideRoot(candidate, root)) {
302
+ if (piCodingAgentDocsPath(candidate) && piCodingAgentDocsBashReadAllowed(command)) continue;
303
+ if (userInstalledSkillPath(candidate) && simpleReadOnlyBashAllowed(command)) continue;
304
+ if (workflowStateReadPath(candidate) && simpleReadOnlyBashAllowed(command)) continue;
305
+ if (piClipboardImageTempFile(candidate) && cpSourceOperands?.has(cleaned)) continue;
306
+ return `Repo Lock blocked sub-agent bash path outside current repository: ${cleaned} -> ${candidate} (repo root: ${root})`;
307
+ }
90
308
  }
91
309
  return undefined;
92
310
  }
@@ -101,11 +319,13 @@ export default function repoLockSubagentGuard(pi: ExtensionAPI): void {
101
319
  const command = String((event.input as { command?: unknown }).command ?? "");
102
320
  const reason = repoLockBashBlock(command, ctx.cwd);
103
321
  if (reason) return { block: true, reason };
322
+ if (commandBlocked(command, ctx.cwd)) return { block: true, reason: "Destructive or out-of-scope command blocked" };
104
323
  }
105
324
  });
106
325
 
107
326
  pi.on("user_bash", (event, ctx) => {
108
327
  const reason = repoLockBashBlock(event.command, ctx.cwd);
109
328
  if (reason) return { result: { output: reason, exitCode: 1, cancelled: false, truncated: false } };
329
+ if (commandBlocked(event.command, ctx.cwd)) return { result: { output: "Destructive command blocked", exitCode: 1, cancelled: false, truncated: false } };
110
330
  });
111
331
  }
@@ -9,6 +9,7 @@ import { execFileSync, spawn } from "node:child_process";
9
9
  import * as fs from "node:fs";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
+ import * as crypto from "node:crypto";
12
13
  import type { Message } from "@earendil-works/pi-ai";
13
14
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
14
15
  import { loadWorkflowSettings } from "../workflow-model-router.js";
@@ -18,6 +19,12 @@ export interface WorkflowSubagentTask {
18
19
  agent: string;
19
20
  task: string;
20
21
  cwd?: string;
22
+ schema?: Record<string, unknown>;
23
+ background?: boolean;
24
+ model?: string;
25
+ skills?: string;
26
+ output?: string;
27
+ workflowPhase?: string;
21
28
  }
22
29
 
23
30
  export interface WorkflowSubagentUsage {
@@ -42,6 +49,7 @@ export interface WorkflowSubagentResult {
42
49
  model?: string;
43
50
  stopReason?: string;
44
51
  errorMessage?: string;
52
+ parsedOutput?: unknown;
45
53
  }
46
54
 
47
55
  export interface WorkflowSubagentRunResult {
@@ -58,9 +66,50 @@ export interface WorkflowSubagentRunOptions {
58
66
  staleMinutes?: number;
59
67
  signal?: AbortSignal;
60
68
  onUpdate?: (results: WorkflowSubagentResult[]) => void;
69
+ concurrency?: number;
70
+ failFast?: boolean;
71
+ background?: boolean;
72
+ }
73
+
74
+ const DEFAULT_CONCURRENCY = 8;
75
+
76
+ // ── Orphan process tracking (#8) ──────────────────────────────
77
+ const trackedPids = new Set<number>();
78
+
79
+ export function trackedOrphanPids(): ReadonlySet<number> {
80
+ return trackedPids;
81
+ }
82
+
83
+ export function trackSubagentPid(pid: number): void {
84
+ trackedPids.add(pid);
85
+ }
86
+
87
+ export function untrackSubagentPid(pid: number): void {
88
+ trackedPids.delete(pid);
89
+ }
90
+
91
+ export function cleanupOrphanProcesses(): void {
92
+ for (const pid of trackedPids) {
93
+ try { process.kill(pid, "SIGTERM"); } catch { /* already dead */ }
94
+ }
95
+ trackedPids.clear();
96
+ }
97
+
98
+ // Clean up on parent exit (unexpected death)
99
+ if (typeof process.on === "function") {
100
+ process.on("exit", () => { for (const pid of trackedPids) { try { process.kill(pid, "SIGTERM"); } catch { /* ignore */ } } });
101
+ }
102
+
103
+ // ── Result caching (#6) ─────────────────────────────────────
104
+ const resultCache = new Map<string, WorkflowSubagentResult>();
105
+
106
+ function cacheKey(agent: string, task: string, cwd: string): string {
107
+ return crypto.createHash("sha256").update(`${agent}\n${task}\n${cwd}`).digest("hex");
61
108
  }
62
109
 
63
- const MAX_CONCURRENCY = 4;
110
+ export function clearSubagentResultCache(): void {
111
+ resultCache.clear();
112
+ }
64
113
  const REPOLOCK_GUARD_EXTENSION = path.join(path.dirname(new URL(import.meta.url).pathname), "repolock-guard.ts");
65
114
 
66
115
  function safeRealpath(candidate: string): string {
@@ -104,19 +153,30 @@ function finalOutput(messages: Message[]): string {
104
153
  return "";
105
154
  }
106
155
 
107
- async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: number, fn: (item: TIn, index: number) => Promise<TOut>): Promise<TOut[]> {
156
+ async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: number, fn: (item: TIn, index: number) => Promise<TOut>, failFast = false): Promise<TOut[]> {
108
157
  if (items.length === 0) return [];
109
158
  const limit = Math.max(1, Math.min(concurrency, items.length));
110
159
  const results: TOut[] = new Array(items.length);
111
160
  let nextIndex = 0;
161
+ let firstError: Error | undefined;
112
162
  const workers = new Array(limit).fill(null).map(async () => {
113
163
  while (true) {
164
+ if (failFast && firstError) return;
114
165
  const current = nextIndex++;
115
166
  if (current >= items.length) return;
116
- results[current] = await fn(items[current], current);
167
+ try {
168
+ results[current] = await fn(items[current], current);
169
+ } catch (err) {
170
+ if (failFast) {
171
+ firstError = err instanceof Error ? err : new Error(String(err));
172
+ return;
173
+ }
174
+ throw err;
175
+ }
117
176
  }
118
177
  });
119
178
  await Promise.all(workers);
179
+ if (failFast && firstError) throw firstError;
120
180
  return results;
121
181
  }
122
182
 
@@ -166,6 +226,12 @@ async function runSingleWorkflowSubagent(
166
226
 
167
227
  const lockRoot = repoLockRootForSubagent(defaultCwd);
168
228
  const effectiveCwd = resolveSubagentCwd(task.cwd, defaultCwd);
229
+
230
+ // ── Result caching (#6): check cache before spawning ──
231
+ const key = cacheKey(agent.name, task.task, effectiveCwd);
232
+ const cached = signal?.aborted ? undefined : resultCache.get(key);
233
+ if (cached) return { ...cached, output: `${cached.output}\n\n[cached]` };
234
+
169
235
  if (lockRoot && !pathInsideRoot(effectiveCwd, lockRoot)) {
170
236
  return {
171
237
  agent: task.agent,
@@ -188,7 +254,7 @@ async function runSingleWorkflowSubagent(
188
254
  const messages: Message[] = [];
189
255
  const usage: WorkflowSubagentUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
190
256
  let stderr = "";
191
- let model = agent.model;
257
+ let model = task.model || agent.model;
192
258
  let stopReason: string | undefined;
193
259
  let errorMessage: string | undefined;
194
260
 
@@ -199,7 +265,11 @@ async function runSingleWorkflowSubagent(
199
265
  tmpPromptPath = tmp.filePath;
200
266
  args.push("--append-system-prompt", tmpPromptPath);
201
267
  }
202
- args.push(`Task: ${task.task}`);
268
+ // ── Structured output (#5): inject schema if present ──
269
+ const schemaInstruction = task.schema
270
+ ? `\n\nReturn your final result as a single valid JSON object matching this schema:\n${JSON.stringify(task.schema, null, 2)}\n\nWrap ONLY the JSON object in a \`\`\`json code block at the end of your response.`
271
+ : "";
272
+ args.push(`Task: ${task.task}${schemaInstruction}`);
203
273
 
204
274
  let wasAborted = false;
205
275
  let timeoutReason = "";
@@ -216,9 +286,13 @@ async function runSingleWorkflowSubagent(
216
286
  ...process.env,
217
287
  PI_SUBAGENT_WORKER: "1",
218
288
  PI_SUBAGENT_NAME: agent.name,
289
+ ...(task.workflowPhase ? { PI_WORKFLOW_SUBAGENT_PHASE: task.workflowPhase } : {}),
219
290
  ...(lockRoot ? { PI_WORKFLOW_REPO_LOCK_ENABLED: "1", PI_WORKFLOW_REPO_LOCK_ROOT: lockRoot } : {}),
291
+ ...(task.skills ? { PI_SUBAGENT_SKILLS: task.skills } : {}),
292
+ ...(task.output ? { PI_SUBAGENT_OUTPUT: task.output } : {}),
220
293
  },
221
294
  });
295
+ trackedPids.add(proc.pid!);
222
296
  let buffer = "";
223
297
  let lastOutputAt = Date.now();
224
298
  let settled = false;
@@ -228,8 +302,8 @@ async function runSingleWorkflowSubagent(
228
302
  timeoutReason = reason;
229
303
  wasAborted = true;
230
304
  errorMessage = reason;
231
- proc.kill("SIGTERM");
232
- setTimeout(() => { if (!proc.killed) proc.kill("SIGKILL"); }, 5000);
305
+ try { process.kill(-proc.pid!, "SIGTERM"); } catch { proc.kill("SIGTERM"); }
306
+ setTimeout(() => { if (!proc.killed) { try { process.kill(-proc.pid!, "SIGKILL"); } catch { proc.kill("SIGKILL"); } } }, 5000);
233
307
  };
234
308
  const timeoutTimer = setTimeout(() => stopProcess(`Sub-agent timed out after ${Math.round(timeoutMs / 60000)} minute(s).`), timeoutMs);
235
309
  const staleTimer = setInterval(() => {
@@ -275,13 +349,19 @@ async function runSingleWorkflowSubagent(
275
349
  proc.stderr.on("data", (data) => { stderr += data.toString(); });
276
350
  proc.on("close", (code) => {
277
351
  settled = true;
352
+ trackedPids.delete(proc.pid!);
278
353
  clearTimeout(timeoutTimer);
279
354
  clearInterval(staleTimer);
280
355
  if (buffer.trim()) processLine(buffer);
356
+ // Kill process group to clean up background child processes
357
+ // (dev servers, static servers, tools — any program the sub-agent started).
358
+ // process.kill(-pid) signals the entire process group; works on all Unix.
359
+ try { if (proc.pid) process.kill(-proc.pid, "SIGTERM"); } catch { /* group empty */ }
281
360
  resolve(code ?? 0);
282
361
  });
283
362
  proc.on("error", () => {
284
363
  settled = true;
364
+ trackedPids.delete(proc.pid!);
285
365
  clearTimeout(timeoutTimer);
286
366
  clearInterval(staleTimer);
287
367
  resolve(1);
@@ -293,19 +373,41 @@ async function runSingleWorkflowSubagent(
293
373
  }
294
374
  });
295
375
 
296
- return {
376
+ const rawOutput = finalOutput(messages);
377
+ // ── Structured output (#5): try JSON parse against schema ──
378
+ let parsedOutput: unknown;
379
+ if (task.schema && rawOutput) {
380
+ const jsonMatch = rawOutput.match(/```json\s*([\s\S]*?)\s*```/);
381
+ const candidate = jsonMatch ? jsonMatch[1].trim() : rawOutput.trim();
382
+ try { parsedOutput = JSON.parse(candidate); } catch { /* free-form output, not JSON */ }
383
+ }
384
+
385
+ const result: WorkflowSubagentResult = {
297
386
  agent: agent.name,
298
387
  agentSource: agent.source,
299
388
  agentTools: agent.tools,
300
389
  task: task.task,
301
390
  exitCode: wasAborted ? 1 : exitCode,
302
- output: finalOutput(messages),
391
+ output: rawOutput,
303
392
  stderr,
304
393
  usage,
305
394
  model,
306
395
  stopReason: wasAborted ? "aborted" : stopReason,
307
396
  errorMessage: wasAborted ? (timeoutReason || "Subagent was aborted") : errorMessage,
397
+ parsedOutput,
308
398
  };
399
+
400
+ // ── Result caching (#6): store successful results ──
401
+ if (!wasAborted && exitCode === 0 && !signal?.aborted) {
402
+ resultCache.set(key, result);
403
+ }
404
+
405
+ // ── Retry-on-timeout (#3): retry once on timeout/stale ──
406
+ if (wasAborted && timeoutReason && !signal?.aborted) {
407
+ return result; // single attempt; retry is handled at the runWorkflowSubagents level
408
+ }
409
+
410
+ return result;
309
411
  } finally {
310
412
  if (tmpPromptPath) try { fs.unlinkSync(tmpPromptPath); } catch { /* ignore */ }
311
413
  if (tmpPromptDir) try { fs.rmdirSync(tmpPromptDir); } catch { /* ignore */ }
@@ -325,12 +427,34 @@ export async function runWorkflowSubagents(options: WorkflowSubagentRunOptions):
325
427
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
326
428
  }));
327
429
  options.onUpdate?.([...running]);
328
- const results = await mapWithConcurrencyLimit(options.tasks, MAX_CONCURRENCY, async (task, index) => {
329
- const result = await runSingleWorkflowSubagent(options.cwd, discovery.agents, task, options.signal, { timeoutMinutes: options.timeoutMinutes, staleMinutes: options.staleMinutes });
430
+
431
+ const executeTask = async (task: WorkflowSubagentTask, index: number): Promise<WorkflowSubagentResult> => {
432
+ const limits = { timeoutMinutes: options.timeoutMinutes, staleMinutes: options.staleMinutes };
433
+ let result = await runSingleWorkflowSubagent(options.cwd, discovery.agents, task, options.signal, limits);
434
+ // ── Retry-on-timeout (#3): retry once on timeout/stale ──
435
+ if (result.exitCode !== 0 && result.stopReason === "aborted" && result.errorMessage?.includes("timed out") && !options.signal?.aborted) {
436
+ const retryResult = await runSingleWorkflowSubagent(options.cwd, discovery.agents, task, options.signal, limits);
437
+ retryResult.output = `[retry after timeout]\n${retryResult.output}`;
438
+ result = retryResult;
439
+ }
330
440
  running[index] = result;
331
441
  options.onUpdate?.([...running]);
332
442
  return result;
333
- });
443
+ };
444
+
445
+ const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
446
+
447
+ if (options.background) {
448
+ // Fire-and-forget: start execution, don't await, deliver results via onUpdate
449
+ mapWithConcurrencyLimit(options.tasks, concurrency, executeTask, options.failFast).then((results) => {
450
+ // Results delivered via onUpdate during execution; final result available for next turn
451
+ }).catch(() => {
452
+ // Background failures are non-fatal; onUpdate already reported individual failures
453
+ });
454
+ return { agentScope, projectAgentsDir: discovery.projectAgentsDir, results: running };
455
+ }
456
+
457
+ const results = await mapWithConcurrencyLimit(options.tasks, concurrency, executeTask, options.failFast);
334
458
  return { agentScope, projectAgentsDir: discovery.projectAgentsDir, results };
335
459
  }
336
460