@ferris1225/pi-subagents 4.3.3 → 4.3.5

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 (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +61 -67
  3. package/agents/artisan.md +0 -1
  4. package/agents/steward.md +1 -2
  5. package/{src/index.ts → index.ts} +19 -19
  6. package/package.json +4 -3
  7. package/src/{config.ts → configuration/config.ts} +19 -24
  8. package/src/configuration/setup.ts +375 -0
  9. package/src/configuration/ui.ts +245 -0
  10. package/src/{agents.ts → delegation/agents.ts} +3 -3
  11. package/src/{dispatch.ts → delegation/dispatch.ts} +12 -18
  12. package/src/{prompt.ts → delegation/prompt.ts} +3 -8
  13. package/src/{background.ts → execution/background.ts} +3 -6
  14. package/src/execution/rpc-control.ts +200 -0
  15. package/src/{rpc-run.ts → execution/rpc-run.ts} +11 -199
  16. package/src/{session-fork.ts → execution/session-fork.ts} +1 -1
  17. package/src/{spawn.ts → execution/spawn.ts} +10 -8
  18. package/src/isolation/git-command.ts +147 -0
  19. package/src/{recovery.ts → isolation/recovery.ts} +1 -1
  20. package/src/{worktree.ts → isolation/worktree.ts} +10 -147
  21. package/src/{completion.ts → lifecycle/completion.ts} +2 -2
  22. package/src/{durable.ts → lifecycle/durable.ts} +3 -3
  23. package/src/{runtime.ts → lifecycle/runtime.ts} +7 -7
  24. package/src/{thread-lifecycle.ts → lifecycle/thread-lifecycle.ts} +25 -519
  25. package/src/lifecycle/thread-restore.ts +250 -0
  26. package/src/lifecycle/thread-shared.ts +269 -0
  27. package/src/{tools.ts → lifecycle/tools.ts} +8 -8
  28. package/src/{announcements.ts → presentation/announcements.ts} +4 -4
  29. package/src/{format.ts → presentation/format.ts} +3 -3
  30. package/src/{monitor.ts → presentation/monitor.ts} +2 -2
  31. package/src/{widget.ts → presentation/widget.ts} +1 -1
  32. package/agents/sentinel.md +0 -16
  33. package/src/setup.ts +0 -344
  34. package/src/ui.ts +0 -160
  35. /package/src/{models.ts → configuration/models.ts} +0 -0
  36. /package/src/{temp-hygiene.ts → isolation/temp-hygiene.ts} +0 -0
  37. /package/src/{status.ts → presentation/status.ts} +0 -0
@@ -36,7 +36,6 @@ function phaseForAgent(agentName: string): string {
36
36
  if (agentName === "scout") return "broad reconnaissance";
37
37
  if (agentName === "artisan") return "primary change";
38
38
  if (agentName === "steward") return "pre-commit cleanup and cross-cutting docs";
39
- if (agentName === "sentinel") return "post-cleanup adversarial review";
40
39
  return "delegated scope";
41
40
  }
42
41
 
@@ -105,19 +104,15 @@ export function buildDelegationDirective(
105
104
  const hasScout = agents.some((agent) => agent.name === "scout");
106
105
  const hasArtisan = agents.some((agent) => agent.name === "artisan");
107
106
  const hasSteward = agents.some((agent) => agent.name === "steward");
108
- const hasSentinel = agents.some((agent) => agent.name === "sentinel");
109
107
 
110
108
  const dispatchRules = [
111
- `Main owns routing, architecture, integration, the final gate, and release. Each child starts a paid context; delegate only when handoff saves more than it costs${hasSentinel ? ", except required sentinel review before commit" : ""}.`,
109
+ "Main owns routing, architecture, integration, the final gate, and release. Each child starts a paid context: proactively delegate substantial self-contained phases when the saved main-context work exceeds handoff cost.",
112
110
  "Keep atomic lookups, focused edits, known answers, and context-heavy work in main. Cluster related reconnaissance into one scout brief, including external research.",
111
+ "Use available parallel capacity for genuinely independent scopes; batch them in one launch. Runtime runs at most six child processes and queues the rest.",
113
112
  ...(hasScout ? ["`scout`: read-only broad code mapping or external research; return file/source citations as leads, not proof."] : []),
114
113
  ...(hasArtisan ? ["`artisan`: one substantial primary change; own root cause, implementation, affected tests/docs, and targeted checks."] : []),
115
114
  ...(hasSteward ? ["`steward`: final cleanup/docs sync for a completed broad or multi-writer diff; keep focused hygiene inline."] : []),
116
- ...(hasSentinel ? ["`sentinel`: read-only post-cleanup review; use matching skills and report only evidenced defects."] : []),
117
- ...(hasSentinel
118
- ? [`Before every commit: cleanup -> sentinel review. ${hasSteward ? "Use steward once only for broad or multi-writer diffs" : "Keep cleanup inline"}. Then dispatch sentinel on the final diff and check evidence. After review fixes, clean and review once more; findings block commit.`]
119
- : []),
120
- "One owner per phase; dependent phases wait. A launch leases that phase: main may inspect its result, citations, diff, and checks, not redo the phase; main still runs the final gate.",
115
+ "One owner per phase; dependent phases wait. Main may consume a child's compact result and cited lines to plan, but never repeats delegated broad search, implementation, or cleanup. A launch leases that phase; main still inspects the integrated diff and runs the final gate.",
121
116
  "Parallelize only independent, disjoint scopes. Brief goal, paths, constraints, and expected output; resume with `subagent_control`.",
122
117
  "`wait: true` only when the result is the immediate dependency; otherwise continue disjoint work. Never sleep or poll, and never finish while a run is active.",
123
118
  "Inspect the integrated diff and actual check output. Never report an unrun check as passed.",
@@ -38,13 +38,10 @@ interface PendingAcquire {
38
38
 
39
39
  type PendingEntry = PendingTask | PendingAcquire;
40
40
 
41
- /** How many sub-agent processes may run at once, derived from the host instead
42
- * of being fixed: children wait on model I/O far more than on CPU, so the pool
43
- * scales with cores while the bounds keep tiny machines usable and huge ones
44
- * from fanning out into an API-rate-limit wall. Pacing only — the queue never
45
- * rejects work; a wider parallel `subagent` call simply waits for a slot. */
41
+ /** Child-process concurrency scales with the host but stays within 4–6.
42
+ * The queue paces wider batches instead of rejecting independent work. */
46
43
  export function resolveSubagentConcurrency(cpuCount: number = cpus().length): number {
47
- return Math.min(16, Math.max(4, Math.floor(cpuCount / 2)));
44
+ return Math.min(6, Math.max(4, Math.floor(cpuCount / 2)));
48
45
  }
49
46
 
50
47
  export class BackgroundTaskQueue {
@@ -0,0 +1,200 @@
1
+ /** Shared RPC result types and stable logical-run control. */
2
+
3
+ import type { Message } from "@earendil-works/pi-ai";
4
+ import type { ThinkingLevel } from "../configuration/config.ts";
5
+ import type { IsolationMode, WorktreeFinalizationStatus } from "../isolation/worktree.ts";
6
+
7
+ export interface UsageStats {
8
+ input: number;
9
+ output: number;
10
+ cacheRead: number;
11
+ cacheWrite: number;
12
+ cost: number;
13
+ contextTokens: number;
14
+ turns: number;
15
+ }
16
+
17
+ export interface RpcSingleResult {
18
+ agent: string;
19
+ task: string;
20
+ exitCode: number;
21
+ messages: Message[];
22
+ stderr: string;
23
+ usage: UsageStats;
24
+ model?: string;
25
+ thinking?: string;
26
+ stopReason?: string;
27
+ errorMessage?: string;
28
+ /** Selected model when this result handed off to the current main model. */
29
+ modelFallbackFrom?: string;
30
+ dispatchFailed?: boolean;
31
+ /** An accepted generation failed because an RPC prompt was rejected before
32
+ * model execution. This remains main-model handoff eligible even when an
33
+ * earlier, aborted objective left assistant text in the session. */
34
+ rpcPromptRejected?: boolean;
35
+ /** Startup handshake failed before the initial prompt was dispatched. This
36
+ * transport miss is safe to retry and is not a model/provider failure. */
37
+ rpcStartupFailed?: boolean;
38
+ /** The parent dispatched the initial prompt command. Until its response is
39
+ * observed, Pi may already be running it, so startup retries must not replay it. */
40
+ rpcPromptDispatched?: boolean;
41
+ /** The child confirmed prompt acceptance (or emitted agent activity). */
42
+ rpcPromptAccepted?: boolean;
43
+ /** Pi emitted agent/turn/model/tool activity for this attempt. */
44
+ rpcActivity?: boolean;
45
+ startupRetries?: number;
46
+ failedTools?: Array<{ toolName: string; error: string }>;
47
+ sessionId?: string;
48
+ sessionDir?: string;
49
+ /** Original task/project cwd used for result-artifact retention buckets. */
50
+ projectCwd?: string;
51
+ /** Stable logical run id assigned by dispatch (also present on queued results). */
52
+ runId?: number;
53
+ /** Filesystem isolation selected for this logical thread. */
54
+ isolation?: IsolationMode;
55
+ /** Final integration state for a worktree-isolated settlement. */
56
+ integrationStatus?: "pending" | WorktreeFinalizationStatus;
57
+ integrationApplied?: boolean;
58
+ integrationError?: string;
59
+ /** Retained only when integration/cleanup failed; never contains patch data. */
60
+ integrationWorktreePath?: string;
61
+ integrationPatchPath?: string;
62
+ }
63
+
64
+ export type SubagentLiveEvent =
65
+ | { kind: "status"; status: "queued" | "running" | "interrupting" | "done" | "failed" }
66
+ | { kind: "model"; model?: string; thinking?: ThinkingLevel; fallbackFrom?: string }
67
+ | { kind: "usage"; usage: UsageStats; model?: string }
68
+ | { kind: "session"; sessionId: string; sessionDir: string }
69
+ | { kind: "tool_start"; toolCallId?: string; toolName: string; args: unknown }
70
+ | { kind: "tool_end"; toolCallId?: string; toolName: string; isError: boolean }
71
+ | { kind: "thinking" }
72
+ | { kind: "text" };
73
+
74
+ export type RpcControlPhase =
75
+ | "queued"
76
+ | "starting"
77
+ | "running"
78
+ | "interrupting"
79
+ | "retrying"
80
+ | "settled"
81
+ | "stopped";
82
+
83
+ export interface AttemptControl {
84
+ stop(reason?: string): Promise<void>;
85
+ }
86
+
87
+ /**
88
+ * Stable control surface for a logical run generation. Startup/main-handoff attempts
89
+ * attach and detach beneath it, so callers never retain a stale child handle.
90
+ * Control calls are serialized to prevent overlapping abort/settle/prompt flows.
91
+ */
92
+ export class RpcRunControl {
93
+ private objective: string;
94
+ private phase: RpcControlPhase = "queued";
95
+ private attempt?: { token: number; control: AttemptControl };
96
+ private nextToken = 1;
97
+ private serial: Promise<void> = Promise.resolve();
98
+ private stopRequested = false;
99
+ private stopMessage = "Subagent was aborted";
100
+ private childPids = new Set<number>();
101
+
102
+ constructor(
103
+ objective: string,
104
+ readonly generation: number,
105
+ private readonly onPhase?: (phase: RpcControlPhase) => void,
106
+ ) {
107
+ this.objective = objective;
108
+ }
109
+
110
+ getObjective(): string {
111
+ return this.objective;
112
+ }
113
+
114
+ getPhase(): RpcControlPhase {
115
+ return this.phase;
116
+ }
117
+
118
+ isStopRequested(): boolean {
119
+ return this.stopRequested;
120
+ }
121
+
122
+ getStopMessage(): string {
123
+ return this.stopMessage;
124
+ }
125
+
126
+ /** Pids of every child process this generation spawned. Persisted with the
127
+ * thread record so a later load can kill orphans that still hold the
128
+ * retained session. */
129
+ noteChildPid(pid: number): void {
130
+ if (Number.isInteger(pid) && pid > 0) this.childPids.add(pid);
131
+ }
132
+
133
+ getChildPids(): number[] {
134
+ return [...this.childPids];
135
+ }
136
+
137
+ markStarting(): void {
138
+ this.setPhase("starting");
139
+ }
140
+
141
+ markRetrying(): void {
142
+ if (!this.stopRequested) this.setPhase("retrying");
143
+ }
144
+
145
+ markSettled(): void {
146
+ this.attempt = undefined;
147
+ if (!this.stopRequested) this.setPhase("settled");
148
+ }
149
+
150
+ /** Allocate an attempt token used to reject state updates from old children. */
151
+ beginAttempt(): number {
152
+ return this.nextToken++;
153
+ }
154
+
155
+ attach(token: number, control: AttemptControl): void {
156
+ this.attempt = { token, control };
157
+ }
158
+
159
+ detach(token: number): void {
160
+ if (this.attempt?.token === token) this.attempt = undefined;
161
+ }
162
+
163
+ updateAttemptPhase(token: number, phase: RpcControlPhase): void {
164
+ if (this.attempt?.token !== token) return;
165
+ this.setPhase(phase);
166
+ }
167
+
168
+ async stop(reason = "Subagent was aborted"): Promise<void> {
169
+ return this.serialize(async () => {
170
+ this.stopRequested = true;
171
+ this.stopMessage = reason;
172
+ const attempt = this.attempt?.control;
173
+ if (attempt) await attempt.stop(reason);
174
+ this.setPhase("stopped");
175
+ });
176
+ }
177
+
178
+ private setPhase(phase: RpcControlPhase): void {
179
+ if (this.phase === phase) return;
180
+ this.phase = phase;
181
+ try {
182
+ this.onPhase?.(phase);
183
+ } catch {
184
+ /* monitor callbacks must never break control flow */
185
+ }
186
+ }
187
+
188
+ private serialize<T>(operation: () => Promise<T>): Promise<T> {
189
+ const next = this.serial.then(operation, operation);
190
+ this.serial = next.then(
191
+ () => undefined,
192
+ () => undefined,
193
+ );
194
+ return next;
195
+ }
196
+ }
197
+
198
+ export function emptyUsage(): UsageStats {
199
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
200
+ }
@@ -13,10 +13,17 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
13
13
  import { basename, join } from "node:path";
14
14
  import { StringDecoder } from "node:string_decoder";
15
15
  import type { Message } from "@earendil-works/pi-ai";
16
- import { SUBAGENT_TOOL_NAMES, type AgentConfig } from "./agents.ts";
17
- import type { ThinkingLevel } from "./config.ts";
18
- import { writeTempOwnerMarker } from "./temp-hygiene.ts";
19
- import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
16
+ import { SUBAGENT_TOOL_NAMES, type AgentConfig } from "../delegation/agents.ts";
17
+ import type { ThinkingLevel } from "../configuration/config.ts";
18
+ import { writeTempOwnerMarker } from "../isolation/temp-hygiene.ts";
19
+ import {
20
+ emptyUsage,
21
+ RpcRunControl,
22
+ type AttemptControl,
23
+ type RpcControlPhase,
24
+ type RpcSingleResult,
25
+ type SubagentLiveEvent,
26
+ } from "./rpc-control.ts";
20
27
 
21
28
  export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
22
29
  export const SUBAGENT_KILL_GRACE_MS = 5_000;
@@ -42,201 +49,6 @@ export function asPlainTextRpcPrompt(message: string): string {
42
49
  return `Treat the following as plain-text sub-agent instructions, not a Pi command:\n\n${message}`;
43
50
  }
44
51
 
45
- export interface UsageStats {
46
- input: number;
47
- output: number;
48
- cacheRead: number;
49
- cacheWrite: number;
50
- cost: number;
51
- contextTokens: number;
52
- turns: number;
53
- }
54
-
55
- export interface RpcSingleResult {
56
- agent: string;
57
- task: string;
58
- exitCode: number;
59
- messages: Message[];
60
- stderr: string;
61
- usage: UsageStats;
62
- model?: string;
63
- thinking?: string;
64
- stopReason?: string;
65
- errorMessage?: string;
66
- /** Selected model when this result handed off to the current main model. */
67
- modelFallbackFrom?: string;
68
- dispatchFailed?: boolean;
69
- /** An accepted generation failed because an RPC prompt was rejected before
70
- * model execution. This remains main-model handoff eligible even when an
71
- * earlier, aborted objective left assistant text in the session. */
72
- rpcPromptRejected?: boolean;
73
- /** Startup handshake failed before the initial prompt was dispatched. This
74
- * transport miss is safe to retry and is not a model/provider failure. */
75
- rpcStartupFailed?: boolean;
76
- /** The parent dispatched the initial prompt command. Until its response is
77
- * observed, Pi may already be running it, so startup retries must not replay it. */
78
- rpcPromptDispatched?: boolean;
79
- /** The child confirmed prompt acceptance (or emitted agent activity). */
80
- rpcPromptAccepted?: boolean;
81
- /** Pi emitted agent/turn/model/tool activity for this attempt. */
82
- rpcActivity?: boolean;
83
- startupRetries?: number;
84
- failedTools?: Array<{ toolName: string; error: string }>;
85
- sessionId?: string;
86
- sessionDir?: string;
87
- /** Original task/project cwd used for result-artifact retention buckets. */
88
- projectCwd?: string;
89
- /** Stable logical run id assigned by dispatch (also present on queued results). */
90
- runId?: number;
91
- /** Filesystem isolation selected for this logical thread. */
92
- isolation?: IsolationMode;
93
- /** Final integration state for a worktree-isolated settlement. */
94
- integrationStatus?: "pending" | WorktreeFinalizationStatus;
95
- integrationApplied?: boolean;
96
- integrationError?: string;
97
- /** Retained only when integration/cleanup failed; never contains patch data. */
98
- integrationWorktreePath?: string;
99
- integrationPatchPath?: string;
100
- }
101
-
102
- export type SubagentLiveEvent =
103
- | { kind: "status"; status: "queued" | "running" | "interrupting" | "done" | "failed" }
104
- | { kind: "model"; model?: string; thinking?: ThinkingLevel; fallbackFrom?: string }
105
- | { kind: "usage"; usage: UsageStats; model?: string }
106
- | { kind: "session"; sessionId: string; sessionDir: string }
107
- | { kind: "tool_start"; toolCallId?: string; toolName: string; args: unknown }
108
- | { kind: "tool_end"; toolCallId?: string; toolName: string; isError: boolean }
109
- | { kind: "thinking" }
110
- | { kind: "text" };
111
-
112
- export type RpcControlPhase =
113
- | "queued"
114
- | "starting"
115
- | "running"
116
- | "interrupting"
117
- | "retrying"
118
- | "settled"
119
- | "stopped";
120
-
121
- interface AttemptControl {
122
- stop(reason?: string): Promise<void>;
123
- }
124
-
125
- /**
126
- * Stable control surface for a logical run generation. Startup/main-handoff attempts
127
- * attach and detach beneath it, so callers never retain a stale child handle.
128
- * Control calls are serialized to prevent overlapping abort/settle/prompt flows.
129
- */
130
- export class RpcRunControl {
131
- private objective: string;
132
- private phase: RpcControlPhase = "queued";
133
- private attempt?: { token: number; control: AttemptControl };
134
- private nextToken = 1;
135
- private serial: Promise<void> = Promise.resolve();
136
- private stopRequested = false;
137
- private stopMessage = "Subagent was aborted";
138
- private childPids = new Set<number>();
139
-
140
- constructor(
141
- objective: string,
142
- readonly generation: number,
143
- private readonly onPhase?: (phase: RpcControlPhase) => void,
144
- ) {
145
- this.objective = objective;
146
- }
147
-
148
- getObjective(): string {
149
- return this.objective;
150
- }
151
-
152
- getPhase(): RpcControlPhase {
153
- return this.phase;
154
- }
155
-
156
- isStopRequested(): boolean {
157
- return this.stopRequested;
158
- }
159
-
160
- getStopMessage(): string {
161
- return this.stopMessage;
162
- }
163
-
164
- /** Pids of every child process this generation spawned. Persisted with the
165
- * thread record so a later load can kill orphans that still hold the
166
- * retained session. */
167
- noteChildPid(pid: number): void {
168
- if (Number.isInteger(pid) && pid > 0) this.childPids.add(pid);
169
- }
170
-
171
- getChildPids(): number[] {
172
- return [...this.childPids];
173
- }
174
-
175
- markStarting(): void {
176
- this.setPhase("starting");
177
- }
178
-
179
- markRetrying(): void {
180
- if (!this.stopRequested) this.setPhase("retrying");
181
- }
182
-
183
- markSettled(): void {
184
- this.attempt = undefined;
185
- if (!this.stopRequested) this.setPhase("settled");
186
- }
187
-
188
- /** Allocate an attempt token used to reject state updates from old children. */
189
- beginAttempt(): number {
190
- return this.nextToken++;
191
- }
192
-
193
- attach(token: number, control: AttemptControl): void {
194
- this.attempt = { token, control };
195
- }
196
-
197
- detach(token: number): void {
198
- if (this.attempt?.token === token) this.attempt = undefined;
199
- }
200
-
201
- updateAttemptPhase(token: number, phase: RpcControlPhase): void {
202
- if (this.attempt?.token !== token) return;
203
- this.setPhase(phase);
204
- }
205
-
206
- async stop(reason = "Subagent was aborted"): Promise<void> {
207
- return this.serialize(async () => {
208
- this.stopRequested = true;
209
- this.stopMessage = reason;
210
- const attempt = this.attempt?.control;
211
- if (attempt) await attempt.stop(reason);
212
- this.setPhase("stopped");
213
- });
214
- }
215
-
216
- private setPhase(phase: RpcControlPhase): void {
217
- if (this.phase === phase) return;
218
- this.phase = phase;
219
- try {
220
- this.onPhase?.(phase);
221
- } catch {
222
- /* monitor callbacks must never break control flow */
223
- }
224
- }
225
-
226
- private serialize<T>(operation: () => Promise<T>): Promise<T> {
227
- const next = this.serial.then(operation, operation);
228
- this.serial = next.then(
229
- () => undefined,
230
- () => undefined,
231
- );
232
- return next;
233
- }
234
- }
235
-
236
- export function emptyUsage(): UsageStats {
237
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
238
- }
239
-
240
52
  export function currentSubagentDepth(env: NodeJS.ProcessEnv = process.env): number {
241
53
  const raw = env[DEPTH_ENV_VAR];
242
54
  const parsed = raw === undefined ? 0 : Number.parseInt(raw, 10);
@@ -4,7 +4,7 @@ import { SessionManager } from "@earendil-works/pi-coding-agent";
4
4
  import { existsSync } from "node:fs";
5
5
  import { mkdir, mkdtemp, rm } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
- import { writeTempOwnerMarker } from "./temp-hygiene.ts";
7
+ import { writeTempOwnerMarker } from "../isolation/temp-hygiene.ts";
8
8
 
9
9
  export interface ForkedSession {
10
10
  sessionDir: string;
@@ -13,25 +13,27 @@ import { type Dirent, mkdirSync, readdirSync, realpathSync, rmSync, statSync, wr
13
13
  import { mkdir, mkdtemp, rm } from "node:fs/promises";
14
14
  import { basename, dirname, join, resolve } from "node:path";
15
15
  import type { Message } from "@earendil-works/pi-ai";
16
- import type { AgentConfig } from "./agents.ts";
17
- import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
16
+ import type { AgentConfig } from "../delegation/agents.ts";
17
+ import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "../configuration/config.ts";
18
+ import {
19
+ emptyUsage,
20
+ RpcRunControl,
21
+ type RpcSingleResult,
22
+ type SubagentLiveEvent,
23
+ type UsageStats,
24
+ } from "./rpc-control.ts";
18
25
  import {
19
26
  currentSubagentDepth,
20
27
  DEPTH_ENV_VAR,
21
- emptyUsage,
22
28
  extractToolErrorText,
23
29
  getPiInvocation,
24
30
  isRpcCommandTimeoutError,
25
- RpcRunControl,
26
31
  runRpcAgentAttempt,
27
32
  sessionExists,
28
33
  writeChildRetryPolicyExtension,
29
34
  SUBAGENT_KILL_GRACE_MS,
30
- type RpcSingleResult,
31
- type SubagentLiveEvent,
32
- type UsageStats,
33
35
  } from "./rpc-run.ts";
34
- import { writeTempOwnerMarker } from "./temp-hygiene.ts";
36
+ import { writeTempOwnerMarker } from "../isolation/temp-hygiene.ts";
35
37
 
36
38
  export {
37
39
  currentSubagentDepth,
@@ -0,0 +1,147 @@
1
+ /** Bounded, abortable process runner used by Git isolation operations. */
2
+
3
+ import { spawn, type ChildProcess } from "node:child_process";
4
+
5
+ export interface CommandRunOptions {
6
+ cwd: string;
7
+ input?: Buffer;
8
+ signal?: AbortSignal;
9
+ timeoutMs?: number;
10
+ maxOutputBytes?: number;
11
+ /** Extra environment entries merged over the inherited environment. */
12
+ env?: Record<string, string>;
13
+ }
14
+
15
+ export interface CommandResult {
16
+ code: number;
17
+ stdout: Buffer;
18
+ stderr: Buffer;
19
+ }
20
+
21
+ /** Injectable, shell-free command runner used by every Git operation. */
22
+ export type CommandRunner = (
23
+ command: string,
24
+ args: readonly string[],
25
+ options: CommandRunOptions,
26
+ ) => Promise<CommandResult>;
27
+
28
+ export const GIT_COMMAND_TIMEOUT_MS = 120_000;
29
+ export const GIT_COMMAND_KILL_GRACE_MS = 2_000;
30
+ export const GIT_OUTPUT_MAX_BYTES = 64 * 1024 * 1024;
31
+ export const WORKTREE_PATCH_MAX_BYTES = GIT_OUTPUT_MAX_BYTES;
32
+
33
+ /** Terminate the complete spawned process tree so checkout filters cannot
34
+ * survive an abort or timeout. */
35
+ function terminateCommandTree(child: ChildProcess, force: boolean, processGroup: boolean): void {
36
+ if (process.platform === "win32" && child.pid !== undefined) {
37
+ const fallback = (): void => {
38
+ try {
39
+ child.kill(force ? "SIGKILL" : "SIGTERM");
40
+ } catch {
41
+ /* process may already be gone */
42
+ }
43
+ };
44
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
45
+ stdio: "ignore",
46
+ windowsHide: true,
47
+ });
48
+ killer.once("error", fallback);
49
+ killer.once("close", (code) => {
50
+ if (code !== 0) fallback();
51
+ });
52
+ return;
53
+ }
54
+ try {
55
+ if (processGroup && child.pid !== undefined) {
56
+ process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
57
+ } else {
58
+ child.kill(force ? "SIGKILL" : "SIGTERM");
59
+ }
60
+ } catch {
61
+ /* process may already be gone */
62
+ }
63
+ }
64
+
65
+ /** Default argument-safe runner. Output is bounded before binary patches enter
66
+ * memory, and timeout/abort terminates the complete checkout-filter process tree. */
67
+ export const runCommand: CommandRunner = (command, args, options) =>
68
+ new Promise<CommandResult>((resolveResult, reject) => {
69
+ if (options.signal?.aborted) {
70
+ reject(new Error(`Command aborted before start: ${command}`));
71
+ return;
72
+ }
73
+ const usePosixProcessGroup = process.platform !== "win32";
74
+ const child = spawn(command, [...args], {
75
+ cwd: options.cwd,
76
+ shell: false,
77
+ windowsHide: true,
78
+ stdio: ["pipe", "pipe", "pipe"],
79
+ detached: usePosixProcessGroup,
80
+ ...(options.env ? { env: { ...process.env, ...options.env } } : {}),
81
+ });
82
+ const stdout: Buffer[] = [];
83
+ const stderr: Buffer[] = [];
84
+ const maxOutputBytes = options.maxOutputBytes ?? GIT_OUTPUT_MAX_BYTES;
85
+ let outputBytes = 0;
86
+ let finished = false;
87
+ let failure: Error | undefined;
88
+ let timeout: ReturnType<typeof setTimeout> | undefined;
89
+ let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
90
+
91
+ const terminate = (): void => {
92
+ terminateCommandTree(child, false, usePosixProcessGroup);
93
+ if (!forceKillTimer) {
94
+ forceKillTimer = setTimeout(
95
+ () => terminateCommandTree(child, true, usePosixProcessGroup),
96
+ GIT_COMMAND_KILL_GRACE_MS,
97
+ );
98
+ if (typeof forceKillTimer.unref === "function") forceKillTimer.unref();
99
+ }
100
+ };
101
+ const fail = (error: Error): void => {
102
+ if (failure || finished) return;
103
+ failure = error;
104
+ terminate();
105
+ };
106
+ const append = (target: Buffer[], chunk: Buffer | string): void => {
107
+ if (failure || finished) return;
108
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
109
+ outputBytes += value.length;
110
+ if (outputBytes > maxOutputBytes) {
111
+ fail(new Error(`Command output exceeded ${maxOutputBytes} bytes: ${command}`));
112
+ return;
113
+ }
114
+ target.push(value);
115
+ };
116
+ const onAbort = (): void => fail(new Error(`Command aborted: ${command}`));
117
+ options.signal?.addEventListener("abort", onAbort, { once: true });
118
+ if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
119
+ timeout = setTimeout(
120
+ () => fail(new Error(`Command timed out after ${options.timeoutMs}ms: ${command}`)),
121
+ options.timeoutMs,
122
+ );
123
+ if (typeof timeout.unref === "function") timeout.unref();
124
+ }
125
+
126
+ child.stdout?.on("data", (chunk: Buffer | string) => append(stdout, chunk));
127
+ child.stderr?.on("data", (chunk: Buffer | string) => append(stderr, chunk));
128
+ child.once("error", (error) => fail(error));
129
+ child.once("close", (code) => {
130
+ if (finished) return;
131
+ finished = true;
132
+ if (timeout) clearTimeout(timeout);
133
+ if (forceKillTimer) clearTimeout(forceKillTimer);
134
+ options.signal?.removeEventListener("abort", onAbort);
135
+ if (failure) {
136
+ reject(failure);
137
+ return;
138
+ }
139
+ resolveResult({
140
+ code: code ?? 1,
141
+ stdout: Buffer.concat(stdout),
142
+ stderr: Buffer.concat(stderr),
143
+ });
144
+ });
145
+ child.stdin?.once("error", () => undefined);
146
+ child.stdin?.end(options.input);
147
+ });
@@ -5,7 +5,7 @@ import { existsSync } from "node:fs";
5
5
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
6
  import { dirname, join } from "node:path";
7
7
  import { stripVTControlCharacters } from "node:util";
8
- import { getSubagentsRoot } from "./spawn.ts";
8
+ import { getSubagentsRoot } from "../execution/spawn.ts";
9
9
  import { removeWorktreeGroup, worktreeGroupDir, type WorktreeFinalization } from "./worktree.ts";
10
10
 
11
11
  export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";