pi-crew 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,7 +32,7 @@ Current highlights:
32
32
  - detached async/background runner
33
33
  - stale async PID detection
34
34
  - active run summary and async completion notifications in Pi sessions
35
- - safe scaffold execution by default; real child Pi workers are opt-in
35
+ - real child Pi worker execution by default, with explicit scaffold/dry-run opt-out
36
36
  - child Pi JSON output parsing for final text, usage, and event counts
37
37
  - retryable model fallback attempts per task
38
38
  - aggregate usage totals in status/summary
@@ -89,19 +89,21 @@ npm run ci
89
89
 
90
90
  ## Runtime safety model
91
91
 
92
- By default, `run` uses safe scaffold mode. It creates run state, task prompts, events, and placeholder result artifacts without launching child Pi workers.
92
+ By default, `run` launches each crew task as a separate child Pi process. This matches the subagent model from `pi-subagents`: the parent session orchestrates while worker sessions execute independently and stream durable output back to run state.
93
93
 
94
- Real child Pi workers only run when explicitly enabled by either:
94
+ Use scaffold/dry-run mode only when you explicitly want prompts/artifacts without launching workers:
95
95
 
96
- ```bash
97
- PI_TEAMS_EXECUTE_WORKERS=1 pi
96
+ ```json
97
+ {
98
+ "runtime": { "mode": "scaffold" }
99
+ }
98
100
  ```
99
101
 
100
- or config:
102
+ or disable workers globally:
101
103
 
102
104
  ```json
103
105
  {
104
- "executeWorkers": true
106
+ "executeWorkers": false
105
107
  }
106
108
  ```
107
109
 
@@ -143,7 +145,7 @@ Supported config:
143
145
  ```json
144
146
  {
145
147
  "asyncByDefault": false,
146
- "executeWorkers": false,
148
+ "executeWorkers": true,
147
149
  "notifierIntervalMs": 5000,
148
150
  "requireCleanWorktreeLeader": true,
149
151
  "autonomous": {
@@ -163,6 +165,11 @@ Supported config:
163
165
  "maxRunMinutes": 60,
164
166
  "maxRetriesPerTask": 1,
165
167
  "heartbeatStaleMs": 60000
168
+ },
169
+ "ui": {
170
+ "widgetPlacement": "aboveEditor",
171
+ "widgetMaxLines": 8,
172
+ "powerbar": true
166
173
  }
167
174
  }
168
175
  ```
@@ -619,7 +626,8 @@ Optional child Pi smoke check is explicit only:
619
626
  ## Environment variables
620
627
 
621
628
  ```text
622
- PI_TEAMS_EXECUTE_WORKERS=1 enable real child Pi worker execution
629
+ PI_CREW_EXECUTE_WORKERS=0 disable child workers and use scaffold/dry-run mode
630
+ PI_TEAMS_EXECUTE_WORKERS=0 legacy disable flag
623
631
  PI_TEAMS_MOCK_CHILD_PI=success test/mock child worker success
624
632
  PI_TEAMS_MOCK_CHILD_PI=json-success
625
633
  PI_TEAMS_MOCK_CHILD_PI=retryable-failure
@@ -32,8 +32,8 @@ Implemented now:
32
32
  - durable run manifests and task files
33
33
  - workflow validation
34
34
  - foreground workflow scheduler
35
- - safe scaffold task execution
36
- - optional real child Pi execution via `PI_TEAMS_EXECUTE_WORKERS=1`
35
+ - child Pi task execution by default
36
+ - explicit scaffold/dry-run task execution via `runtime.mode=scaffold` or `executeWorkers=false`
37
37
  - safety-first create/update/delete for agents, teams, and workflows
38
38
  - backups for update/delete mutations
39
39
  - dry-run support for management mutations
package/docs/usage.md CHANGED
@@ -19,7 +19,7 @@ Supported fields:
19
19
  ```json
20
20
  {
21
21
  "asyncByDefault": false,
22
- "executeWorkers": false,
22
+ "executeWorkers": true,
23
23
  "notifierIntervalMs": 5000,
24
24
  "requireCleanWorktreeLeader": true,
25
25
  "autonomous": {
@@ -28,6 +28,11 @@ Supported fields:
28
28
  "injectPolicy": true,
29
29
  "preferAsyncForLongTasks": false,
30
30
  "allowWorktreeSuggestion": true
31
+ },
32
+ "ui": {
33
+ "widgetPlacement": "aboveEditor",
34
+ "widgetMaxLines": 8,
35
+ "powerbar": true
31
36
  }
32
37
  }
33
38
  ```
@@ -47,9 +52,9 @@ Then open Pi and run:
47
52
  /team-autonomy status
48
53
  ```
49
54
 
50
- ## Safe run
55
+ ## Default run: real worker execution
51
56
 
52
- By default, `pi-crew` does not launch child workers. It creates a durable run, prompts, placeholder results, events, and artifacts.
57
+ By default, `pi-crew` launches each task as a separate child Pi worker process. The parent Pi session orchestrates; workers execute independently and stream output to durable run state.
53
58
 
54
59
  ```json
55
60
  {
@@ -59,16 +64,21 @@ By default, `pi-crew` does not launch child workers. It creates a durable run, p
59
64
  }
60
65
  ```
61
66
 
62
- ## Real worker execution
67
+ ## Scaffold / dry run
63
68
 
64
- Start Pi with:
69
+ Use scaffold mode only when you want durable prompts/artifacts without launching child workers.
65
70
 
66
- ```bash
67
- PI_TEAMS_EXECUTE_WORKERS=1 pi
71
+ ```json
72
+ {
73
+ "action": "run",
74
+ "team": "default",
75
+ "goal": "Plan only",
76
+ "config": {
77
+ "runtime": { "mode": "scaffold" }
78
+ }
79
+ }
68
80
  ```
69
81
 
70
- Then run normally. Each task can spawn a child Pi worker.
71
-
72
82
  ## Async run
73
83
 
74
84
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
package/schema.json CHANGED
@@ -78,6 +78,16 @@
78
78
  "enabled": { "type": "boolean" },
79
79
  "needsAttentionAfterMs": { "type": "integer", "minimum": 1 }
80
80
  }
81
+ },
82
+ "ui": {
83
+ "type": "object",
84
+ "additionalProperties": false,
85
+ "description": "Pi UI settings for the crew widget, dashboard, and optional powerbar segments.",
86
+ "properties": {
87
+ "widgetPlacement": { "type": "string", "enum": ["aboveEditor", "belowEditor"] },
88
+ "widgetMaxLines": { "type": "integer", "minimum": 1, "maximum": 50 },
89
+ "powerbar": { "type": "boolean" }
90
+ }
81
91
  }
82
92
  }
83
93
  }
@@ -47,6 +47,12 @@ export interface CrewWorktreeConfig {
47
47
  linkNodeModules?: boolean;
48
48
  }
49
49
 
50
+ export interface CrewUiConfig {
51
+ widgetPlacement?: "aboveEditor" | "belowEditor";
52
+ widgetMaxLines?: number;
53
+ powerbar?: boolean;
54
+ }
55
+
50
56
  export interface AgentOverrideConfig {
51
57
  disabled?: boolean;
52
58
  model?: string | false;
@@ -71,6 +77,7 @@ export interface PiTeamsConfig {
71
77
  control?: CrewControlConfig;
72
78
  worktree?: CrewWorktreeConfig;
73
79
  agents?: CrewAgentsConfig;
80
+ ui?: CrewUiConfig;
74
81
  }
75
82
 
76
83
  export interface LoadedPiTeamsConfig {
@@ -136,6 +143,12 @@ function mergeConfig(base: PiTeamsConfig, override: PiTeamsConfig): PiTeamsConfi
136
143
  ...withoutUndefined((override.worktree ?? {}) as Record<string, unknown>),
137
144
  };
138
145
  }
146
+ if (base.ui || override.ui) {
147
+ merged.ui = {
148
+ ...(base.ui ?? {}),
149
+ ...withoutUndefined((override.ui ?? {}) as Record<string, unknown>),
150
+ };
151
+ }
139
152
  if (base.agents || override.agents) {
140
153
  merged.agents = {
141
154
  ...(base.agents ?? {}),
@@ -289,6 +302,17 @@ function parseAgentOverride(value: unknown): AgentOverrideConfig | undefined {
289
302
  return Object.values(override).some((entry) => entry !== undefined) ? override : undefined;
290
303
  }
291
304
 
305
+ function parseUiConfig(value: unknown): CrewUiConfig | undefined {
306
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
307
+ const obj = value as Record<string, unknown>;
308
+ const ui: CrewUiConfig = {
309
+ widgetPlacement: obj.widgetPlacement === "aboveEditor" || obj.widgetPlacement === "belowEditor" ? obj.widgetPlacement : undefined,
310
+ widgetMaxLines: parsePositiveInteger(obj.widgetMaxLines, 50),
311
+ powerbar: typeof obj.powerbar === "boolean" ? obj.powerbar : undefined,
312
+ };
313
+ return Object.values(ui).some((entry) => entry !== undefined) ? ui : undefined;
314
+ }
315
+
292
316
  function parseAgentsConfig(value: unknown): CrewAgentsConfig | undefined {
293
317
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
294
318
  const obj = value as Record<string, unknown>;
@@ -320,6 +344,7 @@ function parseConfig(raw: unknown): PiTeamsConfig {
320
344
  control: parseControlConfig(obj.control),
321
345
  worktree: parseWorktreeConfig(obj.worktree),
322
346
  agents: parseAgentsConfig(obj.agents),
347
+ ui: parseUiConfig(obj.ui),
323
348
  };
324
349
  }
325
350
 
@@ -40,6 +40,6 @@ export function piTeamsHelp(): string {
40
40
  "- /team-validate",
41
41
  "- /team-help",
42
42
  "",
43
- "Real child workers are disabled by default. Enable with PI_TEAMS_EXECUTE_WORKERS=1 or config executeWorkers=true.",
43
+ "Real child workers are enabled by default. Use runtime.mode=scaffold or executeWorkers=false only for dry runs.",
44
44
  ].join("\n");
45
45
  }
@@ -11,9 +11,11 @@ import { listRuns } from "./run-index.ts";
11
11
  import { RunDashboard, type RunDashboardSelection } from "../ui/run-dashboard.ts";
12
12
  import { registerPiCrewRpc, type PiCrewRpcHandle } from "./cross-extension-rpc.ts";
13
13
  import { stopCrewWidget, updateCrewWidget, type CrewWidgetState } from "../ui/crew-widget.ts";
14
+ import { clearPiCrewPowerbar, registerPiCrewPowerbarSegments, updatePiCrewPowerbar } from "../ui/powerbar-publisher.ts";
14
15
  import { DurableTextViewer, DurableTranscriptViewer } from "../ui/transcript-viewer.ts";
15
16
  import { loadRunManifestById } from "../state/state-store.ts";
16
17
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
18
+ import { terminateActiveChildPiProcesses } from "../runtime/child-pi.ts";
17
19
 
18
20
  function parseRunArgs(args: string): TeamToolParamsValue {
19
21
  const tokens = args.match(/"[^"]*"|'[^']*'|\S+/g)?.map((token) => token.replace(/^['"]|['"]$/g, "")) ?? [];
@@ -102,6 +104,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
102
104
  let currentCtx: ExtensionContext | undefined;
103
105
  let rpcHandle: PiCrewRpcHandle | undefined;
104
106
  const widgetState: CrewWidgetState = { frame: 0 };
107
+ const foregroundControllers = new Set<AbortController>();
105
108
  registerAutonomousPolicy(pi);
106
109
  rpcHandle = registerPiCrewRpc((pi as unknown as { events?: Parameters<typeof registerPiCrewRpc>[0] }).events, () => currentCtx);
107
110
 
@@ -109,14 +112,24 @@ export function registerPiTeams(pi: ExtensionAPI): void {
109
112
  currentCtx = ctx;
110
113
  notifyActiveRuns(ctx);
111
114
  const loadedConfig = loadConfig(ctx.cwd);
115
+ registerPiCrewPowerbarSegments(pi.events, loadedConfig.config.ui);
112
116
  startAsyncRunNotifier(ctx, notifierState, loadedConfig.config.notifierIntervalMs ?? 5000);
113
- updateCrewWidget(ctx, widgetState);
114
- widgetState.interval = setInterval(() => { if (currentCtx) updateCrewWidget(currentCtx, widgetState); }, 1000);
117
+ updateCrewWidget(ctx, widgetState, loadedConfig.config.ui);
118
+ updatePiCrewPowerbar(pi.events, ctx.cwd, loadedConfig.config.ui);
119
+ widgetState.interval = setInterval(() => {
120
+ if (!currentCtx) return;
121
+ const config = loadConfig(currentCtx.cwd).config.ui;
122
+ updateCrewWidget(currentCtx, widgetState, config);
123
+ updatePiCrewPowerbar(pi.events, currentCtx.cwd, config);
124
+ }, 1000);
115
125
  widgetState.interval.unref?.();
116
126
  });
117
127
  pi.on("session_shutdown", () => {
128
+ for (const controller of foregroundControllers) controller.abort();
129
+ terminateActiveChildPiProcesses();
118
130
  stopAsyncRunNotifier(notifierState);
119
- stopCrewWidget(currentCtx, widgetState);
131
+ stopCrewWidget(currentCtx, widgetState, currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined);
132
+ clearPiCrewPowerbar(pi.events);
120
133
  currentCtx = undefined;
121
134
  rpcHandle?.unsubscribe();
122
135
  rpcHandle = undefined;
@@ -128,10 +141,21 @@ export function registerPiTeams(pi: ExtensionAPI): void {
128
141
  description: "Coordinate Pi teams. Use proactively for complex multi-file work, planning, implementation, tests, reviews, security audits, research, async/background runs, and worktree-isolated execution. Use action='recommend' when unsure which team/workflow to choose. Destructive actions require explicit user confirmation.",
129
142
  promptSnippet: "Use the team tool proactively for coordinated multi-agent work. If unsure, call { action: 'recommend', goal } first, then run or plan with the suggested team/workflow.",
130
143
  parameters: TeamToolParams as never,
131
- async execute(_id, params, _signal, _onUpdate, ctx) {
132
- const output = await handleTeamTool(params as TeamToolParamsValue, ctx);
133
- updateCrewWidget(ctx, widgetState);
134
- return output;
144
+ async execute(_id, params, signal, _onUpdate, ctx) {
145
+ const controller = new AbortController();
146
+ foregroundControllers.add(controller);
147
+ const abort = (): void => controller.abort();
148
+ signal?.addEventListener("abort", abort, { once: true });
149
+ try {
150
+ const output = await handleTeamTool(params as TeamToolParamsValue, { ...ctx, signal: controller.signal });
151
+ const config = loadConfig(ctx.cwd).config.ui;
152
+ updateCrewWidget(ctx, widgetState, config);
153
+ updatePiCrewPowerbar(pi.events, ctx.cwd, config);
154
+ return output;
155
+ } finally {
156
+ signal?.removeEventListener("abort", abort);
157
+ foregroundControllers.delete(controller);
158
+ }
135
159
  },
136
160
  };
137
161
 
@@ -323,7 +347,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
323
347
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
324
348
  for (;;) {
325
349
  const runs = listRuns(ctx.cwd).slice(0, 50);
326
- const selection = await ctx.ui.custom<RunDashboardSelection | undefined>((_tui, _theme, _keybindings, done) => new RunDashboard(runs, done), {
350
+ const selection = await ctx.ui.custom<RunDashboardSelection | undefined>((_tui, theme, _keybindings, done) => new RunDashboard(runs, done, theme), {
327
351
  overlay: true,
328
352
  overlayOptions: { width: "90%", maxHeight: "80%", anchor: "center" },
329
353
  });
@@ -12,7 +12,7 @@ function isFinished(run: TeamRunManifest): boolean {
12
12
  }
13
13
 
14
14
  export function pruneFinishedRuns(cwd: string, keep: number): PruneRunsResult {
15
- const finished = listRuns(cwd).filter(isFinished).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
15
+ const finished = listRuns(cwd).filter((run) => run.cwd === cwd && isFinished(run)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
16
16
  const kept = finished.slice(0, keep).map((run) => run.runId);
17
17
  const removed: string[] = [];
18
18
  for (const run of finished.slice(keep)) {
@@ -56,6 +56,7 @@ type TeamContext = Pick<ExtensionContext, "cwd"> & Partial<Pick<ExtensionContext
56
56
  modelRegistry?: unknown;
57
57
  sessionManager?: { getBranch?: () => unknown[] };
58
58
  events?: { emit?: (event: string, data: unknown) => void };
59
+ signal?: AbortSignal;
59
60
  };
60
61
 
61
62
  function result(text: string, details: TeamToolDetails, isError = false): PiTeamsToolResult {
@@ -306,7 +307,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
306
307
  const runtime = await resolveCrewRuntime(effectiveRunConfig(loadedConfig.config, params.config));
307
308
  const executeWorkers = runtime.kind === "child-process";
308
309
  const executedConfig = effectiveRunConfig(loadedConfig.config, params.config);
309
- const executed = await executeTeamRun({ manifest: updatedManifest, tasks, team, workflow, agents, executeWorkers, limits: executedConfig.limits, runtime, runtimeConfig: executedConfig.runtime, parentContext: buildParentContext(ctx), parentModel: ctx.model, modelRegistry: ctx.modelRegistry });
310
+ const executed = await executeTeamRun({ manifest: updatedManifest, tasks, team, workflow, agents, executeWorkers, limits: executedConfig.limits, runtime, runtimeConfig: executedConfig.runtime, parentContext: buildParentContext(ctx), parentModel: ctx.model, modelRegistry: ctx.modelRegistry, signal: ctx.signal });
310
311
  const text = [
311
312
  `Created pi-crew run ${executed.manifest.runId}.`,
312
313
  `Team: ${team.name}`,
@@ -318,10 +319,10 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
318
319
  "",
319
320
  `Runtime: ${runtime.kind}${runtime.fallback ? ` (fallback from ${runtime.requestedMode})` : ""}${runtime.reason ? ` - ${runtime.reason}` : ""}`,
320
321
  runtime.kind === "child-process"
321
- ? "Child Pi worker execution was enabled."
322
+ ? "Child Pi worker execution is enabled by default; each task is launched as a separate Pi process. Set runtime.mode=scaffold or executeWorkers=false only for dry runs."
322
323
  : runtime.kind === "live-session"
323
324
  ? "Experimental live-session worker execution was enabled."
324
- : "Safe scaffold mode: child Pi workers were not launched. Set PI_CREW_EXECUTE_WORKERS=1, PI_TEAMS_EXECUTE_WORKERS=1, or runtime.mode=child-process to enable real worker execution.",
325
+ : "Safe scaffold mode: child Pi workers were not launched because runtime.mode=scaffold or executeWorkers=false was configured.",
325
326
  ].join("\n");
326
327
  return result(text, { action: "run", status: executed.manifest.status === "failed" ? "error" : "ok", runId: executed.manifest.runId, artifactsRoot: executed.manifest.artifactsRoot }, executed.manifest.status === "failed");
327
328
  }
@@ -429,7 +430,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
429
430
  const loadedConfig = loadConfig(ctx.cwd);
430
431
  const runtime = await resolveCrewRuntime(loadedConfig.config);
431
432
  const executeWorkers = runtime.kind === "child-process";
432
- const executed = await executeTeamRun({ manifest: loaded.manifest, tasks: resetTasks, team, workflow, agents: allAgents(discoverAgents(ctx.cwd)), executeWorkers, limits: loadedConfig.config.limits, runtime, runtimeConfig: loadedConfig.config.runtime, parentContext: buildParentContext(ctx), parentModel: ctx.model, modelRegistry: ctx.modelRegistry });
433
+ const executed = await executeTeamRun({ manifest: loaded.manifest, tasks: resetTasks, team, workflow, agents: allAgents(discoverAgents(ctx.cwd)), executeWorkers, limits: loadedConfig.config.limits, runtime, runtimeConfig: loadedConfig.config.runtime, parentContext: buildParentContext(ctx), parentModel: ctx.model, modelRegistry: ctx.modelRegistry, signal: ctx.signal });
433
434
  return result([`Resumed run ${executed.manifest.runId}.`, `Status: ${executed.manifest.status}`, `Tasks: ${executed.tasks.length}`, `Artifacts: ${executed.manifest.artifactsRoot}`].join("\n"), { action: "resume", status: executed.manifest.status === "failed" ? "error" : "ok", runId: executed.manifest.runId, artifactsRoot: executed.manifest.artifactsRoot }, executed.manifest.status === "failed");
434
435
  });
435
436
  }
@@ -508,6 +509,7 @@ function configPatchFromConfig(config: unknown): PiTeamsConfig {
508
509
  const runtime = configRecord(cfg.runtime);
509
510
  const limits = configRecord(cfg.limits);
510
511
  const worktree = configRecord(cfg.worktree);
512
+ const ui = configRecord(cfg.ui);
511
513
  return {
512
514
  asyncByDefault: typeof cfg.asyncByDefault === "boolean" ? cfg.asyncByDefault : undefined,
513
515
  executeWorkers: typeof cfg.executeWorkers === "boolean" ? cfg.executeWorkers : undefined,
@@ -542,6 +544,11 @@ function configPatchFromConfig(config: unknown): PiTeamsConfig {
542
544
  enabled: typeof control.enabled === "boolean" ? control.enabled : undefined,
543
545
  needsAttentionAfterMs: typeof control.needsAttentionAfterMs === "number" && Number.isInteger(control.needsAttentionAfterMs) && control.needsAttentionAfterMs > 0 ? control.needsAttentionAfterMs : undefined,
544
546
  } : undefined,
547
+ ui: Object.keys(ui).length > 0 ? {
548
+ widgetPlacement: ui.widgetPlacement === "aboveEditor" || ui.widgetPlacement === "belowEditor" ? ui.widgetPlacement : undefined,
549
+ widgetMaxLines: typeof ui.widgetMaxLines === "number" && Number.isInteger(ui.widgetMaxLines) && ui.widgetMaxLines > 0 ? ui.widgetMaxLines : undefined,
550
+ powerbar: typeof ui.powerbar === "boolean" ? ui.powerbar : undefined,
551
+ } : undefined,
545
552
  };
546
553
  }
547
554
 
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { AgentConfig } from "../agents/agent-config.ts";
@@ -8,6 +8,29 @@ import { getPiSpawnCommand } from "./pi-spawn.ts";
8
8
  const POST_EXIT_STDIO_GUARD_MS = 3000;
9
9
  const FINAL_DRAIN_MS = 5000;
10
10
  const HARD_KILL_MS = 3000;
11
+ const activeChildProcesses = new Map<number, ChildProcess>();
12
+
13
+ function killProcessTree(pid: number | undefined): void {
14
+ if (!pid || !Number.isInteger(pid) || pid <= 0) return;
15
+ try {
16
+ if (process.platform === "win32") {
17
+ spawn("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore", windowsHide: true });
18
+ return;
19
+ }
20
+ try { process.kill(-pid, "SIGTERM"); } catch { process.kill(pid, "SIGTERM"); }
21
+ setTimeout(() => {
22
+ try { process.kill(-pid, "SIGKILL"); } catch { try { process.kill(pid, "SIGKILL"); } catch {} }
23
+ }, HARD_KILL_MS).unref?.();
24
+ } catch {
25
+ // Ignore shutdown races.
26
+ }
27
+ }
28
+
29
+ export function terminateActiveChildPiProcesses(): number {
30
+ const pids = [...activeChildProcesses.keys()];
31
+ for (const pid of pids) killProcessTree(pid);
32
+ return pids.length;
33
+ }
11
34
 
12
35
  export interface ChildPiRunInput {
13
36
  cwd: string;
@@ -118,7 +141,9 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
118
141
  cwd: input.cwd,
119
142
  env: { ...process.env, ...built.env },
120
143
  stdio: ["ignore", "pipe", "pipe"],
144
+ detached: process.platform !== "win32",
121
145
  });
146
+ if (child.pid) activeChildProcesses.set(child.pid, child);
122
147
  let stdout = "";
123
148
  let stderr = "";
124
149
  let settled = false;
@@ -167,6 +192,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
167
192
  };
168
193
 
169
194
  const abort = (): void => {
195
+ killProcessTree(child.pid);
170
196
  try {
171
197
  child.kill(process.platform === "win32" ? undefined : "SIGTERM");
172
198
  } catch {
@@ -187,6 +213,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
187
213
  settle({ exitCode: null, stdout, stderr, error: error.message });
188
214
  });
189
215
  child.on("exit", () => {
216
+ if (child.pid) activeChildProcesses.delete(child.pid);
190
217
  childExited = true;
191
218
  clearFinalDrainTimers();
192
219
  postExitGuard = setTimeout(() => {
@@ -196,6 +223,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
196
223
  postExitGuard.unref?.();
197
224
  });
198
225
  child.on("close", (exitCode) => {
226
+ if (child.pid) activeChildProcesses.delete(child.pid);
199
227
  settle({ exitCode, stdout, stderr, ...(forcedFinalDrain && !stderr.trim() ? { error: `Child Pi did not exit within ${finalDrainMs}ms after final assistant message; termination was requested.` } : {}) });
200
228
  });
201
229
  });
@@ -38,6 +38,13 @@ export function isLikelyOrphanedActiveRun(run: TeamRunManifest, agents: CrewAgen
38
38
  return agents.every((agent) => agent.status === "queued" && !agent.completedAt && !agent.progress);
39
39
  }
40
40
 
41
+ function hasDurableActiveAgentEvidence(agent: CrewAgentRecord): boolean {
42
+ if (agent.status !== "running" && agent.status !== "queued") return false;
43
+ return Boolean(agent.statusPath || agent.eventsPath || agent.outputPath || agent.progress || agent.toolUses || agent.jsonEvents);
44
+ }
45
+
41
46
  export function isDisplayActiveRun(run: TeamRunManifest, agents: CrewAgentRecord[] = [], now = Date.now()): boolean {
42
- return isActiveRunStatus(run.status) && !isLikelyOrphanedActiveRun(run, agents, now);
47
+ if (!isActiveRunStatus(run.status) || isLikelyOrphanedActiveRun(run, agents, now)) return false;
48
+ if (agents.length === 0) return true;
49
+ return agents.some(hasDurableActiveAgentEvidence);
43
50
  }
@@ -48,22 +48,21 @@ export async function isLiveSessionRuntimeAvailable(timeoutMs = 1500, env: NodeJ
48
48
 
49
49
  export async function resolveCrewRuntime(config: PiTeamsConfig, env: NodeJS.ProcessEnv = process.env): Promise<CrewRuntimeCapabilities> {
50
50
  const requestedMode = config.runtime?.mode ?? "auto";
51
- const executeWorkers = config.executeWorkers === true || env.PI_CREW_EXECUTE_WORKERS === "1" || env.PI_TEAMS_EXECUTE_WORKERS === "1";
51
+ const workersDisabled = config.executeWorkers === false || env.PI_CREW_EXECUTE_WORKERS === "0" || env.PI_TEAMS_EXECUTE_WORKERS === "0";
52
52
  if (requestedMode === "scaffold") return scaffoldCaps(requestedMode);
53
- if (requestedMode === "child-process") return childCaps(requestedMode, executeWorkers ? undefined : "child-process requested but executeWorkers is not enabled; caller should refuse or fall back explicitly.");
53
+ if (workersDisabled) return scaffoldCaps(requestedMode, "Child worker execution disabled by config/env. Set runtime.mode=scaffold or executeWorkers=false only for dry runs.");
54
+ if (requestedMode === "child-process") return childCaps(requestedMode);
54
55
  if (requestedMode === "live-session" || (requestedMode === "auto" && config.runtime?.preferLiveSession === true)) {
55
56
  const live = await isLiveSessionRuntimeAvailable(1500, env);
56
57
  if (live.available) return liveCaps(requestedMode);
57
58
  if (requestedMode === "live-session" && config.runtime?.allowChildProcessFallback === false) return { ...scaffoldCaps(requestedMode), available: false, reason: live.reason };
58
- if (executeWorkers) return { ...childCaps(requestedMode), fallback: "child-process", reason: live.reason };
59
- return { ...scaffoldCaps(requestedMode), fallback: "scaffold", reason: live.reason };
59
+ return { ...childCaps(requestedMode), fallback: "child-process", reason: live.reason };
60
60
  }
61
- if (executeWorkers) return childCaps(requestedMode);
62
- return scaffoldCaps(requestedMode);
61
+ return childCaps(requestedMode);
63
62
  }
64
63
 
65
- function scaffoldCaps(requestedMode: CrewRuntimeMode): CrewRuntimeCapabilities {
66
- return { kind: "scaffold", requestedMode, available: true, steer: false, resume: false, liveToolActivity: false, transcript: false };
64
+ function scaffoldCaps(requestedMode: CrewRuntimeMode, reason?: string): CrewRuntimeCapabilities {
65
+ return { kind: "scaffold", requestedMode, available: true, steer: false, resume: false, liveToolActivity: false, transcript: false, ...(reason ? { reason } : {}) };
67
66
  }
68
67
 
69
68
  function childCaps(requestedMode: CrewRuntimeMode, reason?: string): CrewRuntimeCapabilities {