@mediadatafusion/pi-workflow-suite 0.0.11 → 0.0.13
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.
- package/CHANGELOG.md +42 -0
- package/README.md +26 -17
- package/VERSION +1 -1
- package/agents/codebase-research.md +7 -5
- package/agents/general-worker.md +9 -7
- package/agents/implementation-planning.md +5 -3
- package/agents/quality-validation.md +9 -8
- package/agents/workflow-orchestrator.md +9 -7
- package/config/prompts/execute-approved-plan.md +12 -2
- package/config/prompts/mission-final-validation.md +38 -5
- package/config/prompts/mission-plan.md +17 -1
- package/config/prompts/mission-repair.md +16 -2
- package/config/prompts/mission-review-prompt.md +19 -6
- package/config/prompts/mission-run.md +18 -5
- package/config/prompts/validate-approved-plan.md +57 -3
- package/config/prompts/workflow-plan-prompt.md +11 -1
- package/config/prompts/workflow-repair.md +18 -2
- package/config/prompts/workflow-reviewer-prompt.md +25 -9
- package/config/prompts/workflow-summary.md +1 -4
- package/config/workflow-settings.example.json +13 -11
- package/extensions/subagent/index.ts +41 -18
- package/extensions/subagent/repolock-guard.ts +224 -4
- package/extensions/subagent/runner.ts +136 -12
- package/extensions/workflow-model-router.ts +124 -41
- package/extensions/workflow-modes.ts +3791 -967
- package/extensions/workflow-settings-capabilities.ts +10 -0
- package/extensions/workflow-state.ts +77 -10
- package/extensions/workflow-subagent-policy.ts +13 -1
- package/extensions/workflow-summary.ts +8 -19
- package/extensions/workflow-tool-guard.ts +326 -35
- package/extensions/workflow-validation-classifier.ts +46 -4
- package/extensions/workflow-web-tools.ts +361 -1
- package/package.json +8 -5
- package/scripts/audit-live.sh +1 -1
- package/scripts/build-package-export.mjs +8 -13
- package/scripts/check-clean-release-tree.sh +3 -2
- package/scripts/check-package-media.mjs +78 -0
- package/scripts/install-to-live.sh +2 -0
- package/scripts/package-media-config.mjs +28 -0
- package/scripts/prepare-package-readme.mjs +19 -18
- package/scripts/quarantine-live-junk.sh +1 -1
- package/scripts/verify-live.sh +9 -1
- package/skills/implementation-planning/SKILL.md +1 -1
- package/skills/safe-execution/SKILL.md +1 -1
- 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))
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
329
|
-
|
|
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
|
|