pi-subagents 0.65.1 → 0.67.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 (148) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +5 -4
  3. package/agents/evidence-auditor.md +34 -0
  4. package/agents/researcher.md +23 -13
  5. package/agents/reviewer.md +3 -2
  6. package/docs/agents.md +20 -3
  7. package/docs/configuration.md +25 -5
  8. package/docs/extension-api.md +124 -18
  9. package/docs/missions.md +8 -0
  10. package/docs/models.md +59 -2
  11. package/docs/observability.md +46 -6
  12. package/docs/standalone-background.md +49 -0
  13. package/docs/tool-reference.md +20 -10
  14. package/docs/watchdog.md +35 -4
  15. package/docs/workflows.md +40 -19
  16. package/inspector-runner.mjs +2 -2
  17. package/package.json +2 -1
  18. package/prompts/parallel-review.md +1 -1
  19. package/{runner-server-preload.mjs → runner-peer-preload.mjs} +8 -3
  20. package/skills/pi-subagents/SKILL.md +14 -0
  21. package/skills/pi-subagents/references/execution-controls.md +20 -5
  22. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  23. package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
  24. package/src/agents/advertised-agent-prompt.ts +94 -0
  25. package/src/agents/agent-management.ts +14 -1
  26. package/src/agents/agent-serializer.ts +2 -0
  27. package/src/agents/agents.ts +14 -0
  28. package/src/agents/builtin-names.ts +1 -0
  29. package/src/api/delegation.ts +4 -0
  30. package/src/api/preflight.ts +76 -45
  31. package/src/api/shared-types.ts +3 -1
  32. package/src/api/workflow-resources.ts +6 -0
  33. package/src/extension/fanout-child.ts +63 -4
  34. package/src/extension/index.ts +58 -8
  35. package/src/extension/public-execution.ts +4 -3
  36. package/src/extension/rpc.ts +8 -21
  37. package/src/extension/schemas.ts +71 -80
  38. package/src/extension/tool-description.ts +29 -81
  39. package/src/inspectors/actions.ts +148 -0
  40. package/src/inspectors/ghostty/actions.ts +74 -0
  41. package/src/inspectors/ghostty/plugin.ts +17 -0
  42. package/src/inspectors/herdr/actions.ts +99 -179
  43. package/src/inspectors/herdr/plugin.ts +20 -0
  44. package/src/inspectors/herdr/project-panes.ts +1 -1
  45. package/src/inspectors/{herdr/inspector-runner.ts → inspector-runner.ts} +12 -12
  46. package/src/inspectors/plugins.ts +8 -0
  47. package/src/inspectors/{herdr/session-roots-codec.ts → session-roots-codec.ts} +3 -14
  48. package/src/inspectors/types.ts +51 -0
  49. package/src/intercom/intercom-bridge.ts +50 -8
  50. package/src/intercom/native-supervisor-channel.ts +104 -67
  51. package/src/runs/background/active-async-capacity.ts +22 -18
  52. package/src/runs/background/async-execution.ts +45 -56
  53. package/src/runs/background/async-job-tracker.ts +35 -3
  54. package/src/runs/background/async-resume.ts +5 -9
  55. package/src/runs/background/async-status-snapshot.ts +10 -12
  56. package/src/runs/background/async-status.ts +17 -9
  57. package/src/runs/background/auto-drain.ts +44 -30
  58. package/src/runs/background/binary-bootstrap.ts +33 -0
  59. package/src/runs/background/chain-root-attachment.ts +8 -0
  60. package/src/runs/background/control-channel.ts +78 -44
  61. package/src/runs/background/fleet-view.ts +30 -2
  62. package/src/runs/background/notify.ts +117 -13
  63. package/src/runs/background/owned-process-tree.ts +35 -8
  64. package/src/runs/background/process-terminal.ts +23 -23
  65. package/src/runs/background/run-child-session.ts +121 -36
  66. package/src/runs/background/run-status.ts +78 -5
  67. package/src/runs/background/runner-aliases.ts +28 -9
  68. package/src/runs/background/runner-child-launch.ts +88 -0
  69. package/src/runs/background/runner-child-sessions.ts +5 -4
  70. package/src/runs/background/scheduled-runs.ts +40 -13
  71. package/src/runs/background/stale-run-reconciler.ts +3 -1
  72. package/src/runs/background/steering.ts +20 -2
  73. package/src/runs/background/subagent-runner.ts +458 -239
  74. package/src/runs/background/subagent-wait.ts +54 -8
  75. package/src/runs/background/wait-completions.ts +4 -0
  76. package/src/runs/background/wait-tool.ts +1 -1
  77. package/src/runs/foreground/async-steering-action.ts +37 -7
  78. package/src/runs/foreground/execution.ts +145 -56
  79. package/src/runs/foreground/prompt-audit.ts +3 -1
  80. package/src/runs/foreground/subagent-executor.ts +584 -297
  81. package/src/runs/foreground/workflow-detach-reconcile.ts +10 -5
  82. package/src/runs/foreground/workflow-foreground-steering.ts +57 -2
  83. package/src/runs/shared/acceptance.ts +7 -4
  84. package/src/runs/shared/agent-contract.ts +1 -1
  85. package/src/runs/shared/async-status-projection.ts +51 -47
  86. package/src/runs/shared/capability-ceiling.ts +2 -0
  87. package/src/runs/shared/child-hooks.ts +167 -3
  88. package/src/runs/shared/child-launch.ts +28 -13
  89. package/src/runs/shared/child-lifecycle.ts +6 -3
  90. package/src/runs/shared/child-runtime-config.ts +3 -1
  91. package/src/runs/shared/child-session.ts +75 -8
  92. package/src/runs/shared/child-tool-plan.ts +124 -5
  93. package/src/runs/shared/completion-evidence.ts +2 -2
  94. package/src/runs/shared/completion-guard.ts +6 -3
  95. package/src/runs/shared/effective-system-prompt.ts +33 -0
  96. package/src/runs/shared/external-cli-runner.ts +9 -7
  97. package/src/runs/shared/host-step-status.ts +11 -11
  98. package/src/runs/shared/llm-intent-arbiter.ts +21 -11
  99. package/src/runs/shared/model-fallback.ts +12 -6
  100. package/src/runs/shared/nested-events.ts +5 -5
  101. package/src/runs/shared/orca-progress-tabs.ts +7 -1
  102. package/src/runs/shared/parallel-handoff.ts +57 -12
  103. package/src/runs/shared/parallel-utils.ts +2 -2
  104. package/src/runs/shared/pi-spawn.ts +10 -0
  105. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  106. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  107. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  108. package/src/runs/shared/run-fanout-budget.ts +8 -8
  109. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  110. package/src/runs/shared/subagent-prompt-runtime.ts +20 -4
  111. package/src/runs/shared/task-intent.ts +46 -13
  112. package/src/runs/shared/workflow-async-child-guidance.ts +18 -0
  113. package/src/runs/shared/worktree-setup-command.ts +190 -0
  114. package/src/runs/shared/worktree.ts +366 -208
  115. package/src/shared/fork-context.ts +15 -72
  116. package/src/shared/launch-contract.ts +65 -2
  117. package/src/shared/opencode-session-headers.ts +30 -0
  118. package/src/shared/types.ts +85 -61
  119. package/src/shared/utils.ts +7 -2
  120. package/src/shared/workflow-child-permit.ts +18 -13
  121. package/src/slash/delegation-adapters.ts +3 -1
  122. package/src/slash/delegation-request.ts +14 -0
  123. package/src/slash/slash-commands.ts +2 -1
  124. package/src/slash/subagents-admin.ts +11 -4
  125. package/src/tui/fleet-status.ts +164 -19
  126. package/src/tui/fleet.ts +27 -19
  127. package/src/tui/render.ts +172 -33
  128. package/src/watchdog/child-status.ts +8 -0
  129. package/src/watchdog/model-selection.ts +20 -0
  130. package/src/watchdog/permission-arbiter.ts +3 -1
  131. package/src/watchdog/register-child.ts +1 -0
  132. package/src/watchdog/register-main.ts +31 -27
  133. package/src/watchdog/review.ts +132 -67
  134. package/src/watchdog/runtime.ts +82 -20
  135. package/src/watchdog/scope.ts +1 -1
  136. package/src/watchdog/settings.ts +9 -3
  137. package/src/watchdog/tool-actions.ts +13 -12
  138. package/src/watchdog/turn-delta.ts +23 -0
  139. package/src/watchdog/types.ts +4 -0
  140. package/src/workflows/chat-progress.ts +3 -3
  141. package/src/workflows/scripted-workflow.ts +275 -17
  142. package/src/workflows/workflow-checklist.ts +13 -17
  143. package/src/workflows/workflow-child-summary.ts +57 -8
  144. package/src/workflows/workflow-preflight.ts +19 -19
  145. package/src/workflows/workflow-receipt.ts +3 -3
  146. package/src/workflows/workflow-resources.ts +96 -21
  147. package/src/workflows/workflow-settlement.ts +3 -0
  148. /package/src/inspectors/{herdr/shell-command.ts → shell-command.ts} +0 -0
@@ -11,14 +11,14 @@ import {
11
11
 
12
12
  const RUN_FANOUT_ROOT = path.join(TEMP_ROOT_DIR, "run-fanout-budgets");
13
13
 
14
- interface ManifestV1 {
14
+ interface FanoutBudgetManifest {
15
15
  version: 1;
16
16
  rootRunId: string;
17
17
  limit: number;
18
18
  createdAt: number;
19
19
  }
20
20
 
21
- interface ClaimV1 {
21
+ interface FanoutBudgetClaim {
22
22
  version: 1;
23
23
  claimId: string;
24
24
  path: string;
@@ -41,16 +41,16 @@ function safeRootRunId(rootRunId: string): string {
41
41
  return rootRunId.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || randomUUID();
42
42
  }
43
43
 
44
- function parseManifest(value: unknown): ManifestV1 | undefined {
44
+ function parseManifest(value: unknown): FanoutBudgetManifest | undefined {
45
45
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
46
- const manifest = value as Partial<ManifestV1>;
46
+ const manifest = value as Partial<FanoutBudgetManifest>;
47
47
  if (manifest.version !== 1 || typeof manifest.rootRunId !== "string" || !manifest.rootRunId
48
48
  || !Number.isInteger(manifest.limit) || (manifest.limit ?? 0) <= 0
49
49
  || typeof manifest.createdAt !== "number" || !Number.isFinite(manifest.createdAt)) return undefined;
50
- return manifest as ManifestV1;
50
+ return manifest as FanoutBudgetManifest;
51
51
  }
52
52
 
53
- function readManifest(directory: string): ManifestV1 {
53
+ function readManifest(directory: string): FanoutBudgetManifest {
54
54
  let parsed: unknown;
55
55
  try {
56
56
  parsed = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf-8"));
@@ -84,7 +84,7 @@ export function createRunFanoutBudget(rootRunId: string, limit: number): RunFano
84
84
  do directory = path.join(RUN_FANOUT_ROOT, `${safeRootRunId(rootRunId)}-${randomUUID()}`);
85
85
  while (fs.existsSync(directory));
86
86
  fs.mkdirSync(path.join(directory, "claims"), { recursive: true, mode: 0o700 });
87
- const manifest: ManifestV1 = { version: 1, rootRunId, limit, createdAt: Date.now() };
87
+ const manifest: FanoutBudgetManifest = { version: 1, rootRunId, limit, createdAt: Date.now() };
88
88
  fs.writeFileSync(path.join(directory, "manifest.json"), `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: "wx" });
89
89
  return { version: 1, rootRunId, directory, limit };
90
90
  }
@@ -230,7 +230,7 @@ function commitRunFanoutBatch<T>(descriptor: RunFanoutBudgetDescriptor, paths: s
230
230
  const fd = fs.openSync(slotPath, "wx", 0o600);
231
231
  created.push(slotPath);
232
232
  try {
233
- const claim: ClaimV1 = { version: 1, claimId: randomUUID(), path: claimPath, claimedAt: Date.now() };
233
+ const claim: FanoutBudgetClaim = { version: 1, claimId: randomUUID(), path: claimPath, claimedAt: Date.now() };
234
234
  fs.writeFileSync(fd, `${JSON.stringify(claim)}\n`, "utf-8");
235
235
  } finally { fs.closeSync(fd); }
236
236
  break;
@@ -1,4 +1,4 @@
1
- import type { RuntimeAcknowledgedChildExtensionsV1 } from "../../shared/types.ts";
1
+ import type { RuntimeAcknowledgedChildExtensions } from "../../shared/types.ts";
2
2
 
3
3
  export const RUNTIME_EXTENSION_ACK_EVENT = "subagent:acknowledge-extension";
4
4
  export const MAX_RUNTIME_ACKNOWLEDGED_EXTENSION_IDS = 32;
@@ -14,7 +14,7 @@ export function isRuntimeAcknowledgedExtensionId(value: unknown): value is strin
14
14
  && !value.includes("\\");
15
15
  }
16
16
 
17
- export function projectRuntimeAcknowledgedExtensions(ids: Iterable<unknown>): RuntimeAcknowledgedChildExtensionsV1 | undefined {
17
+ export function projectRuntimeAcknowledgedExtensions(ids: Iterable<unknown>): RuntimeAcknowledgedChildExtensions | undefined {
18
18
  const unique: string[] = [];
19
19
  const seen = new Set<string>();
20
20
  for (const id of ids) {
@@ -31,7 +31,7 @@ export function projectRuntimeAcknowledgedExtensions(ids: Iterable<unknown>): Ru
31
31
  };
32
32
  }
33
33
 
34
- export function sanitizeRuntimeAcknowledgedExtensions(value: unknown): RuntimeAcknowledgedChildExtensionsV1 | undefined {
34
+ export function sanitizeRuntimeAcknowledgedExtensions(value: unknown): RuntimeAcknowledgedChildExtensions | undefined {
35
35
  if (!value || typeof value !== "object") return undefined;
36
36
  const raw = value as Record<string, unknown>;
37
37
  if (raw.version !== 1 || raw.source !== "child-runtime" || !Array.isArray(raw.ids)) return undefined;
@@ -441,15 +441,21 @@ function registerStructuredOutputTool(pi: ExtensionAPI, structured: NonNullable<
441
441
  }
442
442
 
443
443
  /** Register every child-side hook the prompt runtime owns for one child session. */
444
- export default function registerSubagentPromptRuntime(pi: ExtensionAPI, config: ChildRuntimeConfig): void {
444
+ export default function registerSubagentPromptRuntime(pi: ExtensionAPI, config?: ChildRuntimeConfig, drainObservation?: import("./readonly-drain-observation.ts").ReadonlyDrainObservation): void {
445
+ // A path-based load has no ChildRuntimeConfig. This can happen if an
446
+ // ambient-extension discovery path finds the runtime module in addition to
447
+ // the configured inline factory. It must be inert rather than crashing the
448
+ // child before startup; the inline factory remains the real registration path.
449
+ if (!config) return;
445
450
  registerRuntimeExtensionAcknowledgements(pi, config.runtimeAcknowledgements);
446
451
  registerPermissionGate(pi, config.permissions, config.childWatchdog);
447
452
  registerToolBudget(pi, config.toolBudget);
448
453
  registerChildWatchdog(pi, config.childWatchdog, config.watchdogStatus);
449
- const waitState = {
454
+ const waitState = config.runtimeState ?? {
450
455
  baseCwd: "",
451
456
  currentSessionId: null,
452
457
  asyncJobs: new Map(),
458
+ foregroundRuns: new Map(),
453
459
  foregroundControls: new Map(),
454
460
  lastForegroundControlId: null,
455
461
  cleanupTimers: new Map(),
@@ -481,8 +487,18 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI, config:
481
487
  if (diagnostic) throw new Error(formatChildToolDiagnostic(diagnostic));
482
488
  });
483
489
  onRuntimeEvent("agent_end", async (_event: unknown, ctx: unknown) => {
484
- if ((ctx as { hasUI?: boolean } | undefined)?.hasUI === true) return;
485
- await drainOutstandingWork({ state: waitState, events: pi.events });
490
+ if ((ctx as { hasUI?: boolean } | undefined)?.hasUI === true) drainObservation?.deny();
491
+ if (drainObservation) {
492
+ try {
493
+ if ((ctx as ExtensionContext)?.sessionManager?.getSessionFile() !== waitState.currentSessionId) drainObservation.deny();
494
+ } catch { drainObservation.deny(); }
495
+ }
496
+ config.holdFinalDrain?.(true);
497
+ try {
498
+ await drainOutstandingWork({ state: waitState, events: pi.events }, drainObservation);
499
+ } finally {
500
+ config.holdFinalDrain?.(false);
501
+ }
486
502
  });
487
503
  if (config.structuredOutput) registerStructuredOutputTool(pi, config.structuredOutput);
488
504
 
@@ -7,6 +7,7 @@
7
7
  * - `classifyTaskMutationIntent` / `expectsImplementationMutation`: does the
8
8
  * task REQUIRE file changes? Consumed by the completion mutation guard,
9
9
  * which blocks completion, so its vocabulary is deliberately narrow.
10
+ * Verbs inside filenames and path-like tokens do not count.
10
11
  * - `taskMayMutate`: COULD the task plausibly change files? Consumed by
11
12
  * acceptance level inference, which only raises evidence gates, so its
12
13
  * vocabulary is deliberately broad (any bare write verb).
@@ -18,16 +19,20 @@
18
19
  */
19
20
 
20
21
  const REVIEW_ONLY_PATTERNS = [
21
- /\breview only\b/i,
22
+ /\b(?:review|read)[- ]only\b/i,
23
+ /\bno\s+(?:source\s+)?edits?\b/i,
24
+ /\bwithout\s+edits?\b/i,
22
25
  /\bsuggest fixes only\b/i,
23
26
  /\bonly return findings\b/i,
24
27
  /\breturn findings only\b/i,
25
28
  ];
26
29
 
30
+ const NO_EDIT_BOUNDARY_ASSERTION_PATTERN = /\bno\s+(?:source\s+)?edits?\s*,\s*(?:commits?|pushes?|merges?|installs?|changes?|writes?)\b/i;
31
+
27
32
  const REVIEWER_REQUIRED_EDIT_PATTERNS = [
28
33
  /\bmust\s+(?:edit|modify|change|fix|patch|apply|implement)\b/i,
29
34
  /\brequired\s+to\s+(?:edit|modify|change|fix|patch|apply|implement)\b/i,
30
- /(?:^|[.!?\n]\s*)implement\s+(?:the\s+)?(?:approved|requested|specified|file|code|source|fix(?:es)?|changes?)\b/i,
35
+ /(?:^|[.!?:;,\n]\s*)implement\s+(?:the\s+)?(?:approved|requested|specified|file|code|source|fix(?:es)?|changes?)\b/i,
31
36
  /\bregardless\s+of\s+findings\b/i,
32
37
  /\balways\s+(?:edit|modify|change|fix|patch|apply|implement)\b/i,
33
38
  /\bapply\s+(?:the\s+)?fix(?:es)?\s+directly\b/i,
@@ -40,7 +45,7 @@ const REVIEWER_REQUIRED_EDIT_PATTERNS = [
40
45
  // being swallowed as the object.
41
46
  // Accept serialized line separators too: workflow prompts can carry literal
42
47
  // `\\n`/`\\r\\n` between clauses instead of decoded newlines.
43
- const NO_EDIT_PROHIBITION_PATTERN = /(?:\b|\\(?:r\\n|n))(?:do not|don't|must not)\s+(?:edit|modify|write(?:\s+to)?|touch|change)\b((?:(?!\b(?:but|and|then)\b|\\(?:r\\n|n))[^.;,:!?\n–—-])*)/gi;
48
+ const NO_EDIT_PROHIBITION_PATTERN = /(?:\b|\\(?:r\\n|n))(?:do not|don't|must not)\s+(?:edit|modify|write(?:\s+to)?|touch|change|implement)\b((?:(?!\b(?:but|and|then)\b|\\(?:r\\n|n))[^.;,:!?\n–—-])*)/gi;
44
49
  const COORDINATED_NO_EDIT_PROHIBITION_PATTERN = /(?:\b|\\(?:r\\n|n))(?:do not|don't|must not)\s+((?=(?:(?!\\(?:r\\n|n))[^.;:!?\n–—-])*\b(?:and|or)\s+(?:edit|modify|write(?:\s+to)?|touch|change)\b)(?:(?!\\(?:r\\n|n))[^.;:!?\n–—-])*?\b(?:and|or)\s+(?:edit|modify|write(?:\s+to)?|touch|change)\b(?:(?!\b(?:but|and|then)\b|\\(?:r\\n|n))[^.;,:!?\n–—-])*)/gi;
45
50
 
46
51
  /** Objects of a no-edit prohibition that mean "the codebase in general" rather than a named scope. */
@@ -85,13 +90,25 @@ const RESEARCH_AGENT_PATTERNS = [
85
90
  // CLI flags like "--fix" and genuine clause-level dashes like "branch—fix it"),
86
91
  // strip the known severity compounds (must|should|needs + dash + verb) from the
87
92
  // task text before matching. Dash coverage: ASCII hyphen + U+2010..U+2015.
88
- const SEVERITY_COMPOUND_PATTERN = /\b(?:must|should|needs)[\-\u2010-\u2015](?:fix|edit|update|add|remove|replace|create|apply|make|do|implement|modify|delete|patch)\b/gi;
93
+ const SEVERITY_COMPOUND_PATTERN = /\b(?:must|should|needs)[-\u2010-\u2015](?:fix|edit|update|add|remove|replace|create|apply|make|do|implement|modify|delete|patch)\b/gi;
89
94
 
90
95
  function stripSeverityCompounds(task: string): string {
91
96
  return task.replace(SEVERITY_COMPOUND_PATTERN, " ");
92
97
  }
93
98
  export { stripSeverityCompounds };
94
99
 
100
+ // Quoted categories in a finding-classification request describe fixes, not
101
+ // instructions to perform them. Strip only that request, leaving sibling
102
+ // imperatives (including another "must fix") subject to the normal guards.
103
+ const FINDING_CLASSIFICATION_PATTERN = /\b(?:classify|categorize|weigh)\s+(?:(?:the|remaining)\s+)*(?:findings?|items?|issues?)\s+as\s+["“]must\s+fix\s+before\s+[^"”\n]+["”]\s+(?:vs\.?|versus)\s+["“]must\s+fix\s+before\s+[^"”\n]+["”]/gi;
104
+
105
+ const PATH_LIKE_TOKEN_PATTERN = /[^\s]+[/\\][^\s]+|[^\s/\\]+\.[A-Za-z][A-Za-z0-9]{0,9}\b/g;
106
+ const PATH_INTERNAL_IMPLEMENTATION_VERB = /\b(?:implement|edit|modify|refactor|delete|update|add|remove|replace|create)\b/i;
107
+
108
+ function stripPathLikeTokens(task: string): string {
109
+ return task.replace(PATH_LIKE_TOKEN_PATTERN, (token) => (PATH_INTERNAL_IMPLEMENTATION_VERB.test(token) ? " " : token));
110
+ }
111
+
95
112
  const FIX_OR_PATCH_IMPLEMENTATION_PATTERN = /\b(?:fix|patch)\s+(?:(?:it|this|that|them|each|any|all|these|those)\b|(?:(?:a|an|the|any|all)\s+)?(?:(?:failing|failed|broken|flaky|red|cold|start|current|existing|reported|approved|known|regression|unit|integration|e2e|source|typescript|type-?script|ts|type-?check|compiler)\s+)*(?:bug|defect|issues?|problems?|failures?|regressions?|tests?|errors?|items?|typos?|code|source|implementation|component|function|module|class|method|logic|file|files|readme|docs?|changelog|package\.json|config|manifest|extension|prompt|command|lint(?:ing)?|build|ci|type-?check|type\s+checking)\b)/i;
96
113
 
97
114
  const WORKER_IMPLEMENTATION_PATTERNS = [
@@ -103,12 +120,18 @@ const WORKER_IMPLEMENTATION_PATTERNS = [
103
120
  /\bdo those fixes\b/i,
104
121
  ];
105
122
 
106
- const GENERAL_IMPLEMENTATION_PATTERNS = [
123
+ const FOLLOW_ON_IMPLEMENTATION_PATTERN = /(?:^|[.!?:;,\n])\s*(?:fix|patch|update|add|remove|replace|create|delete)\s+(?:(?:the|a|an|this|that|these|those|requested|specified|current|existing|approved|your|our)\s+)(?!(?:report|summary|findings?|analysis|recommendations?|answer|response|proposal|plan|issue|bug report)\b)[a-z][\w./-]*/i;
124
+ const ADVISORY_INFINITIVE_PATTERN = /\b(?:explain|recommend|describe)\s+how\s+to\s+(?:fix|patch|update|add|remove|replace|create|delete|implement|edit|modify|refactor)\b/i;
125
+ const EXPLICIT_IMPLEMENTATION_PATTERNS = [
107
126
  /\b(?:implement|edit|modify|refactor)\b/i,
108
- FIX_OR_PATCH_IMPLEMENTATION_PATTERN,
109
127
  /\bapply\s+(?:the\s+)?(?:(?:suggested|proposed|recommended)\s+)?(?:changes?|fix(?:es)?|patch)\b/i,
110
128
  /\bmake\s+(?:the\s+)?changes\b/i,
111
129
  /\bdo those fixes\b/i,
130
+ ];
131
+
132
+ const GENERAL_IMPLEMENTATION_PATTERNS = [
133
+ ...EXPLICIT_IMPLEMENTATION_PATTERNS,
134
+ FIX_OR_PATCH_IMPLEMENTATION_PATTERN,
112
135
  /\b(?:update|add|remove|replace|delete|create)\s+(?:the\s+)?(?:file|files|code|source|implementation|test|tests|component|function|module|class|method|logic|import|imports|readme|docs?|changelog|package\.json|config|manifest|extension|prompt|command)\b/i,
113
136
  ];
114
137
 
@@ -141,10 +164,13 @@ interface NoEditProhibitionAnalysis {
141
164
  }
142
165
 
143
166
  function analyzeNoEditProhibitions(taskText: string): NoEditProhibitionAnalysis {
167
+ const readOnlyBoundary = NO_EDIT_BOUNDARY_ASSERTION_PATTERN.test(taskText);
144
168
  let present = REVIEW_ONLY_PATTERNS.some((pattern) => pattern.test(taskText))
145
- || NO_TOOL_INTENT_PATTERNS.some((pattern) => pattern.test(taskText));
146
- let blanket = present;
147
- let strippedText = stripPatterns(taskText, [...REVIEW_ONLY_PATTERNS, ...NO_TOOL_INTENT_PATTERNS]);
169
+ || NO_TOOL_INTENT_PATTERNS.some((pattern) => pattern.test(taskText))
170
+ || readOnlyBoundary;
171
+ let blanket = false;
172
+ let strippedText = stripPatterns(taskText, [...REVIEW_ONLY_PATTERNS, ...NO_TOOL_INTENT_PATTERNS, ADVISORY_INFINITIVE_PATTERN]);
173
+ if (readOnlyBoundary) strippedText = stripPatterns(strippedText, [NO_EDIT_BOUNDARY_ASSERTION_PATTERN]);
148
174
  const stripNoEditProhibition = (match: string, object: string, offset: number, source: string): string => {
149
175
  present = true;
150
176
  if (GENERIC_PROHIBITION_OBJECT.test(object) && !hasScopedProhibitionContinuation(source.slice(offset + match.length))) blanket = true;
@@ -172,16 +198,23 @@ function hasImplementationIntent(agent: string, taskText: string): boolean {
172
198
  }
173
199
 
174
200
  export function classifyTaskMutationIntent(agent: string, task: string): TaskMutationIntent {
175
- const taskText = stripSeverityCompounds(stripFrameworkInstructions(task));
201
+ const taskText = stripPatterns(stripSeverityCompounds(stripFrameworkInstructions(task)), [FINDING_CLASSIFICATION_PATTERN]);
176
202
  const taskTextWithoutScopedConstraints = stripPatterns(taskText, SCOPED_NO_EDIT_CONSTRAINT_PATTERNS);
177
203
  const prohibitions = analyzeNoEditProhibitions(taskTextWithoutScopedConstraints);
178
204
  if (prohibitions.present) {
179
205
  if (prohibitions.blanket) return { kind: "read-only" };
180
- return hasImplementationIntent(agent, prohibitions.strippedText) ? { kind: "implementation" } : { kind: "read-only" };
206
+ const remaining = stripPathLikeTokens(prohibitions.strippedText);
207
+ if (isReviewerStyleAgent(agent)) {
208
+ return hasImplementationIntent(agent, remaining) ? { kind: "implementation" } : { kind: "read-only" };
209
+ }
210
+ return EXPLICIT_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(remaining))
211
+ || FOLLOW_ON_IMPLEMENTATION_PATTERN.test(remaining)
212
+ ? { kind: "implementation" }
213
+ : { kind: "read-only" };
181
214
  }
182
215
 
183
216
  if (RESEARCH_AGENT_PATTERNS.some((pattern) => pattern.test(agent))) return { kind: "read-only" };
184
- if (hasImplementationIntent(agent, taskText)) return { kind: "implementation" };
217
+ if (hasImplementationIntent(agent, stripPathLikeTokens(taskText))) return { kind: "implementation" };
185
218
  if (isReviewerStyleAgent(agent)) return { kind: "read-only" };
186
219
  return taskHasReadOnlyDeliverable(taskTextWithoutScopedConstraints) ? { kind: "read-only" } : { kind: "unknown" };
187
220
  }
@@ -204,7 +237,7 @@ const MAY_MUTATE_VERB_PATTERN = /\b(?:fix|implement|update|write|edit|modify|mig
204
237
  * does.
205
238
  */
206
239
  export function taskMayMutate(task: string): boolean {
207
- const taskText = stripPatterns(stripSeverityCompounds(stripFrameworkInstructions(task)), SCOPED_NO_EDIT_CONSTRAINT_PATTERNS);
240
+ const taskText = stripPatterns(stripSeverityCompounds(stripFrameworkInstructions(task)), [FINDING_CLASSIFICATION_PATTERN, ...SCOPED_NO_EDIT_CONSTRAINT_PATTERNS]);
208
241
  const prohibitions = analyzeNoEditProhibitions(taskText);
209
242
  if (prohibitions.blanket) return false;
210
243
  return MAY_MUTATE_VERB_PATTERN.test(stripPatterns(prohibitions.strippedText, READ_ONLY_DELIVERABLE_PATTERNS));
@@ -0,0 +1,18 @@
1
+ import type { AsyncStatus, SubagentState } from "../../shared/types.ts";
2
+
3
+ /** Projection-only guidance, not a control route or proof of current child liveness. */
4
+ export function workflowAsyncChildSteeringGuidance(status: AsyncStatus, state?: SubagentState): string[] {
5
+ if (status.mode !== "workflow" || (status.state !== "running" && status.state !== "queued")
6
+ || !state?.currentSessionId || status.sessionId !== state.currentSessionId
7
+ || !state.workflowControllers?.has(status.runId)) return [];
8
+ const ids = new Set((status.steps ?? []).flatMap((step) =>
9
+ step.async === true && step.status === "running"
10
+ && step.runner?.type !== "external-cli" && step.runner?.type !== "external-job"
11
+ && typeof step.runId === "string" && /^[A-Za-z0-9_-]+$/.test(step.runId) && step.runId !== status.runId
12
+ ? [step.runId] : []));
13
+ if (ids.size === 0) return [];
14
+ return [
15
+ "Async children recorded as running; direct steering rechecks controls. Choose an exact child ID (queued does not mean consumed):",
16
+ ...[...ids].map((id) => ` subagent({ action: "steer", id: "${id}", mode: "follow_up", message: "..." })`),
17
+ ];
18
+ }
@@ -0,0 +1,190 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createOwnedProcessTreeController } from "../background/owned-process-tree.ts";
3
+ import type { ProcessTreeTerminal } from "../../shared/types.ts";
4
+
5
+ export interface SetupCommandOptions {
6
+ cwd?: string;
7
+ env?: NodeJS.ProcessEnv;
8
+ input?: string;
9
+ /** Worktrunk uses 128 KiB; Git and hooks use spawnSync's 1 MiB default. */
10
+ maxBuffer?: number;
11
+ signal?: AbortSignal;
12
+ deadlineAt?: number;
13
+ /** Only the existing configured setup-hook timeout, not a new Git budget. */
14
+ hookTimeoutMs?: number;
15
+ /** Read-only probes may define negative answers as completed commands. */
16
+ acceptedExitCodes?: readonly number[];
17
+ onSpawn?: (process: { pid: number; processGroupId?: number }) => void;
18
+ }
19
+
20
+ export interface SetupCommandResult {
21
+ stdout: string;
22
+ stderr: string;
23
+ status: number | null;
24
+ signal: NodeJS.Signals | null;
25
+ error?: Error;
26
+ pid?: number;
27
+ processGroupId?: number;
28
+ /** Absent on normal completion: trusted command completion is NOT tree proof. */
29
+ processTree?: ProcessTreeTerminal;
30
+ outputIncomplete: boolean;
31
+ }
32
+
33
+ function commandError(message: string, code: string): NodeJS.ErrnoException {
34
+ return Object.assign(new Error(message), { code });
35
+ }
36
+
37
+ /**
38
+ * Setup I/O only. Commands must await their descendants before reporting success.
39
+ * Exit 0 + natural close + complete output still requires caller JSON/path validation.
40
+ * Invalid output is NOT accepted completion: retain unknown ownership, never signal
41
+ * this completed command's PID later. Callers own compensation and admission.
42
+ */
43
+ export async function runSetupCommand(
44
+ command: string,
45
+ args: string[],
46
+ options: SetupCommandOptions,
47
+ ): Promise<SetupCommandResult> {
48
+ const result: SetupCommandResult = {
49
+ stdout: "", stderr: "", status: null, signal: null, outputIncomplete: false,
50
+ };
51
+ const maxBuffer = options.maxBuffer ?? 1024 * 1024;
52
+ if (!Number.isSafeInteger(maxBuffer) || maxBuffer <= 0) throw new Error("Invalid setup command maxBuffer");
53
+ if (options.deadlineAt !== undefined && !Number.isFinite(options.deadlineAt)) throw new Error("Invalid setup deadline");
54
+ if (options.hookTimeoutMs !== undefined && (!Number.isSafeInteger(options.hookTimeoutMs) || options.hookTimeoutMs <= 0)) {
55
+ throw new Error("Invalid setup hook timeout");
56
+ }
57
+ const cancellation = (): Error | undefined => {
58
+ if (options.signal?.aborted) return commandError("Worktree setup aborted", "ABORT_ERR");
59
+ if (options.deadlineAt !== undefined && Date.now() >= options.deadlineAt) {
60
+ return commandError("Worktree setup deadline exceeded", "ETIMEDOUT");
61
+ }
62
+ return undefined;
63
+ };
64
+ result.error = cancellation();
65
+ if (result.error) return result;
66
+
67
+ let child;
68
+ try {
69
+ child = spawn(command, args, {
70
+ cwd: options.cwd,
71
+ env: options.env ? { ...process.env, ...options.env } : process.env,
72
+ stdio: "pipe", shell: false, windowsHide: true,
73
+ // Isolated owned POSIX group, never unref'd or fire-and-forget.
74
+ detached: process.platform !== "win32",
75
+ });
76
+ } catch (error) {
77
+ result.error = error instanceof Error ? error : new Error(String(error));
78
+ return result;
79
+ }
80
+ result.pid = child.pid;
81
+ if (child.pid !== undefined && process.platform !== "win32") result.processGroupId = child.pid;
82
+ const tree = child.pid === undefined ? undefined : createOwnedProcessTreeController(child.pid);
83
+ let termination: Promise<ProcessTreeTerminal> | undefined;
84
+ let directSettled = false;
85
+ const releaseUnknownIO = () => {
86
+ if (!directSettled || result.processTree?.state !== "unknown") return;
87
+ result.error ??= commandError("Worktree setup process tree settlement is unverified", "PROCESS_TREE_UNVERIFIED");
88
+ result.outputIncomplete = true;
89
+ // Only local I/O is released; unknown descendant ownership remains retained.
90
+ child.stdin.destroy();
91
+ child.stdout.destroy();
92
+ child.stderr.destroy();
93
+ };
94
+ const terminate = () => {
95
+ if (termination || !tree) return;
96
+ if (directSettled) {
97
+ result.processTree = {
98
+ state: "unknown", reason: "verification-failed",
99
+ diagnostic: "Command exited before termination began; no longer safe to signal its recorded PID.",
100
+ };
101
+ releaseUnknownIO();
102
+ return;
103
+ }
104
+ termination = tree.terminate().then((proof) => {
105
+ result.processTree = proof;
106
+ releaseUnknownIO();
107
+ return proof;
108
+ });
109
+ };
110
+ const fail = (error: Error) => {
111
+ result.error ??= error;
112
+ terminate();
113
+ };
114
+ const onAbort = () => fail(commandError("Worktree setup aborted", "ABORT_ERR"));
115
+ const stdout: Buffer[] = [];
116
+ const stderr: Buffer[] = [];
117
+ let capturedBytes = 0;
118
+ const capture = (chunks: Buffer[], chunk: Buffer) => {
119
+ const remaining = maxBuffer - capturedBytes;
120
+ if (remaining > 0) {
121
+ const saved = Buffer.from(chunk.subarray(0, remaining));
122
+ chunks.push(saved);
123
+ capturedBytes += saved.length;
124
+ }
125
+ if (chunk.length > remaining) {
126
+ result.outputIncomplete = true;
127
+ fail(commandError("Worktree setup command output exceeds maxBuffer", "ENOBUFS"));
128
+ }
129
+ };
130
+ const onStdout = (chunk: Buffer) => capture(stdout, chunk);
131
+ const onStderr = (chunk: Buffer) => capture(stderr, chunk);
132
+ child.stdout.on("data", onStdout);
133
+ child.stderr.on("data", onStderr);
134
+ child.stdin.on("error", fail);
135
+ const close = new Promise<void>((resolve) => child.once("close", () => resolve()));
136
+ const directExit = new Promise<void>((resolve) => {
137
+ child.once("exit", (status, signal) => {
138
+ directSettled = true;
139
+ result.status = status;
140
+ result.signal = signal;
141
+ if (status === null || !(options.acceptedExitCodes ?? [0]).includes(status)) terminate();
142
+ releaseUnknownIO();
143
+ resolve();
144
+ });
145
+ child.once("error", (error) => {
146
+ fail(error);
147
+ // A failed spawn has no direct process to await. Other errors do not prove exit.
148
+ if (child.pid === undefined) {
149
+ directSettled = true;
150
+ resolve();
151
+ }
152
+ });
153
+ });
154
+ let timer: ReturnType<typeof setTimeout> | undefined;
155
+ const deadlineAt = Math.min(options.deadlineAt ?? Infinity,
156
+ options.hookTimeoutMs === undefined ? Infinity : Date.now() + options.hookTimeoutMs);
157
+ const armDeadline = () => {
158
+ if (deadlineAt === Infinity) return;
159
+ const remaining = deadlineAt - Date.now();
160
+ if (remaining <= 0) fail(commandError("Worktree setup command timed out", "ETIMEDOUT"));
161
+ else timer = setTimeout(armDeadline, Math.min(remaining, 2 ** 31 - 1));
162
+ };
163
+ options.signal?.addEventListener("abort", onAbort, { once: true });
164
+ try {
165
+ armDeadline();
166
+ if (options.signal?.aborted) onAbort();
167
+ if (child.pid !== undefined) {
168
+ try { options.onSpawn?.({ pid: child.pid, processGroupId: result.processGroupId }); }
169
+ catch (error) { fail(error instanceof Error ? error : new Error(String(error))); }
170
+ }
171
+ child.stdin.end(options.input);
172
+ await directExit;
173
+ if (termination) result.processTree = await termination;
174
+ releaseUnknownIO();
175
+ await close;
176
+ // Abort/overflow may arrive while draining stdio after direct exit.
177
+ if (termination) result.processTree = await termination;
178
+ const cancelled = cancellation();
179
+ if (cancelled) fail(cancelled);
180
+ result.stdout = Buffer.concat(stdout).toString("utf8");
181
+ result.stderr = Buffer.concat(stderr).toString("utf8");
182
+ return result;
183
+ } finally {
184
+ if (timer) clearTimeout(timer);
185
+ options.signal?.removeEventListener("abort", onAbort);
186
+ child.stdout.removeListener("data", onStdout);
187
+ child.stderr.removeListener("data", onStderr);
188
+ child.stdin.removeListener("error", fail);
189
+ }
190
+ }