pi-crew 0.7.4 → 0.7.6

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 (56) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +11 -11
  3. package/docs/commands-reference.md +14 -10
  4. package/docs/troubleshooting.md +131 -0
  5. package/docs/usage.md +9 -4
  6. package/package.json +1 -1
  7. package/src/config/config.ts +11 -4
  8. package/src/config/types.ts +2 -0
  9. package/src/errors.ts +66 -0
  10. package/src/extension/action-suggestions.ts +71 -0
  11. package/src/extension/context-status-injection.ts +174 -0
  12. package/src/extension/knowledge-injection.ts +29 -1
  13. package/src/extension/register.ts +81 -65
  14. package/src/extension/team-tool/api.ts +3 -2
  15. package/src/extension/team-tool/cancel.ts +5 -4
  16. package/src/extension/team-tool/explain.ts +2 -1
  17. package/src/extension/team-tool/failure-patterns.ts +124 -0
  18. package/src/extension/team-tool/inspect.ts +10 -6
  19. package/src/extension/team-tool/lifecycle-actions.ts +5 -4
  20. package/src/extension/team-tool/respond.ts +4 -3
  21. package/src/extension/team-tool/run-not-found.ts +54 -0
  22. package/src/extension/team-tool/run.ts +26 -4
  23. package/src/extension/team-tool/status.ts +58 -4
  24. package/src/extension/team-tool.ts +5 -3
  25. package/src/runtime/async-runner.ts +7 -0
  26. package/src/runtime/background-runner.ts +7 -1
  27. package/src/runtime/chain-parser.ts +13 -5
  28. package/src/runtime/checkpoint.ts +13 -1
  29. package/src/runtime/child-pi.ts +9 -1
  30. package/src/runtime/live-session-runtime.ts +15 -1
  31. package/src/runtime/parent-guard.ts +2 -2
  32. package/src/runtime/pipeline-runner.ts +3 -1
  33. package/src/runtime/stale-reconciler.ts +28 -4
  34. package/src/runtime/task-runner.ts +50 -20
  35. package/src/runtime/team-runner.ts +19 -2
  36. package/src/runtime/verification-gates.ts +21 -1
  37. package/src/runtime/workspace-tree.ts +28 -2
  38. package/src/schema/team-tool-schema.ts +9 -0
  39. package/src/state/blob-store.ts +12 -10
  40. package/src/state/event-log-rotation.ts +114 -93
  41. package/src/state/event-log.ts +83 -23
  42. package/src/state/health-store.ts +6 -1
  43. package/src/state/locks.ts +66 -16
  44. package/src/state/state-store.ts +46 -2
  45. package/src/ui/card-colors.ts +7 -3
  46. package/src/ui/dashboard-panes/agents-pane.ts +15 -2
  47. package/src/ui/live-duration.ts +58 -0
  48. package/src/ui/tool-render.ts +7 -11
  49. package/src/ui/tool-renderers/index.ts +6 -3
  50. package/src/ui/widget/widget-formatters.ts +2 -13
  51. package/src/utils/fs-watch.ts +11 -60
  52. package/src/utils/run-watcher-registry.ts +164 -0
  53. package/src/workflows/discover-workflows.ts +2 -1
  54. package/src/workflows/workflow-config.ts +5 -0
  55. package/src/runtime/dynamic-script-runner.ts +0 -497
  56. package/src/runtime/sandbox.ts +0 -335
@@ -5,13 +5,15 @@ import { aggregateUsage, formatUsage, formatCostReport } from "../../state/usage
5
5
  import type { PiTeamsToolResult } from "../tool-result.ts";
6
6
  import { locateRunCwd } from "../team-tool.ts";
7
7
  import { result, type TeamContext } from "./context.ts";
8
+ import { RUN_NOT_FOUND_HINT } from "./run-not-found.ts";
9
+ import { formatFailurePatterns } from "./failure-patterns.ts";
8
10
 
9
11
  export function handleEvents(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
10
12
  if (!params.runId) return result("Events requires runId.", { action: "events", status: "error" }, true);
11
13
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
12
- if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "events", status: "error" }, true);
14
+ if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "events", status: "error" }, true);
13
15
  const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
14
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "events", status: "error" }, true);
16
+ if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "events", status: "error" }, true);
15
17
  const events = readEvents(loaded.manifest.eventsPath);
16
18
  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)"])];
17
19
  return result(lines.join("\n"), { action: "events", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
@@ -20,9 +22,9 @@ export function handleEvents(params: TeamToolParamsValue, ctx: TeamContext): PiT
20
22
  export function handleArtifacts(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
21
23
  if (!params.runId) return result("Artifacts requires runId.", { action: "artifacts", status: "error" }, true);
22
24
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
23
- if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "artifacts", status: "error" }, true);
25
+ if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "artifacts", status: "error" }, true);
24
26
  const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
25
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "artifacts", status: "error" }, true);
27
+ if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "artifacts", status: "error" }, true);
26
28
  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)"])];
27
29
  return result(lines.join("\n"), { action: "artifacts", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
28
30
  }
@@ -30,10 +32,11 @@ export function handleArtifacts(params: TeamToolParamsValue, ctx: TeamContext):
30
32
  export function handleSummary(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
31
33
  if (!params.runId) return result("Summary requires runId.", { action: "summary", status: "error" }, true);
32
34
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
33
- if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "summary", status: "error" }, true);
35
+ if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "summary", status: "error" }, true);
34
36
  const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
35
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "summary", status: "error" }, true);
37
+ if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "summary", status: "error" }, true);
36
38
  const usage = aggregateUsage(loaded.tasks);
39
+ const failurePatternLines = formatFailurePatterns(loaded.tasks);
37
40
  const lines = [
38
41
  `Summary for ${loaded.manifest.runId}`,
39
42
  `Status: ${loaded.manifest.status}`,
@@ -43,6 +46,7 @@ export function handleSummary(params: TeamToolParamsValue, ctx: TeamContext): Pi
43
46
  `Usage: ${formatUsage(usage)}`,
44
47
  "",
45
48
  formatCostReport(loaded.tasks),
49
+ ...(failurePatternLines.length > 0 ? ["", ...failurePatternLines] : []),
46
50
  "",
47
51
  "Tasks:",
48
52
  ...loaded.tasks.map((task) => `- ${task.id}: ${task.status} (${task.role} -> ${task.agent})${task.error ? ` - ${task.error}` : ""}`),
@@ -9,6 +9,7 @@ import { importRunBundle } from "../run-import.ts";
9
9
  import { pruneFinishedRuns } from "../run-maintenance.ts";
10
10
  import type { PiTeamsToolResult } from "../tool-result.ts";
11
11
  import { configRecord, result, type TeamContext } from "./context.ts";
12
+ import { RUN_NOT_FOUND_HINT } from "./run-not-found.ts";
12
13
  import { enforceDestructiveIntent, intentFromConfig } from "./intent-policy.ts";
13
14
  import { executeHook, appendHookEvent } from "../../hooks/registry.ts";
14
15
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
@@ -18,7 +19,7 @@ import * as path from "node:path";
18
19
  export function handleWorktrees(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
19
20
  if (!params.runId) return result("Worktrees requires runId.", { action: "worktrees", status: "error" }, true);
20
21
  const loaded = loadRunManifestById(ctx.cwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
21
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "worktrees", status: "error" }, true);
22
+ if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "worktrees", status: "error" }, true);
22
23
  const withWorktrees = loaded.tasks.filter((task) => task.worktree);
23
24
  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)"])];
24
25
  return result(lines.join("\n"), { action: "worktrees", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
@@ -47,7 +48,7 @@ export function handleImport(params: TeamToolParamsValue, ctx: TeamContext): PiT
47
48
  export async function handleExport(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
48
49
  if (!params.runId) return result("Export requires runId.", { action: "export", status: "error" }, true);
49
50
  const loaded = loadRunManifestById(ctx.cwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
50
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "export", status: "error" }, true);
51
+ if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "export", status: "error" }, true);
51
52
 
52
53
  // SECURITY: Ownership check — only the owner session may export a run.
53
54
  // Foreign-run export requires confirm: true (explicit user intent).
@@ -96,7 +97,7 @@ export async function handleForget(params: TeamToolParamsValue, ctx: TeamContext
96
97
  if (!params.runId) return result("Forget requires runId.", { action: "forget", status: "error" }, true);
97
98
  if (!params.confirm) return result("forget requires confirm: true.", { action: "forget", status: "error" }, true);
98
99
  const loaded = loadRunManifestById(ctx.cwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
99
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "forget", status: "error" }, true);
100
+ if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "forget", status: "error" }, true);
100
101
 
101
102
  // Ownership check — prevent cross-session deletion unless force is set
102
103
  const foreignRun = typeof loaded.manifest.ownerSessionId === "string" && loaded.manifest.ownerSessionId !== ctx.sessionId;
@@ -126,7 +127,7 @@ export async function handleCleanup(params: TeamToolParamsValue, ctx: TeamContex
126
127
  if (intentError) return intentError;
127
128
  if (!params.runId) return result("Cleanup requires runId.", { action: "cleanup", status: "error" }, true);
128
129
  const loaded = loadRunManifestById(ctx.cwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
129
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "cleanup", status: "error" }, true);
130
+ if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "cleanup", status: "error" }, true);
130
131
 
131
132
  // Ownership check — prevent cross-session worktree cleanup unless force is set
132
133
  const foreignRun = typeof loaded.manifest.ownerSessionId === "string" && loaded.manifest.ownerSessionId !== ctx.sessionId;
@@ -8,6 +8,7 @@ import { logInternalError } from "../../utils/internal-error.ts";
8
8
  import type { PiTeamsToolResult } from "../tool-result.ts";
9
9
  import { locateRunCwd } from "../team-tool.ts";
10
10
  import { result, type TeamContext } from "./context.ts";
11
+ import { RUN_NOT_FOUND_HINT } from "./run-not-found.ts";
11
12
 
12
13
  /**
13
14
  * Handle `respond` action: send a message to a waiting (interactive) task.
@@ -19,13 +20,13 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
19
20
  if (!params.message && !params.taskId) return result("Respond requires taskId and/or message.", { action: "respond", status: "error" }, true);
20
21
 
21
22
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
22
- if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "respond", status: "error" }, true);
23
+ if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "respond", status: "error" }, true);
23
24
  const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
24
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "respond", status: "error" }, true);
25
+ if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "respond", status: "error" }, true);
25
26
 
26
27
  return withRunLockSync(loaded.manifest, () => {
27
28
  const fresh = loadRunManifestById(loaded.manifest.cwd, params.runId!); // NOTE: inside withRunLockSync - consistent read
28
- if (!fresh) return result(`Run '${params.runId}' not found.`, { action: "respond", status: "error" }, true);
29
+ if (!fresh) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "respond", status: "error" }, true);
29
30
  const foreignRun = typeof fresh.manifest.ownerSessionId === "string" && fresh.manifest.ownerSessionId !== ctx.sessionId;
30
31
  if (foreignRun && !params.force) return result(`Run ${fresh.manifest.runId} belongs to another session. Use force: true to override.`, { action: "respond", status: "error", runId: fresh.manifest.runId }, true);
31
32
 
@@ -0,0 +1,54 @@
1
+ /**
2
+ * run-not-found.ts — Centralized "Run not found" error helper (DX: F2).
3
+ *
4
+ * Round 16 DX audit found that a stale/typo'd runId hits a blank
5
+ * "Run '<id>' not found." wall in 8+ handlers (status, resume, steer, export,
6
+ * forget, cleanup, invalidate, worktrees, events, artifacts). The run IDs are
7
+ * long (`team_20260615173318_b9c8fe49a74e0760`), so typos/truncation are
8
+ * near-certain for new users — yet `team list` (which shows recent runs) is
9
+ * never suggested.
10
+ *
11
+ * This module centralizes the message + recovery hint so every handler stays
12
+ * consistent and the hint never drifts.
13
+ */
14
+
15
+ import { result, type TeamContext } from "./context.ts";
16
+ import type { TeamToolDetails } from "../team-tool-types.ts";
17
+
18
+ /** Recovery hint appended to every "Run not found" message. */
19
+ export const RUN_NOT_FOUND_HINT =
20
+ "\n\nTip: run action='list' to see recent runs and their IDs.";
21
+
22
+ /**
23
+ * Build the standard "Run not found" error result with a recovery hint.
24
+ *
25
+ * @param runId the (missing/typo'd) run id the caller passed
26
+ * @param action the action that was attempted (for the details.action field)
27
+ */
28
+ export function runNotFound(runId: string, action: string): ReturnType<typeof result> {
29
+ return result(
30
+ `Run '${runId}' not found.${RUN_NOT_FOUND_HINT}`,
31
+ { action, status: "error" } satisfies TeamToolDetails,
32
+ true,
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Helper: resolve a runId to its cwd, returning a runNotFound() result when
38
+ * missing. Reduces the boilerplate `locateRunCwd → if (!runCwd) return ...`
39
+ * duplicated across handlers.
40
+ */
41
+ export function resolveRunOrNotFound(
42
+ runId: string,
43
+ action: string,
44
+ cwd: string,
45
+ locate: (runId: string, cwd: string) => string | undefined,
46
+ ): { kind: "found"; runCwd: string } | { kind: "notfound"; result: ReturnType<typeof result> } {
47
+ const runCwd = locate(runId, cwd);
48
+ if (!runCwd) return { kind: "notfound", result: runNotFound(runId, action) };
49
+ return { kind: "found", runCwd };
50
+ }
51
+
52
+ // Re-export TeamContext so callers importing this helper don't need a second
53
+ // import line — keeps the diff in each handler to a single import swap.
54
+ export type { TeamContext };
@@ -184,13 +184,17 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
184
184
  // connecting PipelineRunner to the actual team execution system
185
185
  const stageInfo = pipelineWorkflow.stages.map((s) => `- ${s.name} (${s.team})`).join("\n");
186
186
  return result([
187
- `Pipeline workflow: ${workflow.name}`,
187
+ `Pipeline workflow '${workflow.name}' is not yet wired into the team execution system.`,
188
188
  `Goal: ${goal}`,
189
- `Stages (${pipelineWorkflow.stages.length}):`,
189
+ `Defined stages (${pipelineWorkflow.stages.length}):`,
190
190
  stageInfo,
191
191
  "",
192
- "Pipeline execution is available via the PipelineRunner API.",
193
- "Full CLI integration requires connecting to the team execution system.",
192
+ "To actually run work right now, use a supported workflow instead:",
193
+ " - action='run' workflow='default' (explore plan execute verify)",
194
+ " - action='run' workflow='implementation' (adaptive, parallel specialists)",
195
+ " - action='run' workflow='research' (explore → analyze → write)",
196
+ "",
197
+ "Run action='list' resource='workflow' to see all available workflows.",
194
198
  ].join("\n"), { action: "run", status: "ok" }, false);
195
199
  }
196
200
 
@@ -219,6 +223,24 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
219
223
  registerActiveRun(updatedManifest);
220
224
 
221
225
  const loadedConfig = loadConfig(resolvedCtx.cwd);
226
+ // DX (Round 16 F4): surface config errors/warnings instead of silently
227
+ // proceeding with defaults. Non-blocking: emit a config.warning event so
228
+ // it shows in the run timeline and status, and log it. A malformed config
229
+ // (bad JSON / wrong types) should not be a silent no-op — doctor/config
230
+ // actions already surface these; run should too.
231
+ const configIssues = [
232
+ ...(loadedConfig.error ? [`Config error: ${loadedConfig.error}`] : []),
233
+ ...(loadedConfig.warnings ?? []),
234
+ ];
235
+ if (configIssues.length > 0) {
236
+ void appendEventAsync(updatedManifest.eventsPath, {
237
+ type: "config.warning",
238
+ runId: updatedManifest.runId,
239
+ message: `Loaded config from ${loadedConfig.path || "(defaults)"} with ${configIssues.length} issue(s): ${configIssues.join("; ")}`,
240
+ data: { error: loadedConfig.error, warnings: loadedConfig.warnings, path: loadedConfig.path },
241
+ }).catch((error) => logInternalError("team-tool.run.configWarning", error, `runId=${updatedManifest.runId}`));
242
+ logInternalError("team-tool.run.configWarning", new Error(`config issues: ${configIssues.join("; ")}`), `runId=${updatedManifest.runId} path=${loadedConfig.path ?? "(defaults)"}`);
243
+ }
222
244
  const executedConfig = effectiveRunConfig(loadedConfig.config, params.config);
223
245
  const runtime = await resolveCrewRuntime(executedConfig);
224
246
  const runtimeResolution = runtimeResolutionState(runtime);
@@ -3,24 +3,31 @@ import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
3
3
  import { appendEvent, readEvents } from "../../state/event-log.ts";
4
4
  import { readDeliveryState, readMailbox } from "../../state/mailbox.ts";
5
5
  import { loadRunManifestById, updateRunStatus, saveRunTasks } from "../../state/state-store.ts";
6
- import { aggregateUsage, formatUsage } from "../../state/usage.ts";
6
+ import { aggregateUsage, formatUsage, formatCost } from "../../state/usage.ts";
7
7
  import { applyAttentionState, formatActivityAge, resolveCrewControlConfig } from "../../runtime/agent-control.ts";
8
8
  import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
9
9
  import { checkProcessLiveness, isActiveRunStatus } from "../../runtime/process-status.ts";
10
10
  import { formatTaskGraphLines, waitingReason } from "../../runtime/task-display.ts";
11
+ import { computePhaseProgress } from "../../runtime/phase-progress.ts";
12
+ import { formatDuration } from "../../ui/tool-render.ts";
11
13
  import { verifyTaskCompletion } from "../../runtime/completion-guard.ts";
12
14
  import { evaluateRunEffectiveness } from "../../runtime/effectiveness.ts";
13
15
  import type { PiTeamsToolResult } from "../tool-result.ts";
14
16
  import { locateRunCwd } from "../team-tool.ts";
15
17
  import { result, type TeamContext } from "./context.ts";
18
+ import { RUN_NOT_FOUND_HINT } from "./run-not-found.ts";
16
19
 
17
20
  export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
18
21
  if (!params.runId) return result("Status requires runId.", { action: "status", status: "error" }, true);
19
22
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
20
- if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "status", status: "error" }, true);
23
+ if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "status", status: "error" }, true);
21
24
  const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
22
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "status", status: "error" }, true);
25
+ if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "status", status: "error" }, true);
23
26
  let { manifest, tasks } = loaded;
27
+ // DX (Round 16 F3): compact status mode. Default = full (backward compatible).
28
+ // details=false gives a tight summary (status, goal, counts, failed/attention
29
+ // errors) for quick checks without 40 lines of dense key=value noise.
30
+ const fullDetails = params.details !== false;
24
31
  let asyncLivenessLine: string | undefined;
25
32
  if (manifest.async) {
26
33
  const asyncState = manifest.async;
@@ -35,6 +42,7 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
35
42
  }
36
43
  const counts = new Map<string, number>();
37
44
  for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
45
+ const phaseProgress = computePhaseProgress(tasks);
38
46
  const allEvents = readEvents(manifest.eventsPath);
39
47
  const events = allEvents.slice(-8);
40
48
  const attentionByTask = new Map(allEvents.filter((event) => event.type === "task.attention" && event.taskId).map((event) => [event.taskId!, event]));
@@ -62,12 +70,13 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
62
70
  const activeAgents = crewAgents.filter((agent) => agent.status === "running");
63
71
  const completedAgents = crewAgents.filter((agent) => agent.status !== "running");
64
72
  const waitingTasks = tasks.filter((task) => task.status === "queued" || task.status === "waiting");
65
- 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 ? ` activityState=${agent.progress.activityState}` : ""}${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.outputPath ? ` output=${agent.outputPath}` : ""}${agent.transcriptPath ? ` transcript=${agent.transcriptPath}` : ""}${agent.statusPath ? ` status=${agent.statusPath}` : ""}${agent.error ? ` error=${agent.error}` : ""}`;
73
+ 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.usage?.cost ? ` cost=${formatCost(agent.usage.cost)}` : ""}${agent.progress?.activityState ? ` activityState=${agent.progress.activityState}` : ""}${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.outputPath ? ` output=${agent.outputPath}` : ""}${agent.transcriptPath ? ` transcript=${agent.transcriptPath}` : ""}${agent.statusPath ? ` status=${agent.statusPath}` : ""}${agent.error ? ` error=${agent.error}` : ""}`;
66
74
  const lines = [
67
75
  `Run: ${manifest.runId}`,
68
76
  `Team: ${manifest.team}`,
69
77
  `Workflow: ${manifest.workflow ?? "(none)"}`,
70
78
  `Status: ${manifest.status}`,
79
+ `Progress: ${phaseProgress.overallPercentage}% (~${formatDuration(phaseProgress.estimatedRemainingMs)} remaining)`,
71
80
  `Workspace mode: ${manifest.workspaceMode}`,
72
81
  ...(manifest.runtimeResolution ? [`Runtime: ${manifest.runtimeResolution.kind}`, `Runtime safety: ${manifest.runtimeResolution.safety}`, `Runtime requested: ${manifest.runtimeResolution.requestedMode}${manifest.runtimeResolution.reason ? ` (${manifest.runtimeResolution.reason})` : ""}`] : []),
73
82
  `Goal: ${manifest.goal}`,
@@ -109,5 +118,50 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
109
118
  "Recent events:",
110
119
  ...(events.length ? events.map((event) => `- ${event.time} ${event.type}${event.taskId ? ` ${event.taskId}` : ""}${event.message ? `: ${event.message}` : ""}`) : ["- (none)"]),
111
120
  ];
121
+ if (!fullDetails) {
122
+ return result(
123
+ buildCompactStatus(manifest, tasks, counts, asyncLivenessLine, phaseProgress).join("\n"),
124
+ { action: "status", status: "ok", runId: manifest.runId, artifactsRoot: manifest.artifactsRoot, intent: `status ${manifest.runId}: ${manifest.status} (compact)` },
125
+ );
126
+ }
112
127
  return result(lines.join("\n"), { action: "status", status: "ok", runId: manifest.runId, artifactsRoot: manifest.artifactsRoot, intent: `status ${manifest.runId}: ${manifest.status}` });
113
128
  }
129
+
130
+ /**
131
+ * Compact status builder (DX: Round 16 F3). A tight summary for quick checks:
132
+ * identity, status, goal, task counts, and ONLY failed / attention task
133
+ * errors — not the 40-line dense dump. Invoked when params.details === false.
134
+ *
135
+ * Exported for unit testing.
136
+ */
137
+ export function buildCompactStatus(
138
+ manifest: { runId: string; team: string; workflow?: string; status: string; goal: string; workspaceMode?: string },
139
+ tasks: Array<{ id: string; status: string; role: string; agent: string; error?: string }>,
140
+ counts: Map<string, number>,
141
+ asyncLivenessLine?: string,
142
+ progress?: { overallPercentage: number; estimatedRemainingMs: number },
143
+ ): string[] {
144
+ const failedOrAttention = tasks.filter(
145
+ (t) =>
146
+ t.status === "failed" ||
147
+ t.status === "needs_attention" ||
148
+ t.status === "cancelled",
149
+ );
150
+ const lines = [
151
+ `Run: ${manifest.runId}`,
152
+ `Team: ${manifest.team}${manifest.workflow ? ` (${manifest.workflow})` : ""}`,
153
+ `Status: ${manifest.status}`,
154
+ ...(progress ? [`Progress: ${progress.overallPercentage}% (~${formatDuration(progress.estimatedRemainingMs)} remaining)`] : []),
155
+ `Goal: ${manifest.goal}`,
156
+ ...(asyncLivenessLine ? [asyncLivenessLine] : []),
157
+ `Tasks: ${[...counts.entries()].map(([s, c]) => `${s}=${c}`).join(", ") || "none"}`,
158
+ ];
159
+ if (failedOrAttention.length > 0) {
160
+ lines.push("Issues:");
161
+ for (const t of failedOrAttention) {
162
+ lines.push(`- ${t.id} [${t.status}] ${t.role}: ${t.error ?? "(no error detail)"}`);
163
+ }
164
+ }
165
+ lines.push("Tip: pass details=true for full output (task graph, agents, effectiveness, events).");
166
+ return lines;
167
+ }
@@ -156,6 +156,8 @@ import { handleParallel } from "./team-tool/parallel-dispatch.ts";
156
156
  import { handlePlan } from "./team-tool/plan.ts";
157
157
  import { handleRespond } from "./team-tool/respond.ts";
158
158
  import { handleStatus } from "./team-tool/status.ts";
159
+ import { RUN_NOT_FOUND_HINT } from "./team-tool/run-not-found.ts";
160
+ import { formatActionSuggestion } from "./action-suggestions.ts";
159
161
 
160
162
  export { handleApi } from "./team-tool/api.ts";
161
163
  export { handleRetry } from "./team-tool/cancel.ts";
@@ -459,14 +461,14 @@ export async function handleResume(
459
461
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
460
462
  if (!runCwd)
461
463
  return result(
462
- `Run '${params.runId}' not found.`,
464
+ `Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`,
463
465
  { action: "resume", status: "error" },
464
466
  true,
465
467
  );
466
468
  const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
467
469
  if (!loaded)
468
470
  return result(
469
- `Run '${params.runId}' not found.`,
471
+ `Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`,
470
472
  { action: "resume", status: "error" },
471
473
  true,
472
474
  );
@@ -1347,7 +1349,7 @@ export async function handleTeamTool(
1347
1349
  }
1348
1350
  default:
1349
1351
  return result(
1350
- `Unknown action: ${action}`,
1352
+ `Unknown action: ${action}${formatActionSuggestion(String(action))}`,
1351
1353
  { action: "unknown", status: "error" },
1352
1354
  true,
1353
1355
  );
@@ -231,6 +231,13 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
231
231
  windowsHide: true,
232
232
  } as unknown as Parameters<typeof spawn>[2];
233
233
  const child = spawn(process.execPath, command.args, spawnOpts);
234
+ // Round 27 (BUG 3): the piped stdout/stderr are NEVER read or destroyed →
235
+ // 2 FDs leak per background spawn, and if the child writes >64KB (pipe
236
+ // buffer) it blocks forever (nobody drains the pipe) → background runner
237
+ // hangs. The background runner redirects its own console to a file, so we
238
+ // don't need this output — destroy the read ends immediately.
239
+ child.stdout?.destroy();
240
+ child.stderr?.destroy();
234
241
  child.on("error", (error: Error) => {
235
242
  logInternalError("async-runner.spawn", error, `pid=${child.pid ?? "unknown"}`);
236
243
  });
@@ -525,7 +525,13 @@ async function main(): Promise<void> {
525
525
  const agents = allAgents(discoverAgents(cwd));
526
526
  debugLog(`[background-runner] discoverAgents done, ${agents.length} agents`,
527
527
  );
528
- try { fs.fsyncSync(fs.openSync(manifest.eventsPath, "a")); } catch { /* best-effort */ } // FORCE flush so we see this before death
528
+ // Round 27 (BUG 2): openSync returned an fd that was never closed FD
529
+ // leak per background runner startup. Close it in a finally (matches the
530
+ // canonical pattern in checkpoint.ts:83 and event-log.ts:582).
531
+ try {
532
+ const fd = fs.openSync(manifest.eventsPath, "a");
533
+ try { fs.fsyncSync(fd); } finally { try { fs.closeSync(fd); } catch { /* best-effort */ } }
534
+ } catch { /* best-effort */ } // FORCE flush so we see this before death
529
535
  debugLog(`[background-runner] calling directTeamAndWorkflowFromRun`,
530
536
  );
531
537
  const direct = directTeamAndWorkflowFromRun(manifest, tasks, agents);
@@ -122,10 +122,10 @@ class ChainParser {
122
122
 
123
123
  parse(): ChainStep[] {
124
124
  const steps: ChainStep[] = [];
125
- steps.push(this.parseStep());
125
+ steps.push(this.parseStep(0));
126
126
  while (this.peek("ARROW")) {
127
127
  this.consume("ARROW");
128
- steps.push(this.parseStep());
128
+ steps.push(this.parseStep(0));
129
129
  }
130
130
  if (this.pos < this.tokens.length) {
131
131
  throw new Error(`Unexpected token '${this.tokens[this.pos]?.value}' at position ${this.pos}`);
@@ -133,16 +133,24 @@ class ChainParser {
133
133
  return steps;
134
134
  }
135
135
 
136
- private parseStep(): ChainStep {
136
+ private parseStep(depth: number = 0): ChainStep {
137
+ // Round 22 (BUG 2): guard against stack overflow on deeply nested input.
138
+ // Without this, a crafted 'parallel(parallel(parallel(...)))' input would
139
+ // recurse unbounded and crash the process with RangeError. Each nesting
140
+ // level needs >=9 chars, so ~130KB could overflow V8's ~15K-frame stack.
141
+ const MAX_CHAIN_NESTING = 100;
142
+ if (depth > MAX_CHAIN_NESTING) {
143
+ throw new Error(`Chain DSL nesting too deep (max ${MAX_CHAIN_NESTING}); likely unbalanced or malicious input`);
144
+ }
137
145
  // Check for parallel(...) construct
138
146
  if (this.peek("NAME", "parallel")) {
139
147
  this.consume("NAME"); // eat "parallel"
140
148
  this.consume("LPAREN");
141
149
  const parallel: ChainStep[] = [];
142
- parallel.push(this.parseStep());
150
+ parallel.push(this.parseStep(depth + 1));
143
151
  while (this.peek("COMMA")) {
144
152
  this.consume("COMMA");
145
- parallel.push(this.parseStep());
153
+ parallel.push(this.parseStep(depth + 1));
146
154
  }
147
155
  this.consume("RPAREN");
148
156
  const step: ChainStep = { name: "parallel", parallel };
@@ -64,7 +64,19 @@ export class FileCheckpointStore implements CheckpointStore {
64
64
  // Atomic write: write to temp file first, then rename, then fsync parent.
65
65
  // This guarantees either the old file or the new file, never a partial
66
66
  // write, even on network filesystems or certain journal modes.
67
- const tmp = path.join(this.checkpointDir(), ".tmp.checkpoint");
67
+ //
68
+ // Round 22 (BUG 1): the temp filename MUST be unique per save call.
69
+ // Previously a fixed '.tmp.checkpoint' was shared across ALL concurrent
70
+ // saves; pi-crew's multi-process architecture (main + detached background
71
+ // workers each checkpointing their own tasks) made this realistic: two
72
+ // processes writing '.tmp.checkpoint' at once → one's rename picks up the
73
+ // other's data (silent corruption) and the second rename hits ENOENT
74
+ // (silent data loss). Including taskId + pid + timestamp guarantees
75
+ // uniqueness across processes and across tasks.
76
+ const tmp = path.join(
77
+ this.checkpointDir(),
78
+ `.tmp.${checkpoint.taskId}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`,
79
+ );
68
80
  fs.writeFileSync(tmp, JSON.stringify(checkpoint, null, 2), "utf-8");
69
81
  fs.renameSync(tmp, p);
70
82
  // fsync parent directory to ensure the rename is durable
@@ -628,7 +628,14 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
628
628
  let graceTurns = input.graceTurns;
629
629
  if (graceTurns !== undefined && graceTurns > 1000) graceTurns = 1000;
630
630
  let abortDueToParentSignal = false;
631
- input.signal?.addEventListener("abort", () => { abortDueToParentSignal = true; }, { once: true });
631
+ // Round 27 (BUG 4): extract to a named handler so settle() can remove it.
632
+ // The previous anonymous listener was never removed → on runs with >10
633
+ // tasks sharing one AbortSignal (background-runner), Node emitted
634
+ // MaxListenersExceededWarning and each leaked listener pinned the task's
635
+ // stack frame (abortDueToParentSignal closure) in memory. { once: true }
636
+ // only auto-removes AFTER the signal fires; on normal completion it leaks.
637
+ const onParentAbort = (): void => { abortDueToParentSignal = true; };
638
+ input.signal?.addEventListener("abort", onParentAbort, { once: true });
632
639
  const restartNoResponseTimer = (): void => {
633
640
  if (responseTimeoutMs <= 0) return;
634
641
  if (noResponseTimer) clearTimeout(noResponseTimer);
@@ -747,6 +754,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
747
754
  clearChildPiTimeouts();
748
755
  lineObserver.flush();
749
756
  input.signal?.removeEventListener("abort", abort);
757
+ input.signal?.removeEventListener("abort", onParentAbort);
750
758
  try {
751
759
  cleanupTempDir(built.tempDir);
752
760
  } catch (error) {
@@ -384,6 +384,12 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
384
384
 
385
385
  const agentId = `${input.manifest.runId}:${input.task.id}`;
386
386
 
387
+ // Round 27 (BUG 4): hoisted to function scope so the finally block can remove
388
+ // it. const inside try{} is block-scoped and invisible to finally{}. The
389
+ // handler resolves `session` lazily at call time (it may be assigned later
390
+ // inside the try), so declaring it here is safe.
391
+ let onSignalAbort: (() => void) | undefined;
392
+
387
393
  try {
388
394
  const agentDir = typeof mod.getAgentDir === "function" ? mod.getAgentDir() : undefined;
389
395
  let resourceLoader: unknown;
@@ -545,9 +551,14 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
545
551
  }
546
552
  });
547
553
  }
554
+ // Round 27 (BUG 4): named abort handler (removed in finally below).
555
+ onSignalAbort = (): void => { void session?.abort?.(); };
548
556
  if (input.signal) {
549
557
  if (input.signal.aborted) await session.abort?.();
550
- else input.signal.addEventListener("abort", () => { void session?.abort?.(); }, { once: true });
558
+ // Round 27 (BUG 4): named handler so the finally block can remove it.
559
+ // The previous anonymous listener leaked on normal completion (only
560
+ // auto-removed by { once: true } AFTER the signal fires).
561
+ else input.signal.addEventListener("abort", onSignalAbort, { once: true });
551
562
  }
552
563
  const effectivePrompt = input.runtimeConfig?.inheritContext === true && input.parentContext ? `${input.parentContext}\n\n---\n# Live Subagent Task\n${input.prompt}` : input.prompt;
553
564
 
@@ -687,6 +698,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
687
698
  // H6: Unsubscribe listeners FIRST before clearing timer to prevent race
688
699
  unsubscribe?.();
689
700
  unsubscribeControlRealtime?.();
701
+ // Round 27 (BUG 4): remove the named abort listener to avoid leaking it
702
+ // on the shared AbortSignal across many live-session tasks.
703
+ if (onSignalAbort) input.signal?.removeEventListener("abort", onSignalAbort);
690
704
  if (controlTimer) clearInterval(controlTimer);
691
705
  streamOut?.close();
692
706
  if (input.signal?.aborted) {
@@ -29,8 +29,8 @@
29
29
  * signal, NOT a security boundary:
30
30
  * - It only causes the (already-compromised) child to exit earlier.
31
31
  * - A truly malicious child can simply not call `startParentGuard()`.
32
- * - Real protection against hostile children comes from the sandbox,
33
- * env-filter allowlist, and redaction — all enforced before spawn.
32
+ * - Real protection against hostile children comes from the env-filter
33
+ * allowlist and redaction — all enforced before spawn.
34
34
  *
35
35
  * The guard exists for the benign case: a parent dies (user closes the
36
36
  * terminal, pi crashes, machine loses power) and we want all detached
@@ -3,6 +3,7 @@ import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.
3
3
  import type { TeamConfig } from "../teams/team-config.ts";
4
4
  import type { AgentConfig } from "../agents/agent-config.ts";
5
5
  import { appendEventAsync } from "../state/event-log.ts";
6
+ import { errors } from "../errors.ts";
6
7
  import { mapConcurrent } from "./parallel-utils.ts";
7
8
 
8
9
  /**
@@ -242,7 +243,8 @@ export class PipelineRunner {
242
243
  ): Promise<unknown[]> {
243
244
  // CRITICAL-6: Prevent stack overflow from deep recursion
244
245
  if (depth > 50) {
245
- throw new Error(`Pipeline recursion depth limit exceeded (${depth}). Possible circular stage dependency.`);
246
+ // E1 (Round 15): structured CrewError (E011) with help hint.
247
+ throw errors.depthLimitExceeded(depth, "pipeline");
246
248
  }
247
249
 
248
250
  const fanOut = stage.fanOut ?? true;