@mediadatafusion/pi-workflow-suite 0.0.10 → 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 +67 -0
  2. package/README.md +146 -20
  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 +55 -0
  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 +60 -0
  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 +152 -55
  42. package/extensions/workflow-modes.ts +4784 -1087
  43. package/extensions/workflow-settings-capabilities.ts +10 -0
  44. package/extensions/workflow-state.ts +139 -15
  45. package/extensions/workflow-subagent-policy.ts +13 -1
  46. package/extensions/workflow-summary.ts +8 -19
  47. package/extensions/workflow-tool-guard.ts +420 -39
  48. package/extensions/workflow-validation-classifier.ts +46 -4
  49. package/extensions/workflow-web-tools.ts +361 -1
  50. package/package.json +10 -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
@@ -26,6 +26,7 @@ import { StringEnum } from "@earendil-works/pi-ai";
26
26
  import { type ExtensionAPI, getAgentDir, getMarkdownTheme, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
27
27
  import { Type } from "typebox";
28
28
  import { loadWorkflowSettings } from "../workflow-model-router.js";
29
+ import { trackSubagentPid, untrackSubagentPid } from "./runner.js";
29
30
  import { type AgentConfig, type AgentScope, type AgentSource, discoverAgents } from "./agents.js";
30
31
 
31
32
  const requireFromExtension = createRequire(import.meta.url);
@@ -106,8 +107,8 @@ class SafeContainer {
106
107
  }
107
108
  }
108
109
 
109
- const MAX_PARALLEL_TASKS = 8;
110
- const MAX_CONCURRENCY = 4;
110
+ const MAX_PARALLEL_TASKS = 16;
111
+ const DEFAULT_CONCURRENCY = 8;
111
112
  const COLLAPSED_ITEM_COUNT = 10;
112
113
  const REPOLOCK_GUARD_EXTENSION = path.join(path.dirname(new URL(import.meta.url).pathname), "repolock-guard.ts");
113
114
 
@@ -359,6 +360,7 @@ async function runSingleAgent(
359
360
  agentName: string,
360
361
  task: string,
361
362
  cwd: string | undefined,
363
+ workflowPhase: string | undefined,
362
364
  step: number | undefined,
363
365
  signal: AbortSignal | undefined,
364
366
  limits: { timeoutMinutes?: number; staleMinutes?: number } | undefined,
@@ -449,9 +451,11 @@ async function runSingleAgent(
449
451
  ...process.env,
450
452
  PI_SUBAGENT_WORKER: "1",
451
453
  PI_SUBAGENT_NAME: agent.name,
454
+ ...(workflowPhase ? { PI_WORKFLOW_SUBAGENT_PHASE: workflowPhase } : {}),
452
455
  ...(lockRoot ? { PI_WORKFLOW_REPO_LOCK_ENABLED: "1", PI_WORKFLOW_REPO_LOCK_ROOT: lockRoot } : {}),
453
456
  },
454
457
  });
458
+ if (proc.pid) trackSubagentPid(proc.pid);
455
459
  let buffer = "";
456
460
  let lastOutputAt = Date.now();
457
461
  let settled = false;
@@ -460,9 +464,9 @@ async function runSingleAgent(
460
464
  timeoutReason = reason;
461
465
  wasAborted = true;
462
466
  currentResult.errorMessage = reason;
463
- proc.kill("SIGTERM");
467
+ try { process.kill(-proc.pid!, "SIGTERM"); } catch { proc.kill("SIGTERM"); }
464
468
  setTimeout(() => {
465
- if (!proc.killed) proc.kill("SIGKILL");
469
+ if (!proc.killed) { try { process.kill(-proc.pid!, "SIGKILL"); } catch { proc.kill("SIGKILL"); } }
466
470
  }, 5000);
467
471
  };
468
472
  const timeoutTimer = setTimeout(() => stopProcess(`Sub-agent timed out after ${Math.round(timeoutMs / 60000)} minute(s).`), timeoutMs);
@@ -520,11 +524,15 @@ async function runSingleAgent(
520
524
  currentResult.stderr += data.toString();
521
525
  });
522
526
 
523
- proc.on("close", (code) => {
527
+ proc.on("close", (code) => { if (proc.pid) untrackSubagentPid(proc.pid);
524
528
  settled = true;
525
529
  clearTimeout(timeoutTimer);
526
530
  clearInterval(staleTimer);
527
531
  if (buffer.trim()) processLine(buffer);
532
+ // Kill process group to clean up background child processes
533
+ // (dev servers, static servers, tools — any program the sub-agent started).
534
+ // process.kill(-pid) signals the entire process group; works on all Unix.
535
+ try { if (proc.pid) process.kill(-proc.pid, "SIGTERM"); } catch { /* group empty */ }
528
536
  resolve(code ?? 0);
529
537
  });
530
538
 
@@ -598,7 +606,8 @@ const SubagentParams = Type.Object({
598
606
  Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }),
599
607
  ),
600
608
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
601
- });
609
+ concurrency: Type.Optional(Type.Number({ description: "Max concurrent sub-agents for parallel mode. Default: 8.", minimum: 1, maximum: 16 })),
610
+ failFast: Type.Optional(Type.Boolean({ description: "Stop remaining tasks on first failure. Default: false.", default: false })),});
602
611
 
603
612
  export default function (pi: ExtensionAPI) {
604
613
  pi.registerTool({
@@ -713,10 +722,13 @@ export default function (pi: ExtensionAPI) {
713
722
  if (params.chain && params.chain.length > 0) {
714
723
  const results: SingleResult[] = [];
715
724
  let previousOutput = "";
725
+ const chainOutputs: Record<string, string> = {};
716
726
 
717
727
  for (let i = 0; i < params.chain.length; i++) {
718
728
  const step = params.chain[i];
719
- const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
729
+ let taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
730
+ // Replace {outputs.name} with named outputs from prior steps
731
+ taskWithContext = taskWithContext.replace(/\{outputs\.([^}]+)\}/g, (_match, name: string) => chainOutputs[name.trim()] ?? `{outputs.${name}}`);
720
732
 
721
733
  // Create update callback that includes all previous results
722
734
  const chainUpdate: OnUpdateCallback | undefined = onUpdate
@@ -739,6 +751,7 @@ export default function (pi: ExtensionAPI) {
739
751
  step.agent,
740
752
  taskWithContext,
741
753
  step.cwd,
754
+ params.workflowPhase,
742
755
  i + 1,
743
756
  signal,
744
757
  subagentLimits,
@@ -747,22 +760,29 @@ export default function (pi: ExtensionAPI) {
747
760
  );
748
761
  results.push(result);
749
762
 
763
+ // ── Chain mode resiliency (#2): continue on individual failure ──
750
764
  const isError =
751
765
  result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
752
- if (isError) {
753
- const errorMsg =
754
- result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
755
- return {
756
- content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
757
- details: makeDetails("chain")(results),
758
- isError: true,
759
- };
766
+ const stepOutput = isError
767
+ ? result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(step failed)"
768
+ : getFinalOutput(result.messages);
769
+ previousOutput = stepOutput;
770
+ // Store named output for downstream {outputs.name} references
771
+ const stepAs = (step as Record<string, unknown>).as;
772
+ if (typeof stepAs === "string" && stepAs.trim()) {
773
+ chainOutputs[stepAs.trim()] = stepOutput;
760
774
  }
761
- previousOutput = getFinalOutput(result.messages);
762
775
  }
776
+ // Report all results — successes and failures
777
+ const failedSteps = results.filter((r) => r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted");
778
+ const successCount = results.length - failedSteps.length;
779
+ const summaryText = successCount === results.length
780
+ ? getFinalOutput(results[results.length - 1].messages) || "(no output)"
781
+ : `${successCount}/${results.length} steps succeeded. Failed: ${failedSteps.map((r, i) => `step ${i + 1} (${r.agent}): ${r.errorMessage || r.stderr || "(no output)"}`).join("; ")}`;
763
782
  return {
764
- content: [{ type: "text", text: getFinalOutput(results[results.length - 1].messages) || "(no output)" }],
783
+ content: [{ type: "text", text: summaryText }],
765
784
  details: makeDetails("chain")(results),
785
+ isError: failedSteps.length > 0 ? true : undefined,
766
786
  };
767
787
  }
768
788
 
@@ -807,13 +827,15 @@ export default function (pi: ExtensionAPI) {
807
827
  }
808
828
  };
809
829
 
810
- const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
830
+ const concurrency = typeof params.concurrency === "number" && params.concurrency >= 1 ? params.concurrency : DEFAULT_CONCURRENCY;
831
+ const results = await mapWithConcurrencyLimit(params.tasks, concurrency, async (t, index) => {
811
832
  const result = await runSingleAgent(
812
833
  ctx.cwd,
813
834
  agents,
814
835
  t.agent,
815
836
  t.task,
816
837
  t.cwd,
838
+ params.workflowPhase,
817
839
  undefined,
818
840
  signal,
819
841
  subagentLimits,
@@ -855,6 +877,7 @@ export default function (pi: ExtensionAPI) {
855
877
  params.agent,
856
878
  params.task,
857
879
  params.cwd,
880
+ params.workflowPhase,
858
881
  undefined,
859
882
  signal,
860
883
  subagentLimits,
@@ -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
  }