pi-crew 0.6.1 → 0.6.3

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 (102) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +70 -33
  3. package/docs/issue-29-analysis.md +189 -0
  4. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
  5. package/package.json +2 -2
  6. package/src/config/config.ts +106 -15
  7. package/src/errors.ts +107 -0
  8. package/src/extension/async-notifier.ts +6 -2
  9. package/src/extension/crew-cleanup.ts +8 -5
  10. package/src/extension/management.ts +464 -109
  11. package/src/extension/register.ts +194 -34
  12. package/src/extension/registration/commands.ts +2 -2
  13. package/src/extension/registration/subagent-helpers.ts +2 -2
  14. package/src/extension/registration/subagent-tools.ts +1 -1
  15. package/src/extension/registration/team-tool.ts +1 -1
  16. package/src/extension/registration/viewers.ts +2 -2
  17. package/src/extension/team-tool/api.ts +3 -3
  18. package/src/extension/team-tool/cancel.ts +3 -3
  19. package/src/extension/team-tool/explain.ts +1 -1
  20. package/src/extension/team-tool/handle-schedule.ts +40 -0
  21. package/src/extension/team-tool/inspect.ts +3 -3
  22. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  23. package/src/extension/team-tool/respond.ts +2 -2
  24. package/src/extension/team-tool/run.ts +60 -11
  25. package/src/extension/team-tool/status.ts +1 -1
  26. package/src/extension/team-tool.ts +173 -46
  27. package/src/hooks/registry.ts +77 -13
  28. package/src/hooks/types.ts +9 -0
  29. package/src/plugins/plugin-define.ts +6 -0
  30. package/src/plugins/plugin-registry.ts +32 -0
  31. package/src/plugins/plugins/index.ts +3 -0
  32. package/src/plugins/plugins/nextjs.ts +19 -0
  33. package/src/plugins/plugins/vite.ts +14 -0
  34. package/src/plugins/plugins/vitest.ts +14 -0
  35. package/src/runtime/async-runner.ts +35 -7
  36. package/src/runtime/background-runner.ts +529 -144
  37. package/src/runtime/chain-parser.ts +5 -1
  38. package/src/runtime/chain-runner.ts +9 -3
  39. package/src/runtime/checkpoint.ts +262 -180
  40. package/src/runtime/child-pi.ts +164 -37
  41. package/src/runtime/crash-recovery.ts +25 -14
  42. package/src/runtime/crew-agent-records.ts +2 -0
  43. package/src/runtime/diagnostic-export.ts +1 -1
  44. package/src/runtime/dynamic-script-runner.ts +10 -7
  45. package/src/runtime/foreground-watchdog.ts +1 -1
  46. package/src/runtime/heartbeat-watcher.ts +19 -2
  47. package/src/runtime/intercom-bridge.ts +7 -0
  48. package/src/runtime/iteration-hooks.ts +1 -1
  49. package/src/runtime/manifest-cache.ts +4 -0
  50. package/src/runtime/orphan-worker-registry.ts +444 -0
  51. package/src/runtime/parent-guard.ts +70 -16
  52. package/src/runtime/pi-args.ts +437 -14
  53. package/src/runtime/pi-spawn.ts +1 -0
  54. package/src/runtime/post-checks.ts +1 -1
  55. package/src/runtime/retry-runner.ts +9 -3
  56. package/src/runtime/run-tracker.ts +38 -10
  57. package/src/runtime/sandbox.ts +17 -2
  58. package/src/runtime/scheduler.ts +25 -2
  59. package/src/runtime/skill-effectiveness.ts +105 -62
  60. package/src/runtime/skill-instructions.ts +110 -32
  61. package/src/runtime/stale-reconciler.ts +310 -69
  62. package/src/runtime/subagent-manager.ts +251 -57
  63. package/src/runtime/task-health.ts +76 -0
  64. package/src/runtime/task-id.ts +20 -0
  65. package/src/runtime/task-runner/live-executor.ts +1 -1
  66. package/src/runtime/task-runner/progress.ts +1 -1
  67. package/src/runtime/task-runner/state-helpers.ts +110 -6
  68. package/src/runtime/task-runner.ts +92 -70
  69. package/src/runtime/team-runner.ts +186 -20
  70. package/src/schema/team-tool-schema.ts +27 -9
  71. package/src/skills/discover-skills.ts +9 -3
  72. package/src/state/active-run-registry.ts +170 -38
  73. package/src/state/artifact-store.ts +25 -13
  74. package/src/state/atomic-write-v2.ts +86 -0
  75. package/src/state/atomic-write.ts +346 -55
  76. package/src/state/blob-store.ts +178 -10
  77. package/src/state/crew-init.ts +161 -28
  78. package/src/state/decision-ledger.ts +172 -111
  79. package/src/state/event-log-rotation.ts +82 -52
  80. package/src/state/event-log.ts +254 -70
  81. package/src/state/health-store.ts +71 -0
  82. package/src/state/locks.ts +102 -20
  83. package/src/state/mailbox.ts +45 -7
  84. package/src/state/observation-store.ts +4 -1
  85. package/src/state/run-graph.ts +141 -130
  86. package/src/state/run-metrics.ts +24 -8
  87. package/src/state/state-store.ts +330 -43
  88. package/src/ui/live-run-sidebar.ts +1 -1
  89. package/src/ui/loaders.ts +4 -0
  90. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  91. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  92. package/src/ui/run-action-dispatcher.ts +2 -2
  93. package/src/ui/run-snapshot-cache.ts +1 -1
  94. package/src/utils/env-filter.ts +66 -0
  95. package/src/utils/file-coalescer.ts +9 -1
  96. package/src/utils/paths.ts +80 -5
  97. package/src/utils/redaction.ts +6 -1
  98. package/src/utils/safe-paths.ts +281 -10
  99. package/src/worktree/cleanup.ts +112 -24
  100. package/src/worktree/worktree-manager.ts +128 -15
  101. package/test-bugs-all.mjs +10 -6
  102. package/test-integration-check.ts +4 -0
@@ -17,7 +17,7 @@ import * as path from "node:path";
17
17
 
18
18
  export function handleWorktrees(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
19
19
  if (!params.runId) return result("Worktrees requires runId.", { action: "worktrees", status: "error" }, true);
20
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
20
+ const loaded = loadRunManifestById(ctx.cwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
21
21
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "worktrees", status: "error" }, true);
22
22
  const withWorktrees = loaded.tasks.filter((task) => task.worktree);
23
23
  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)"])];
@@ -46,7 +46,7 @@ export function handleImport(params: TeamToolParamsValue, ctx: TeamContext): PiT
46
46
 
47
47
  export async function handleExport(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
48
48
  if (!params.runId) return result("Export requires runId.", { action: "export", status: "error" }, true);
49
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
49
+ const loaded = loadRunManifestById(ctx.cwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
50
50
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "export", status: "error" }, true);
51
51
 
52
52
  // SECURITY: Ownership check — only the owner session may export a run.
@@ -95,7 +95,7 @@ export async function handleForget(params: TeamToolParamsValue, ctx: TeamContext
95
95
  if (intentError) return intentError;
96
96
  if (!params.runId) return result("Forget requires runId.", { action: "forget", status: "error" }, true);
97
97
  if (!params.confirm) return result("forget requires confirm: true.", { action: "forget", status: "error" }, true);
98
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
98
+ const loaded = loadRunManifestById(ctx.cwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
99
99
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "forget", status: "error" }, true);
100
100
 
101
101
  // Ownership check — prevent cross-session deletion unless force is set
@@ -125,7 +125,7 @@ export async function handleCleanup(params: TeamToolParamsValue, ctx: TeamContex
125
125
  const intentError = enforceDestructiveIntent("cleanup", params, ctx.config);
126
126
  if (intentError) return intentError;
127
127
  if (!params.runId) return result("Cleanup requires runId.", { action: "cleanup", status: "error" }, true);
128
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
128
+ const loaded = loadRunManifestById(ctx.cwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
129
129
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "cleanup", status: "error" }, true);
130
130
 
131
131
  // Ownership check — prevent cross-session worktree cleanup unless force is set
@@ -20,11 +20,11 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
20
20
 
21
21
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
22
22
  if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "respond", status: "error" }, true);
23
- const loaded = loadRunManifestById(runCwd, params.runId);
23
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
24
24
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "respond", status: "error" }, true);
25
25
 
26
26
  return withRunLockSync(loaded.manifest, () => {
27
- const fresh = loadRunManifestById(loaded.manifest.cwd, params.runId!);
27
+ const fresh = loadRunManifestById(loaded.manifest.cwd, params.runId!); // NOTE: inside withRunLockSync - consistent read
28
28
  if (!fresh) return result(`Run '${params.runId}' not found.`, { action: "respond", status: "error" }, true);
29
29
  const foreignRun = typeof fresh.manifest.ownerSessionId === "string" && fresh.manifest.ownerSessionId !== ctx.sessionId;
30
30
  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);
@@ -2,6 +2,7 @@ import { allAgents, discoverAgents } from "../../agents/discover-agents.ts";
2
2
  import { allTeams, discoverTeams } from "../../teams/discover-teams.ts";
3
3
  import { allWorkflows, discoverWorkflows } from "../../workflows/discover-workflows.ts";
4
4
  import { loadConfig } from "../../config/config.ts";
5
+ import { findGitRoot, assertCleanLeader } from "../../worktree/worktree-manager.ts";
5
6
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
6
7
  import { writeArtifact } from "../../state/artifact-store.ts";
7
8
  import { registerActiveRun, unregisterActiveRun } from "../../state/active-run-registry.ts";
@@ -85,9 +86,57 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
85
86
  const { ensureCrewDirectory } = await import("../../state/crew-init.ts");
86
87
  await ensureCrewDirectory(workingDir);
87
88
 
88
- const teams = allTeams(discoverTeams(ctx.cwd));
89
- const workflows = allWorkflows(discoverWorkflows(ctx.cwd));
90
- const agents = allAgents(discoverAgents(ctx.cwd));
89
+ // WORKTREE FIX: If worktree mode is needed but cwd is not a git repo,
90
+ // auto-correct to the nearest git repo root. This prevents "not a git repository"
91
+ // errors when ctx.cwd points to a parent directory that isn't a git repo.
92
+ let resolvedCtx = ctx;
93
+ if (workingDir) {
94
+ try {
95
+ const gitRoot = findGitRoot(workingDir);
96
+ if (gitRoot && gitRoot !== workingDir) {
97
+ resolvedCtx = { ...ctx, cwd: gitRoot };
98
+ }
99
+ } catch {
100
+ // cwd is not in a git repo — validate below if worktree mode is needed
101
+ }
102
+ }
103
+
104
+ // WORKTREE PRECONDITION CHECK: validate git repo exists and is clean
105
+ // BEFORE creating the run manifest, so we return a friendly error
106
+ // instead of crashing mid-execution in prepareTaskWorkspace.
107
+ if (params.workspaceMode === "worktree") {
108
+ let gitRoot: string | undefined;
109
+ try {
110
+ gitRoot = findGitRoot(resolvedCtx.cwd);
111
+ } catch {
112
+ // not a git repo
113
+ }
114
+ if (!gitRoot) {
115
+ return result(
116
+ `Worktree mode requires a git repository. '${resolvedCtx.cwd}' is not inside a git repo.\nUse workspaceMode: 'single' or run from a git repository.`,
117
+ { action: "run", status: "error" },
118
+ true,
119
+ );
120
+ }
121
+ // Check if clean leader is required (can be disabled via config)
122
+ const preCheckConfig = loadConfig(resolvedCtx.cwd);
123
+ if (preCheckConfig.config.requireCleanWorktreeLeader !== false) {
124
+ try {
125
+ assertCleanLeader(gitRoot);
126
+ } catch (err) {
127
+ const msg = err instanceof Error ? err.message : String(err);
128
+ return result(
129
+ `${msg}\nCommit or stash changes before using worktree mode, or use workspaceMode: 'single'.`,
130
+ { action: "run", status: "error" },
131
+ true,
132
+ );
133
+ }
134
+ }
135
+ }
136
+
137
+ const teams = allTeams(discoverTeams(resolvedCtx.cwd));
138
+ const workflows = allWorkflows(discoverWorkflows(resolvedCtx.cwd));
139
+ const agents = allAgents(discoverAgents(resolvedCtx.cwd));
91
140
  const directAgent = params.agent ? agents.find((item) => item.name === params.agent) : undefined;
92
141
  if (params.agent && !directAgent) return result(`Agent '${params.agent}' not found.`, { action: "run", status: "error" }, true);
93
142
  const teamName = params.team ?? "default";
@@ -110,7 +159,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
110
159
  steps: [{ id: "01_agent", role: params.role ?? "agent", task: "{goal}", model: params.model }],
111
160
  } : workflows.find((item) => item.name === workflowName);
112
161
  if (!baseWorkflow) return result(`Workflow '${workflowName}' not found.`, { action: "run", status: "error" }, true);
113
- const workflow = directAgent ? baseWorkflow : expandParallelResearchWorkflow(baseWorkflow, ctx.cwd);
162
+ const workflow = directAgent ? baseWorkflow : expandParallelResearchWorkflow(baseWorkflow, resolvedCtx.cwd);
114
163
 
115
164
  // Check if this is a pipeline workflow - special handling for multi-stage execution
116
165
  const isPipelineWorkflow = workflowName === "pipeline" && !directAgent;
@@ -152,7 +201,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
152
201
 
153
202
  const skillOverride = normalizeSkillOverride(params.skill);
154
203
  const { manifest, tasks, paths } = createRunManifest({
155
- cwd: ctx.cwd,
204
+ cwd: resolvedCtx.cwd,
156
205
  team,
157
206
  workflow,
158
207
  goal,
@@ -169,7 +218,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
169
218
  atomicWriteJson(paths.manifestPath, updatedManifest);
170
219
  registerActiveRun(updatedManifest);
171
220
 
172
- const loadedConfig = loadConfig(ctx.cwd);
221
+ const loadedConfig = loadConfig(resolvedCtx.cwd);
173
222
  const executedConfig = effectiveRunConfig(loadedConfig.config, params.config);
174
223
  const runtime = await resolveCrewRuntime(executedConfig);
175
224
  const runtimeResolution = runtimeResolutionState(runtime);
@@ -205,11 +254,11 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
205
254
  const asyncManifest = { ...effectiveManifest, async: { pid: spawned.pid, logPath: spawned.logPath, spawnedAt: new Date().toISOString() } };
206
255
  atomicWriteJson(paths.manifestPath, asyncManifest);
207
256
  void appendEventAsync(effectiveManifest.eventsPath, { type: "async.spawned", runId: effectiveManifest.runId, data: { pid: spawned.pid, logPath: spawned.logPath } });
208
- scheduleBackgroundEarlyExitGuard(ctx.cwd, effectiveManifest.runId, spawned.pid, spawned.logPath);
257
+ scheduleBackgroundEarlyExitGuard(resolvedCtx.cwd, effectiveManifest.runId, spawned.pid, spawned.logPath);
209
258
  // Wait for the async run to complete and return actual results.
210
259
  try {
211
- const completed = await waitForRun(updatedManifest.runId, ctx.cwd, { timeoutMs: 3600000 });
212
- const metrics = collectRunMetrics(ctx.cwd, completed.manifest.runId);
260
+ const completed = await waitForRun(updatedManifest.runId, resolvedCtx.cwd, { timeoutMs: 3600000 });
261
+ const metrics = collectRunMetrics(resolvedCtx.cwd, completed.manifest.runId);
213
262
  const lines: string[] = [
214
263
  `pi-crew run ${completed.manifest.status}: ${completed.manifest.runId} (${team.name})`,
215
264
  `Goal: ${goal.slice(0, 100)}`,
@@ -341,8 +390,8 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
341
390
 
342
391
  // Wait for the foreground run to complete and return actual results.
343
392
  try {
344
- const completed = await waitForRun(updatedManifest.runId, ctx.cwd, { timeoutMs: 3600000 });
345
- const metrics = collectRunMetrics(ctx.cwd, completed.manifest.runId);
393
+ const completed = await waitForRun(updatedManifest.runId, resolvedCtx.cwd, { timeoutMs: 3600000 });
394
+ const metrics = collectRunMetrics(resolvedCtx.cwd, completed.manifest.runId);
346
395
  const lines: string[] = [
347
396
  `pi-crew run ${completed.manifest.status}: ${completed.manifest.runId} (${team.name})`,
348
397
  `Goal: ${goal.slice(0, 100)}`,
@@ -18,7 +18,7 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
18
18
  if (!params.runId) return result("Status requires runId.", { action: "status", status: "error" }, true);
19
19
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
20
20
  if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "status", status: "error" }, true);
21
- const loaded = loadRunManifestById(runCwd, params.runId);
21
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
22
22
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "status", status: "error" }, true);
23
23
  let { manifest, tasks } = loaded;
24
24
  let asyncLivenessLine: string | undefined;
@@ -18,7 +18,7 @@ import {
18
18
  import type { executeTeamRun as _executeTeamRunFn } from "../runtime/team-runner.ts";
19
19
  import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
20
20
  import { writeArtifact } from "../state/artifact-store.ts";
21
- import { appendEvent } from "../state/event-log.ts";
21
+ import { appendEvent, appendEventFireAndForget } from "../state/event-log.ts";
22
22
  import { withRunLock } from "../state/locks.ts";
23
23
  import { replayPendingMailboxMessages } from "../state/mailbox.ts";
24
24
  import {
@@ -33,6 +33,7 @@ import type {
33
33
  TeamTaskState,
34
34
  } from "../state/types.ts";
35
35
  import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
36
+ import { assertSafePathId } from "../utils/safe-paths.ts";
36
37
  import {
37
38
  allWorkflows,
38
39
  discoverWorkflows,
@@ -99,10 +100,31 @@ async function handleRun(
99
100
  return _cachedHandleRun(...args);
100
101
  }
101
102
 
103
+ import { FileCheckpointStore } from "../runtime/checkpoint.ts";
102
104
  import { waitForRun } from "../runtime/run-tracker.ts";
103
105
  import { normalizeSkillOverride } from "../runtime/skill-instructions.ts";
106
+ import {
107
+ computeRunCacheKey,
108
+ getCachedRun,
109
+ getCacheStats,
110
+ } from "../state/run-cache.ts";
111
+ import { listRunGraphs, loadRunGraph } from "../state/run-graph.ts";
104
112
  import { searchAgents, searchTeams } from "../utils/bm25-search.ts";
105
113
  import { projectCrewRoot } from "../utils/paths.ts";
114
+ import { buildTeamOnboarding } from "./team-onboard.ts";
115
+ import {
116
+ handleAnchorAccumulate,
117
+ handleAnchorClear,
118
+ handleAnchorSet,
119
+ handleAnchorStatus,
120
+ } from "./team-tool/anchor.ts";
121
+ import {
122
+ createAutoSummarizeService,
123
+ handleAutoSummarizeConfig,
124
+ handleAutoSummarizeOff,
125
+ handleAutoSummarizeOn,
126
+ handleAutoSummarizeStatus,
127
+ } from "./team-tool/auto-summarize.ts";
106
128
  import {
107
129
  type CacheControlDeps,
108
130
  invalidateSnapshot,
@@ -110,6 +132,10 @@ import {
110
132
  import { handleCancel, handleRetry } from "./team-tool/cancel.ts";
111
133
  import { handleDoctor } from "./team-tool/doctor.ts";
112
134
  import { handleExplain } from "./team-tool/explain.ts";
135
+ import {
136
+ handleListScheduled,
137
+ handleSchedule,
138
+ } from "./team-tool/handle-schedule.ts";
113
139
  import { handleHealthMonitor } from "./team-tool/health-monitor.ts";
114
140
  import {
115
141
  handleArtifacts,
@@ -125,30 +151,17 @@ import {
125
151
  handlePrune,
126
152
  handleWorktrees,
127
153
  } from "./team-tool/lifecycle-actions.ts";
128
- import {
129
- getCachedRun,
130
- computeRunCacheKey,
131
- getCacheStats,
132
- } from "../state/run-cache.ts";
133
- import {
134
- loadRunGraph,
135
- listRunGraphs,
136
- } from "../state/run-graph.ts";
137
- import { FileCheckpointStore } from "../runtime/checkpoint.ts";
138
- import { buildTeamOnboarding } from "./team-onboard.ts";
154
+ import { handleOrchestrate } from "./team-tool/orchestrate.ts";
139
155
  import { handleParallel } from "./team-tool/parallel-dispatch.ts";
140
- import { handleSchedule, handleListScheduled } from "./team-tool/handle-schedule.ts";
141
156
  import { handlePlan } from "./team-tool/plan.ts";
142
- import { handleOrchestrate } from "./team-tool/orchestrate.ts";
143
157
  import { handleRespond } from "./team-tool/respond.ts";
144
158
  import { handleStatus } from "./team-tool/status.ts";
145
- import { handleAnchorSet, handleAnchorClear, handleAnchorStatus, handleAnchorAccumulate } from "./team-tool/anchor.ts";
146
- import { handleAutoSummarizeOn, handleAutoSummarizeOff, handleAutoSummarizeStatus, handleAutoSummarizeConfig, createAutoSummarizeService } from "./team-tool/auto-summarize.ts";
147
159
 
148
160
  export { handleApi } from "./team-tool/api.ts";
149
161
  export { handleRetry } from "./team-tool/cancel.ts";
150
162
  export type { TeamContext } from "./team-tool/context.ts";
151
163
  export { handleDoctor } from "./team-tool/doctor.ts";
164
+ export { handleSchedule } from "./team-tool/handle-schedule.ts";
152
165
  export {
153
166
  handleArtifacts,
154
167
  handleEvents,
@@ -163,12 +176,11 @@ export {
163
176
  handlePrune,
164
177
  handleWorktrees,
165
178
  } from "./team-tool/lifecycle-actions.ts";
166
- export { handleSchedule } from "./team-tool/handle-schedule.ts";
179
+ export { handleOrchestrate } from "./team-tool/orchestrate.ts";
167
180
  export { handlePlan } from "./team-tool/plan.ts";
168
181
  export { handleStatus } from "./team-tool/status.ts";
169
182
  export type { TeamToolDetails } from "./team-tool-types.ts";
170
183
  export { handleRun };
171
- export { handleOrchestrate } from "./team-tool/orchestrate.ts";
172
184
 
173
185
  export function handleList(
174
186
  params: TeamToolParamsValue,
@@ -451,7 +463,7 @@ export async function handleResume(
451
463
  { action: "resume", status: "error" },
452
464
  true,
453
465
  );
454
- const loaded = loadRunManifestById(runCwd, params.runId);
466
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
455
467
  if (!loaded)
456
468
  return result(
457
469
  `Run '${params.runId}' not found.`,
@@ -674,7 +686,7 @@ export function handleSteer(
674
686
  { action: "steer", status: "error" },
675
687
  true,
676
688
  );
677
- const loaded = loadRunManifestById(runCwd, runId);
689
+ const loaded = loadRunManifestById(runCwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
678
690
  if (!loaded)
679
691
  return result(
680
692
  `Run '${runId}' not found`,
@@ -689,12 +701,23 @@ export function handleSteer(
689
701
  true,
690
702
  );
691
703
  if (!task.pendingSteers) task.pendingSteers = [];
692
- // HIGH-04: Cap pendingSteers array to prevent unbounded memory growth
693
- const MAX_PENDING_STEERS = 100;
694
- if (task.pendingSteers.length >= MAX_PENDING_STEERS) {
695
- task.pendingSteers = task.pendingSteers.slice(-(MAX_PENDING_STEERS - 1));
696
- }
697
- task.pendingSteers.push(message);
704
+ // HIGH-04: Cap pendingSteers array to prevent unbounded memory growth
705
+ const MAX_PENDING_STEERS = 100;
706
+ if (task.pendingSteers.length >= MAX_PENDING_STEERS) {
707
+ // Log warning before dropping the oldest message
708
+ appendEventFireAndForget(loaded.manifest.eventsPath, {
709
+ type: "task.steer_dropped",
710
+ runId,
711
+ taskId,
712
+ data: {
713
+ droppedMessage: task.pendingSteers[0],
714
+ reason: "pendingSteers cap exceeded",
715
+ queueDepth: task.pendingSteers.length,
716
+ },
717
+ });
718
+ task.pendingSteers = task.pendingSteers.slice(-(MAX_PENDING_STEERS - 1));
719
+ }
720
+ task.pendingSteers.push(message);
698
721
  saveRunTasks(loaded.manifest, loaded.tasks);
699
722
  appendEvent(loaded.manifest.eventsPath, {
700
723
  type: "task.steer_queued",
@@ -751,17 +774,57 @@ function handleInvalidate(
751
774
  /**
752
775
  * Locate the CWD where a run's state is stored.
753
776
  * Tries ctx.cwd first, then scans immediate child directories for .crew/state/runs/<runId>.
777
+ *
778
+ * Defensive bounds (prevent hang on large dirs like /tmp in CI):
779
+ * - Skips entries that are well-known system/ephemeral dirs (e.g. .npm, node_modules, .git)
780
+ * - Caps the scan at MAX_SCAN_ENTRIES to avoid pathological scans
781
+ * - Skips hidden entries (starting with `.`) unless they look like run directories
782
+ * (e.g. .crew, .pi, .tmp-crew-runs)
754
783
  */
755
- export function locateRunCwd(runId: string, baseCwd: string): string | undefined {
784
+ const MAX_SCAN_ENTRIES = 1000;
785
+ const SKIP_SCAN_DIRS = new Set([
786
+ "node_modules",
787
+ ".git",
788
+ ".npm",
789
+ ".cache",
790
+ ".local",
791
+ "proc",
792
+ "sys",
793
+ "dev",
794
+ "Library",
795
+ "Applications",
796
+ ]);
797
+
798
+ export function locateRunCwd(
799
+ runId: string,
800
+ baseCwd: string,
801
+ ): string | undefined {
756
802
  // Fast path: run is in the current CWD
757
- if (loadRunManifestById(baseCwd, runId)) return baseCwd;
803
+ if (loadRunManifestById(baseCwd, runId)) {
804
+ return baseCwd;
805
+ }
758
806
 
759
- // Scan immediate child directories
807
+ // Scan immediate child directories, but with defensive bounds.
760
808
  try {
761
- for (const entry of fs.readdirSync(baseCwd, { withFileTypes: true })) {
809
+ const entries = fs.readdirSync(baseCwd, { withFileTypes: true });
810
+ const boundedEntries = entries.length > MAX_SCAN_ENTRIES
811
+ ? entries.slice(0, MAX_SCAN_ENTRIES)
812
+ : entries;
813
+ for (const entry of boundedEntries) {
762
814
  if (!entry.isDirectory()) continue;
815
+ if (SKIP_SCAN_DIRS.has(entry.name)) continue;
816
+ // Skip hidden entries except well-known run-storage prefixes
817
+ if (entry.name.startsWith(".")) {
818
+ if (
819
+ !entry.name.startsWith(".crew") &&
820
+ !entry.name.startsWith(".pi") &&
821
+ !entry.name.startsWith(".tmp-crew")
822
+ ) continue;
823
+ }
763
824
  const candidate = path.join(baseCwd, entry.name);
764
- if (loadRunManifestById(candidate, runId)) return candidate;
825
+ if (loadRunManifestById(candidate, runId)) {
826
+ return candidate;
827
+ }
765
828
  }
766
829
  } catch {
767
830
  /* ignore unreadable dirs */
@@ -1090,23 +1153,32 @@ export async function handleTeamTool(
1090
1153
  return handleWait(params, ctx);
1091
1154
  case "graph": {
1092
1155
  if (params.runId) {
1156
+ assertSafePathId("runId", params.runId);
1093
1157
  const graph = loadRunGraph(ctx.cwd, params.runId);
1094
1158
  return result(
1095
- graph ? JSON.stringify(graph, null, 2) : "No graph found for this run.",
1159
+ graph
1160
+ ? JSON.stringify(graph, null, 2)
1161
+ : "No graph found for this run.",
1096
1162
  { action: "graph", status: graph ? "ok" : "error" },
1097
1163
  !graph,
1098
1164
  );
1099
1165
  }
1100
1166
  const graphs = listRunGraphs(ctx.cwd);
1101
1167
  return result(
1102
- graphs.length ? `Available graphs:\n${graphs.join("\n")}` : "No graphs available.",
1168
+ graphs.length
1169
+ ? `Available graphs:\n${graphs.join("\n")}`
1170
+ : "No graphs available.",
1103
1171
  { action: "graph", status: "ok" },
1104
1172
  );
1105
1173
  }
1106
1174
  case "search": {
1107
1175
  const query = params.goal ?? params.task ?? "";
1108
1176
  if (!query) {
1109
- return result("Search requires goal or task query.", { action: "search", status: "error" }, true);
1177
+ return result(
1178
+ "Search requires goal or task query.",
1179
+ { action: "search", status: "error" },
1180
+ true,
1181
+ );
1110
1182
  }
1111
1183
  try {
1112
1184
  const [agentResults, teamResults] = await Promise.all([
@@ -1117,19 +1189,30 @@ export async function handleTeamTool(
1117
1189
  if (teamResults.length) {
1118
1190
  lines.push("## Teams");
1119
1191
  for (const r of teamResults) {
1120
- lines.push(`- [${r.team.name}] score=${r.score.toFixed(2)}: ${r.team.description ?? "(no description)"}`);
1192
+ lines.push(
1193
+ `- [${r.team.name}] score=${r.score.toFixed(2)}: ${r.team.description ?? "(no description)"}`,
1194
+ );
1121
1195
  }
1122
1196
  }
1123
1197
  if (agentResults.length) {
1124
1198
  lines.push("## Agents");
1125
1199
  for (const r of agentResults) {
1126
- lines.push(`- [${r.agent.name}] score=${r.score.toFixed(2)}: ${r.agent.description ?? "(no description)"}`);
1200
+ lines.push(
1201
+ `- [${r.agent.name}] score=${r.score.toFixed(2)}: ${r.agent.description ?? "(no description)"}`,
1202
+ );
1127
1203
  }
1128
1204
  }
1129
- return result(lines.length ? lines.join("\n") : "No results found.", { action: "search", status: "ok" });
1205
+ return result(
1206
+ lines.length ? lines.join("\n") : "No results found.",
1207
+ { action: "search", status: "ok" },
1208
+ );
1130
1209
  } catch (err) {
1131
1210
  const msg = err instanceof Error ? err.message : String(err);
1132
- return result(`Search failed: ${msg}`, { action: "search", status: "error" }, true);
1211
+ return result(
1212
+ `Search failed: ${msg}`,
1213
+ { action: "search", status: "error" },
1214
+ true,
1215
+ );
1133
1216
  }
1134
1217
  }
1135
1218
  case "schedule":
@@ -1137,7 +1220,10 @@ export async function handleTeamTool(
1137
1220
  case "scheduled":
1138
1221
  return handleListScheduled(params, ctx);
1139
1222
  case "anchor": {
1140
- const subAction = typeof params.config?.subAction === "string" ? params.config.subAction : "status";
1223
+ const subAction =
1224
+ typeof params.config?.subAction === "string"
1225
+ ? params.config.subAction
1226
+ : "status";
1141
1227
  switch (subAction) {
1142
1228
  case "set":
1143
1229
  return handleAnchorSet(params, ctx);
@@ -1151,7 +1237,12 @@ export async function handleTeamTool(
1151
1237
  }
1152
1238
  case "auto-summarize":
1153
1239
  case "auto_boomerang": {
1154
- const subAction = typeof params.config?.subAction === "string" ? params.config.subAction : ((params.action as string) === "auto_boomerang" ? "toggle" : "status");
1240
+ const subAction =
1241
+ typeof params.config?.subAction === "string"
1242
+ ? params.config.subAction
1243
+ : (params.action as string) === "auto_boomerang"
1244
+ ? "toggle"
1245
+ : "status";
1155
1246
  switch (subAction) {
1156
1247
  case "on":
1157
1248
  return handleAutoSummarizeOn(params, ctx);
@@ -1178,7 +1269,14 @@ export async function handleTeamTool(
1178
1269
  }
1179
1270
  case "explain": {
1180
1271
  const explainResult = handleExplain(params, ctx.cwd);
1181
- return result(explainResult.text, { action: "explain", status: explainResult.isError ? "error" : "ok" }, explainResult.isError);
1272
+ return result(
1273
+ explainResult.text,
1274
+ {
1275
+ action: "explain",
1276
+ status: explainResult.isError ? "error" : "ok",
1277
+ },
1278
+ explainResult.isError,
1279
+ );
1182
1280
  }
1183
1281
  case "cache": {
1184
1282
  if (params.goal) {
@@ -1192,10 +1290,24 @@ export async function handleTeamTool(
1192
1290
  if (cached) {
1193
1291
  return result(
1194
1292
  `Cached run found (${new Date(cached.cachedAt).toISOString()}): runId=${cached.runId}, status=${cached.status}, ${cached.tasks.length} tasks`,
1195
- { action: "cache", status: "ok", data: { cacheKey: key, cacheHit: true, runId: cached.runId, status: cached.status, taskCount: cached.tasks.length } },
1293
+ {
1294
+ action: "cache",
1295
+ status: "ok",
1296
+ data: {
1297
+ cacheKey: key,
1298
+ cacheHit: true,
1299
+ runId: cached.runId,
1300
+ status: cached.status,
1301
+ taskCount: cached.tasks.length,
1302
+ },
1303
+ },
1196
1304
  );
1197
1305
  }
1198
- return result(`No cached result for key: ${key}`, { action: "cache", status: "ok", data: { cacheKey: key, cacheHit: false } });
1306
+ return result(`No cached result for key: ${key}`, {
1307
+ action: "cache",
1308
+ status: "ok",
1309
+ data: { cacheKey: key, cacheHit: false },
1310
+ });
1199
1311
  }
1200
1312
  const stats = getCacheStats(ctx.cwd);
1201
1313
  return result(
@@ -1205,13 +1317,28 @@ export async function handleTeamTool(
1205
1317
  }
1206
1318
  case "checkpoint": {
1207
1319
  if (!params.runId || !params.taskId) {
1208
- return result("Checkpoint requires runId and taskId.", { action: "checkpoint", status: "error" }, true);
1320
+ return result(
1321
+ "Checkpoint requires runId and taskId.",
1322
+ { action: "checkpoint", status: "error" },
1323
+ true,
1324
+ );
1209
1325
  }
1210
- const stateRoot = path.join(projectCrewRoot(ctx.cwd), "state", "runs", params.runId);
1326
+ assertSafePathId("runId", params.runId);
1327
+ assertSafePathId("taskId", params.taskId);
1328
+ const stateRoot = path.join(
1329
+ projectCrewRoot(ctx.cwd),
1330
+ "state",
1331
+ "runs",
1332
+ params.runId,
1333
+ );
1211
1334
  const store = new FileCheckpointStore(stateRoot);
1212
1335
  const checkpoint = store.load(params.runId, params.taskId);
1213
1336
  if (!checkpoint) {
1214
- return result("No checkpoint found.", { action: "checkpoint", status: "error" }, true);
1337
+ return result(
1338
+ "No checkpoint found.",
1339
+ { action: "checkpoint", status: "error" },
1340
+ true,
1341
+ );
1215
1342
  }
1216
1343
  return result(
1217
1344
  `Checkpoint: step=${checkpoint.step}, progress=${checkpoint.progress}, savedAt=${new Date(checkpoint.savedAt).toISOString()}`,