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.
- package/CHANGELOG.md +123 -0
- package/README.md +5 -4
- package/agents/evidence-auditor.md +34 -0
- package/agents/researcher.md +23 -13
- package/agents/reviewer.md +3 -2
- package/docs/agents.md +20 -3
- package/docs/configuration.md +25 -5
- package/docs/extension-api.md +124 -18
- package/docs/missions.md +8 -0
- package/docs/models.md +59 -2
- package/docs/observability.md +46 -6
- package/docs/standalone-background.md +49 -0
- package/docs/tool-reference.md +20 -10
- package/docs/watchdog.md +35 -4
- package/docs/workflows.md +40 -19
- package/inspector-runner.mjs +2 -2
- package/package.json +2 -1
- package/prompts/parallel-review.md +1 -1
- package/{runner-server-preload.mjs → runner-peer-preload.mjs} +8 -3
- package/skills/pi-subagents/SKILL.md +14 -0
- package/skills/pi-subagents/references/execution-controls.md +20 -5
- package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
- package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
- package/src/agents/advertised-agent-prompt.ts +94 -0
- package/src/agents/agent-management.ts +14 -1
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +14 -0
- package/src/agents/builtin-names.ts +1 -0
- package/src/api/delegation.ts +4 -0
- package/src/api/preflight.ts +76 -45
- package/src/api/shared-types.ts +3 -1
- package/src/api/workflow-resources.ts +6 -0
- package/src/extension/fanout-child.ts +63 -4
- package/src/extension/index.ts +58 -8
- package/src/extension/public-execution.ts +4 -3
- package/src/extension/rpc.ts +8 -21
- package/src/extension/schemas.ts +71 -80
- package/src/extension/tool-description.ts +29 -81
- package/src/inspectors/actions.ts +148 -0
- package/src/inspectors/ghostty/actions.ts +74 -0
- package/src/inspectors/ghostty/plugin.ts +17 -0
- package/src/inspectors/herdr/actions.ts +99 -179
- package/src/inspectors/herdr/plugin.ts +20 -0
- package/src/inspectors/herdr/project-panes.ts +1 -1
- package/src/inspectors/{herdr/inspector-runner.ts → inspector-runner.ts} +12 -12
- package/src/inspectors/plugins.ts +8 -0
- package/src/inspectors/{herdr/session-roots-codec.ts → session-roots-codec.ts} +3 -14
- package/src/inspectors/types.ts +51 -0
- package/src/intercom/intercom-bridge.ts +50 -8
- package/src/intercom/native-supervisor-channel.ts +104 -67
- package/src/runs/background/active-async-capacity.ts +22 -18
- package/src/runs/background/async-execution.ts +45 -56
- package/src/runs/background/async-job-tracker.ts +35 -3
- package/src/runs/background/async-resume.ts +5 -9
- package/src/runs/background/async-status-snapshot.ts +10 -12
- package/src/runs/background/async-status.ts +17 -9
- package/src/runs/background/auto-drain.ts +44 -30
- package/src/runs/background/binary-bootstrap.ts +33 -0
- package/src/runs/background/chain-root-attachment.ts +8 -0
- package/src/runs/background/control-channel.ts +78 -44
- package/src/runs/background/fleet-view.ts +30 -2
- package/src/runs/background/notify.ts +117 -13
- package/src/runs/background/owned-process-tree.ts +35 -8
- package/src/runs/background/process-terminal.ts +23 -23
- package/src/runs/background/run-child-session.ts +121 -36
- package/src/runs/background/run-status.ts +78 -5
- package/src/runs/background/runner-aliases.ts +28 -9
- package/src/runs/background/runner-child-launch.ts +88 -0
- package/src/runs/background/runner-child-sessions.ts +5 -4
- package/src/runs/background/scheduled-runs.ts +40 -13
- package/src/runs/background/stale-run-reconciler.ts +3 -1
- package/src/runs/background/steering.ts +20 -2
- package/src/runs/background/subagent-runner.ts +458 -239
- package/src/runs/background/subagent-wait.ts +54 -8
- package/src/runs/background/wait-completions.ts +4 -0
- package/src/runs/background/wait-tool.ts +1 -1
- package/src/runs/foreground/async-steering-action.ts +37 -7
- package/src/runs/foreground/execution.ts +145 -56
- package/src/runs/foreground/prompt-audit.ts +3 -1
- package/src/runs/foreground/subagent-executor.ts +584 -297
- package/src/runs/foreground/workflow-detach-reconcile.ts +10 -5
- package/src/runs/foreground/workflow-foreground-steering.ts +57 -2
- package/src/runs/shared/acceptance.ts +7 -4
- package/src/runs/shared/agent-contract.ts +1 -1
- package/src/runs/shared/async-status-projection.ts +51 -47
- package/src/runs/shared/capability-ceiling.ts +2 -0
- package/src/runs/shared/child-hooks.ts +167 -3
- package/src/runs/shared/child-launch.ts +28 -13
- package/src/runs/shared/child-lifecycle.ts +6 -3
- package/src/runs/shared/child-runtime-config.ts +3 -1
- package/src/runs/shared/child-session.ts +75 -8
- package/src/runs/shared/child-tool-plan.ts +124 -5
- package/src/runs/shared/completion-evidence.ts +2 -2
- package/src/runs/shared/completion-guard.ts +6 -3
- package/src/runs/shared/effective-system-prompt.ts +33 -0
- package/src/runs/shared/external-cli-runner.ts +9 -7
- package/src/runs/shared/host-step-status.ts +11 -11
- package/src/runs/shared/llm-intent-arbiter.ts +21 -11
- package/src/runs/shared/model-fallback.ts +12 -6
- package/src/runs/shared/nested-events.ts +5 -5
- package/src/runs/shared/orca-progress-tabs.ts +7 -1
- package/src/runs/shared/parallel-handoff.ts +57 -12
- package/src/runs/shared/parallel-utils.ts +2 -2
- package/src/runs/shared/pi-spawn.ts +10 -0
- package/src/runs/shared/readonly-drain-observation.ts +42 -0
- package/src/runs/shared/readonly-model-continuation.ts +69 -0
- package/src/runs/shared/readonly-session-evidence.ts +307 -0
- package/src/runs/shared/run-fanout-budget.ts +8 -8
- package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
- package/src/runs/shared/subagent-prompt-runtime.ts +20 -4
- package/src/runs/shared/task-intent.ts +46 -13
- package/src/runs/shared/workflow-async-child-guidance.ts +18 -0
- package/src/runs/shared/worktree-setup-command.ts +190 -0
- package/src/runs/shared/worktree.ts +366 -208
- package/src/shared/fork-context.ts +15 -72
- package/src/shared/launch-contract.ts +65 -2
- package/src/shared/opencode-session-headers.ts +30 -0
- package/src/shared/types.ts +85 -61
- package/src/shared/utils.ts +7 -2
- package/src/shared/workflow-child-permit.ts +18 -13
- package/src/slash/delegation-adapters.ts +3 -1
- package/src/slash/delegation-request.ts +14 -0
- package/src/slash/slash-commands.ts +2 -1
- package/src/slash/subagents-admin.ts +11 -4
- package/src/tui/fleet-status.ts +164 -19
- package/src/tui/fleet.ts +27 -19
- package/src/tui/render.ts +172 -33
- package/src/watchdog/child-status.ts +8 -0
- package/src/watchdog/model-selection.ts +20 -0
- package/src/watchdog/permission-arbiter.ts +3 -1
- package/src/watchdog/register-child.ts +1 -0
- package/src/watchdog/register-main.ts +31 -27
- package/src/watchdog/review.ts +132 -67
- package/src/watchdog/runtime.ts +82 -20
- package/src/watchdog/scope.ts +1 -1
- package/src/watchdog/settings.ts +9 -3
- package/src/watchdog/tool-actions.ts +13 -12
- package/src/watchdog/turn-delta.ts +23 -0
- package/src/watchdog/types.ts +4 -0
- package/src/workflows/chat-progress.ts +3 -3
- package/src/workflows/scripted-workflow.ts +275 -17
- package/src/workflows/workflow-checklist.ts +13 -17
- package/src/workflows/workflow-child-summary.ts +57 -8
- package/src/workflows/workflow-preflight.ts +19 -19
- package/src/workflows/workflow-receipt.ts +3 -3
- package/src/workflows/workflow-resources.ts +96 -21
- package/src/workflows/workflow-settlement.ts +3 -0
- /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
|
|
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
|
|
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):
|
|
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<
|
|
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
|
|
50
|
+
return manifest as FanoutBudgetManifest;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
function readManifest(directory: string):
|
|
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:
|
|
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:
|
|
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 {
|
|
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>):
|
|
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):
|
|
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
|
|
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)
|
|
485
|
-
|
|
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
|
-
/\
|
|
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
|
-
/(?:^|[
|
|
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)[
|
|
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
|
|
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
|
-
|
|
147
|
-
let
|
|
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
|
-
|
|
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
|
+
}
|