pi-crew 0.1.11 → 0.1.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -15,6 +15,7 @@ import { clearPiCrewPowerbar, registerPiCrewPowerbarSegments, updatePiCrewPowerb
15
15
  import { DurableTextViewer, DurableTranscriptViewer } from "../ui/transcript-viewer.ts";
16
16
  import { loadRunManifestById } from "../state/state-store.ts";
17
17
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
18
+ import { terminateActiveChildPiProcesses } from "../runtime/child-pi.ts";
18
19
 
19
20
  function parseRunArgs(args: string): TeamToolParamsValue {
20
21
  const tokens = args.match(/"[^"]*"|'[^']*'|\S+/g)?.map((token) => token.replace(/^['"]|['"]$/g, "")) ?? [];
@@ -99,14 +100,38 @@ function setNestedConfig(config: Record<string, unknown>, key: string, value: un
99
100
  }
100
101
 
101
102
  export function registerPiTeams(pi: ExtensionAPI): void {
103
+ const globalStore = globalThis as Record<string, unknown>;
104
+ const runtimeCleanupStoreKey = "__piCrewRuntimeCleanup";
105
+ const previousRuntimeCleanup = globalStore[runtimeCleanupStoreKey];
106
+ if (typeof previousRuntimeCleanup === "function") {
107
+ try { previousRuntimeCleanup(); } catch {}
108
+ }
102
109
  const notifierState: AsyncNotifierState = { seenFinishedRunIds: new Set() };
103
110
  let currentCtx: ExtensionContext | undefined;
104
111
  let rpcHandle: PiCrewRpcHandle | undefined;
112
+ let cleanedUp = false;
105
113
  const widgetState: CrewWidgetState = { frame: 0 };
114
+ const foregroundControllers = new Set<AbortController>();
106
115
  registerAutonomousPolicy(pi);
107
116
  rpcHandle = registerPiCrewRpc((pi as unknown as { events?: Parameters<typeof registerPiCrewRpc>[0] }).events, () => currentCtx);
117
+ const cleanupRuntime = (): void => {
118
+ if (cleanedUp) return;
119
+ cleanedUp = true;
120
+ for (const controller of foregroundControllers) controller.abort();
121
+ foregroundControllers.clear();
122
+ terminateActiveChildPiProcesses();
123
+ stopAsyncRunNotifier(notifierState);
124
+ stopCrewWidget(currentCtx, widgetState, currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined);
125
+ clearPiCrewPowerbar(pi.events);
126
+ rpcHandle?.unsubscribe();
127
+ rpcHandle = undefined;
128
+ currentCtx = undefined;
129
+ if (globalStore[runtimeCleanupStoreKey] === cleanupRuntime) delete globalStore[runtimeCleanupStoreKey];
130
+ };
131
+ globalStore[runtimeCleanupStoreKey] = cleanupRuntime;
108
132
 
109
133
  pi.on("session_start", (_event, ctx) => {
134
+ cleanedUp = false;
110
135
  currentCtx = ctx;
111
136
  notifyActiveRuns(ctx);
112
137
  const loadedConfig = loadConfig(ctx.cwd);
@@ -123,12 +148,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
123
148
  widgetState.interval.unref?.();
124
149
  });
125
150
  pi.on("session_shutdown", () => {
126
- stopAsyncRunNotifier(notifierState);
127
- stopCrewWidget(currentCtx, widgetState, currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined);
128
- clearPiCrewPowerbar(pi.events);
129
- currentCtx = undefined;
130
- rpcHandle?.unsubscribe();
131
- rpcHandle = undefined;
151
+ cleanupRuntime();
132
152
  });
133
153
 
134
154
  const tool: ToolDefinition = {
@@ -137,12 +157,21 @@ export function registerPiTeams(pi: ExtensionAPI): void {
137
157
  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.",
138
158
  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.",
139
159
  parameters: TeamToolParams as never,
140
- async execute(_id, params, _signal, _onUpdate, ctx) {
141
- const output = await handleTeamTool(params as TeamToolParamsValue, ctx);
142
- const config = loadConfig(ctx.cwd).config.ui;
143
- updateCrewWidget(ctx, widgetState, config);
144
- updatePiCrewPowerbar(pi.events, ctx.cwd, config);
145
- return output;
160
+ async execute(_id, params, signal, _onUpdate, ctx) {
161
+ const controller = new AbortController();
162
+ foregroundControllers.add(controller);
163
+ const abort = (): void => controller.abort();
164
+ signal?.addEventListener("abort", abort, { once: true });
165
+ try {
166
+ const output = await handleTeamTool(params as TeamToolParamsValue, { ...ctx, signal: controller.signal });
167
+ const config = loadConfig(ctx.cwd).config.ui;
168
+ updateCrewWidget(ctx, widgetState, config);
169
+ updatePiCrewPowerbar(pi.events, ctx.cwd, config);
170
+ return output;
171
+ } finally {
172
+ signal?.removeEventListener("abort", abort);
173
+ foregroundControllers.delete(controller);
174
+ }
146
175
  },
147
176
  };
148
177
 
@@ -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}`,
@@ -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
  }
@@ -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
  }
@@ -160,6 +160,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
160
160
  if (failed) {
161
161
  tasks = markBlocked(tasks, `Blocked by failed task '${failed.id}'.`);
162
162
  saveRunTasks(manifest, tasks);
163
+ saveCrewAgents(manifest, tasks.map((task) => recordFromTask(manifest, task, runtimeKind)));
163
164
  manifest = updateRunStatus(manifest, "failed", `Failed at task '${failed.id}'.`);
164
165
  return { manifest, tasks };
165
166
  }
@@ -169,6 +170,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
169
170
  if (readyBatch.length === 0) {
170
171
  tasks = markBlocked(tasks, "No ready queued task; dependency graph may be invalid.");
171
172
  saveRunTasks(manifest, tasks);
173
+ saveCrewAgents(manifest, tasks.map((task) => recordFromTask(manifest, task, runtimeKind)));
172
174
  manifest = updateRunStatus(manifest, "blocked", "No ready queued task.");
173
175
  return { manifest, tasks };
174
176
  }
@@ -108,9 +108,18 @@ function activeWidgetRuns(cwd: string): WidgetRun[] {
108
108
  }
109
109
 
110
110
  function statusSummary(runs: WidgetRun[]): string {
111
- const runningAgents = runs.flatMap((item) => item.agents).filter((agent) => agent.status === "running").length;
112
- const queuedAgents = runs.flatMap((item) => item.agents).filter((agent) => agent.status === "queued").length;
113
- return `● pi-crew · runs=${runs.length} agents=${runningAgents} running${queuedAgents ? ` · ${queuedAgents} queued` : ""} · /team-dashboard`;
111
+ const agents = runs.flatMap((item) => item.agents);
112
+ const runningAgents = agents.filter((agent) => agent.status === "running").length;
113
+ const queuedAgents = agents.filter((agent) => agent.status === "queued").length;
114
+ const completedAgents = agents.filter((agent) => agent.status === "completed").length;
115
+ const parts = [`${runningAgents} running`];
116
+ if (queuedAgents) parts.push(`${queuedAgents} queued`);
117
+ if (completedAgents) parts.push(`${completedAgents}/${agents.length} done`);
118
+ return `⚙ pi-crew · ${parts.join(" · ")} · /team-dashboard`;
119
+ }
120
+
121
+ function shortRunLabel(run: TeamRunManifest): string {
122
+ return `${run.team}/${run.workflow ?? "none"}`;
114
123
  }
115
124
 
116
125
  export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8): string[] {
@@ -119,15 +128,12 @@ export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8): stri
119
128
  const runningGlyph = SPINNER[frame % SPINNER.length] ?? "⠋";
120
129
  const lines: string[] = [statusSummary(runs)];
121
130
  for (const { run, agents } of runs) {
122
- const running = agents.find((agent) => agent.status === "running");
123
- const queued = agents.find((agent) => agent.status === "queued");
124
- const step = running?.taskId ?? queued?.taskId ?? run.status;
125
- const completed = agents.filter((agent) => agent.status === "completed").length;
126
- lines.push(`${glyph(run.status, runningGlyph)} ${run.runId.slice(-8)} ${run.team}/${run.workflow ?? "none"} · ${step} · ${completed}/${agents.length} done`);
127
131
  const activeAgents = agents.filter((item) => item.status === "running" || item.status === "queued");
132
+ const completed = agents.filter((agent) => agent.status === "completed").length;
133
+ lines.push(`${glyph(run.status, runningGlyph)} ${shortRunLabel(run)} · ${completed}/${agents.length} done · ${run.runId.slice(-8)}`);
128
134
  for (const agent of activeAgents.slice(0, 3)) {
129
135
  const stats = agentStats(agent);
130
- lines.push(` ${glyph(agent.status, runningGlyph)} ${agent.taskId} ${agent.role}→${agent.agent} · ${agentActivity(agent)}${stats ? ` · ${stats}` : ""}`);
136
+ lines.push(` ${glyph(agent.status, runningGlyph)} ${agent.agent} (${agent.role}) · ${agentActivity(agent)}${stats ? ` · ${stats}` : ""}`);
131
137
  }
132
138
  if (activeAgents.length > 3) lines.push(` … +${activeAgents.length - 3} more agents`);
133
139
  if (lines.length >= maxLines) break;
@@ -153,7 +159,7 @@ class CrewWidgetComponent implements WidgetComponent {
153
159
  const bold = this.theme.bold?.bind(this.theme) ?? ((text: string) => text);
154
160
  return buildCrewWidgetLines(this.cwd, this.frame, this.maxLines).map((line, index) => {
155
161
  const colored = index === 0
156
- ? line.replace(" pi-crew", `${fg("accent", "")} ${bold("pi-crew")}`)
162
+ ? line.replace(" pi-crew", `${fg("accent", "")} ${bold("pi-crew")}`)
157
163
  : line.replace(/^\s*([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏▶◦✓✗■·])/, (match, icon: string) => match.replace(icon, fg(icon === "✓" ? "success" : icon === "✗" ? "error" : icon === "◦" ? "dim" : "accent", icon)));
158
164
  return truncate(colored, width);
159
165
  });