pi-crew 0.1.5 → 0.1.7

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 (35) hide show
  1. package/package.json +2 -1
  2. package/schema.json +24 -0
  3. package/src/agents/agent-config.ts +2 -0
  4. package/src/agents/discover-agents.ts +29 -5
  5. package/src/config/config.ts +148 -9
  6. package/src/extension/register.ts +10 -2
  7. package/src/extension/team-tool.ts +113 -10
  8. package/src/prompt/prompt-runtime.ts +12 -2
  9. package/src/runtime/agent-control.ts +64 -0
  10. package/src/runtime/agent-observability.ts +88 -0
  11. package/src/runtime/async-runner.ts +30 -1
  12. package/src/runtime/background-runner.ts +4 -2
  13. package/src/runtime/child-pi.ts +137 -7
  14. package/src/runtime/crew-agent-records.ts +137 -0
  15. package/src/runtime/crew-agent-runtime.ts +54 -0
  16. package/src/runtime/foreground-control.ts +82 -0
  17. package/src/runtime/group-join.ts +88 -0
  18. package/src/runtime/live-session-runtime.ts +33 -0
  19. package/src/runtime/pi-args.ts +29 -0
  20. package/src/runtime/policy-engine.ts +23 -0
  21. package/src/runtime/recovery-recipes.ts +74 -0
  22. package/src/runtime/role-permission.ts +28 -0
  23. package/src/runtime/runtime-resolver.ts +75 -0
  24. package/src/runtime/session-usage.ts +79 -0
  25. package/src/runtime/task-graph-scheduler.ts +107 -0
  26. package/src/runtime/task-output-context.ts +106 -0
  27. package/src/runtime/task-runner.ts +220 -4
  28. package/src/runtime/team-runner.ts +86 -14
  29. package/src/runtime/worker-startup.ts +57 -0
  30. package/src/state/contracts.ts +7 -0
  31. package/src/state/event-log.ts +103 -2
  32. package/src/state/state-store.ts +23 -2
  33. package/src/state/types.ts +3 -0
  34. package/src/ui/run-dashboard.ts +82 -10
  35. package/src/worktree/branch-freshness.ts +45 -0
@@ -0,0 +1,64 @@
1
+ import type { PiTeamsConfig } from "../config/config.ts";
2
+ import type { TeamRunManifest } from "../state/types.ts";
3
+ import { appendEvent } from "../state/event-log.ts";
4
+ import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
5
+ import { upsertCrewAgent } from "./crew-agent-records.ts";
6
+
7
+ export interface CrewControlConfig {
8
+ enabled: boolean;
9
+ needsAttentionAfterMs: number;
10
+ }
11
+
12
+ const DEFAULT_NEEDS_ATTENTION_MS = 60_000;
13
+
14
+ function positiveInt(value: unknown): number | undefined {
15
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
16
+ }
17
+
18
+ export function resolveCrewControlConfig(config: PiTeamsConfig | undefined): CrewControlConfig {
19
+ const raw = config as PiTeamsConfig & { control?: { enabled?: unknown; needsAttentionAfterMs?: unknown } } | undefined;
20
+ return {
21
+ enabled: raw?.control?.enabled === false ? false : true,
22
+ needsAttentionAfterMs: positiveInt(raw?.control?.needsAttentionAfterMs) ?? DEFAULT_NEEDS_ATTENTION_MS,
23
+ };
24
+ }
25
+
26
+ export function activityAgeMs(agent: CrewAgentRecord, now = Date.now()): number | undefined {
27
+ const timestamp = agent.progress?.lastActivityAt ?? agent.startedAt;
28
+ if (!timestamp) return undefined;
29
+ const ms = now - new Date(timestamp).getTime();
30
+ return Number.isFinite(ms) ? Math.max(0, ms) : undefined;
31
+ }
32
+
33
+ export function formatActivityAge(agent: CrewAgentRecord, now = Date.now()): string | undefined {
34
+ const age = activityAgeMs(agent, now);
35
+ if (age === undefined) return undefined;
36
+ if (age < 1000) return "active now";
37
+ const seconds = Math.floor(age / 1000);
38
+ if (seconds < 60) return agent.progress?.activityState === "needs_attention" ? `no activity for ${seconds}s` : `active ${seconds}s ago`;
39
+ const minutes = Math.floor(seconds / 60);
40
+ return agent.progress?.activityState === "needs_attention" ? `no activity for ${minutes}m` : `active ${minutes}m ago`;
41
+ }
42
+
43
+ export function applyAttentionState(manifest: TeamRunManifest, agent: CrewAgentRecord, config: CrewControlConfig, now = Date.now()): CrewAgentRecord {
44
+ if (!config.enabled || agent.status !== "running") return agent;
45
+ const age = activityAgeMs(agent, now);
46
+ if (age === undefined || age <= config.needsAttentionAfterMs) return agent;
47
+ if (agent.progress?.activityState === "needs_attention") return agent;
48
+ const updated: CrewAgentRecord = {
49
+ ...agent,
50
+ progress: {
51
+ ...(agent.progress ?? { recentTools: [], recentOutput: [], toolCount: agent.toolUses ?? 0 }),
52
+ activityState: "needs_attention",
53
+ },
54
+ };
55
+ upsertCrewAgent(manifest, updated);
56
+ appendEvent(manifest.eventsPath, {
57
+ type: "agent.needs_attention",
58
+ runId: manifest.runId,
59
+ taskId: agent.taskId,
60
+ message: `${agent.agent} needs attention (no observed activity for ${Math.floor(age / 1000)}s).`,
61
+ data: { agentId: agent.id, ageMs: age, needsAttentionAfterMs: config.needsAttentionAfterMs },
62
+ });
63
+ return updated;
64
+ }
@@ -0,0 +1,88 @@
1
+ import * as fs from "node:fs";
2
+ import type { TeamRunManifest } from "../state/types.ts";
3
+ import { agentOutputPath, readCrewAgents } from "./crew-agent-records.ts";
4
+ import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
5
+
6
+ export interface TextTailResult {
7
+ path: string;
8
+ text: string;
9
+ bytes: number;
10
+ truncated: boolean;
11
+ }
12
+
13
+ export function readTextTail(filePath: string, maxBytes = 64_000): TextTailResult {
14
+ if (!fs.existsSync(filePath)) return { path: filePath, text: "", bytes: 0, truncated: false };
15
+ const stat = fs.statSync(filePath);
16
+ const bytesToRead = Math.min(stat.size, Math.max(0, maxBytes));
17
+ const fd = fs.openSync(filePath, "r");
18
+ try {
19
+ const buffer = Buffer.alloc(bytesToRead);
20
+ fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
21
+ return { path: filePath, text: buffer.toString("utf-8"), bytes: stat.size, truncated: stat.size > bytesToRead };
22
+ } finally {
23
+ fs.closeSync(fd);
24
+ }
25
+ }
26
+
27
+ function activityText(agent: CrewAgentRecord): string {
28
+ const parts: string[] = [];
29
+ if (agent.progress?.activityState) parts.push(agent.progress.activityState);
30
+ if (agent.progress?.currentTool) parts.push(`tool=${agent.progress.currentTool}`);
31
+ if (agent.toolUses !== undefined) parts.push(`tools=${agent.toolUses}`);
32
+ if (agent.progress?.tokens !== undefined) parts.push(`tokens=${agent.progress.tokens}`);
33
+ if (agent.progress?.turns !== undefined) parts.push(`turns=${agent.progress.turns}`);
34
+ if (agent.progress?.durationMs !== undefined) parts.push(`durationMs=${agent.progress.durationMs}`);
35
+ if (agent.progress?.failedTool) parts.push(`failedTool=${agent.progress.failedTool}`);
36
+ if (agent.progress?.recentOutput?.length) parts.push(`last=${agent.progress.recentOutput.at(-1)}`);
37
+ return parts.join(" ") || "idle";
38
+ }
39
+
40
+ function statusGlyph(status: CrewAgentRecord["status"]): string {
41
+ if (status === "completed") return "✓";
42
+ if (status === "failed") return "✗";
43
+ if (status === "running") return "▶";
44
+ if (status === "cancelled" || status === "stopped") return "■";
45
+ return "·";
46
+ }
47
+
48
+ function outputWarning(agent: CrewAgentRecord): string {
49
+ if (agent.status !== "completed") return "";
50
+ if (!agent.outputPath || !fs.existsSync(agent.outputPath)) return " no-output";
51
+ try {
52
+ return fs.statSync(agent.outputPath).size === 0 ? " no-output" : "";
53
+ } catch {
54
+ return " no-output";
55
+ }
56
+ }
57
+
58
+ function agentLine(agent: CrewAgentRecord): string {
59
+ return `- ${statusGlyph(agent.status)} ${agent.taskId} [${agent.status}] ${agent.role}->${agent.agent} runtime=${agent.runtime} ${activityText(agent)}${outputWarning(agent)}${agent.error ? ` error=${agent.error}` : ""}`;
60
+ }
61
+
62
+ export function buildAgentDashboard(manifest: TeamRunManifest): { text: string; groups: Record<string, CrewAgentRecord[]> } {
63
+ const agents = readCrewAgents(manifest);
64
+ const groups: Record<string, CrewAgentRecord[]> = {
65
+ running: agents.filter((agent) => agent.status === "running"),
66
+ queued: agents.filter((agent) => agent.status === "queued"),
67
+ recent: agents.filter((agent) => agent.status !== "running" && agent.status !== "queued"),
68
+ };
69
+ const lines = [
70
+ `Crew agents for ${manifest.runId}`,
71
+ `Run status: ${manifest.status}`,
72
+ `Counts: running=${groups.running.length}, queued=${groups.queued.length}, recent=${groups.recent.length}`,
73
+ "",
74
+ "## Running",
75
+ ...(groups.running.length ? groups.running.map(agentLine) : ["- (none)"]),
76
+ "",
77
+ "## Queued",
78
+ ...(groups.queued.length ? groups.queued.map(agentLine) : ["- (none)"]),
79
+ "",
80
+ "## Recent",
81
+ ...(groups.recent.length ? groups.recent.slice(-10).map(agentLine) : ["- (none)"]),
82
+ ];
83
+ return { text: lines.join("\n"), groups };
84
+ }
85
+
86
+ export function readAgentOutput(manifest: TeamRunManifest, taskId: string, maxBytes?: number): TextTailResult {
87
+ return readTextTail(agentOutputPath(manifest, taskId), maxBytes);
88
+ }
@@ -1,9 +1,36 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { createRequire } from "node:module";
2
3
  import * as fs from "node:fs";
3
4
  import * as path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import type { TeamRunManifest } from "../state/types.ts";
6
7
 
8
+ const require = createRequire(import.meta.url);
9
+
10
+ function resolveJitiCliPath(): string | undefined {
11
+ const candidates: Array<() => string> = [
12
+ () => path.join(path.dirname(require.resolve("jiti/package.json")), "lib/jiti-cli.mjs"),
13
+ () => path.join(path.dirname(require.resolve("@mariozechner/jiti/package.json")), "lib/jiti-cli.mjs"),
14
+ ];
15
+ for (const candidate of candidates) {
16
+ try {
17
+ const filePath = candidate();
18
+ if (fs.existsSync(filePath)) return filePath;
19
+ } catch {
20
+ // Try the next possible runtime dependency location.
21
+ }
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ export function getBackgroundRunnerCommand(runnerPath: string, cwd: string, runId: string): { args: string[]; loader: "jiti" | "strip-types" } {
27
+ const jitiCliPath = resolveJitiCliPath();
28
+ const runnerArgs = [runnerPath, "--cwd", cwd, "--run-id", runId];
29
+ return jitiCliPath
30
+ ? { args: [jitiCliPath, ...runnerArgs], loader: "jiti" }
31
+ : { args: ["--experimental-strip-types", ...runnerArgs], loader: "strip-types" };
32
+ }
33
+
7
34
  export interface SpawnBackgroundTeamRunResult {
8
35
  pid?: number;
9
36
  logPath: string;
@@ -14,7 +41,9 @@ export function spawnBackgroundTeamRun(manifest: TeamRunManifest): SpawnBackgrou
14
41
  const logPath = path.join(manifest.stateRoot, "background.log");
15
42
  fs.mkdirSync(manifest.stateRoot, { recursive: true });
16
43
  const logFd = fs.openSync(logPath, "a");
17
- const child = spawn(process.execPath, ["--experimental-strip-types", runnerPath, "--cwd", manifest.cwd, "--run-id", manifest.runId], {
44
+ const command = getBackgroundRunnerCommand(runnerPath, manifest.cwd, manifest.runId);
45
+ fs.appendFileSync(logPath, `[pi-crew] background loader=${command.loader}\n`, "utf-8");
46
+ const child = spawn(process.execPath, command.args, {
18
47
  cwd: manifest.cwd,
19
48
  detached: true,
20
49
  stdio: ["ignore", logFd, logFd],
@@ -5,6 +5,7 @@ import { loadRunManifestById, updateRunStatus } from "../state/state-store.ts";
5
5
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
6
6
  import { loadConfig } from "../config/config.ts";
7
7
  import { executeTeamRun } from "./team-runner.ts";
8
+ import { resolveCrewRuntime } from "./runtime-resolver.ts";
8
9
 
9
10
  function argValue(name: string): string | undefined {
10
11
  const index = process.argv.indexOf(name);
@@ -29,8 +30,9 @@ async function main(): Promise<void> {
29
30
  if (!workflow) throw new Error(`Workflow '${manifest.workflow ?? ""}' not found.`);
30
31
  const agents = allAgents(discoverAgents(cwd));
31
32
  const loadedConfig = loadConfig(cwd);
32
- const executeWorkers = loadedConfig.config.executeWorkers === true || process.env.PI_TEAMS_EXECUTE_WORKERS === "1";
33
- const result = await executeTeamRun({ manifest, tasks, team, workflow, agents, executeWorkers, limits: loadedConfig.config.limits });
33
+ const runtime = await resolveCrewRuntime(loadedConfig.config);
34
+ const executeWorkers = runtime.kind === "child-process";
35
+ const result = await executeTeamRun({ manifest, tasks, team, workflow, agents, executeWorkers, limits: loadedConfig.config.limits, runtime, runtimeConfig: loadedConfig.config.runtime });
34
36
  manifest = result.manifest;
35
37
  tasks = result.tasks;
36
38
  appendEvent(manifest.eventsPath, { type: "async.completed", runId: manifest.runId, data: { status: manifest.status, tasks: tasks.length } });
@@ -1,14 +1,26 @@
1
1
  import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
2
4
  import type { AgentConfig } from "../agents/agent-config.ts";
3
- import { buildPiWorkerArgs, cleanupTempDir } from "./pi-args.ts";
5
+ import { buildPiWorkerArgs, checkCrewDepth, cleanupTempDir } from "./pi-args.ts";
4
6
  import { getPiSpawnCommand } from "./pi-spawn.ts";
5
7
 
8
+ const POST_EXIT_STDIO_GUARD_MS = 3000;
9
+ const FINAL_DRAIN_MS = 5000;
10
+ const HARD_KILL_MS = 3000;
11
+
6
12
  export interface ChildPiRunInput {
7
13
  cwd: string;
8
14
  task: string;
9
15
  agent: AgentConfig;
10
16
  model?: string;
11
17
  signal?: AbortSignal;
18
+ transcriptPath?: string;
19
+ onStdoutLine?: (line: string) => void;
20
+ onJsonEvent?: (event: unknown) => void;
21
+ maxDepth?: number;
22
+ finalDrainMs?: number;
23
+ hardKillMs?: number;
12
24
  }
13
25
 
14
26
  export interface ChildPiRunResult {
@@ -18,15 +30,87 @@ export interface ChildPiRunResult {
18
30
  error?: string;
19
31
  }
20
32
 
33
+ function appendTranscript(input: ChildPiRunInput, line: string): void {
34
+ if (!input.transcriptPath) return;
35
+ fs.mkdirSync(path.dirname(input.transcriptPath), { recursive: true });
36
+ fs.appendFileSync(input.transcriptPath, `${line}\n`, "utf-8");
37
+ }
38
+
39
+ export class ChildPiLineObserver {
40
+ private buffer = "";
41
+ private readonly input: ChildPiRunInput;
42
+
43
+ constructor(input: ChildPiRunInput) {
44
+ this.input = input;
45
+ }
46
+
47
+ observe(text: string): void {
48
+ this.buffer += text;
49
+ const lines = this.buffer.split(/\r?\n/);
50
+ this.buffer = lines.pop() ?? "";
51
+ for (const line of lines) this.emitLine(line);
52
+ }
53
+
54
+ flush(): void {
55
+ if (!this.buffer) return;
56
+ const line = this.buffer;
57
+ this.buffer = "";
58
+ this.emitLine(line);
59
+ }
60
+
61
+ private emitLine(line: string): void {
62
+ if (!line.trim()) return;
63
+ appendTranscript(this.input, line);
64
+ this.input.onStdoutLine?.(line);
65
+ try {
66
+ this.input.onJsonEvent?.(JSON.parse(line));
67
+ } catch {
68
+ // Raw stdout is allowed.
69
+ }
70
+ }
71
+ }
72
+
73
+ function observeStdoutChunk(input: ChildPiRunInput, text: string): void {
74
+ const observer = new ChildPiLineObserver(input);
75
+ observer.observe(text);
76
+ observer.flush();
77
+ }
78
+
79
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
80
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
81
+ }
82
+
83
+ function isFinalAssistantEvent(event: unknown): boolean {
84
+ const obj = asRecord(event);
85
+ if (!obj || obj.type !== "message_end") return false;
86
+ const message = asRecord(obj.message);
87
+ const role = message?.role;
88
+ if (role !== undefined && role !== "assistant") return false;
89
+ const stopReason = typeof message?.stopReason === "string" ? message.stopReason : typeof obj.stopReason === "string" ? obj.stopReason : undefined;
90
+ if (stopReason !== undefined && stopReason !== "stop") return false;
91
+ const content = Array.isArray(message?.content) ? message.content : [];
92
+ return !content.some((part) => asRecord(part)?.type === "toolCall");
93
+ }
94
+
21
95
  export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResult> {
96
+ const depth = checkCrewDepth(input.maxDepth);
97
+ if (depth.blocked) return { exitCode: 1, stdout: "", stderr: `pi-crew depth guard blocked child worker: depth ${depth.depth} >= max ${depth.maxDepth}` };
22
98
  const mock = process.env.PI_TEAMS_MOCK_CHILD_PI;
23
99
  if (mock) {
24
- if (mock === "success") return { exitCode: 0, stdout: `Mock child Pi success for ${input.agent.name}\n`, stderr: "" };
25
- if (mock === "json-success") return { exitCode: 0, stdout: `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: `Mock JSON success for ${input.agent.name}` }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`, stderr: "" };
100
+ if (mock === "success") {
101
+ const stdout = `Mock child Pi success for ${input.agent.name}\n`;
102
+ observeStdoutChunk(input, stdout);
103
+ return { exitCode: 0, stdout, stderr: "" };
104
+ }
105
+ if (mock === "json-success") {
106
+ const stdout = `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: `Mock JSON success for ${input.agent.name}` }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`;
107
+ observeStdoutChunk(input, stdout);
108
+ return { exitCode: 0, stdout, stderr: "" };
109
+ }
26
110
  if (mock === "retryable-failure") return { exitCode: 1, stdout: "", stderr: "rate limit: mock failure" };
27
111
  return { exitCode: 1, stdout: "", stderr: `mock failure: ${mock}` };
28
112
  }
29
- const built = buildPiWorkerArgs({ task: input.task, agent: input.agent, model: input.model, sessionEnabled: false });
113
+ const built = buildPiWorkerArgs({ task: input.task, agent: input.agent, model: input.model, sessionEnabled: false, maxDepth: input.maxDepth });
30
114
  const spawnSpec = getPiSpawnCommand(built.args);
31
115
  try {
32
116
  return await new Promise<ChildPiRunResult>((resolve) => {
@@ -38,10 +122,46 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
38
122
  let stdout = "";
39
123
  let stderr = "";
40
124
  let settled = false;
125
+ let childExited = false;
126
+ let postExitGuard: NodeJS.Timeout | undefined;
127
+ let finalDrainTimer: NodeJS.Timeout | undefined;
128
+ let hardKillTimer: NodeJS.Timeout | undefined;
129
+ const finalDrainMs = input.finalDrainMs ?? FINAL_DRAIN_MS;
130
+ const hardKillMs = input.hardKillMs ?? HARD_KILL_MS;
131
+ let forcedFinalDrain = false;
132
+ const lineObserver = new ChildPiLineObserver({
133
+ ...input,
134
+ onJsonEvent: (event) => {
135
+ input.onJsonEvent?.(event);
136
+ if (!isFinalAssistantEvent(event) || childExited || settled || finalDrainTimer) return;
137
+ finalDrainTimer = setTimeout(() => {
138
+ if (settled || childExited) return;
139
+ forcedFinalDrain = true;
140
+ try { child.kill(process.platform === "win32" ? undefined : "SIGTERM"); } catch {}
141
+ hardKillTimer = setTimeout(() => {
142
+ if (settled || childExited) return;
143
+ try { child.kill(process.platform === "win32" ? undefined : "SIGKILL"); } catch {}
144
+ }, hardKillMs);
145
+ hardKillTimer.unref?.();
146
+ }, finalDrainMs);
147
+ finalDrainTimer.unref?.();
148
+ },
149
+ });
150
+
151
+ const clearFinalDrainTimers = (): void => {
152
+ if (finalDrainTimer) clearTimeout(finalDrainTimer);
153
+ if (hardKillTimer) clearTimeout(hardKillTimer);
154
+ finalDrainTimer = undefined;
155
+ hardKillTimer = undefined;
156
+ };
41
157
 
42
158
  const settle = (result: ChildPiRunResult): void => {
43
159
  if (settled) return;
44
160
  settled = true;
161
+ if (postExitGuard) clearTimeout(postExitGuard);
162
+ clearFinalDrainTimers();
163
+ lineObserver.flush();
164
+ input.signal?.removeEventListener("abort", abort);
45
165
  cleanupTempDir(built.tempDir);
46
166
  resolve(result);
47
167
  };
@@ -56,7 +176,9 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
56
176
 
57
177
  input.signal?.addEventListener("abort", abort, { once: true });
58
178
  child.stdout?.on("data", (chunk: Buffer) => {
59
- stdout += chunk.toString("utf-8");
179
+ const text = chunk.toString("utf-8");
180
+ stdout += text;
181
+ lineObserver.observe(text);
60
182
  });
61
183
  child.stderr?.on("data", (chunk: Buffer) => {
62
184
  stderr += chunk.toString("utf-8");
@@ -64,9 +186,17 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
64
186
  child.on("error", (error) => {
65
187
  settle({ exitCode: null, stdout, stderr, error: error.message });
66
188
  });
189
+ child.on("exit", () => {
190
+ childExited = true;
191
+ clearFinalDrainTimers();
192
+ postExitGuard = setTimeout(() => {
193
+ child.stdout?.destroy();
194
+ child.stderr?.destroy();
195
+ }, POST_EXIT_STDIO_GUARD_MS);
196
+ postExitGuard.unref?.();
197
+ });
67
198
  child.on("close", (exitCode) => {
68
- input.signal?.removeEventListener("abort", abort);
69
- settle({ exitCode, stdout, stderr });
199
+ settle({ exitCode, stdout, stderr, ...(forcedFinalDrain && !stderr.trim() ? { error: `Child Pi did not exit within ${finalDrainMs}ms after final assistant message; termination was requested.` } : {}) });
70
200
  });
71
201
  });
72
202
  } finally {
@@ -0,0 +1,137 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
4
+ import { atomicWriteJson, readJsonFile } from "../state/atomic-write.ts";
5
+ import type { CrewAgentProgress, CrewAgentRecord, CrewRuntimeKind } from "./crew-agent-runtime.ts";
6
+ import { taskStatusToAgentStatus } from "./crew-agent-runtime.ts";
7
+
8
+ export function agentsPath(manifest: TeamRunManifest): string {
9
+ return path.join(manifest.stateRoot, "agents.json");
10
+ }
11
+
12
+ export function agentStateDir(manifest: TeamRunManifest, taskId: string): string {
13
+ return path.join(manifest.stateRoot, "agents", taskId);
14
+ }
15
+
16
+ export function agentStatusPath(manifest: TeamRunManifest, taskId: string): string {
17
+ return path.join(agentStateDir(manifest, taskId), "status.json");
18
+ }
19
+
20
+ export function agentEventsPath(manifest: TeamRunManifest, taskId: string): string {
21
+ return path.join(agentStateDir(manifest, taskId), "events.jsonl");
22
+ }
23
+
24
+ export function agentOutputPath(manifest: TeamRunManifest, taskId: string): string {
25
+ return path.join(agentStateDir(manifest, taskId), "output.log");
26
+ }
27
+
28
+ export function readCrewAgents(manifest: TeamRunManifest): CrewAgentRecord[] {
29
+ return readJsonFile<CrewAgentRecord[]>(agentsPath(manifest)) ?? [];
30
+ }
31
+
32
+ export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
33
+ fs.mkdirSync(manifest.stateRoot, { recursive: true });
34
+ atomicWriteJson(agentsPath(manifest), records);
35
+ for (const record of records) writeCrewAgentStatus(manifest, record);
36
+ }
37
+
38
+ export function upsertCrewAgent(manifest: TeamRunManifest, record: CrewAgentRecord): void {
39
+ const records = readCrewAgents(manifest).filter((item) => item.id !== record.id);
40
+ records.push(record);
41
+ saveCrewAgents(manifest, records);
42
+ writeCrewAgentStatus(manifest, record);
43
+ }
44
+
45
+ export function writeCrewAgentStatus(manifest: TeamRunManifest, record: CrewAgentRecord): void {
46
+ fs.mkdirSync(agentStateDir(manifest, record.taskId), { recursive: true });
47
+ atomicWriteJson(agentStatusPath(manifest, record.taskId), record);
48
+ }
49
+
50
+ export function readCrewAgentStatus(manifest: TeamRunManifest, taskOrAgentId: string): CrewAgentRecord | undefined {
51
+ const taskId = taskOrAgentId.includes(":") ? taskOrAgentId.split(":").pop()! : taskOrAgentId;
52
+ return readJsonFile<CrewAgentRecord>(agentStatusPath(manifest, taskId));
53
+ }
54
+
55
+ function nextAgentEventSeq(filePath: string): number {
56
+ if (!fs.existsSync(filePath)) return 1;
57
+ let max = 0;
58
+ for (const line of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
59
+ if (!line.trim()) continue;
60
+ try {
61
+ const parsed = JSON.parse(line) as { seq?: unknown };
62
+ if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) max = Math.max(max, parsed.seq);
63
+ else max += 1;
64
+ } catch {
65
+ max += 1;
66
+ }
67
+ }
68
+ return max + 1;
69
+ }
70
+
71
+ export function appendCrewAgentEvent(manifest: TeamRunManifest, taskId: string, event: unknown): void {
72
+ fs.mkdirSync(agentStateDir(manifest, taskId), { recursive: true });
73
+ const filePath = agentEventsPath(manifest, taskId);
74
+ fs.appendFileSync(filePath, `${JSON.stringify({ seq: nextAgentEventSeq(filePath), time: new Date().toISOString(), event })}\n`, "utf-8");
75
+ }
76
+
77
+ export interface CrewAgentEventCursorOptions {
78
+ sinceSeq?: number;
79
+ limit?: number;
80
+ }
81
+
82
+ export function readCrewAgentEvents(manifest: TeamRunManifest, taskId: string): unknown[] {
83
+ return readCrewAgentEventsCursor(manifest, taskId).events;
84
+ }
85
+
86
+ export function readCrewAgentEventsCursor(manifest: TeamRunManifest, taskId: string, options: CrewAgentEventCursorOptions = {}): { path: string; events: unknown[]; nextSeq: number; total: number } {
87
+ const filePath = agentEventsPath(manifest, taskId);
88
+ if (!fs.existsSync(filePath)) return { path: filePath, events: [], nextSeq: options.sinceSeq ?? 0, total: 0 };
89
+ const sinceSeq = typeof options.sinceSeq === "number" && Number.isInteger(options.sinceSeq) && options.sinceSeq >= 0 ? options.sinceSeq : 0;
90
+ const limit = typeof options.limit === "number" && Number.isInteger(options.limit) && options.limit >= 0 ? options.limit : undefined;
91
+ const parsed = fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean).map((line, index) => {
92
+ try {
93
+ const event = JSON.parse(line) as Record<string, unknown>;
94
+ if (typeof event.seq !== "number") event.seq = index + 1;
95
+ return event;
96
+ } catch {
97
+ return { seq: index + 1, raw: line };
98
+ }
99
+ });
100
+ const filtered = parsed.filter((event) => typeof event.seq === "number" && event.seq > sinceSeq);
101
+ const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
102
+ const returnedMaxSeq = events.reduce((max, event) => typeof event.seq === "number" ? Math.max(max, event.seq) : max, sinceSeq);
103
+ return { path: filePath, events, nextSeq: returnedMaxSeq, total: filtered.length };
104
+ }
105
+
106
+ export function appendCrewAgentOutput(manifest: TeamRunManifest, taskId: string, text: string): void {
107
+ if (!text.trim()) return;
108
+ fs.mkdirSync(agentStateDir(manifest, taskId), { recursive: true });
109
+ fs.appendFileSync(agentOutputPath(manifest, taskId), `${text}\n`, "utf-8");
110
+ }
111
+
112
+ export function emptyCrewAgentProgress(): CrewAgentProgress {
113
+ return { recentTools: [], recentOutput: [], toolCount: 0 };
114
+ }
115
+
116
+ export function recordFromTask(manifest: TeamRunManifest, task: TeamTaskState, runtime: CrewRuntimeKind): CrewAgentRecord {
117
+ return {
118
+ id: `${manifest.runId}:${task.id}`,
119
+ runId: manifest.runId,
120
+ taskId: task.id,
121
+ agent: task.agent,
122
+ role: task.role,
123
+ runtime,
124
+ status: taskStatusToAgentStatus(task.status),
125
+ startedAt: task.startedAt ?? new Date().toISOString(),
126
+ completedAt: task.finishedAt,
127
+ resultArtifactPath: task.resultArtifact?.path,
128
+ transcriptPath: task.transcriptArtifact?.path ?? task.logArtifact?.path,
129
+ statusPath: agentStatusPath(manifest, task.id),
130
+ eventsPath: agentEventsPath(manifest, task.id),
131
+ outputPath: agentOutputPath(manifest, task.id),
132
+ toolUses: task.agentProgress?.toolCount,
133
+ jsonEvents: task.jsonEvents,
134
+ progress: task.agentProgress,
135
+ error: task.error,
136
+ };
137
+ }
@@ -0,0 +1,54 @@
1
+ import type { TeamTaskStatus } from "../state/contracts.ts";
2
+
3
+ export type CrewRuntimeKind = "scaffold" | "child-process" | "live-session";
4
+ export type CrewAgentStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "stopped";
5
+
6
+ export interface CrewAgentRecentTool {
7
+ tool: string;
8
+ args?: string;
9
+ endedAt: string;
10
+ }
11
+
12
+ export interface CrewAgentProgress {
13
+ currentTool?: string;
14
+ currentToolArgs?: string;
15
+ currentToolStartedAt?: string;
16
+ recentTools: CrewAgentRecentTool[];
17
+ recentOutput: string[];
18
+ toolCount: number;
19
+ tokens?: number;
20
+ turns?: number;
21
+ durationMs?: number;
22
+ lastActivityAt?: string;
23
+ activityState?: "active" | "needs_attention" | "stale";
24
+ failedTool?: string;
25
+ }
26
+
27
+ export interface CrewAgentRecord {
28
+ id: string;
29
+ runId: string;
30
+ taskId: string;
31
+ agent: string;
32
+ role: string;
33
+ runtime: CrewRuntimeKind;
34
+ status: CrewAgentStatus;
35
+ startedAt: string;
36
+ completedAt?: string;
37
+ resultArtifactPath?: string;
38
+ transcriptPath?: string;
39
+ statusPath?: string;
40
+ eventsPath?: string;
41
+ outputPath?: string;
42
+ toolUses?: number;
43
+ jsonEvents?: number;
44
+ progress?: CrewAgentProgress;
45
+ error?: string;
46
+ }
47
+
48
+ export function taskStatusToAgentStatus(status: TeamTaskStatus): CrewAgentStatus {
49
+ if (status === "completed") return "completed";
50
+ if (status === "failed") return "failed";
51
+ if (status === "cancelled") return "cancelled";
52
+ if (status === "running") return "running";
53
+ return "queued";
54
+ }