pi-subagents 0.58.0 → 0.60.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 (116) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/docs/agents.md +5 -3
  3. package/docs/configuration.md +4 -4
  4. package/docs/extension-api.md +1 -1
  5. package/docs/models.md +24 -1
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +93 -5
  8. package/docs/workflows.md +94 -3
  9. package/package.json +3 -1
  10. package/prompts/review-loop.md +2 -2
  11. package/skills/council-mode/SKILL.md +48 -243
  12. package/skills/council-mode/references/pass-contracts.md +150 -0
  13. package/skills/pi-subagents/SKILL.md +87 -37
  14. package/skills/pi-subagents/references/constraints-and-recipes.md +29 -233
  15. package/skills/pi-subagents/references/execution-controls.md +52 -7
  16. package/skills/pi-subagents/references/management-authoring-rpc.md +3 -4
  17. package/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
  18. package/skills/pi-subagents/references/prompting-and-roles.md +54 -26
  19. package/skills/pi-subagents/references/review-and-validation.md +73 -0
  20. package/src/agents/agent-management.ts +159 -42
  21. package/src/agents/agent-serializer.ts +4 -2
  22. package/src/agents/agents.ts +67 -26
  23. package/src/agents/runtime-agent-registry.ts +9 -16
  24. package/src/api/background-work.ts +5 -1
  25. package/src/api/delegation.ts +0 -7
  26. package/src/api/preflight.ts +6 -9
  27. package/src/api/shared-types.ts +2 -0
  28. package/src/extension/fanout-child.ts +5 -3
  29. package/src/extension/index.ts +51 -23
  30. package/src/extension/public-execution.ts +16 -2
  31. package/src/extension/schemas.ts +36 -10
  32. package/src/extension/tool-description.ts +24 -5
  33. package/src/intercom/result-intercom.ts +2 -0
  34. package/src/profiles/profiles.ts +5 -6
  35. package/src/runs/background/active-async-capacity.ts +2 -2
  36. package/src/runs/background/async-execution.ts +52 -37
  37. package/src/runs/background/async-job-tracker.ts +17 -12
  38. package/src/runs/background/async-resume.ts +23 -25
  39. package/src/runs/background/async-status-snapshot.ts +23 -261
  40. package/src/runs/background/async-status.ts +110 -8
  41. package/src/runs/background/chain-append.ts +6 -3
  42. package/src/runs/background/chain-root-attachment.ts +60 -8
  43. package/src/runs/background/control-channel.ts +3 -2
  44. package/src/runs/background/fleet-view.ts +21 -11
  45. package/src/runs/background/notify.ts +158 -6
  46. package/src/runs/background/result-files.ts +2 -1
  47. package/src/runs/background/result-watcher.ts +2 -0
  48. package/src/runs/background/resume-guidance.ts +1 -1
  49. package/src/runs/background/retained-children.ts +1 -1
  50. package/src/runs/background/run-status.ts +30 -9
  51. package/src/runs/background/scheduled-runs.ts +86 -7
  52. package/src/runs/background/stale-run-reconciler.ts +10 -4
  53. package/src/runs/background/steering.ts +4 -14
  54. package/src/runs/background/subagent-runner.ts +351 -358
  55. package/src/runs/background/subagent-wait.ts +68 -12
  56. package/src/runs/background/terminal-run-index.ts +1 -1
  57. package/src/runs/background/wait-completions.ts +25 -1
  58. package/src/runs/background/wait-config.ts +23 -9
  59. package/src/runs/background/wait-tool.ts +9 -2
  60. package/src/runs/foreground/async-steering-action.ts +2 -2
  61. package/src/runs/foreground/execution.ts +125 -122
  62. package/src/runs/foreground/foreground-control.ts +3 -0
  63. package/src/runs/foreground/foreground-history.ts +1 -0
  64. package/src/runs/foreground/subagent-executor.ts +576 -240
  65. package/src/runs/foreground/workflow-detach-reconcile.ts +99 -200
  66. package/src/runs/shared/abort-recovery.ts +119 -0
  67. package/src/runs/shared/async-status-projection.ts +597 -0
  68. package/src/runs/shared/background-process-options.ts +9 -0
  69. package/src/runs/shared/child-identity.ts +19 -4
  70. package/src/runs/shared/child-launch-plan.ts +151 -0
  71. package/src/runs/shared/completion-evidence.ts +89 -0
  72. package/src/runs/shared/completion-guard.ts +1 -1
  73. package/src/runs/shared/dynamic-fanout.ts +2 -2
  74. package/src/runs/shared/host-step-status.ts +230 -0
  75. package/src/runs/shared/lane-metadata.ts +105 -0
  76. package/src/runs/shared/mcp-config-sources.ts +42 -6
  77. package/src/runs/shared/mcp-direct-tool-allowlist.ts +93 -143
  78. package/src/runs/shared/mcp-direct-tool-grant.ts +194 -0
  79. package/src/runs/shared/model-fallback.ts +9 -2
  80. package/src/runs/shared/mutation-evidence.ts +52 -3
  81. package/src/runs/shared/nested-events.ts +6 -2
  82. package/src/runs/shared/nested-render.ts +7 -3
  83. package/src/runs/shared/parallel-handoff.ts +419 -7
  84. package/src/runs/shared/parallel-utils.ts +7 -0
  85. package/src/runs/shared/pi-args.ts +67 -3
  86. package/src/runs/shared/single-output.ts +72 -22
  87. package/src/runs/shared/subagent-prompt-runtime.ts +33 -6
  88. package/src/runs/shared/workflow-graph.ts +15 -0
  89. package/src/runs/shared/worktree-cleanup-plan.ts +847 -0
  90. package/src/runs/shared/worktree.ts +18 -0
  91. package/src/shared/child-session-name.ts +46 -0
  92. package/src/shared/extension-context.ts +24 -0
  93. package/src/shared/formatters.ts +5 -2
  94. package/src/shared/launch-contract.ts +1 -1
  95. package/src/shared/settings.ts +9 -103
  96. package/src/shared/types.ts +232 -30
  97. package/src/shared/utils.ts +35 -55
  98. package/src/slash/delegation-adapters.ts +1 -8
  99. package/src/slash/delegation-request.ts +0 -4
  100. package/src/slash/slash-bridge.ts +1 -2
  101. package/src/slash/slash-commands.ts +369 -90
  102. package/src/slash/slash-live-state.ts +22 -11
  103. package/src/tui/fleet-status.ts +134 -75
  104. package/src/tui/fleet.ts +11 -5
  105. package/src/tui/render-helpers.ts +31 -0
  106. package/src/tui/render.ts +895 -121
  107. package/src/watchdog/change-signature.ts +40 -1
  108. package/src/watchdog/turn-delta.ts +1 -1
  109. package/src/workflows/chat-progress.ts +6 -3
  110. package/src/workflows/host-command.ts +235 -0
  111. package/src/workflows/scripted-workflow.ts +503 -38
  112. package/src/workflows/workflow-child-summary.ts +9 -5
  113. package/src/workflows/workflow-preflight.ts +270 -0
  114. package/src/workflows/workflow-receipt.ts +43 -4
  115. package/src/workflows/workflow-settlement.ts +246 -0
  116. package/src/runs/shared/turn-budget.ts +0 -98
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
+ import * as os from "node:os";
4
5
  import * as path from "node:path";
5
6
  import { PROJECT_SUBAGENTS_RELATIVE_DIR } from "../shared/artifacts.ts";
6
7
 
@@ -11,6 +12,7 @@ const IGNORED_CHANGE_SEGMENTS = new Set([".git", "node_modules"]);
11
12
  const DEFAULT_MAX_HASH_FILE_BYTES = 64 * 1024 * 1024;
12
13
  const DEFAULT_MAX_HASH_TOTAL_BYTES = 64 * 1024 * 1024;
13
14
  const DEFAULT_MAX_HASH_ENTRIES = 2_000;
15
+ const TRACKED_ENTRY_PROBE_MAX_BYTES = 1_024;
14
16
 
15
17
  function positiveEnvNumber(name: string, fallback: number): number {
16
18
  const parsed = Number(process.env[name]);
@@ -43,6 +45,37 @@ function git(cwd: string, args: string[]): string | undefined {
43
45
  return result.stdout;
44
46
  }
45
47
 
48
+ function comparablePath(value: string): string {
49
+ let resolved = path.resolve(value);
50
+ try {
51
+ resolved = fs.realpathSync.native(resolved);
52
+ } catch {
53
+ // Keep the lexical path when canonicalization is unavailable.
54
+ }
55
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
56
+ }
57
+
58
+ function isHomeRepoRoot(root: string): boolean {
59
+ return comparablePath(root) === comparablePath(os.homedir());
60
+ }
61
+
62
+ /**
63
+ * Probe the index without asking Git to inspect the working tree. The output
64
+ * cap keeps this check bounded even for repositories with a very large index;
65
+ * an ENOBUFS result still proves that at least one tracked path exists.
66
+ */
67
+ function hasTrackedEntries(root: string): boolean | undefined {
68
+ const result = spawnSync("git", ["-C", root, "ls-files", "--cached", "--error-unmatch", "--", "."], {
69
+ encoding: "utf-8",
70
+ maxBuffer: TRACKED_ENTRY_PROBE_MAX_BYTES,
71
+ windowsHide: true,
72
+ });
73
+ const errorCode = (result.error as NodeJS.ErrnoException | undefined)?.code;
74
+ if (result.status === 0 || errorCode === "ENOBUFS") return true;
75
+ if (result.status === 1) return false;
76
+ return undefined;
77
+ }
78
+
46
79
  function normalizeRelPath(value: string): string {
47
80
  return value.replaceAll(path.sep, "/").replace(/^\.\//, "");
48
81
  }
@@ -181,7 +214,13 @@ function buildRepoChangeSignature(root: string, statusOutput: string): WatchdogR
181
214
  export function computeWatchdogRepoChangeSignature(cwd: string): WatchdogRepoChangeSignature | undefined {
182
215
  const root = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
183
216
  if (!root) return undefined;
184
- const statusOutput = git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
217
+ const skipUntracked = isHomeRepoRoot(root) || hasTrackedEntries(root) === false;
218
+ const statusOutput = git(root, [
219
+ "status",
220
+ "--porcelain=v1",
221
+ "-z",
222
+ `--untracked-files=${skipUntracked ? "no" : "all"}`,
223
+ ]);
185
224
  if (statusOutput === undefined) return undefined;
186
225
  try {
187
226
  return buildRepoChangeSignature(root, statusOutput);
@@ -47,7 +47,7 @@ function formatValue(value: unknown, indent = ""): string {
47
47
  if (value === undefined) return "undefined";
48
48
  if (value === null || typeof value === "number" || typeof value === "boolean") return JSON.stringify(value);
49
49
  if (Array.isArray(value)) {
50
- return value.map((item, index) => `${indent}- ${formatValue(item, `${indent} `)}`).join("\n");
50
+ return value.map((item) => `${indent}- ${formatValue(item, `${indent} `)}`).join("\n");
51
51
  }
52
52
  if (typeof value === "object") {
53
53
  const lines: string[] = [];
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import type { Details } from "../shared/types.ts";
4
+ import type { Details, WorkflowPreflightLaneV1, WorkflowPreflightV1 } from "../shared/types.ts";
5
5
 
6
6
  export const WORKFLOW_CHAT_PROGRESS_MODES = ["auto", "off", "live-card"] as const;
7
7
  export type WorkflowChatProgressMode = typeof WORKFLOW_CHAT_PROGRESS_MODES[number];
@@ -96,20 +96,22 @@ export function resolveWorkflowChatProgress(input: ResolveWorkflowChatProgressIn
96
96
 
97
97
  export interface WorkflowChatProgressRow {
98
98
  key: string;
99
- state: "running" | "complete" | "failed" | "detached" | "stopped";
99
+ state: "planned" | "running" | "complete" | "failed" | "detached" | "stopped";
100
100
  label?: string;
101
101
  phase?: string;
102
102
  runId?: string;
103
103
  durationMs?: number;
104
104
  error?: string;
105
+ preflight?: WorkflowPreflightLaneV1;
105
106
  }
106
107
 
107
108
  function cleanLabel(value: unknown): string | undefined {
108
109
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
109
110
  }
110
111
 
111
- export function buildWorkflowChatProgressRows(trace: NonNullable<Details["workflow"]>["trace"]): WorkflowChatProgressRow[] {
112
+ export function buildWorkflowChatProgressRows(trace: NonNullable<Details["workflow"]>["trace"], preflight?: WorkflowPreflightV1): WorkflowChatProgressRow[] {
112
113
  const rows = new Map<string, WorkflowChatProgressRow>();
114
+ for (const lane of preflight?.lanes ?? []) rows.set(lane.key, { key: lane.key, state: "planned", preflight: lane });
113
115
  for (const entry of trace) {
114
116
  if (entry.operation !== "run") continue;
115
117
  const existing = rows.get(entry.key);
@@ -123,6 +125,7 @@ export function buildWorkflowChatProgressRows(trace: NonNullable<Details["workfl
123
125
  continue;
124
126
  }
125
127
  const next: WorkflowChatProgressRow = existing ?? { key: entry.key, state: "running" };
128
+ if (existing?.preflight) next.preflight = existing.preflight;
126
129
  next.state = entry.state === "completed"
127
130
  ? "complete"
128
131
  : entry.state === "failed"
@@ -0,0 +1,235 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { quoteExecutableForShell } from "../runs/shared/acceptance.ts";
6
+ import { createOwnedProcessTreeController, type OwnedProcessTreeController } from "../runs/background/owned-process-tree.ts";
7
+ import { resolveSingleOutputClaimPath } from "../runs/shared/single-output.ts";
8
+ import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, runFileSystemOperationWithRetry } from "../shared/file-system-retry.ts";
9
+
10
+ const MAX_COMMAND_BYTES = 16 * 1024;
11
+ const MAX_OUTPUT_PATH_BYTES = 240;
12
+ const MAX_CAPTURE_BYTES = 1024 * 1024;
13
+ const MAX_PREVIEW_BYTES = 4 * 1024;
14
+ const MAX_TIMEOUT_MS = 24 * 60 * 60 * 1000;
15
+
16
+ export interface WorkflowHostCommandParams {
17
+ kind: "command";
18
+ command: string;
19
+ timeoutMs: number;
20
+ output?: string;
21
+ role?: "ci" | "gate";
22
+ provider?: string;
23
+ }
24
+
25
+ export interface WorkflowHostCommandResult {
26
+ key: string;
27
+ kind: "command";
28
+ ok: boolean;
29
+ state: "passed" | "failed" | "timed-out" | "stopped";
30
+ exitCode: number | null;
31
+ stdout: string;
32
+ stderr: string;
33
+ outputPath: string;
34
+ durationMs: number;
35
+ error?: string;
36
+ }
37
+
38
+ type ProcessTreeTerminal = Awaited<ReturnType<OwnedProcessTreeController["terminate"]>>;
39
+ type ProcessTreeCleanup = ProcessTreeTerminal | { state: "unknown"; reason: "missing-process-id" };
40
+
41
+ function byteLength(value: string): number {
42
+ return Buffer.byteLength(value, "utf8");
43
+ }
44
+
45
+ function boundedText(value: string, maxBytes: number): string {
46
+ if (byteLength(value) <= maxBytes) return value;
47
+ let end = Math.max(0, maxBytes - 3);
48
+ while (end > 0 && byteLength(value.slice(0, end)) > maxBytes - 3) end -= 1;
49
+ return `${value.slice(0, end)}...`;
50
+ }
51
+
52
+ export function normalizeWorkflowHostCommandParams(value: unknown, label = "runs.host params"): WorkflowHostCommandParams {
53
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
54
+ const params = value as Record<string, unknown>;
55
+ const unknownFields = Object.keys(params).filter((field) => !["kind", "command", "timeoutMs", "output", "role", "provider"].includes(field));
56
+ if (unknownFields.length) {
57
+ const cwdHint = unknownFields.includes("cwd")
58
+ ? " The host step does not accept per-step cwd; commands and relative output paths use the workflow cwd. Set cwd on the outer subagent request, or put a trusted directory change in command (for example, 'cd /path/to/worktree && npm test')."
59
+ : "";
60
+ throw new Error(`${label} has unsupported fields: ${unknownFields.join(", ")}.${cwdHint}`);
61
+ }
62
+ if (params.kind !== "command") throw new Error(`${label}.kind must be 'command'.`);
63
+ if (typeof params.command !== "string" || !params.command.trim()) throw new Error(`${label}.command must be a non-empty string.`);
64
+ const command = params.command.trim();
65
+ if (byteLength(command) > MAX_COMMAND_BYTES || /[\u0000]/.test(command)) throw new Error(`${label}.command exceeds the ${MAX_COMMAND_BYTES}-byte limit or contains NUL.`);
66
+ if (typeof params.timeoutMs !== "number" || !Number.isInteger(params.timeoutMs) || params.timeoutMs < 1 || params.timeoutMs > MAX_TIMEOUT_MS) throw new Error(`${label}.timeoutMs must be an integer from 1 to ${MAX_TIMEOUT_MS}.`);
67
+ let output: string | undefined;
68
+ if (params.output !== undefined) {
69
+ if (typeof params.output !== "string" || !params.output.trim()) throw new Error(`${label}.output must be a non-empty relative path.`);
70
+ output = params.output.trim();
71
+ if (path.isAbsolute(output) || output.split(/[\\/]/).includes("..") || /[\u0000-\u001f\u007f]/.test(output) || byteLength(output) > MAX_OUTPUT_PATH_BYTES) throw new Error(`${label}.output must be a bounded relative path without traversal or control characters.`);
72
+ }
73
+ if (params.role !== undefined && params.role !== "ci" && params.role !== "gate") throw new Error(`${label}.role must be 'ci' or 'gate'.`);
74
+ let provider: string | undefined;
75
+ if (params.provider !== undefined) {
76
+ if (typeof params.provider !== "string" || !params.provider.trim() || /[\r\n\u0000]/.test(params.provider) || byteLength(params.provider.trim()) > 64) throw new Error(`${label}.provider must be a non-empty single-line string of at most 64 bytes.`);
77
+ provider = params.provider.trim();
78
+ }
79
+ return {
80
+ kind: "command",
81
+ command,
82
+ timeoutMs: params.timeoutMs,
83
+ ...(output ? { output } : {}),
84
+ ...(params.role ? { role: params.role } : {}),
85
+ ...(provider ? { provider } : {}),
86
+ };
87
+ }
88
+
89
+ function appendBounded(current: string, chunk: Buffer, limit: number): string {
90
+ if (byteLength(current) >= limit) return current;
91
+ return boundedText(current + chunk.toString("utf8"), limit);
92
+ }
93
+
94
+ export function resolveWorkflowHostOutputClaimPath(outputPath: string): string {
95
+ return resolveSingleOutputClaimPath(outputPath);
96
+ }
97
+
98
+ function assertSafeExplicitOutput(cwd: string, outputPath: string, key: string): string {
99
+ const root = fs.realpathSync(cwd);
100
+ const outputParent = path.dirname(outputPath);
101
+ let existingParent = outputParent;
102
+ while (!fs.existsSync(existingParent)) existingParent = path.dirname(existingParent);
103
+ const existingParentReal = fs.realpathSync(existingParent);
104
+ const existingRelative = path.relative(root, existingParentReal);
105
+ if (existingRelative === ".." || existingRelative.startsWith(`..${path.sep}`) || path.isAbsolute(existingRelative)) throw new Error(`runs.host('${key}') output resolves outside the workflow cwd.`);
106
+ fs.mkdirSync(outputParent, { recursive: true });
107
+ const parent = fs.realpathSync(outputParent);
108
+ const relative = path.relative(root, parent);
109
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error(`runs.host('${key}') output resolves outside the workflow cwd.`);
110
+ try {
111
+ const stat = fs.lstatSync(outputPath);
112
+ if (!stat.isFile() || stat.nlink > 1) throw new Error(`runs.host('${key}') output must be a regular, non-linked file path.`);
113
+ } catch (error) {
114
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
115
+ }
116
+ return parent;
117
+ }
118
+
119
+ function writeExplicitOutput(outputParent: string, outputPath: string, capture: string, key: string): void {
120
+ const destination = path.join(outputParent, path.basename(outputPath));
121
+ const temporaryPath = path.join(outputParent, `.pi-host-output-${process.pid}-${randomUUID()}.tmp`);
122
+ let descriptor: number | undefined;
123
+ try {
124
+ descriptor = fs.openSync(temporaryPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
125
+ fs.writeFileSync(descriptor, capture, { encoding: "utf8" });
126
+ fs.closeSync(descriptor);
127
+ descriptor = undefined;
128
+ fs.chmodSync(temporaryPath, 0o600);
129
+ runFileSystemOperationWithRetry(() => fs.renameSync(temporaryPath, destination), {
130
+ retryDelaysMs: process.platform === "win32" ? DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS : [],
131
+ });
132
+ } catch (error) {
133
+ throw new Error(`runs.host('${key}') could not atomically replace its output.`, { cause: error instanceof Error ? error : undefined });
134
+ } finally {
135
+ if (descriptor !== undefined) fs.closeSync(descriptor);
136
+ try {
137
+ fs.unlinkSync(temporaryPath);
138
+ } catch (error) {
139
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
140
+ }
141
+ }
142
+ }
143
+
144
+ function terminateProcessTree(pid: number, controller: OwnedProcessTreeController): Promise<ProcessTreeTerminal> {
145
+ if (process.platform !== "win32") return controller.terminate();
146
+ return new Promise<ProcessTreeTerminal>((resolve) => {
147
+ const cleanup = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
148
+ cleanup.once("error", () => { void controller.terminate().then(resolve); });
149
+ cleanup.once("close", () => { void controller.terminate().then(resolve); });
150
+ });
151
+ }
152
+
153
+ export async function executeWorkflowHostCommand(input: {
154
+ key: string;
155
+ params: WorkflowHostCommandParams;
156
+ cwd: string;
157
+ defaultOutputPath: string;
158
+ claimedOutputPath?: string;
159
+ signal: AbortSignal;
160
+ }): Promise<WorkflowHostCommandResult> {
161
+ const outputPath = input.params.output ? path.resolve(input.cwd, input.params.output) : input.defaultOutputPath;
162
+ const relative = path.relative(input.cwd, outputPath);
163
+ if (input.params.output && (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))) throw new Error(`runs.host('${input.key}') output escapes the workflow cwd.`);
164
+ const explicitOutputParent = input.params.output ? assertSafeExplicitOutput(input.cwd, outputPath, input.key) : undefined;
165
+ if (!input.params.output) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
166
+ const startedAt = Date.now();
167
+ let stdout = "";
168
+ let stderr = "";
169
+ let capture = "";
170
+ let timedOut = false;
171
+ let stopped = false;
172
+ let settled = false;
173
+
174
+ return new Promise((resolve, reject) => {
175
+ const child = spawn(quoteExecutableForShell(input.params.command), {
176
+ cwd: input.cwd,
177
+ env: process.env,
178
+ shell: true,
179
+ stdio: ["ignore", "pipe", "pipe"],
180
+ windowsHide: true,
181
+ detached: process.platform !== "win32",
182
+ });
183
+ const processTree = typeof child.pid === "number" ? createOwnedProcessTreeController(child.pid, { termGraceMs: 1_000 }) : undefined;
184
+ let termination: Promise<ProcessTreeCleanup> | undefined;
185
+ const terminate = (reason: "timeout" | "abort") => {
186
+ if (settled) return;
187
+ timedOut = reason === "timeout";
188
+ stopped = reason === "abort";
189
+ if (processTree && child.pid !== undefined) termination ??= terminateProcessTree(child.pid, processTree);
190
+ else {
191
+ child.kill("SIGTERM");
192
+ termination ??= Promise.resolve({ state: "unknown", reason: "missing-process-id" });
193
+ }
194
+ };
195
+ const timeout = setTimeout(() => terminate("timeout"), input.params.timeoutMs);
196
+ timeout.unref?.();
197
+ const onAbort = () => terminate("abort");
198
+ if (input.signal.aborted) onAbort();
199
+ else input.signal.addEventListener("abort", onAbort, { once: true });
200
+ child.stdout.on("data", (chunk: Buffer) => {
201
+ stdout = appendBounded(stdout, chunk, MAX_PREVIEW_BYTES);
202
+ capture = appendBounded(capture, chunk, MAX_CAPTURE_BYTES);
203
+ });
204
+ child.stderr.on("data", (chunk: Buffer) => {
205
+ stderr = appendBounded(stderr, chunk, MAX_PREVIEW_BYTES);
206
+ capture = appendBounded(capture, chunk, MAX_CAPTURE_BYTES);
207
+ });
208
+ const finish = async (exitCode: number | null, spawnError?: unknown) => {
209
+ if (settled) return;
210
+ settled = true;
211
+ clearTimeout(timeout);
212
+ input.signal.removeEventListener("abort", onAbort);
213
+ const terminal = termination ? await termination : await processTree?.finishAfterWriterClose();
214
+ const cleanupError = process.platform !== "win32" && terminal?.state === "unknown" ? terminal.reason : undefined;
215
+ const state = timedOut ? "timed-out" : stopped ? "stopped" : exitCode === 0 && !spawnError && !cleanupError ? "passed" : "failed";
216
+ const error = spawnError instanceof Error ? spawnError.message : spawnError ? String(spawnError) : cleanupError ? `Process-tree cleanup failed: ${cleanupError}.` : state === "timed-out" ? `Command timed out after ${input.params.timeoutMs}ms.` : state === "stopped" ? "Command stopped because the workflow was aborted." : state === "failed" ? `Command exited with code ${exitCode ?? "unknown"}.` : undefined;
217
+ try {
218
+ if (input.claimedOutputPath && resolveWorkflowHostOutputClaimPath(outputPath) !== input.claimedOutputPath) throw new Error(`output path changed after it was claimed.`);
219
+ if (explicitOutputParent) {
220
+ const currentParent = assertSafeExplicitOutput(input.cwd, outputPath, input.key);
221
+ if (currentParent !== explicitOutputParent) throw new Error(`output parent changed while the command was running.`);
222
+ writeExplicitOutput(explicitOutputParent, outputPath, capture, input.key);
223
+ } else {
224
+ fs.writeFileSync(outputPath, capture, { encoding: "utf8", mode: 0o600 });
225
+ }
226
+ } catch (writeError) {
227
+ reject(new Error(`runs.host('${input.key}') could not save command output: ${writeError instanceof Error ? writeError.message : String(writeError)}`, { cause: writeError instanceof Error ? writeError : undefined }));
228
+ return;
229
+ }
230
+ resolve({ key: input.key, kind: "command", ok: state === "passed", state, exitCode, stdout, stderr, outputPath, durationMs: Date.now() - startedAt, ...(error ? { error } : {}) });
231
+ };
232
+ child.on("close", (code) => { void finish(code); });
233
+ child.on("error", (error) => { void finish(null, error); });
234
+ });
235
+ }