pi-crew 0.1.28 → 0.1.30

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 (59) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/NOTICE.md +1 -0
  3. package/docs/architecture.md +164 -92
  4. package/docs/refactor-tasks-phase6.md +662 -0
  5. package/docs/runtime-flow.md +148 -0
  6. package/package.json +1 -1
  7. package/schema.json +1 -0
  8. package/skills/git-master/SKILL.md +19 -0
  9. package/skills/read-only-explorer/SKILL.md +21 -0
  10. package/skills/safe-bash/SKILL.md +16 -0
  11. package/skills/task-packet/SKILL.md +23 -0
  12. package/skills/verify-evidence/SKILL.md +22 -0
  13. package/src/config/config.ts +2 -0
  14. package/src/config/defaults.ts +1 -0
  15. package/src/extension/async-notifier.ts +33 -4
  16. package/src/extension/register.ts +15 -522
  17. package/src/extension/registration/artifact-cleanup.ts +14 -0
  18. package/src/extension/registration/commands.ts +208 -0
  19. package/src/extension/registration/subagent-helpers.ts +1 -1
  20. package/src/extension/registration/subagent-tools.ts +110 -0
  21. package/src/extension/registration/team-tool.ts +44 -0
  22. package/src/extension/team-tool/api.ts +4 -4
  23. package/src/extension/team-tool/cancel.ts +31 -0
  24. package/src/extension/team-tool/inspect.ts +41 -0
  25. package/src/extension/team-tool/lifecycle-actions.ts +79 -0
  26. package/src/extension/team-tool/plan.ts +19 -0
  27. package/src/extension/team-tool/run.ts +41 -3
  28. package/src/extension/team-tool/status.ts +73 -0
  29. package/src/extension/team-tool.ts +57 -224
  30. package/src/runtime/async-marker.ts +26 -0
  31. package/src/runtime/async-runner.ts +44 -9
  32. package/src/runtime/background-runner.ts +2 -0
  33. package/src/runtime/child-pi.ts +5 -1
  34. package/src/runtime/concurrency.ts +9 -3
  35. package/src/runtime/crew-agent-records.ts +1 -0
  36. package/src/runtime/crew-agent-runtime.ts +2 -1
  37. package/src/runtime/model-fallback.ts +21 -4
  38. package/src/runtime/pi-args.ts +2 -0
  39. package/src/runtime/process-status.ts +1 -0
  40. package/src/runtime/role-permission.ts +11 -0
  41. package/src/runtime/task-runner/live-executor.ts +98 -0
  42. package/src/runtime/task-runner/progress.ts +111 -0
  43. package/src/runtime/task-runner/prompt-builder.ts +72 -0
  44. package/src/runtime/task-runner/result-utils.ts +14 -0
  45. package/src/runtime/task-runner/state-helpers.ts +22 -0
  46. package/src/runtime/task-runner.ts +38 -283
  47. package/src/runtime/team-runner.ts +116 -7
  48. package/src/schema/config-schema.ts +1 -0
  49. package/src/state/mailbox.ts +28 -0
  50. package/src/state/types.ts +16 -0
  51. package/src/subagents/async-entry.ts +1 -0
  52. package/src/subagents/index.ts +3 -0
  53. package/src/subagents/live/control.ts +1 -0
  54. package/src/subagents/live/manager.ts +1 -0
  55. package/src/subagents/live/realtime.ts +1 -0
  56. package/src/subagents/live/session-runtime.ts +1 -0
  57. package/src/subagents/manager.ts +1 -0
  58. package/src/subagents/spawn.ts +1 -0
  59. package/src/ui/live-run-sidebar.ts +1 -1
@@ -4,18 +4,55 @@ import { allWorkflows, discoverWorkflows } from "../../workflows/discover-workfl
4
4
  import { loadConfig } from "../../config/config.ts";
5
5
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
6
6
  import { writeArtifact } from "../../state/artifact-store.ts";
7
- import { createRunManifest } from "../../state/state-store.ts";
7
+ import { createRunManifest, loadRunManifestById, updateRunStatus } from "../../state/state-store.ts";
8
8
  import { atomicWriteJson } from "../../state/atomic-write.ts";
9
9
  import { validateWorkflowForTeam } from "../../workflows/validate-workflow.ts";
10
10
  import { executeTeamRun } from "../../runtime/team-runner.ts";
11
- import { spawnBackgroundTeamRun } from "../../runtime/async-runner.ts";
12
- import { appendEvent } from "../../state/event-log.ts";
11
+ import { spawnBackgroundTeamRun } from "../../subagents/async-entry.ts";
12
+ import { appendEvent, readEvents } from "../../state/event-log.ts";
13
13
  import { resolveCrewRuntime } from "../../runtime/runtime-resolver.ts";
14
14
  import { expandParallelResearchWorkflow } from "../../runtime/parallel-research.ts";
15
+ import { checkProcessLiveness, isActiveRunStatus } from "../../runtime/process-status.ts";
16
+ import { hasAsyncStartMarker } from "../../runtime/async-marker.ts";
17
+ import * as fs from "node:fs";
15
18
  import type { PiTeamsToolResult } from "../tool-result.ts";
16
19
  import { buildParentContext, result, type TeamContext } from "./context.ts";
17
20
  import { effectiveRunConfig } from "./config-patch.ts";
18
21
 
22
+ function tailFile(filePath: string, maxBytes = 4096): string | undefined {
23
+ try {
24
+ const stat = fs.statSync(filePath);
25
+ const start = Math.max(0, stat.size - maxBytes);
26
+ const fd = fs.openSync(filePath, "r");
27
+ try {
28
+ const buffer = Buffer.alloc(stat.size - start);
29
+ fs.readSync(fd, buffer, 0, buffer.length, start);
30
+ return buffer.toString("utf-8").trim();
31
+ } finally {
32
+ fs.closeSync(fd);
33
+ }
34
+ } catch {
35
+ return undefined;
36
+ }
37
+ }
38
+
39
+ function scheduleBackgroundEarlyExitGuard(cwd: string, runId: string, pid: number | undefined, logPath: string): void {
40
+ if (process.env.PI_CREW_ASYNC_EARLY_EXIT_GUARD === "0") return;
41
+ const timer = setTimeout(() => {
42
+ const loaded = loadRunManifestById(cwd, runId);
43
+ if (!loaded || !isActiveRunStatus(loaded.manifest.status)) return;
44
+ if (hasAsyncStartMarker(loaded.manifest)) return;
45
+ if (readEvents(loaded.manifest.eventsPath).some((event) => event.type === "async.started" || event.type === "async.completed" || event.type === "async.failed")) return;
46
+ const liveness = checkProcessLiveness(pid);
47
+ if (liveness.alive) return;
48
+ const tail = tailFile(logPath);
49
+ const message = `Background runner exited within 3s; see background.log${tail ? `\n${tail}` : ""}`;
50
+ const failed = updateRunStatus(loaded.manifest, "failed", "Background runner exited within 3s; see background.log");
51
+ appendEvent(failed.eventsPath, { type: "async.failed", runId: failed.runId, message, data: { pid, detail: liveness.detail } });
52
+ }, 3000);
53
+ timer.unref?.();
54
+ }
55
+
19
56
  export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
20
57
  const goal = params.goal ?? params.task;
21
58
  if (!goal) return result("Run requires goal or task.", { action: "run", status: "error" }, true);
@@ -75,6 +112,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
75
112
  const asyncManifest = { ...updatedManifest, async: { pid: spawned.pid, logPath: spawned.logPath, spawnedAt: new Date().toISOString() } };
76
113
  atomicWriteJson(paths.manifestPath, asyncManifest);
77
114
  appendEvent(updatedManifest.eventsPath, { type: "async.spawned", runId: updatedManifest.runId, data: { pid: spawned.pid, logPath: spawned.logPath } });
115
+ scheduleBackgroundEarlyExitGuard(ctx.cwd, updatedManifest.runId, spawned.pid, spawned.logPath);
78
116
  const text = [
79
117
  `Started async pi-crew run ${updatedManifest.runId}.`,
80
118
  `Team: ${team.name}`,
@@ -0,0 +1,73 @@
1
+ import { loadConfig } from "../../config/config.ts";
2
+ import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
3
+ import { appendEvent, readEvents } from "../../state/event-log.ts";
4
+ import { loadRunManifestById, updateRunStatus } from "../../state/state-store.ts";
5
+ import { aggregateUsage, formatUsage } from "../../state/usage.ts";
6
+ import { applyAttentionState, formatActivityAge, resolveCrewControlConfig } from "../../runtime/agent-control.ts";
7
+ import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
8
+ import { checkProcessLiveness, isActiveRunStatus } from "../../runtime/process-status.ts";
9
+ import { formatTaskGraphLines, waitingReason } from "../../runtime/task-display.ts";
10
+ import type { PiTeamsToolResult } from "../tool-result.ts";
11
+ import { result, type TeamContext } from "./context.ts";
12
+
13
+ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
14
+ if (!params.runId) return result("Status requires runId.", { action: "status", status: "error" }, true);
15
+ const loaded = loadRunManifestById(ctx.cwd, params.runId);
16
+ if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "status", status: "error" }, true);
17
+ let { manifest, tasks } = loaded;
18
+ let asyncLivenessLine: string | undefined;
19
+ if (manifest.async) {
20
+ const asyncState = manifest.async;
21
+ const liveness = checkProcessLiveness(asyncState.pid);
22
+ asyncLivenessLine = `Async: pid=${asyncState.pid ?? "unknown"} alive=${liveness.alive ? "true" : "false"} detail=${liveness.detail} log=${asyncState.logPath} spawnedAt=${asyncState.spawnedAt}`;
23
+ if (!liveness.alive && isActiveRunStatus(manifest.status)) {
24
+ manifest = updateRunStatus(manifest, "failed", `Async process stale: ${liveness.detail}`);
25
+ appendEvent(manifest.eventsPath, { type: "async.stale", runId: manifest.runId, message: liveness.detail, data: { pid: asyncState.pid } });
26
+ }
27
+ }
28
+ const counts = new Map<string, number>();
29
+ for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
30
+ const events = readEvents(manifest.eventsPath).slice(-8);
31
+ const controlConfig = resolveCrewControlConfig(loadConfig(ctx.cwd).config);
32
+ const crewAgents = readCrewAgents(manifest).map((agent) => applyAttentionState(manifest, agent, controlConfig));
33
+ const artifactLines = manifest.artifacts.slice(-10).map((artifact) => `- ${artifact.kind}: ${artifact.path}${artifact.sizeBytes !== undefined ? ` (${artifact.sizeBytes} bytes)` : ""}`);
34
+ const totalUsage = aggregateUsage(tasks);
35
+ const activeAgents = crewAgents.filter((agent) => agent.status === "running");
36
+ const completedAgents = crewAgents.filter((agent) => agent.status !== "running");
37
+ const waitingTasks = tasks.filter((task) => task.status === "queued");
38
+ const agentLine = (agent: typeof crewAgents[number]): string => `- ${agent.id} [${agent.status}] ${agent.role} -> ${agent.agent} runtime=${agent.runtime}${agent.model ? ` model=${agent.model}` : ""}${agent.usage ? ` usage=${formatUsage(agent.usage)}` : ""}${agent.progress?.activityState === "needs_attention" ? " needs_attention" : ""}${formatActivityAge(agent) ? ` activity=${formatActivityAge(agent)}` : ""}${agent.progress?.currentTool ? ` tool=${agent.progress.currentTool}` : ""}${agent.toolUses ? ` tools=${agent.toolUses}` : ""}${!agent.usage && agent.progress?.tokens ? ` tokens=${agent.progress.tokens}` : ""}${agent.progress?.turns ? ` turns=${agent.progress.turns}` : ""}${agent.jsonEvents !== undefined ? ` jsonEvents=${agent.jsonEvents}` : ""}${agent.statusPath ? ` status=${agent.statusPath}` : ""}${agent.error ? ` error=${agent.error}` : ""}`;
39
+ const lines = [
40
+ `Run: ${manifest.runId}`,
41
+ `Team: ${manifest.team}`,
42
+ `Workflow: ${manifest.workflow ?? "(none)"}`,
43
+ `Status: ${manifest.status}`,
44
+ `Workspace mode: ${manifest.workspaceMode}`,
45
+ `Goal: ${manifest.goal}`,
46
+ `Created: ${manifest.createdAt}`,
47
+ `Updated: ${manifest.updatedAt}`,
48
+ `State: ${manifest.stateRoot}`,
49
+ `Artifacts: ${manifest.artifactsRoot}`,
50
+ ...(asyncLivenessLine ? [asyncLivenessLine] : []),
51
+ "Task graph:",
52
+ ...formatTaskGraphLines(tasks),
53
+ "Tasks:",
54
+ ...(tasks.length ? tasks.map((task) => `- ${task.id} [${task.status}] ${task.role} -> ${task.agent}${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.modelAttempts?.length ? ` attempts=${task.modelAttempts.length}` : ""}${task.modelRouting ? ` modelRouting=${task.modelRouting.requested ? `${task.modelRouting.requested}->` : ""}${task.modelRouting.resolved}${task.modelRouting.usedAttempt ? ` attempt=${task.modelRouting.usedAttempt + 1}` : ""}` : ""}${task.jsonEvents !== undefined ? ` jsonEvents=${task.jsonEvents}` : ""}${task.usage ? ` usage=${JSON.stringify(task.usage)}` : ""}${task.worktree ? ` worktree=${task.worktree.path}` : ""}${task.error ? ` error=${task.error}` : ""}`) : ["- (none)"]),
55
+ `Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
56
+ "Active agents:",
57
+ ...(activeAgents.length ? activeAgents.map(agentLine) : ["- (none)"]),
58
+ "Waiting tasks:",
59
+ ...(waitingTasks.length ? waitingTasks.map((task) => `- ${task.id} [queued] ${task.role} -> ${task.agent} ${waitingReason(task, tasks) ?? "waiting"}`) : ["- (none)"]),
60
+ "Completed agents:",
61
+ ...(completedAgents.length ? completedAgents.map(agentLine) : ["- (none)"]),
62
+ "Policy decisions:",
63
+ ...(manifest.policyDecisions?.length ? manifest.policyDecisions.map((item) => `- ${item.action} (${item.reason})${item.taskId ? ` ${item.taskId}` : ""}: ${item.message}`) : ["- (none)"]),
64
+ `Total usage: ${formatUsage(totalUsage)}`,
65
+ "",
66
+ "Recent artifacts:",
67
+ ...(artifactLines.length ? artifactLines : ["- (none)"]),
68
+ "",
69
+ "Recent events:",
70
+ ...(events.length ? events.map((event) => `- ${event.time} ${event.type}${event.taskId ? ` ${event.taskId}` : ""}${event.message ? `: ${event.message}` : ""}`) : ["- (none)"]),
71
+ ];
72
+ return result(lines.join("\n"), { action: "status", status: "ok", runId: manifest.runId, artifactsRoot: manifest.artifactsRoot });
73
+ }
@@ -1,13 +1,16 @@
1
1
  import * as fs from "node:fs";
2
+ import * as path from "node:path";
2
3
  import { allAgents, discoverAgents } from "../agents/discover-agents.ts";
3
4
  import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
4
5
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
5
6
  import { loadConfig, updateAutonomousConfig, updateConfig } from "../config/config.ts";
6
7
  import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
7
- import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../state/state-store.ts";
8
+ import { loadRunManifestById, saveRunManifest, saveRunTasks, updateRunStatus } from "../state/state-store.ts";
8
9
  import { withRunLock, withRunLockSync } from "../state/locks.ts";
9
10
  import { aggregateUsage, formatUsage } from "../state/usage.ts";
10
11
  import { appendEvent, readEvents } from "../state/event-log.ts";
12
+ import { writeArtifact } from "../state/artifact-store.ts";
13
+ import { replayPendingMailboxMessages } from "../state/mailbox.ts";
11
14
  import { cleanupRunWorktrees } from "../worktree/cleanup.ts";
12
15
  import { piTeamsHelp } from "./help.ts";
13
16
  import { initializeProject } from "./project-init.ts";
@@ -21,6 +24,7 @@ import { validateWorkflowForTeam } from "../workflows/validate-workflow.ts";
21
24
  import { formatValidationReport, validateResources } from "./validate-resources.ts";
22
25
  import { formatRecommendation, recommendTeam } from "./team-recommendation.ts";
23
26
  import type { PiTeamsToolResult } from "./tool-result.ts";
27
+ import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
24
28
  import { executeTeamRun } from "../runtime/team-runner.ts";
25
29
  import { checkProcessLiveness, isActiveRunStatus } from "../runtime/process-status.ts";
26
30
  import { saveCrewAgents, readCrewAgents, recordFromTask } from "../runtime/crew-agent-records.ts";
@@ -29,17 +33,28 @@ import { applyAttentionState, formatActivityAge, resolveCrewControlConfig } from
29
33
  import { writeForegroundInterruptRequest } from "../runtime/foreground-control.ts";
30
34
  import { formatTaskGraphLines, waitingReason } from "../runtime/task-display.ts";
31
35
  import { directTeamAndWorkflowFromRun } from "../runtime/direct-run.ts";
36
+ import { parsePiJsonOutput } from "../runtime/pi-json-output.ts";
32
37
  import { buildParentContext, configRecord, formatScoped, result, type TeamContext } from "./team-tool/context.ts";
33
38
  import { autonomousPatchFromConfig, configPatchFromConfig, formatAutonomyStatus } from "./team-tool/config-patch.ts";
34
39
  import { handleApi } from "./team-tool/api.ts";
35
40
  import { handleRun } from "./team-tool/run.ts";
36
41
  import { handleDoctor } from "./team-tool/doctor.ts";
42
+ import { handleStatus } from "./team-tool/status.ts";
43
+ import { handleArtifacts, handleEvents, handleSummary } from "./team-tool/inspect.ts";
44
+ import { handleCleanup, handleExport, handleForget, handleImport, handleImports, handlePrune, handleWorktrees } from "./team-tool/lifecycle-actions.ts";
45
+ import { handleCancel } from "./team-tool/cancel.ts";
46
+ import { handlePlan } from "./team-tool/plan.ts";
37
47
  import { logInternalError } from "../utils/internal-error.ts";
38
48
 
39
49
  export type { TeamToolDetails } from "./team-tool-types.ts";
40
50
  export type { TeamContext } from "./team-tool/context.ts";
41
51
  export { handleRun } from "./team-tool/run.ts";
42
52
  export { handleDoctor } from "./team-tool/doctor.ts";
53
+ export { handleStatus } from "./team-tool/status.ts";
54
+ export { handleArtifacts, handleEvents, handleSummary } from "./team-tool/inspect.ts";
55
+ export { handleCleanup, handleExport, handleForget, handleImport, handleImports, handlePrune, handleWorktrees } from "./team-tool/lifecycle-actions.ts";
56
+ export { handleCancel } from "./team-tool/cancel.ts";
57
+ export { handlePlan } from "./team-tool/plan.ts";
43
58
  export { handleApi } from "./team-tool/api.ts";
44
59
 
45
60
  export function handleList(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
@@ -108,111 +123,43 @@ export function handleGet(params: TeamToolParamsValue, ctx: TeamContext): PiTeam
108
123
  return result("Specify team, workflow, or agent for get.", { action: "get", status: "error" }, true);
109
124
  }
110
125
 
111
- export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
112
- if (!params.runId) return result("Status requires runId.", { action: "status", status: "error" }, true);
113
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
114
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "status", status: "error" }, true);
115
- let { manifest, tasks } = loaded;
116
- let asyncLivenessLine: string | undefined;
117
- if (manifest.async) {
118
- const asyncState = manifest.async;
119
- const liveness = checkProcessLiveness(asyncState.pid);
120
- asyncLivenessLine = `Async: pid=${asyncState.pid ?? "unknown"} alive=${liveness.alive ? "true" : "false"} detail=${liveness.detail} log=${asyncState.logPath} spawnedAt=${asyncState.spawnedAt}`;
121
- if (!liveness.alive && isActiveRunStatus(manifest.status)) {
122
- manifest = updateRunStatus(manifest, "failed", `Async process stale: ${liveness.detail}`);
123
- appendEvent(manifest.eventsPath, { type: "async.stale", runId: manifest.runId, message: liveness.detail, data: { pid: asyncState.pid } });
124
- }
125
- }
126
- const counts = new Map<string, number>();
127
- for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
128
- const events = readEvents(manifest.eventsPath).slice(-8);
129
- const controlConfig = resolveCrewControlConfig(loadConfig(ctx.cwd).config);
130
- const crewAgents = readCrewAgents(manifest).map((agent) => applyAttentionState(manifest, agent, controlConfig));
131
- const artifactLines = manifest.artifacts.slice(-10).map((artifact) => `- ${artifact.kind}: ${artifact.path}${artifact.sizeBytes !== undefined ? ` (${artifact.sizeBytes} bytes)` : ""}`);
132
- const totalUsage = aggregateUsage(tasks);
133
- const activeAgents = crewAgents.filter((agent) => agent.status === "running");
134
- const completedAgents = crewAgents.filter((agent) => agent.status !== "running");
135
- const waitingTasks = tasks.filter((task) => task.status === "queued");
136
- const agentLine = (agent: typeof crewAgents[number]): string => `- ${agent.id} [${agent.status}] ${agent.role} -> ${agent.agent} runtime=${agent.runtime}${agent.model ? ` model=${agent.model}` : ""}${agent.usage ? ` usage=${formatUsage(agent.usage)}` : ""}${agent.progress?.activityState === "needs_attention" ? " needs_attention" : ""}${formatActivityAge(agent) ? ` activity=${formatActivityAge(agent)}` : ""}${agent.progress?.currentTool ? ` tool=${agent.progress.currentTool}` : ""}${agent.toolUses ? ` tools=${agent.toolUses}` : ""}${!agent.usage && agent.progress?.tokens ? ` tokens=${agent.progress.tokens}` : ""}${agent.progress?.turns ? ` turns=${agent.progress.turns}` : ""}${agent.jsonEvents !== undefined ? ` jsonEvents=${agent.jsonEvents}` : ""}${agent.statusPath ? ` status=${agent.statusPath}` : ""}${agent.error ? ` error=${agent.error}` : ""}`;
137
- const lines = [
138
- `Run: ${manifest.runId}`,
139
- `Team: ${manifest.team}`,
140
- `Workflow: ${manifest.workflow ?? "(none)"}`,
141
- `Status: ${manifest.status}`,
142
- `Workspace mode: ${manifest.workspaceMode}`,
143
- `Goal: ${manifest.goal}`,
144
- `Created: ${manifest.createdAt}`,
145
- `Updated: ${manifest.updatedAt}`,
146
- `State: ${manifest.stateRoot}`,
147
- `Artifacts: ${manifest.artifactsRoot}`,
148
- ...(asyncLivenessLine ? [asyncLivenessLine] : []),
149
- "Task graph:",
150
- ...formatTaskGraphLines(tasks),
151
- "Tasks:",
152
- ...(tasks.length ? tasks.map((task) => `- ${task.id} [${task.status}] ${task.role} -> ${task.agent}${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.modelAttempts?.length ? ` attempts=${task.modelAttempts.length}` : ""}${task.jsonEvents !== undefined ? ` jsonEvents=${task.jsonEvents}` : ""}${task.usage ? ` usage=${JSON.stringify(task.usage)}` : ""}${task.worktree ? ` worktree=${task.worktree.path}` : ""}${task.error ? ` error=${task.error}` : ""}`) : ["- (none)"]),
153
- `Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
154
- "Active agents:",
155
- ...(activeAgents.length ? activeAgents.map(agentLine) : ["- (none)"]),
156
- "Waiting tasks:",
157
- ...(waitingTasks.length ? waitingTasks.map((task) => `- ${task.id} [queued] ${task.role} -> ${task.agent} ${waitingReason(task, tasks) ?? "waiting"}`) : ["- (none)"]),
158
- "Completed agents:",
159
- ...(completedAgents.length ? completedAgents.map(agentLine) : ["- (none)"]),
160
- "Policy decisions:",
161
- ...(manifest.policyDecisions?.length ? manifest.policyDecisions.map((item) => `- ${item.action} (${item.reason})${item.taskId ? ` ${item.taskId}` : ""}: ${item.message}`) : ["- (none)"]),
162
- `Total usage: ${formatUsage(totalUsage)}`,
163
- "",
164
- "Recent artifacts:",
165
- ...(artifactLines.length ? artifactLines : ["- (none)"]),
166
- "",
167
- "Recent events:",
168
- ...(events.length ? events.map((event) => `- ${event.time} ${event.type}${event.taskId ? ` ${event.taskId}` : ""}${event.message ? `: ${event.message}` : ""}`) : ["- (none)"]),
169
- ];
170
- return result(lines.join("\n"), { action: "status", status: "ok", runId: manifest.runId, artifactsRoot: manifest.artifactsRoot });
171
- }
172
-
173
- export function handlePlan(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
174
- const teamName = params.team ?? "default";
175
- const team = allTeams(discoverTeams(ctx.cwd)).find((item) => item.name === teamName);
176
- if (!team) return result(`Team '${teamName}' not found.`, { action: "plan", status: "error" }, true);
177
- const workflowName = params.workflow ?? team.defaultWorkflow ?? "default";
178
- const workflow = allWorkflows(discoverWorkflows(ctx.cwd)).find((item) => item.name === workflowName);
179
- if (!workflow) return result(`Workflow '${workflowName}' not found.`, { action: "plan", status: "error" }, true);
180
- const errors = validateWorkflowForTeam(workflow, team);
181
- if (errors.length > 0) return result([`Workflow '${workflow.name}' is not valid for team '${team.name}':`, ...errors.map((error) => `- ${error}`)].join("\n"), { action: "plan", status: "error" }, true);
182
- const lines = [
183
- `Team plan: ${team.name}`,
184
- `Workflow: ${workflow.name}`,
185
- `Goal: ${params.goal ?? params.task ?? "(not provided)"}`,
186
- "",
187
- "Steps:",
188
- ...workflow.steps.map((step, index) => `${index + 1}. ${step.id} [${step.role}]${step.dependsOn?.length ? ` after ${step.dependsOn.join(", ")}` : ""}`),
189
- ];
190
- return result(lines.join("\n"), { action: "plan", status: "ok" });
126
+ function artifactKey(artifact: ArtifactDescriptor): string {
127
+ return `${artifact.kind}:${artifact.path}`;
191
128
  }
192
129
 
193
- export function handleCancel(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
194
- if (!params.runId) return result("Cancel requires runId.", { action: "cancel", status: "error" }, true);
195
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
196
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "cancel", status: "error" }, true);
197
- return withRunLockSync(loaded.manifest, () => {
198
- if (loaded.manifest.status === "completed" && !params.force) {
199
- return result(`Run ${loaded.manifest.runId} is already completed; nothing to cancel. Use force: true to mark it cancelled anyway.`, { action: "cancel", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
200
- }
201
- const tasks = loaded.tasks.map((task) => task.status === "queued" || task.status === "running" ? { ...task, status: "cancelled" as const, finishedAt: new Date().toISOString(), error: "Run cancelled by user request." } : task);
202
- saveRunTasks(loaded.manifest, tasks);
203
- try {
204
- saveCrewAgents(loaded.manifest, tasks.map((task) => recordFromTask(loaded.manifest, task, "child-process")));
205
- } catch (error) {
206
- logInternalError("team-tool.handleCancel.crewAgents", error, `runId=${loaded.manifest.runId}`);
130
+ function recoverCheckpointedTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]): { manifest: TeamRunManifest; tasks: TeamTaskState[]; recovered: string[] } {
131
+ const recovered: string[] = [];
132
+ let nextManifest = manifest;
133
+ let nextTasks = tasks.map((task) => {
134
+ if (task.status !== "running" || !task.checkpoint) return task;
135
+ if (task.checkpoint.phase === "artifact-written" && task.resultArtifact) {
136
+ recovered.push(task.id);
137
+ return { ...task, status: "completed" as const, finishedAt: task.finishedAt ?? task.checkpoint.updatedAt, error: undefined, claim: undefined };
207
138
  }
208
- try {
209
- writeForegroundInterruptRequest(loaded.manifest, "Run cancelled by user request.");
210
- } catch (error) {
211
- logInternalError("team-tool.handleCancel.interruptRequest", error, `runId=${loaded.manifest.runId}`);
139
+ if (task.checkpoint.phase === "child-stdout-final") {
140
+ const transcriptPath = path.join(manifest.artifactsRoot, "transcripts", `${task.id}.jsonl`);
141
+ if (!fs.existsSync(transcriptPath)) return task;
142
+ const transcript = fs.readFileSync(transcriptPath, "utf-8");
143
+ const parsed = parsePiJsonOutput(transcript);
144
+ if (!parsed.finalText && !parsed.usage) return task;
145
+ const resultArtifact = writeArtifact(manifest.artifactsRoot, { kind: "result", relativePath: `results/${task.id}.txt`, content: parsed.finalText ?? "(recovered from completed child transcript)", producer: task.id });
146
+ const transcriptArtifact = writeArtifact(manifest.artifactsRoot, { kind: "log", relativePath: `transcripts/${task.id}.jsonl`, content: transcript, producer: task.id });
147
+ recovered.push(task.id);
148
+ return { ...task, status: "completed" as const, finishedAt: task.finishedAt ?? task.checkpoint.updatedAt, error: undefined, claim: undefined, resultArtifact, transcriptArtifact, usage: parsed.usage, jsonEvents: parsed.jsonEvents };
212
149
  }
213
- const updated = updateRunStatus(loaded.manifest, "cancelled", "Run cancelled by user request. Already-finished worker processes are not retroactively changed.");
214
- return result(`Cancelled run ${updated.runId}.`, { action: "cancel", status: "ok", runId: updated.runId, artifactsRoot: updated.artifactsRoot });
150
+ return task;
215
151
  });
152
+ if (recovered.length) {
153
+ const artifacts = new Map(nextManifest.artifacts.map((artifact) => [artifactKey(artifact), artifact]));
154
+ for (const task of nextTasks) {
155
+ if (!recovered.includes(task.id)) continue;
156
+ for (const artifact of [task.promptArtifact, task.resultArtifact, task.logArtifact, task.transcriptArtifact].filter(Boolean) as ArtifactDescriptor[]) artifacts.set(artifactKey(artifact), artifact);
157
+ }
158
+ nextManifest = { ...nextManifest, artifacts: [...artifacts.values()], updatedAt: new Date().toISOString() };
159
+ saveRunManifest(nextManifest);
160
+ saveRunTasks(nextManifest, nextTasks);
161
+ }
162
+ return { manifest: nextManifest, tasks: nextTasks, recovered };
216
163
  }
217
164
 
218
165
  export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
@@ -227,136 +174,22 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
227
174
  const workflow = direct?.workflow ?? allWorkflows(discoverWorkflows(ctx.cwd)).find((candidate) => candidate.name === loaded.manifest.workflow);
228
175
  if (!workflow) return result(`Workflow '${loaded.manifest.workflow}' not found.`, { action: "resume", status: "error" }, true);
229
176
  return await withRunLock(loaded.manifest, async () => {
230
- const resetTasks = loaded.tasks.map((task) => task.status === "failed" || task.status === "cancelled" || task.status === "skipped" || task.status === "running" ? { ...task, status: "queued" as const, error: undefined, startedAt: undefined, finishedAt: undefined, claim: undefined } : task);
231
- saveRunTasks(loaded.manifest, resetTasks);
232
- appendEvent(loaded.manifest.eventsPath, { type: "run.resume_requested", runId: loaded.manifest.runId });
177
+ const recovered = recoverCheckpointedTasks(loaded.manifest, loaded.tasks);
178
+ const resumeManifest = recovered.manifest;
179
+ const resetTasks = recovered.tasks.map((task) => task.status === "failed" || task.status === "cancelled" || task.status === "skipped" || task.status === "running" ? { ...task, status: "queued" as const, error: undefined, startedAt: undefined, finishedAt: undefined, claim: undefined } : task);
180
+ saveRunTasks(resumeManifest, resetTasks);
181
+ const replay = replayPendingMailboxMessages(resumeManifest);
182
+ appendEvent(resumeManifest.eventsPath, { type: "run.resume_requested", runId: resumeManifest.runId, data: { replayedMailboxMessages: replay.messages.length, recoveredCheckpointTasks: recovered.recovered } });
183
+ if (recovered.recovered.length) appendEvent(resumeManifest.eventsPath, { type: "task.checkpoint_recovered", runId: resumeManifest.runId, message: `Recovered ${recovered.recovered.length} task(s) from artifact-written checkpoints.`, data: { taskIds: recovered.recovered } });
184
+ if (replay.messages.length) appendEvent(resumeManifest.eventsPath, { type: "mailbox.replayed", runId: resumeManifest.runId, message: `Replayed ${replay.messages.length} pending inbox message(s).`, data: { messageIds: replay.messages.map((message) => message.id), taskIds: replay.messages.map((message) => message.taskId).filter(Boolean) } });
233
185
  const loadedConfig = loadConfig(ctx.cwd);
234
186
  const runtime = await resolveCrewRuntime(loadedConfig.config);
235
187
  const executeWorkers = runtime.kind !== "scaffold";
236
- const executed = await executeTeamRun({ manifest: loaded.manifest, tasks: resetTasks, team, workflow, agents, executeWorkers, limits: loadedConfig.config.limits, runtime, runtimeConfig: loadedConfig.config.runtime, parentContext: buildParentContext(ctx), parentModel: ctx.model, modelRegistry: ctx.modelRegistry, modelOverride: params.model, signal: ctx.signal });
188
+ const executed = await executeTeamRun({ manifest: resumeManifest, tasks: resetTasks, team, workflow, agents, executeWorkers, limits: loadedConfig.config.limits, runtime, runtimeConfig: loadedConfig.config.runtime, parentContext: buildParentContext(ctx), parentModel: ctx.model, modelRegistry: ctx.modelRegistry, modelOverride: params.model, signal: ctx.signal });
237
189
  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");
238
190
  });
239
191
  }
240
192
 
241
- export function handleEvents(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
242
- if (!params.runId) return result("Events requires runId.", { action: "events", status: "error" }, true);
243
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
244
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "events", status: "error" }, true);
245
- const events = readEvents(loaded.manifest.eventsPath);
246
- const lines = [`Events for ${loaded.manifest.runId}:`, ...(events.length ? events.map((event) => `${event.time} ${event.type}${event.taskId ? ` ${event.taskId}` : ""}${event.message ? `: ${event.message}` : ""}${event.data ? ` ${JSON.stringify(event.data)}` : ""}`) : ["(none)"])];
247
- return result(lines.join("\n"), { action: "events", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
248
- }
249
-
250
- export function handleArtifacts(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
251
- if (!params.runId) return result("Artifacts requires runId.", { action: "artifacts", status: "error" }, true);
252
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
253
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "artifacts", status: "error" }, true);
254
- const lines = [`Artifacts for ${loaded.manifest.runId}:`, ...(loaded.manifest.artifacts.length ? loaded.manifest.artifacts.map((artifact) => `- ${artifact.kind}: ${artifact.path}${artifact.sizeBytes !== undefined ? ` (${artifact.sizeBytes} bytes)` : ""}${artifact.contentHash ? ` sha256=${artifact.contentHash.slice(0, 12)}` : ""}`) : ["- (none)"])];
255
- return result(lines.join("\n"), { action: "artifacts", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
256
- }
257
-
258
- export function handleSummary(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
259
- if (!params.runId) return result("Summary requires runId.", { action: "summary", status: "error" }, true);
260
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
261
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "summary", status: "error" }, true);
262
- const usage = aggregateUsage(loaded.tasks);
263
- const lines = [
264
- `Summary for ${loaded.manifest.runId}`,
265
- `Status: ${loaded.manifest.status}`,
266
- `Team: ${loaded.manifest.team}`,
267
- `Workflow: ${loaded.manifest.workflow ?? "(none)"}`,
268
- `Goal: ${loaded.manifest.goal}`,
269
- `Usage: ${formatUsage(usage)}`,
270
- "Tasks:",
271
- ...loaded.tasks.map((task) => `- ${task.id}: ${task.status} (${task.role} -> ${task.agent})${task.error ? ` - ${task.error}` : ""}`),
272
- ];
273
- return result(lines.join("\n"), { action: "summary", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
274
- }
275
-
276
- export function handleWorktrees(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
277
- if (!params.runId) return result("Worktrees requires runId.", { action: "worktrees", status: "error" }, true);
278
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
279
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "worktrees", status: "error" }, true);
280
- const withWorktrees = loaded.tasks.filter((task) => task.worktree);
281
- const lines = [
282
- `Worktrees for ${loaded.manifest.runId}:`,
283
- ...(withWorktrees.length ? withWorktrees.map((task) => `- ${task.id}: ${task.worktree!.path} branch=${task.worktree!.branch} reused=${task.worktree!.reused ? "true" : "false"}`) : ["- (none)"]),
284
- ];
285
- return result(lines.join("\n"), { action: "worktrees", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
286
- }
287
-
288
- export function handleImports(_params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
289
- const imports = listImportedRuns(ctx.cwd);
290
- const lines = [
291
- "Imported pi-crew runs:",
292
- ...(imports.length ? imports.map((entry) => `- ${entry.runId} (${entry.scope})${entry.status ? ` [${entry.status}]` : ""} ${entry.team ?? "unknown"}/${entry.workflow ?? "none"}: ${entry.goal ?? ""}\n Bundle: ${entry.bundlePath}\n Summary: ${entry.summaryPath}`) : ["- (none)"]),
293
- ];
294
- return result(lines.join("\n"), { action: "imports", status: "ok" });
295
- }
296
-
297
- export function handleImport(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
298
- const cfg = configRecord(params.config);
299
- const bundlePath = typeof cfg.path === "string" ? cfg.path : typeof cfg.bundlePath === "string" ? cfg.bundlePath : undefined;
300
- if (!bundlePath) return result("Import requires config.path pointing at run-export.json.", { action: "import", status: "error" }, true);
301
- const scope = cfg.scope === "user" ? "user" : "project";
302
- try {
303
- const imported = importRunBundle(ctx.cwd, bundlePath, scope);
304
- return result([`Imported run bundle ${imported.runId}.`, `Bundle: ${imported.bundlePath}`, `Summary: ${imported.summaryPath}`].join("\n"), { action: "import", status: "ok" });
305
- } catch (error) {
306
- const message = error instanceof Error ? error.message : String(error);
307
- return result(`Import failed: ${message}`, { action: "import", status: "error" }, true);
308
- }
309
- }
310
-
311
- export function handleExport(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
312
- if (!params.runId) return result("Export requires runId.", { action: "export", status: "error" }, true);
313
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
314
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "export", status: "error" }, true);
315
- const exported = exportRunBundle(loaded.manifest, loaded.tasks);
316
- appendEvent(loaded.manifest.eventsPath, { type: "run.exported", runId: loaded.manifest.runId, data: exported });
317
- return result([`Exported run ${loaded.manifest.runId}.`, `JSON: ${exported.jsonPath}`, `Markdown: ${exported.markdownPath}`].join("\n"), { action: "export", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
318
- }
319
-
320
- export function handlePrune(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
321
- const keep = params.keep ?? 20;
322
- if (!params.confirm) return result("prune requires confirm: true.", { action: "prune", status: "error" }, true);
323
- if (keep < 0 || !Number.isInteger(keep)) return result("keep must be an integer >= 0.", { action: "prune", status: "error" }, true);
324
- const pruned = pruneFinishedRuns(ctx.cwd, keep);
325
- return result([`Pruned finished pi-crew runs.`, `Kept: ${pruned.kept.length}`, `Removed: ${pruned.removed.length}`, ...(pruned.removed.length ? ["Removed runs:", ...pruned.removed.map((runId) => `- ${runId}`)] : [])].join("\n"), { action: "prune", status: "ok" });
326
- }
327
-
328
- export function handleForget(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
329
- if (!params.runId) return result("Forget requires runId.", { action: "forget", status: "error" }, true);
330
- if (!params.confirm) return result("forget requires confirm: true.", { action: "forget", status: "error" }, true);
331
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
332
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "forget", status: "error" }, true);
333
- const cleanup = cleanupRunWorktrees(loaded.manifest, { force: params.force });
334
- if (cleanup.preserved.length > 0 && !params.force) {
335
- return result([`Run '${params.runId}' has preserved worktrees. Use force: true to forget anyway.`, ...cleanup.preserved.map((item) => `- ${item.path}: ${item.reason}`)].join("\n"), { action: "forget", status: "error", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot }, true);
336
- }
337
- fs.rmSync(loaded.manifest.stateRoot, { recursive: true, force: true });
338
- fs.rmSync(loaded.manifest.artifactsRoot, { recursive: true, force: true });
339
- return result([`Forgot run ${loaded.manifest.runId}.`, `Removed state: ${loaded.manifest.stateRoot}`, `Removed artifacts: ${loaded.manifest.artifactsRoot}`, ...(cleanup.removed.length ? ["Removed worktrees:", ...cleanup.removed.map((item) => `- ${item}`)] : [])].join("\n"), { action: "forget", status: "ok", runId: loaded.manifest.runId });
340
- }
341
-
342
- export function handleCleanup(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
343
- if (!params.runId) return result("Cleanup requires runId.", { action: "cleanup", status: "error" }, true);
344
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
345
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "cleanup", status: "error" }, true);
346
- const cleanup = cleanupRunWorktrees(loaded.manifest, { force: params.force });
347
- appendEvent(loaded.manifest.eventsPath, { type: "worktree.cleanup", runId: loaded.manifest.runId, data: { removed: cleanup.removed, preserved: cleanup.preserved, artifacts: cleanup.artifactPaths } });
348
- const lines = [
349
- `Worktree cleanup for ${loaded.manifest.runId}:`,
350
- "Removed:",
351
- ...(cleanup.removed.length ? cleanup.removed.map((item) => `- ${item}`) : ["- (none)"]),
352
- "Preserved:",
353
- ...(cleanup.preserved.length ? cleanup.preserved.map((item) => `- ${item.path}: ${item.reason}`) : ["- (none)"]),
354
- "Artifacts:",
355
- ...(cleanup.artifactPaths.length ? cleanup.artifactPaths.map((item) => `- ${item}`) : ["- (none)"]),
356
- ];
357
- return result(lines.join("\n"), { action: "cleanup", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
358
- }
359
-
360
193
  export async function handleTeamTool(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
361
194
  const action = params.action ?? "list";
362
195
  switch (action) {
@@ -0,0 +1,26 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { atomicWriteJson } from "../state/atomic-write.ts";
4
+ import type { TeamRunManifest } from "../state/types.ts";
5
+
6
+ export interface AsyncStartMarker {
7
+ pid: number;
8
+ startedAt: string;
9
+ }
10
+
11
+ export function asyncStartMarkerPath(manifest: Pick<TeamRunManifest, "stateRoot">): string {
12
+ return path.join(manifest.stateRoot, "async.pid");
13
+ }
14
+
15
+ export function writeAsyncStartMarker(manifest: Pick<TeamRunManifest, "stateRoot">, marker: AsyncStartMarker): void {
16
+ atomicWriteJson(asyncStartMarkerPath(manifest), marker);
17
+ }
18
+
19
+ export function hasAsyncStartMarker(manifest: Pick<TeamRunManifest, "stateRoot">): boolean {
20
+ try {
21
+ const raw = JSON.parse(fs.readFileSync(asyncStartMarkerPath(manifest), "utf-8")) as Partial<AsyncStartMarker>;
22
+ return typeof raw.pid === "number" && Number.isInteger(raw.pid) && raw.pid > 0 && typeof raw.startedAt === "string" && raw.startedAt.length > 0;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
@@ -1,12 +1,38 @@
1
1
  import { spawn, type SpawnOptions } 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, pathToFileURL } from "node:url";
6
+ import { appendEvent } from "../state/event-log.ts";
5
7
  import type { TeamRunManifest } from "../state/types.ts";
6
8
 
7
- export function getBackgroundRunnerCommand(runnerPath: string, cwd: string, runId: string): { args: string[]; loader: "jiti" } {
8
- const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
9
- const jitiRegisterPath = path.join(packageRoot, "node_modules", "jiti", "lib", "jiti-register.mjs");
9
+ export type FileExists = (filePath: string) => boolean;
10
+
11
+ const requireFromHere = createRequire(import.meta.url);
12
+
13
+ function packageRootFromRuntime(): string {
14
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
15
+ }
16
+
17
+ function jitiRegisterPathFromPackageJson(packageJsonPath: string): string {
18
+ return path.join(path.dirname(packageJsonPath), "lib", "jiti-register.mjs");
19
+ }
20
+
21
+ export function resolveJitiRegisterPath(packageRoot = packageRootFromRuntime(), exists: FileExists = fs.existsSync): string | undefined {
22
+ const candidates = [
23
+ path.join(packageRoot, "node_modules", "jiti", "lib", "jiti-register.mjs"),
24
+ path.join(packageRoot, "..", "..", "node_modules", "jiti", "lib", "jiti-register.mjs"),
25
+ ];
26
+ try {
27
+ candidates.push(jitiRegisterPathFromPackageJson(requireFromHere.resolve("jiti/package.json")));
28
+ } catch {
29
+ // Fall through to explicit candidate checks.
30
+ }
31
+ return [...new Set(candidates)].find((candidate) => exists(candidate));
32
+ }
33
+
34
+ export function getBackgroundRunnerCommand(runnerPath: string, cwd: string, runId: string, jitiRegisterPath: string | false | undefined = resolveJitiRegisterPath()): { args: string[]; loader: "jiti" } {
35
+ if (!jitiRegisterPath) throw new Error("pi-crew background runner cannot start: jiti loader not found. Reinstall pi-crew (`pi install npm:pi-crew`) or ensure node_modules/jiti is present.");
10
36
  return {
11
37
  args: ["--import", pathToFileURL(jitiRegisterPath).href, runnerPath, "--cwd", cwd, "--run-id", runId],
12
38
  loader: "jiti",
@@ -33,10 +59,19 @@ export function spawnBackgroundTeamRun(manifest: TeamRunManifest): SpawnBackgrou
33
59
  const logPath = path.join(manifest.stateRoot, "background.log");
34
60
  fs.mkdirSync(manifest.stateRoot, { recursive: true });
35
61
  const logFd = fs.openSync(logPath, "a");
36
- const command = getBackgroundRunnerCommand(runnerPath, manifest.cwd, manifest.runId);
37
- fs.appendFileSync(logPath, `[pi-crew] background loader=${command.loader}\n`, "utf-8");
38
- const child = spawn(process.execPath, command.args, buildBackgroundSpawnOptions(manifest, logFd));
39
- child.unref();
40
- fs.closeSync(logFd);
41
- return { pid: child.pid, logPath };
62
+ try {
63
+ const jitiRegisterPath = resolveJitiRegisterPath();
64
+ if (!jitiRegisterPath) {
65
+ const message = "pi-crew background runner cannot start: jiti loader not found. Reinstall pi-crew (`pi install npm:pi-crew`) or ensure node_modules/jiti is present.";
66
+ appendEvent(manifest.eventsPath, { type: "async.failed", runId: manifest.runId, message });
67
+ throw new Error(message);
68
+ }
69
+ const command = getBackgroundRunnerCommand(runnerPath, manifest.cwd, manifest.runId, jitiRegisterPath);
70
+ fs.appendFileSync(logPath, `[pi-crew] background loader=${command.loader}\n`, "utf-8");
71
+ const child = spawn(process.execPath, command.args, buildBackgroundSpawnOptions(manifest, logFd));
72
+ child.unref();
73
+ return { pid: child.pid, logPath };
74
+ } finally {
75
+ fs.closeSync(logFd);
76
+ }
42
77
  }
@@ -8,6 +8,7 @@ import { executeTeamRun } from "./team-runner.ts";
8
8
  import { resolveCrewRuntime } from "./runtime-resolver.ts";
9
9
  import { directTeamAndWorkflowFromRun } from "./direct-run.ts";
10
10
  import { expandParallelResearchWorkflow } from "./parallel-research.ts";
11
+ import { writeAsyncStartMarker } from "./async-marker.ts";
11
12
 
12
13
  function argValue(name: string): string | undefined {
13
14
  const index = process.argv.indexOf(name);
@@ -24,6 +25,7 @@ async function main(): Promise<void> {
24
25
  if (!loaded) throw new Error(`Run '${runId}' not found.`);
25
26
  let { manifest, tasks } = loaded;
26
27
  appendEvent(manifest.eventsPath, { type: "async.started", runId: manifest.runId, data: { pid: process.pid } });
28
+ writeAsyncStartMarker(manifest, { pid: process.pid, startedAt: new Date().toISOString() });
27
29
 
28
30
  try {
29
31
  const agents = allAgents(discoverAgents(cwd));
@@ -91,6 +91,7 @@ export interface ChildPiRunInput {
91
91
  transcriptPath?: string;
92
92
  onStdoutLine?: (line: string) => void;
93
93
  onJsonEvent?: (event: unknown) => void;
94
+ onSpawn?: (pid: number) => void;
94
95
  maxDepth?: number;
95
96
  finalDrainMs?: number;
96
97
  hardKillMs?: number;
@@ -286,7 +287,10 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
286
287
  try {
287
288
  return await new Promise<ChildPiRunResult>((resolve) => {
288
289
  const child = spawn(spawnSpec.command, spawnSpec.args, buildChildPiSpawnOptions(input.cwd, { ...process.env, ...built.env }));
289
- if (child.pid) activeChildProcesses.set(child.pid, child);
290
+ if (child.pid) {
291
+ activeChildProcesses.set(child.pid, child);
292
+ input.onSpawn?.(child.pid);
293
+ }
290
294
  let stdout = "";
291
295
  let stderr = "";
292
296
  let settled = false;