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 +1 -1
- package/src/extension/register.ts +41 -12
- package/src/extension/run-maintenance.ts +1 -1
- package/src/extension/team-tool.ts +3 -2
- package/src/runtime/child-pi.ts +29 -1
- package/src/runtime/process-status.ts +8 -1
- package/src/runtime/team-runner.ts +2 -0
- package/src/ui/crew-widget.ts +16 -10
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
}
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/ui/crew-widget.ts
CHANGED
|
@@ -108,9 +108,18 @@ function activeWidgetRuns(cwd: string): WidgetRun[] {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
function statusSummary(runs: WidgetRun[]): string {
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
|
|
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.
|
|
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("
|
|
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
|
});
|