pi-crew 0.1.45 → 0.1.49

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 (178) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +5 -5
  3. package/agents/analyst.md +11 -11
  4. package/agents/critic.md +11 -11
  5. package/agents/executor.md +11 -11
  6. package/agents/explorer.md +11 -11
  7. package/agents/planner.md +11 -11
  8. package/agents/reviewer.md +11 -11
  9. package/agents/security-reviewer.md +11 -11
  10. package/agents/test-engineer.md +11 -11
  11. package/agents/verifier.md +11 -11
  12. package/agents/writer.md +11 -11
  13. package/docs/next-upgrade-roadmap.md +808 -0
  14. package/docs/research/AGENT-EXECUTION-ARCHITECTURE.md +261 -0
  15. package/docs/research/AGENT-LIFECYCLE-COMPARISON.md +111 -0
  16. package/docs/research/AUDIT_OH_MY_PI.md +261 -0
  17. package/docs/research/AUDIT_PI_CREW.md +457 -0
  18. package/docs/research/CAVEMAN-DEEP-RESEARCH.md +281 -0
  19. package/docs/research/COMPARISON_OH_MY_PI_VS_PI_CREW.md +264 -0
  20. package/docs/research/DEEP-RESEARCH-PI-POWERBAR.md +343 -0
  21. package/docs/research/DEEP_RESEARCH_SUBAGENT_ARCHITECTURE.md +480 -0
  22. package/docs/research/GAP_CLOSURE_IMPLEMENTATION_PLAN.md +354 -0
  23. package/docs/research/IMPLEMENTATION_PLAN.md +385 -0
  24. package/docs/research/LIVE-SESSION-PRODUCTION-READY-PLAN.md +502 -0
  25. package/docs/research/OH-MY-PI-DEEP-RESEARCH-v14.7.6.md +266 -0
  26. package/docs/research/REMAINING-GAPS-PLAN.md +363 -0
  27. package/docs/research/SESSION-SUMMARY-2026-05-08.md +146 -0
  28. package/docs/research/UI-RESPONSIVENESS-AUDIT.md +173 -0
  29. package/docs/research-awesome-agent-skills-distillation.md +100 -0
  30. package/docs/research-oh-my-pi-distillation.md +369 -0
  31. package/docs/source-runtime-refactor-map.md +24 -0
  32. package/docs/usage.md +3 -3
  33. package/install.mjs +52 -8
  34. package/package.json +99 -98
  35. package/schema.json +10 -1
  36. package/skills/async-worker-recovery/SKILL.md +42 -0
  37. package/skills/context-artifact-hygiene/SKILL.md +52 -0
  38. package/skills/delegation-patterns/SKILL.md +54 -0
  39. package/skills/mailbox-interactive/SKILL.md +40 -0
  40. package/skills/model-routing-context/SKILL.md +39 -0
  41. package/skills/multi-perspective-review/SKILL.md +58 -0
  42. package/skills/observability-reliability/SKILL.md +41 -0
  43. package/skills/orchestration/SKILL.md +157 -0
  44. package/skills/ownership-session-security/SKILL.md +41 -0
  45. package/skills/pi-extension-lifecycle/SKILL.md +39 -0
  46. package/skills/requirements-to-task-packet/SKILL.md +63 -0
  47. package/skills/resource-discovery-config/SKILL.md +41 -0
  48. package/skills/runtime-state-reader/SKILL.md +44 -0
  49. package/skills/secure-agent-orchestration-review/SKILL.md +45 -0
  50. package/skills/state-mutation-locking/SKILL.md +42 -0
  51. package/skills/systematic-debugging/SKILL.md +67 -0
  52. package/skills/ui-render-performance/SKILL.md +39 -0
  53. package/skills/verification-before-done/SKILL.md +57 -0
  54. package/skills/worktree-isolation/SKILL.md +39 -0
  55. package/src/agents/agent-config.ts +6 -0
  56. package/src/agents/agent-search.ts +98 -0
  57. package/src/agents/agent-serializer.ts +38 -34
  58. package/src/agents/discover-agents.ts +29 -15
  59. package/src/config/config.ts +72 -24
  60. package/src/config/defaults.ts +25 -0
  61. package/src/extension/autonomous-policy.ts +26 -33
  62. package/src/extension/help.ts +1 -0
  63. package/src/extension/management.ts +5 -0
  64. package/src/extension/project-init.ts +62 -2
  65. package/src/extension/register.ts +69 -22
  66. package/src/extension/registration/commands.ts +64 -25
  67. package/src/extension/registration/compaction-guard.ts +1 -1
  68. package/src/extension/registration/subagent-helpers.ts +8 -0
  69. package/src/extension/registration/subagent-tools.ts +149 -148
  70. package/src/extension/registration/team-tool.ts +14 -10
  71. package/src/extension/run-index.ts +35 -21
  72. package/src/extension/run-maintenance.ts +30 -5
  73. package/src/extension/team-tool/api.ts +47 -9
  74. package/src/extension/team-tool/cancel.ts +109 -5
  75. package/src/extension/team-tool/context.ts +8 -0
  76. package/src/extension/team-tool/intent-policy.ts +42 -0
  77. package/src/extension/team-tool/lifecycle-actions.ts +120 -79
  78. package/src/extension/team-tool/parallel-dispatch.ts +156 -0
  79. package/src/extension/team-tool/respond.ts +46 -18
  80. package/src/extension/team-tool/run.ts +55 -12
  81. package/src/extension/team-tool/status.ts +13 -2
  82. package/src/extension/team-tool-types.ts +3 -0
  83. package/src/extension/team-tool.ts +45 -14
  84. package/src/hooks/registry.ts +61 -0
  85. package/src/hooks/types.ts +41 -0
  86. package/src/observability/event-to-metric.ts +8 -1
  87. package/src/runtime/agent-control.ts +169 -63
  88. package/src/runtime/async-runner.ts +3 -1
  89. package/src/runtime/background-runner.ts +78 -53
  90. package/src/runtime/cancellation-token.ts +89 -0
  91. package/src/runtime/cancellation.ts +61 -0
  92. package/src/runtime/capability-inventory.ts +116 -0
  93. package/src/runtime/child-pi.ts +458 -444
  94. package/src/runtime/code-summary.ts +247 -0
  95. package/src/runtime/crash-recovery.ts +182 -0
  96. package/src/runtime/crew-agent-records.ts +70 -10
  97. package/src/runtime/crew-agent-runtime.ts +1 -0
  98. package/src/runtime/custom-tools/irc-tool.ts +201 -0
  99. package/src/runtime/custom-tools/submit-result-tool.ts +90 -0
  100. package/src/runtime/deadletter.ts +1 -0
  101. package/src/runtime/delivery-coordinator.ts +48 -25
  102. package/src/runtime/effectiveness.ts +81 -0
  103. package/src/runtime/event-stream-bridge.ts +90 -0
  104. package/src/runtime/live-agent-control.ts +2 -1
  105. package/src/runtime/live-agent-manager.ts +179 -85
  106. package/src/runtime/live-control-realtime.ts +1 -1
  107. package/src/runtime/live-extension-bridge.ts +150 -0
  108. package/src/runtime/live-irc.ts +92 -0
  109. package/src/runtime/live-session-health.ts +100 -0
  110. package/src/runtime/live-session-runtime.ts +599 -305
  111. package/src/runtime/manifest-cache.ts +17 -2
  112. package/src/runtime/mcp-proxy.ts +113 -0
  113. package/src/runtime/model-fallback.ts +6 -4
  114. package/src/runtime/notebook-helpers.ts +90 -0
  115. package/src/runtime/orphan-sentinel.ts +7 -0
  116. package/src/runtime/output-validator.ts +187 -0
  117. package/src/runtime/parallel-utils.ts +57 -0
  118. package/src/runtime/parent-guard.ts +80 -0
  119. package/src/runtime/pi-args.ts +18 -3
  120. package/src/runtime/process-status.ts +5 -1
  121. package/src/runtime/prose-compressor.ts +164 -0
  122. package/src/runtime/result-extractor.ts +121 -0
  123. package/src/runtime/retry-executor.ts +81 -64
  124. package/src/runtime/runtime-resolver.ts +23 -10
  125. package/src/runtime/semaphore.ts +131 -0
  126. package/src/runtime/sensitive-paths.ts +92 -0
  127. package/src/runtime/skill-instructions.ts +222 -0
  128. package/src/runtime/stale-reconciler.ts +4 -14
  129. package/src/runtime/stream-preview.ts +177 -0
  130. package/src/runtime/subagent-manager.ts +6 -2
  131. package/src/runtime/subprocess-tool-registry.ts +67 -0
  132. package/src/runtime/task-output-context.ts +177 -127
  133. package/src/runtime/task-runner/capabilities.ts +78 -0
  134. package/src/runtime/task-runner/live-executor.ts +107 -101
  135. package/src/runtime/task-runner/prompt-builder.ts +72 -8
  136. package/src/runtime/task-runner/prompt-pipeline.ts +64 -0
  137. package/src/runtime/task-runner/run-projection.ts +104 -0
  138. package/src/runtime/task-runner.ts +115 -5
  139. package/src/runtime/team-runner.ts +134 -19
  140. package/src/runtime/workspace-tree.ts +298 -0
  141. package/src/runtime/yield-handler.ts +189 -0
  142. package/src/schema/config-schema.ts +7 -0
  143. package/src/schema/team-tool-schema.ts +14 -4
  144. package/src/skills/discover-skills.ts +67 -0
  145. package/src/state/active-run-registry.ts +167 -0
  146. package/src/state/artifact-store.ts +4 -1
  147. package/src/state/atomic-write.ts +50 -1
  148. package/src/state/blob-store.ts +117 -0
  149. package/src/state/contracts.ts +2 -1
  150. package/src/state/event-log-rotation.ts +158 -0
  151. package/src/state/event-log.ts +52 -2
  152. package/src/state/mailbox.ts +129 -9
  153. package/src/state/state-store.ts +32 -5
  154. package/src/state/types.ts +64 -2
  155. package/src/teams/team-config.ts +1 -0
  156. package/src/ui/agent-management-overlay.ts +144 -0
  157. package/src/ui/crew-widget.ts +15 -5
  158. package/src/ui/dashboard-panes/cancellation-pane.ts +43 -0
  159. package/src/ui/dashboard-panes/capability-pane.ts +60 -0
  160. package/src/ui/dashboard-panes/mailbox-pane.ts +35 -11
  161. package/src/ui/dashboard-panes/progress-pane.ts +2 -0
  162. package/src/ui/live-run-sidebar.ts +4 -0
  163. package/src/ui/powerbar-publisher.ts +77 -15
  164. package/src/ui/render-coalescer.ts +51 -0
  165. package/src/ui/run-dashboard.ts +4 -0
  166. package/src/ui/run-event-bus.ts +209 -0
  167. package/src/ui/run-snapshot-cache.ts +78 -18
  168. package/src/ui/snapshot-types.ts +10 -0
  169. package/src/ui/transcript-entries.ts +258 -0
  170. package/src/utils/ids.ts +5 -0
  171. package/src/utils/incremental-reader.ts +104 -0
  172. package/src/utils/paths.ts +4 -2
  173. package/src/utils/scan-cache.ts +137 -0
  174. package/src/utils/sse-parser.ts +134 -0
  175. package/src/utils/task-name-generator.ts +337 -0
  176. package/src/utils/visual.ts +33 -2
  177. package/src/workflows/workflow-config.ts +1 -0
  178. package/src/worktree/cleanup.ts +2 -1
@@ -5,7 +5,7 @@ import { loadRunManifestById, saveRunManifest, saveRunTasks, updateRunStatus } f
5
5
  import { withRunLockSync } from "../../state/locks.ts";
6
6
  import { canTransitionTaskStatus, isTeamTaskStatus } from "../../state/contracts.ts";
7
7
  import { claimTask, releaseTaskClaim, transitionClaimedTaskStatus } from "../../state/task-claims.ts";
8
- import { acknowledgeMailboxMessage, appendMailboxMessage, readDeliveryState, readMailbox, readMailboxMessage, validateMailbox, type MailboxDirection } from "../../state/mailbox.ts";
8
+ import { acknowledgeMailboxMessage, appendFollowUpMessage, appendMailboxMessage, appendSteeringMessage, readDeliveryState, readMailbox, readMailboxMessage, validateMailbox, type MailboxDirection, type MailboxMessageKind } from "../../state/mailbox.ts";
9
9
  import { appendEvent, readEvents, readEventsCursor } from "../../state/event-log.ts";
10
10
  import { resolveCrewRuntime } from "../../runtime/runtime-resolver.ts";
11
11
  import { probeLiveSessionRuntime } from "../../subagents/live/session-runtime.ts";
@@ -14,9 +14,10 @@ import { touchWorkerHeartbeat } from "../../runtime/worker-heartbeat.ts";
14
14
  import { agentOutputPath, readCrewAgentEventsCursor, readCrewAgentStatus, readCrewAgents } from "../../runtime/crew-agent-records.ts";
15
15
  import { buildAgentDashboard, readAgentOutput } from "../../runtime/agent-observability.ts";
16
16
  import { readForegroundControlStatus, writeForegroundInterruptRequest } from "../../runtime/foreground-control.ts";
17
- import { getLiveAgent, listLiveAgents, resumeLiveAgent, steerLiveAgent, stopLiveAgent } from "../../subagents/live/manager.ts";
17
+ import { followUpLiveAgent, getLiveAgent, listLiveAgents, resumeLiveAgent, steerLiveAgent, stopLiveAgent } from "../../subagents/live/manager.ts";
18
18
  import { appendLiveAgentControlRequest } from "../../subagents/live/control.ts";
19
19
  import { liveControlRealtimeMessage, publishLiveControlRealtime } from "../../subagents/live/realtime.ts";
20
+ import { buildCapabilityInventory } from "../../runtime/capability-inventory.ts";
20
21
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
21
22
  import type { PiTeamsToolResult } from "../tool-result.ts";
22
23
  import { configRecord, result, type TeamContext } from "./context.ts";
@@ -76,6 +77,10 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
76
77
  });
77
78
  return result(JSON.stringify(filtered, null, 2), { action: "api", status: "ok", ...(runIdFilter ? { runId: runIdFilter } : {}) });
78
79
  }
80
+ if (operation === "inventory") {
81
+ const inventory = buildCapabilityInventory(ctx.cwd, ctx.config);
82
+ return result(JSON.stringify(inventory, null, 2), { action: "api", status: "ok" });
83
+ }
79
84
  if (!params.runId) return result("API requires runId.", { action: "api", status: "error" }, true);
80
85
  const loaded = loadRunManifestById(ctx.cwd, params.runId);
81
86
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "api", status: "error" }, true);
@@ -212,7 +217,7 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
212
217
  const agent = readCrewAgents(loaded.manifest).find((item) => item.id === agentId || item.taskId === agentId);
213
218
  if (!agent) return result("API nudge-agent requires config.agentId matching an agent id or task id.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
214
219
  const messageText = typeof cfg.message === "string" && cfg.message.trim() ? cfg.message.trim() : "Please report your current status, blocker, or smallest next step.";
215
- const message = appendMailboxMessage(loaded.manifest, { direction: "inbox", from: "leader", to: agent.taskId, taskId: agent.taskId, body: messageText });
220
+ const message = appendSteeringMessage(loaded.manifest, { taskId: agent.taskId, to: agent.taskId, body: messageText, priority: "normal", data: { source: "nudge-agent" } });
216
221
  appendEvent(loaded.manifest.eventsPath, { type: "agent.nudged", runId: loaded.manifest.runId, taskId: agent.taskId, message: messageText, data: { agentId: agent.id, mailboxMessageId: message.id } });
217
222
  ctx.events?.emit?.("crew.mailbox.message", { runId: loaded.manifest.runId, id: message.id, direction: message.direction, from: message.from, to: message.to, taskId: message.taskId, source: "nudge-agent" });
218
223
  return result(JSON.stringify({ agentId: agent.id, mailboxMessage: message }, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
@@ -220,7 +225,7 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
220
225
  if (operation === "list-live-agents") {
221
226
  return result(JSON.stringify(listLiveAgents().filter((agent) => agent.runId === loaded.manifest.runId), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
222
227
  }
223
- if (operation === "steer-agent" || operation === "stop-agent" || operation === "resume-agent" || operation === "interrupt-agent") {
228
+ if (operation === "steer-agent" || operation === "follow-up-agent" || operation === "stop-agent" || operation === "resume-agent" || operation === "interrupt-agent") {
224
229
  const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
225
230
  if (!agentId) return result(`API ${operation} requires config.agentId.`, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
226
231
  const message = typeof cfg.message === "string" && cfg.message.trim() ? cfg.message.trim() : undefined;
@@ -228,7 +233,22 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
228
233
  try {
229
234
  const live = getLiveAgent(agentId);
230
235
  if (live && live.runId !== loaded.manifest.runId) return result(`Live agent '${agentId}' does not belong to run ${loaded.manifest.runId}.`, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
231
- if (operation === "steer-agent") return result(JSON.stringify(await steerLiveAgent(agentId, message ?? "Please report current status and wrap up if possible."), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
236
+ if (!live && (operation === "steer-agent" || operation === "follow-up-agent")) throw new Error(`Live agent '${agentId}' not found.`);
237
+ const liveTaskId = live?.taskId;
238
+ if ((operation === "steer-agent" || operation === "follow-up-agent") && !liveTaskId) throw new Error(`Live agent '${agentId}' not found.`);
239
+ const targetTaskId = liveTaskId ?? agentId;
240
+ if (operation === "steer-agent") {
241
+ const text = message ?? "Please report current status and wrap up if possible.";
242
+ const realtime = await steerLiveAgent(agentId, text);
243
+ const mailboxMessage = appendSteeringMessage(loaded.manifest, { taskId: targetTaskId, body: text, status: "delivered", data: { source: "steer-agent", realtime: true } });
244
+ return result(JSON.stringify({ realtime, mailboxMessage }, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
245
+ }
246
+ if (operation === "follow-up-agent") {
247
+ if (!prompt) return result("API follow-up-agent requires config.prompt or config.message.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
248
+ const realtime = await followUpLiveAgent(agentId, prompt);
249
+ const mailboxMessage = appendFollowUpMessage(loaded.manifest, { taskId: targetTaskId, body: prompt, status: "delivered", data: { source: "follow-up-agent", realtime: true } });
250
+ return result(JSON.stringify({ realtime, mailboxMessage }, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
251
+ }
232
252
  if (operation === "resume-agent") {
233
253
  if (!prompt) return result("API resume-agent requires config.prompt or config.message.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
234
254
  return result(JSON.stringify(await resumeLiveAgent(agentId, prompt), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
@@ -243,12 +263,14 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
243
263
  const task = loaded.tasks.find((item) => item.id === agent.taskId);
244
264
  if (!task) return result(`API ${operation} agent '${agentId}' does not match a run task.`, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
245
265
  if (operation === "resume-agent" && !prompt) return result("API resume-agent requires config.prompt or config.message.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
266
+ if (operation === "follow-up-agent" && !prompt) return result("API follow-up-agent requires config.prompt or config.message.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
246
267
  try {
247
- const request = appendLiveAgentControlRequest(loaded.manifest, { taskId: task.id, agentId: agent.id, operation: operation === "resume-agent" ? "resume" : operation === "steer-agent" ? "steer" : "stop", message: operation === "resume-agent" ? prompt : message });
268
+ const request = appendLiveAgentControlRequest(loaded.manifest, { taskId: task.id, agentId: agent.id, operation: operation === "resume-agent" ? "resume" : operation === "follow-up-agent" ? "follow-up" : operation === "steer-agent" ? "steer" : "stop", message: operation === "resume-agent" || operation === "follow-up-agent" ? prompt : message });
269
+ const mailboxMessage = operation === "steer-agent" ? appendSteeringMessage(loaded.manifest, { taskId: task.id, to: agent.id, body: message ?? "Please report current status and wrap up if possible.", status: "delivered", data: { source: "steer-agent", liveControlRequestId: request.id } }) : operation === "follow-up-agent" && prompt ? appendFollowUpMessage(loaded.manifest, { taskId: task.id, to: agent.id, body: prompt, status: "delivered", data: { source: "follow-up-agent", liveControlRequestId: request.id } }) : undefined;
248
270
  publishLiveControlRealtime(request);
249
271
  ctx.events?.emit?.("pi-crew:live-control", liveControlRealtimeMessage(request));
250
- appendEvent(loaded.manifest.eventsPath, { type: "agent.control.queued", runId: loaded.manifest.runId, taskId: agent.taskId, message: `Queued ${request.operation} control request for live agent.`, data: { request, realtime: true } });
251
- return result(JSON.stringify({ queued: true, request }, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
272
+ appendEvent(loaded.manifest.eventsPath, { type: "agent.control.queued", runId: loaded.manifest.runId, taskId: agent.taskId, message: `Queued ${request.operation} control request for live agent.`, data: { request, mailboxMessageId: mailboxMessage?.id, realtime: true } });
273
+ return result(JSON.stringify({ queued: true, request, mailboxMessage }, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
252
274
  } catch (queueError) {
253
275
  const message = queueError instanceof Error ? queueError.message : String(queueError);
254
276
  return result(message, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
@@ -258,9 +280,10 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
258
280
  if (operation === "read-mailbox") {
259
281
  const direction = cfg.direction === "inbox" || cfg.direction === "outbox" ? cfg.direction as MailboxDirection : undefined;
260
282
  const taskId = typeof cfg.taskId === "string" ? cfg.taskId : undefined;
283
+ const kind = typeof cfg.kind === "string" && ["message", "steer", "follow-up", "response", "group_join"].includes(cfg.kind) ? cfg.kind as MailboxMessageKind : undefined;
261
284
  if (taskId && !loaded.tasks.some((task) => task.id === taskId)) return result(`API read-mailbox taskId '${taskId}' does not match a run task.`, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
262
285
  try {
263
- return result(JSON.stringify(readMailbox(loaded.manifest, direction, taskId), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
286
+ return result(JSON.stringify(readMailbox(loaded.manifest, direction, taskId, kind), null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
264
287
  } catch (error) {
265
288
  const message = error instanceof Error ? error.message : String(error);
266
289
  return result(message, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
@@ -399,5 +422,20 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
399
422
  return result(message, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
400
423
  }
401
424
  }
425
+ if (operation === "diff") {
426
+ const diffArtifacts = loaded.manifest.artifacts.filter(a => a.kind === "diff" || a.kind === "patch");
427
+ if (diffArtifacts.length === 0) {
428
+ return result(`No diff artifacts found for run ${loaded.manifest.runId}. Diffs are captured in worktree mode.`, { action: "api", status: "ok", runId: loaded.manifest.runId, intent: `diff ${loaded.manifest.runId}: no diffs` });
429
+ }
430
+ const parts: string[] = [`Diff artifacts for run ${loaded.manifest.runId}:`];
431
+ for (const artifact of diffArtifacts) {
432
+ const content = safeReadContainedFile(loaded.manifest.artifactsRoot, artifact.path);
433
+ if (content) {
434
+ const display = content.length > 4000 ? content.slice(0, 4000) + "\n... (truncated)" : content;
435
+ parts.push(`\n--- ${artifact.path} ---\n${display}`);
436
+ }
437
+ }
438
+ return result(parts.join("\n"), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot, intent: `diff ${loaded.manifest.runId}` });
439
+ }
402
440
  return result(`Unknown API operation: ${operation}`, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
403
441
  }
@@ -3,9 +3,13 @@ import { withRunLockSync } from "../../state/locks.ts";
3
3
  import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/state-store.ts";
4
4
  import { saveCrewAgents, recordFromTask } from "../../runtime/crew-agent-records.ts";
5
5
  import { writeForegroundInterruptRequest } from "../../runtime/foreground-control.ts";
6
+ import { cancellationReasonFromUnknown, buildSyntheticTerminalEvidence, type CancellationReason } from "../../runtime/cancellation.ts";
7
+ import { appendEvent } from "../../state/event-log.ts";
6
8
  import { logInternalError } from "../../utils/internal-error.ts";
9
+ import { executeHook, appendHookEvent } from "../../hooks/registry.ts";
7
10
  import type { PiTeamsToolResult } from "../tool-result.ts";
8
11
  import { result, type TeamContext } from "./context.ts";
12
+ import { enforceDestructiveIntent, intentFromConfig } from "./intent-policy.ts";
9
13
 
10
14
  export interface AbortOwnedResult {
11
15
  abortedIds: string[];
@@ -55,23 +59,118 @@ export function abortOwned(
55
59
  return result;
56
60
  }
57
61
 
58
- export function handleCancel(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
62
+ function configFromParams(params: TeamToolParamsValue): Record<string, unknown> | undefined {
63
+ return params.config && typeof params.config === "object" && !Array.isArray(params.config) ? params.config : undefined;
64
+ }
65
+
66
+ function cancelReasonFromParams(params: TeamToolParamsValue): CancellationReason {
67
+ const config = configFromParams(params);
68
+ const rawReason = config?.reason ?? config?.cancelReason;
69
+ const reason = rawReason === undefined ? { code: "caller_cancelled" as const, message: "Run cancelled by user request." } : cancellationReasonFromUnknown(rawReason);
70
+ return { code: reason.code, message: reason.message };
71
+ }
72
+
73
+ export async function handleRetry(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
74
+ if (!params.runId) return result("Retry requires runId.", { action: "retry", status: "error" }, true);
75
+ const loaded = loadRunManifestById(ctx.cwd, params.runId);
76
+ if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "retry", status: "error" }, true);
77
+
78
+ // Pre-lock ownership check: reject foreign-owned runs
79
+ const foreignRun = typeof loaded.manifest.ownerSessionId === "string" && loaded.manifest.ownerSessionId !== ctx.sessionId;
80
+ if (foreignRun) {
81
+ return result(`Run ${loaded.manifest.runId} belongs to another session; not retried.`, { action: "retry", status: "error", runId: loaded.manifest.runId }, true);
82
+ }
83
+
84
+ // Execute before_retry hook after ownership confirmed, before mutation lock
85
+ const hookReport = await executeHook("before_retry", { runId: loaded.manifest.runId, cwd: ctx.cwd });
86
+ appendHookEvent(loaded.manifest, hookReport);
87
+ if (hookReport.outcome === "block") {
88
+ return result(`Retry blocked by hook: ${hookReport.reason ?? "before_retry hook blocked the operation."}`, { action: "retry", status: "error", runId: loaded.manifest.runId }, true);
89
+ }
90
+
91
+ const targetTaskId = typeof params.taskId === "string" ? params.taskId : undefined;
92
+
93
+ return withRunLockSync(loaded.manifest, () => {
94
+ const retryableStatuses: ReadonlySet<string> = new Set(["failed", "cancelled"]);
95
+
96
+ const matchingTasks = loaded.tasks.filter((task) => {
97
+ if (targetTaskId && task.id !== targetTaskId) return false;
98
+ return retryableStatuses.has(task.status);
99
+ });
100
+
101
+ if (matchingTasks.length === 0) {
102
+ return result(targetTaskId ? `Task '${targetTaskId}' is not failed/cancelled; nothing to retry.` : "No failed/cancelled tasks to retry.", { action: "retry", status: "error", runId: loaded.manifest.runId }, true);
103
+ }
104
+
105
+ const retriedIds = new Set(matchingTasks.map((t) => t.id));
106
+ const tasks = loaded.tasks.map((task) => {
107
+ if (!retriedIds.has(task.id)) return task;
108
+ const { error: _error, finishedAt: _finishedAt, terminalEvidence: _terminalEvidence, ...rest } = task;
109
+ return { ...rest, status: "queued" as const };
110
+ });
111
+ saveRunTasks(loaded.manifest, tasks);
112
+ try {
113
+ saveCrewAgents(loaded.manifest, tasks.map((task) => recordFromTask(loaded.manifest, task, "child-process")));
114
+ } catch (error) {
115
+ logInternalError("team-tool.handleRetry.crewAgents", error, `runId=${loaded.manifest.runId}`);
116
+ }
117
+
118
+ const retriedTaskIds = [...retriedIds];
119
+ for (const taskId of retriedTaskIds) {
120
+ appendEvent(loaded.manifest.eventsPath, { type: "task.retried", runId: loaded.manifest.runId, taskId, message: `Task ${taskId} queued for retry.` });
121
+ }
122
+
123
+ return result(`Retried ${retriedTaskIds.length} task(s) in run ${loaded.manifest.runId}.`, {
124
+ action: "retry",
125
+ status: "ok",
126
+ runId: loaded.manifest.runId,
127
+ retriedTaskIds: retriedTaskIds,
128
+ intent: `retrying ${retriedTaskIds.length} task(s) in ${loaded.manifest.runId}`,
129
+ });
130
+ });
131
+ }
132
+
133
+ export async function handleCancel(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
134
+ const intentError = enforceDestructiveIntent("cancel", params, ctx.config);
135
+ if (intentError) return intentError;
59
136
  if (!params.runId) return result("Cancel requires runId.", { action: "cancel", status: "error" }, true);
60
137
  const loaded = loadRunManifestById(ctx.cwd, params.runId);
61
138
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "cancel", status: "error" }, true);
139
+
140
+ // Pre-lock ownership check: reject foreign-owned runs before executing hooks
141
+ const preCheck = abortOwned(loaded.manifest.runId, undefined, ctx);
142
+ if (preCheck.abortedIds.length === 0 && preCheck.foreignIds.length > 0) {
143
+ return result(`Run ${loaded.manifest.runId} belongs to another session; not cancelled.`, { action: "cancel", status: "error", runId: loaded.manifest.runId, foreignIds: preCheck.foreignIds }, true);
144
+ }
145
+
146
+ // Execute before_cancel hook after ownership confirmed, before mutation lock
147
+ const hookReport = await executeHook("before_cancel", { runId: loaded.manifest.runId, cwd: ctx.cwd });
148
+ appendHookEvent(loaded.manifest, hookReport);
149
+ if (hookReport.outcome === "block") {
150
+ return result(`Cancel blocked by hook: ${hookReport.reason ?? "before_cancel hook blocked the operation."}`, { action: "cancel", status: "error", runId: loaded.manifest.runId }, true);
151
+ }
152
+
62
153
  return withRunLockSync(loaded.manifest, () => {
63
154
  if ((loaded.manifest.status === "completed" || loaded.manifest.status === "cancelled") && !params.force) return result(`Run ${loaded.manifest.runId} is already ${loaded.manifest.status}; nothing to cancel. Use force: true to mark it cancelled anyway.`, { action: "cancel", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
64
155
 
65
156
  // Classify tasks for foreign-aware cancellation
66
157
  const abortResult = abortOwned(loaded.manifest.runId, undefined, ctx);
67
158
  if (abortResult.abortedIds.length === 0 && abortResult.foreignIds.length > 0) {
68
- return result(`Run ${loaded.manifest.runId} belongs to another session; not cancelled.`, { action: "cancel", status: "error", runId: loaded.manifest.runId, foreignIds: abortResult.foreignIds } as never, true);
159
+ return result(`Run ${loaded.manifest.runId} belongs to another session; not cancelled.`, { action: "cancel", status: "error", runId: loaded.manifest.runId, foreignIds: abortResult.foreignIds }, true);
69
160
  }
70
161
  const cancellableIds = new Set(abortResult.abortedIds);
162
+ const cancelReason = cancelReasonFromParams(params);
163
+ const cancelIntent = intentFromConfig(params.config);
164
+ const cancelData = cancelIntent ? { reason: cancelReason.code, intent: cancelIntent } : { reason: cancelReason.code };
165
+ const cancelMessage = `${cancelReason.message} (${cancelReason.code})`;
71
166
 
72
167
  const tasks = loaded.tasks.map((task) => {
73
168
  if (cancellableIds.has(task.id) && (task.status === "queued" || task.status === "running" || task.status === "waiting")) {
74
- return { ...task, status: "cancelled" as const, finishedAt: new Date().toISOString(), error: "Run cancelled by user request." };
169
+ const base = { ...task, status: "cancelled" as const, finishedAt: new Date().toISOString(), error: cancelMessage };
170
+ if (task.status === "running") {
171
+ return { ...base, terminalEvidence: [...(task.terminalEvidence ?? []), buildSyntheticTerminalEvidence("worker", cancelReason, task.startedAt)] };
172
+ }
173
+ return base;
75
174
  }
76
175
  return task;
77
176
  });
@@ -82,11 +181,15 @@ export function handleCancel(params: TeamToolParamsValue, ctx: TeamContext): PiT
82
181
  logInternalError("team-tool.handleCancel.crewAgents", error, `runId=${loaded.manifest.runId}`);
83
182
  }
84
183
  try {
85
- writeForegroundInterruptRequest(loaded.manifest, "Run cancelled by user request.");
184
+ writeForegroundInterruptRequest(loaded.manifest, cancelMessage);
86
185
  } catch (error) {
87
186
  logInternalError("team-tool.handleCancel.interruptRequest", error, `runId=${loaded.manifest.runId}`);
88
187
  }
89
- const updated = updateRunStatus(loaded.manifest, "cancelled", "Run cancelled by user request. Already-finished worker processes are not retroactively changed.");
188
+ ctx.abortForegroundRun?.(loaded.manifest.runId);
189
+ for (const taskId of abortResult.abortedIds) {
190
+ appendEvent(loaded.manifest.eventsPath, { type: "task.cancelled", runId: loaded.manifest.runId, taskId, message: cancelMessage, data: cancelData });
191
+ }
192
+ const updated = updateRunStatus(loaded.manifest, "cancelled", `${cancelMessage} Already-finished worker processes are not retroactively changed.`, { data: cancelData });
90
193
 
91
194
  // Build descriptive message including foreign/missing info
92
195
  const parts = [`Cancelled run ${updated.runId}.`];
@@ -101,6 +204,7 @@ export function handleCancel(params: TeamToolParamsValue, ctx: TeamContext): PiT
101
204
  abortedIds: abortResult.abortedIds,
102
205
  missingIds: abortResult.missingIds,
103
206
  foreignIds: abortResult.foreignIds,
207
+ intent: cancelIntent,
104
208
  });
105
209
  });
106
210
  }
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import type { PiTeamsConfig } from "../../config/config.ts";
2
3
  import type { MetricRegistry } from "../../observability/metric-registry.ts";
3
4
  import type { TeamToolDetails } from "../team-tool-types.ts";
4
5
  import { toolResult, type PiTeamsToolResult } from "../tool-result.ts";
@@ -11,10 +12,17 @@ export type TeamContext = Pick<ExtensionContext, "cwd"> & Partial<Pick<Extension
11
12
  metricRegistry?: MetricRegistry;
12
13
  signal?: AbortSignal;
13
14
  startForegroundRun?: (runner: (signal?: AbortSignal) => Promise<void>, runId?: string) => void;
15
+ abortForegroundRun?: (runId: string) => boolean;
14
16
  onRunStarted?: (runId: string) => void;
15
17
  onJsonEvent?: (taskId: string, runId: string, event: unknown) => void;
18
+ config?: PiTeamsConfig;
16
19
  };
17
20
 
21
+ export function withSessionId<T extends Pick<ExtensionContext, "sessionManager">>(ctx: T): T & { sessionId?: string } {
22
+ const sessionId = ctx.sessionManager.getSessionId();
23
+ return sessionId ? { ...ctx, sessionId } : { ...ctx };
24
+ }
25
+
18
26
  export function result(text: string, details: TeamToolDetails, isError = false): PiTeamsToolResult {
19
27
  return toolResult(text, details, isError);
20
28
  }
@@ -0,0 +1,42 @@
1
+ import type { PiTeamsConfig } from "../../config/config.ts";
2
+ import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
3
+ import type { PiTeamsToolResult } from "../tool-result.ts";
4
+ import { configRecord, result } from "./context.ts";
5
+
6
+ export type DestructiveIntentAction = "cancel" | "cleanup" | "delete" | "forget" | "prune";
7
+
8
+ const DESTRUCTIVE_ACTION_LABELS: Record<DestructiveIntentAction, string> = {
9
+ cancel: "cancel",
10
+ cleanup: "forced cleanup",
11
+ delete: "delete",
12
+ forget: "forget",
13
+ prune: "prune",
14
+ };
15
+
16
+ export function intentFromConfig(config: unknown): string | undefined {
17
+ const cfg = configRecord(config);
18
+ const rawIntent = cfg.intent ?? cfg._intent;
19
+ if (typeof rawIntent !== "string") return undefined;
20
+ const intent = rawIntent.replace(/\s+/g, " ").trim();
21
+ return intent ? intent.slice(0, 500) : undefined;
22
+ }
23
+
24
+ export function shouldRequireIntent(config: PiTeamsConfig | undefined): boolean {
25
+ return config?.policy?.requireIntentForDestructiveActions === true;
26
+ }
27
+
28
+ export function enforceDestructiveIntent(
29
+ action: DestructiveIntentAction,
30
+ params: TeamToolParamsValue,
31
+ config: PiTeamsConfig | undefined,
32
+ ): PiTeamsToolResult | undefined {
33
+ if (!shouldRequireIntent(config)) return undefined;
34
+ if (action === "cleanup" && params.force !== true) return undefined;
35
+ if (intentFromConfig(params.config)) return undefined;
36
+ const label = DESTRUCTIVE_ACTION_LABELS[action];
37
+ return result(
38
+ `Destructive action '${label}' requires config.intent when policy.requireIntentForDestructiveActions is enabled.`,
39
+ { action: action === "delete" ? "management" : action, status: "error" },
40
+ true,
41
+ );
42
+ }
@@ -1,79 +1,120 @@
1
- import * as fs from "node:fs";
2
- import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
3
- import { appendEvent } from "../../state/event-log.ts";
4
- import { loadRunManifestById } from "../../state/state-store.ts";
5
- import { cleanupRunWorktrees } from "../../worktree/cleanup.ts";
6
- import { listImportedRuns } from "../import-index.ts";
7
- import { exportRunBundle } from "../run-export.ts";
8
- import { importRunBundle } from "../run-import.ts";
9
- import { pruneFinishedRuns } from "../run-maintenance.ts";
10
- import type { PiTeamsToolResult } from "../tool-result.ts";
11
- import { configRecord, result, type TeamContext } from "./context.ts";
12
-
13
- export function handleWorktrees(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
14
- if (!params.runId) return result("Worktrees requires runId.", { action: "worktrees", status: "error" }, true);
15
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
16
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "worktrees", status: "error" }, true);
17
- const withWorktrees = loaded.tasks.filter((task) => task.worktree);
18
- const lines = [`Worktrees for ${loaded.manifest.runId}:`, ...(withWorktrees.length ? withWorktrees.map((task) => `- ${task.id}: ${task.worktree!.path} branch=${task.worktree!.branch} reused=${task.worktree!.reused ? "true" : "false"}`) : ["- (none)"])];
19
- return result(lines.join("\n"), { action: "worktrees", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
20
- }
21
-
22
- export function handleImports(_params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
23
- const imports = listImportedRuns(ctx.cwd);
24
- const lines = ["Imported pi-crew runs:", ...(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)"])];
25
- return result(lines.join("\n"), { action: "imports", status: "ok" });
26
- }
27
-
28
- export function handleImport(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
29
- const cfg = configRecord(params.config);
30
- const bundlePath = typeof cfg.path === "string" ? cfg.path : typeof cfg.bundlePath === "string" ? cfg.bundlePath : undefined;
31
- if (!bundlePath) return result("Import requires config.path pointing at run-export.json.", { action: "import", status: "error" }, true);
32
- const scope = cfg.scope === "user" ? "user" : "project";
33
- try {
34
- const imported = importRunBundle(ctx.cwd, bundlePath, scope);
35
- return result([`Imported run bundle ${imported.runId}.`, `Bundle: ${imported.bundlePath}`, `Summary: ${imported.summaryPath}`].join("\n"), { action: "import", status: "ok" });
36
- } catch (error) {
37
- const message = error instanceof Error ? error.message : String(error);
38
- return result(`Import failed: ${message}`, { action: "import", status: "error" }, true);
39
- }
40
- }
41
-
42
- export function handleExport(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
43
- if (!params.runId) return result("Export requires runId.", { action: "export", status: "error" }, true);
44
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
45
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "export", status: "error" }, true);
46
- const exported = exportRunBundle(loaded.manifest, loaded.tasks);
47
- appendEvent(loaded.manifest.eventsPath, { type: "run.exported", runId: loaded.manifest.runId, data: exported });
48
- 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 });
49
- }
50
-
51
- export function handlePrune(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
52
- const keep = params.keep ?? 20;
53
- if (!params.confirm) return result("prune requires confirm: true.", { action: "prune", status: "error" }, true);
54
- if (keep < 0 || !Number.isInteger(keep)) return result("keep must be an integer >= 0.", { action: "prune", status: "error" }, true);
55
- const pruned = pruneFinishedRuns(ctx.cwd, keep);
56
- 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" });
57
- }
58
-
59
- export function handleForget(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
60
- if (!params.runId) return result("Forget requires runId.", { action: "forget", status: "error" }, true);
61
- if (!params.confirm) return result("forget requires confirm: true.", { action: "forget", status: "error" }, true);
62
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
63
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "forget", status: "error" }, true);
64
- const cleanup = cleanupRunWorktrees(loaded.manifest, { force: params.force });
65
- if (cleanup.preserved.length > 0 && !params.force) 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);
66
- fs.rmSync(loaded.manifest.stateRoot, { recursive: true, force: true });
67
- fs.rmSync(loaded.manifest.artifactsRoot, { recursive: true, force: true });
68
- 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 });
69
- }
70
-
71
- export function handleCleanup(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
72
- if (!params.runId) return result("Cleanup requires runId.", { action: "cleanup", status: "error" }, true);
73
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
74
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "cleanup", status: "error" }, true);
75
- const cleanup = cleanupRunWorktrees(loaded.manifest, { force: params.force });
76
- appendEvent(loaded.manifest.eventsPath, { type: "worktree.cleanup", runId: loaded.manifest.runId, data: { removed: cleanup.removed, preserved: cleanup.preserved, artifacts: cleanup.artifactPaths } });
77
- const lines = [`Worktree cleanup for ${loaded.manifest.runId}:`, "Removed:", ...(cleanup.removed.length ? cleanup.removed.map((item) => `- ${item}`) : ["- (none)"]), "Preserved:", ...(cleanup.preserved.length ? cleanup.preserved.map((item) => `- ${item.path}: ${item.reason}`) : ["- (none)"]), "Artifacts:", ...(cleanup.artifactPaths.length ? cleanup.artifactPaths.map((item) => `- ${item}`) : ["- (none)"])];
78
- return result(lines.join("\n"), { action: "cleanup", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
79
- }
1
+ import * as fs from "node:fs";
2
+ import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
3
+ import { appendEvent } from "../../state/event-log.ts";
4
+ import { loadRunManifestById } from "../../state/state-store.ts";
5
+ import { cleanupRunWorktrees } from "../../worktree/cleanup.ts";
6
+ import { listImportedRuns } from "../import-index.ts";
7
+ import { exportRunBundle } from "../run-export.ts";
8
+ import { importRunBundle } from "../run-import.ts";
9
+ import { pruneFinishedRuns } from "../run-maintenance.ts";
10
+ import type { PiTeamsToolResult } from "../tool-result.ts";
11
+ import { configRecord, result, type TeamContext } from "./context.ts";
12
+ import { enforceDestructiveIntent, intentFromConfig } from "./intent-policy.ts";
13
+ import { executeHook, appendHookEvent } from "../../hooks/registry.ts";
14
+
15
+ export function handleWorktrees(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
16
+ if (!params.runId) return result("Worktrees requires runId.", { action: "worktrees", status: "error" }, true);
17
+ const loaded = loadRunManifestById(ctx.cwd, params.runId);
18
+ if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "worktrees", status: "error" }, true);
19
+ const withWorktrees = loaded.tasks.filter((task) => task.worktree);
20
+ const lines = [`Worktrees for ${loaded.manifest.runId}:`, ...(withWorktrees.length ? withWorktrees.map((task) => `- ${task.id}: ${task.worktree!.path} branch=${task.worktree!.branch} reused=${task.worktree!.reused ? "true" : "false"}`) : ["- (none)"])];
21
+ return result(lines.join("\n"), { action: "worktrees", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
22
+ }
23
+
24
+ export function handleImports(_params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
25
+ const imports = listImportedRuns(ctx.cwd);
26
+ const lines = ["Imported pi-crew runs:", ...(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)"])];
27
+ return result(lines.join("\n"), { action: "imports", status: "ok" });
28
+ }
29
+
30
+ export function handleImport(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
31
+ const cfg = configRecord(params.config);
32
+ const bundlePath = typeof cfg.path === "string" ? cfg.path : typeof cfg.bundlePath === "string" ? cfg.bundlePath : undefined;
33
+ if (!bundlePath) return result("Import requires config.path pointing at run-export.json.", { action: "import", status: "error" }, true);
34
+ const scope = cfg.scope === "user" ? "user" : "project";
35
+ try {
36
+ const imported = importRunBundle(ctx.cwd, bundlePath, scope);
37
+ return result([`Imported run bundle ${imported.runId}.`, `Bundle: ${imported.bundlePath}`, `Summary: ${imported.summaryPath}`].join("\n"), { action: "import", status: "ok" });
38
+ } catch (error) {
39
+ const message = error instanceof Error ? error.message : String(error);
40
+ return result(`Import failed: ${message}`, { action: "import", status: "error" }, true);
41
+ }
42
+ }
43
+
44
+ export async function handleExport(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
45
+ if (!params.runId) return result("Export requires runId.", { action: "export", status: "error" }, true);
46
+ const loaded = loadRunManifestById(ctx.cwd, params.runId);
47
+ if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "export", status: "error" }, true);
48
+
49
+ const hookReport = await executeHook("before_publish", { runId: loaded.manifest.runId, cwd: ctx.cwd });
50
+ appendHookEvent(loaded.manifest, hookReport);
51
+ if (hookReport.outcome === "block") {
52
+ return result(`Export blocked by hook: ${hookReport.reason ?? "before_publish hook blocked the operation."}`, { action: "export", status: "error", runId: loaded.manifest.runId }, true);
53
+ }
54
+
55
+ const exported = exportRunBundle(loaded.manifest, loaded.tasks);
56
+ appendEvent(loaded.manifest.eventsPath, { type: "run.exported", runId: loaded.manifest.runId, data: exported });
57
+ 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 });
58
+ }
59
+
60
+ export async function handlePrune(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
61
+ const intentError = enforceDestructiveIntent("prune", params, ctx.config);
62
+ if (intentError) return intentError;
63
+ if (!params.confirm) return result("prune requires confirm: true.", { action: "prune", status: "error" }, true);
64
+ const keep = params.keep ?? 20;
65
+ if (keep < 0 || !Number.isInteger(keep)) return result("keep must be an integer >= 0.", { action: "prune", status: "error" }, true);
66
+ const intent = intentFromConfig(params.config);
67
+ const pruned = pruneFinishedRuns(ctx.cwd, keep, { intent, signal: ctx.signal });
68
+ const firstRunId = pruned.kept[0] ?? pruned.removed[0];
69
+ if (firstRunId) {
70
+ const manifest = loadRunManifestById(ctx.cwd, firstRunId)?.manifest;
71
+ if (manifest) {
72
+ const hookReport = await executeHook("before_cleanup", { runId: manifest.runId, cwd: ctx.cwd });
73
+ appendHookEvent(manifest, hookReport);
74
+ }
75
+ }
76
+ return result([`Pruned finished pi-crew runs.`, `Kept: ${pruned.kept.length}`, `Removed: ${pruned.removed.length}`, ...(pruned.auditPath ? [`Audit: ${pruned.auditPath}`] : []), ...(pruned.removed.length ? ["Removed runs:", ...pruned.removed.map((runId) => `- ${runId}`)] : [])].join("\n"), { action: "prune", status: "ok", intent });
77
+ }
78
+
79
+ export async function handleForget(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
80
+ const intentError = enforceDestructiveIntent("forget", params, ctx.config);
81
+ if (intentError) return intentError;
82
+ if (!params.runId) return result("Forget requires runId.", { action: "forget", status: "error" }, true);
83
+ if (!params.confirm) return result("forget requires confirm: true.", { action: "forget", status: "error" }, true);
84
+ const loaded = loadRunManifestById(ctx.cwd, params.runId);
85
+ if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "forget", status: "error" }, true);
86
+
87
+ const hookReport = await executeHook("before_forget", { runId: loaded.manifest.runId, cwd: ctx.cwd });
88
+ appendHookEvent(loaded.manifest, hookReport);
89
+ if (hookReport.outcome === "block") {
90
+ return result(`Forget blocked by hook: ${hookReport.reason ?? "before_forget hook blocked the operation."}`, { action: "forget", status: "error", runId: loaded.manifest.runId }, true);
91
+ }
92
+
93
+ const cleanup = cleanupRunWorktrees(loaded.manifest, { force: params.force });
94
+ if (cleanup.preserved.length > 0 && !params.force) 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);
95
+ const intent = intentFromConfig(params.config);
96
+ appendEvent(loaded.manifest.eventsPath, { type: "run.forget_requested", runId: loaded.manifest.runId, message: "Run state and artifacts are being forgotten.", data: { force: params.force === true, removedWorktrees: cleanup.removed, preservedWorktrees: cleanup.preserved, intent } });
97
+ fs.rmSync(loaded.manifest.stateRoot, { recursive: true, force: true });
98
+ fs.rmSync(loaded.manifest.artifactsRoot, { recursive: true, force: true });
99
+ 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, intent });
100
+ }
101
+
102
+ export async function handleCleanup(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
103
+ const intentError = enforceDestructiveIntent("cleanup", params, ctx.config);
104
+ if (intentError) return intentError;
105
+ if (!params.runId) return result("Cleanup requires runId.", { action: "cleanup", status: "error" }, true);
106
+ const loaded = loadRunManifestById(ctx.cwd, params.runId);
107
+ if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "cleanup", status: "error" }, true);
108
+
109
+ const hookReport = await executeHook("before_cleanup", { runId: loaded.manifest.runId, cwd: ctx.cwd });
110
+ appendHookEvent(loaded.manifest, hookReport);
111
+ if (hookReport.outcome === "block") {
112
+ return result(`Cleanup blocked by hook: ${hookReport.reason ?? "before_cleanup hook blocked the operation."}`, { action: "cleanup", status: "error", runId: loaded.manifest.runId }, true);
113
+ }
114
+
115
+ const cleanup = cleanupRunWorktrees(loaded.manifest, { force: params.force, signal: ctx.signal });
116
+ const intent = intentFromConfig(params.config);
117
+ appendEvent(loaded.manifest.eventsPath, { type: "worktree.cleanup", runId: loaded.manifest.runId, data: { removed: cleanup.removed, preserved: cleanup.preserved, artifacts: cleanup.artifactPaths, intent } });
118
+ const lines = [`Worktree cleanup for ${loaded.manifest.runId}:`, "Removed:", ...(cleanup.removed.length ? cleanup.removed.map((item) => `- ${item}`) : ["- (none)"]), "Preserved:", ...(cleanup.preserved.length ? cleanup.preserved.map((item) => `- ${item.path}: ${item.reason}`) : ["- (none)"]), "Artifacts:", ...(cleanup.artifactPaths.length ? cleanup.artifactPaths.map((item) => `- ${item}`) : ["- (none)"])];
119
+ return result(lines.join("\n"), { action: "cleanup", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot, intent });
120
+ }