pi-crew 0.6.0 → 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 (135) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/README.md +70 -31
  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/agents/agent-config.ts +2 -1
  7. package/src/benchmark/feedback-loop.ts +4 -2
  8. package/src/config/config.ts +106 -15
  9. package/src/errors.ts +107 -0
  10. package/src/extension/async-notifier.ts +6 -2
  11. package/src/extension/crew-cleanup.ts +8 -5
  12. package/src/extension/cross-extension-rpc.ts +48 -0
  13. package/src/extension/management.ts +464 -109
  14. package/src/extension/register.ts +194 -34
  15. package/src/extension/registration/commands.ts +4 -3
  16. package/src/extension/registration/subagent-helpers.ts +2 -2
  17. package/src/extension/registration/subagent-tools.ts +3 -1
  18. package/src/extension/registration/team-tool.ts +3 -1
  19. package/src/extension/registration/viewers.ts +3 -2
  20. package/src/extension/run-export.ts +16 -1
  21. package/src/extension/run-import.ts +16 -0
  22. package/src/extension/team-tool/anchor.ts +5 -1
  23. package/src/extension/team-tool/api.ts +12 -7
  24. package/src/extension/team-tool/cancel.ts +3 -3
  25. package/src/extension/team-tool/config-patch.ts +15 -1
  26. package/src/extension/team-tool/explain.ts +1 -1
  27. package/src/extension/team-tool/handle-schedule.ts +40 -0
  28. package/src/extension/team-tool/inspect.ts +3 -3
  29. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  30. package/src/extension/team-tool/respond.ts +2 -2
  31. package/src/extension/team-tool/run.ts +60 -11
  32. package/src/extension/team-tool/status.ts +1 -1
  33. package/src/extension/team-tool.ts +175 -47
  34. package/src/hooks/registry.ts +84 -12
  35. package/src/hooks/types.ts +12 -3
  36. package/src/i18n.ts +15 -2
  37. package/src/observability/exporters/otlp-exporter.ts +73 -0
  38. package/src/plugins/plugin-define.ts +6 -0
  39. package/src/plugins/plugin-registry.ts +32 -0
  40. package/src/plugins/plugins/index.ts +3 -0
  41. package/src/plugins/plugins/nextjs.ts +19 -0
  42. package/src/plugins/plugins/vite.ts +14 -0
  43. package/src/plugins/plugins/vitest.ts +14 -0
  44. package/src/runtime/adaptive-plan.ts +24 -0
  45. package/src/runtime/agent-control.ts +6 -3
  46. package/src/runtime/async-runner.ts +86 -3
  47. package/src/runtime/background-runner.ts +529 -144
  48. package/src/runtime/chain-parser.ts +5 -1
  49. package/src/runtime/chain-runner.ts +67 -3
  50. package/src/runtime/checkpoint.ts +262 -180
  51. package/src/runtime/child-pi.ts +165 -38
  52. package/src/runtime/crash-recovery.ts +25 -14
  53. package/src/runtime/crew-agent-records.ts +6 -3
  54. package/src/runtime/cross-extension-rpc.ts +34 -8
  55. package/src/runtime/diagnostic-export.ts +4 -5
  56. package/src/runtime/dynamic-script-runner.ts +9 -6
  57. package/src/runtime/foreground-watchdog.ts +3 -3
  58. package/src/runtime/heartbeat-watcher.ts +19 -2
  59. package/src/runtime/intercom-bridge.ts +7 -0
  60. package/src/runtime/iteration-hooks.ts +1 -1
  61. package/src/runtime/live-agent-manager.ts +6 -3
  62. package/src/runtime/live-irc.ts +4 -2
  63. package/src/runtime/manifest-cache.ts +4 -0
  64. package/src/runtime/orphan-worker-registry.ts +444 -0
  65. package/src/runtime/parallel-utils.ts +2 -1
  66. package/src/runtime/parent-guard.ts +70 -16
  67. package/src/runtime/pi-args.ts +437 -14
  68. package/src/runtime/pi-spawn.ts +1 -0
  69. package/src/runtime/post-checks.ts +11 -4
  70. package/src/runtime/retry-runner.ts +9 -3
  71. package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
  72. package/src/runtime/run-tracker.ts +38 -10
  73. package/src/runtime/sandbox.ts +42 -21
  74. package/src/runtime/scheduler.ts +25 -2
  75. package/src/runtime/semaphore.ts +2 -1
  76. package/src/runtime/settings-store.ts +14 -2
  77. package/src/runtime/skill-effectiveness.ts +109 -64
  78. package/src/runtime/skill-instructions.ts +114 -33
  79. package/src/runtime/stale-reconciler.ts +310 -69
  80. package/src/runtime/subagent-manager.ts +265 -53
  81. package/src/runtime/subprocess-tool-registry.ts +2 -2
  82. package/src/runtime/task-health.ts +76 -0
  83. package/src/runtime/task-id.ts +20 -0
  84. package/src/runtime/task-packet.ts +13 -1
  85. package/src/runtime/task-runner/live-executor.ts +1 -1
  86. package/src/runtime/task-runner/progress.ts +1 -1
  87. package/src/runtime/task-runner/state-helpers.ts +110 -6
  88. package/src/runtime/task-runner.ts +98 -67
  89. package/src/runtime/team-runner.ts +186 -20
  90. package/src/runtime/usage-tracker.ts +4 -2
  91. package/src/runtime/verification-gates.ts +36 -9
  92. package/src/schema/team-tool-schema.ts +27 -9
  93. package/src/skills/discover-skills.ts +9 -3
  94. package/src/state/active-run-registry.ts +170 -38
  95. package/src/state/artifact-store.ts +25 -13
  96. package/src/state/atomic-write-v2.ts +86 -0
  97. package/src/state/atomic-write.ts +346 -55
  98. package/src/state/blob-store.ts +178 -10
  99. package/src/state/contracts.ts +2 -1
  100. package/src/state/crew-init.ts +161 -28
  101. package/src/state/decision-ledger.ts +172 -111
  102. package/src/state/event-log-rotation.ts +82 -52
  103. package/src/state/event-log.ts +270 -75
  104. package/src/state/health-store.ts +71 -0
  105. package/src/state/hook-instinct-bridge.ts +2 -1
  106. package/src/state/locks.ts +109 -20
  107. package/src/state/mailbox.ts +45 -7
  108. package/src/state/observation-store.ts +4 -1
  109. package/src/state/run-graph.ts +141 -130
  110. package/src/state/run-metrics.ts +24 -8
  111. package/src/state/state-store.ts +333 -44
  112. package/src/state/task-claims.ts +9 -2
  113. package/src/tools/safe-bash.ts +69 -20
  114. package/src/types/new-api-types.ts +10 -5
  115. package/src/ui/keybinding-map.ts +2 -1
  116. package/src/ui/live-run-sidebar.ts +1 -1
  117. package/src/ui/loaders.ts +4 -0
  118. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  119. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  120. package/src/ui/run-action-dispatcher.ts +4 -3
  121. package/src/ui/run-snapshot-cache.ts +1 -1
  122. package/src/ui/status-colors.ts +2 -1
  123. package/src/ui/syntax-highlight.ts +2 -1
  124. package/src/ui/tool-render.ts +13 -3
  125. package/src/utils/env-filter.ts +66 -0
  126. package/src/utils/file-coalescer.ts +9 -1
  127. package/src/utils/fs-watch.ts +4 -2
  128. package/src/utils/gh-protocol.ts +2 -1
  129. package/src/utils/paths.ts +80 -5
  130. package/src/utils/redaction.ts +6 -1
  131. package/src/utils/safe-paths.ts +287 -10
  132. package/src/worktree/cleanup.ts +117 -26
  133. package/src/worktree/worktree-manager.ts +128 -15
  134. package/test-bugs-all.mjs +10 -6
  135. package/test-integration-check.ts +4 -0
@@ -25,9 +25,14 @@ import type { PiTeamsToolResult } from "../tool-result.ts";
25
25
  import { locateRunCwd } from "../team-tool.ts";
26
26
  import { configRecord, result, type TeamContext } from "./context.ts";
27
27
 
28
- function globMatch(value: string, pattern: string): boolean {
29
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\?/g, "\\?").replace(/\*/g, ".*");
30
- return new RegExp(`^${escaped}$`).test(value);
28
+ export function globMatch(value: string, pattern: string): boolean {
29
+ // Prevent ReDoS: reject excessively long patterns
30
+ if (pattern.length > 200) return false;
31
+ const regex = pattern
32
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex special chars
33
+ .replace(/\*/g, "[^/]*") // * matches non-slash characters only
34
+ .replace(/\?/g, "[^/]"); // ? matches single non-slash
35
+ return new RegExp(`^${regex}$`).test(value);
31
36
  }
32
37
 
33
38
  function safeReadContainedFile(baseDir: string, filePath: string | undefined): string | undefined {
@@ -87,7 +92,7 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
87
92
  if (!params.runId) return result("API requires runId.", { action: "api", status: "error" }, true);
88
93
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
89
94
  if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "api", status: "error" }, true);
90
- const loaded = loadRunManifestById(runCwd, params.runId);
95
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
91
96
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "api", status: "error" }, true);
92
97
  if (operation === "read-manifest") {
93
98
  return result(JSON.stringify(loaded.manifest, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
@@ -97,7 +102,7 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
97
102
  if (!permission.allowed) return result(permission.reason ?? "Plan approval is not allowed in this context.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
98
103
  try {
99
104
  return withRunLockSync(loaded.manifest, () => {
100
- const current = loadRunManifestById(ctx.cwd, loaded.manifest.runId) ?? loaded;
105
+ const current = loadRunManifestById(ctx.cwd, loaded.manifest.runId) ?? loaded; // NOTE: inside withRunLockSync - consistent read
101
106
  const approval = current.manifest.planApproval;
102
107
  if (!approval?.required || approval.status !== "pending") return result("Run has no pending plan approval request.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
103
108
  const now = new Date().toISOString();
@@ -116,7 +121,7 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
116
121
  if (!permission.allowed) return result(permission.reason ?? "Plan approval cancellation is not allowed in this context.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
117
122
  try {
118
123
  return withRunLockSync(loaded.manifest, () => {
119
- const current = loadRunManifestById(ctx.cwd, loaded.manifest.runId) ?? loaded;
124
+ const current = loadRunManifestById(ctx.cwd, loaded.manifest.runId) ?? loaded; // NOTE: inside withRunLockSync - consistent read
120
125
  const approval = current.manifest.planApproval;
121
126
  if (!approval?.required || approval.status !== "pending") return result("Run has no pending plan approval request.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
122
127
  const now = new Date().toISOString();
@@ -364,7 +369,7 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
364
369
  const updatedTask = claimTask(task, owner);
365
370
  const tasks = loaded.tasks.map((item) => item.id === task.id ? updatedTask : item);
366
371
  saveRunTasks(loaded.manifest, tasks);
367
- appendEvent(loaded.manifest.eventsPath, { type: "task.claimed", runId: loaded.manifest.runId, taskId: task.id, data: { owner, token: updatedTask.claim?.token, leasedUntil: updatedTask.claim?.leasedUntil } });
372
+ appendEvent(loaded.manifest.eventsPath, { type: "task.claimed", runId: loaded.manifest.runId, taskId: task.id, data: { owner, token: "[REDACTED]", leasedUntil: updatedTask.claim?.leasedUntil } });
368
373
  return result(JSON.stringify(updatedTask.claim, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
369
374
  });
370
375
  } catch (error) {
@@ -41,7 +41,7 @@ export function abortOwned(
41
41
  ): AbortOwnedResult {
42
42
  const runCwd = locateRunCwd(runId, ctx.cwd);
43
43
  if (!runCwd) return { abortedIds: [], missingIds: taskIds ?? [], foreignIds: [] };
44
- const loaded = loadRunManifestById(runCwd, runId);
44
+ const loaded = loadRunManifestById(runCwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
45
45
  if (!loaded) return { abortedIds: [], missingIds: taskIds ?? [], foreignIds: [] };
46
46
 
47
47
  const result: AbortOwnedResult = { abortedIds: [], missingIds: [], foreignIds: [] };
@@ -81,7 +81,7 @@ export async function handleRetry(params: TeamToolParamsValue, ctx: TeamContext,
81
81
  if (!params.runId) return result("Retry requires runId.", { action: "retry", status: "error" }, true);
82
82
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
83
83
  if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "retry", status: "error" }, true);
84
- const loaded = loadRunManifestById(runCwd, params.runId);
84
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
85
85
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "retry", status: "error" }, true);
86
86
 
87
87
  // Pre-lock ownership check: reject foreign-owned runs unless force is set
@@ -146,7 +146,7 @@ export async function handleCancel(params: TeamToolParamsValue, ctx: TeamContext
146
146
  if (!params.runId) return result("Cancel requires runId.", { action: "cancel", status: "error" }, true);
147
147
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
148
148
  if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "cancel", status: "error" }, true);
149
- const loaded = loadRunManifestById(runCwd, params.runId);
149
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
150
150
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "cancel", status: "error" }, true);
151
151
 
152
152
  // Pre-lock ownership check: reject foreign-owned runs unless force is set
@@ -1,5 +1,19 @@
1
1
  import { effectiveAutonomousConfig, parseConfig, type PiTeamsAutonomousConfig, type PiTeamsConfig } from "../../config/config.ts";
2
2
 
3
+ const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
4
+
5
+ /** Recursively strip dangerous prototype-pollution keys from all levels of an object. */
6
+ export function sanitizeObject<T>(obj: T): T {
7
+ if (obj === null || obj === undefined || typeof obj !== "object") return obj;
8
+ if (Array.isArray(obj)) return obj.map((item) => sanitizeObject(item)) as T;
9
+ const safe: Record<string, unknown> = {};
10
+ for (const key of Object.keys(obj as Record<string, unknown>)) {
11
+ if (DANGEROUS_KEYS.has(key)) continue;
12
+ safe[key] = sanitizeObject((obj as Record<string, unknown>)[key]);
13
+ }
14
+ return safe as T;
15
+ }
16
+
3
17
  export function autonomousPatchFromConfig(config: unknown): PiTeamsAutonomousConfig {
4
18
  const rootPatch = parseConfig(config).autonomous;
5
19
  if (rootPatch) return rootPatch;
@@ -11,7 +25,7 @@ export function configPatchFromConfig(config: unknown): PiTeamsConfig {
11
25
  }
12
26
 
13
27
  export function effectiveRunConfig(base: PiTeamsConfig, rawOverride: unknown): PiTeamsConfig {
14
- const patch = parseConfig(rawOverride);
28
+ const patch = sanitizeObject(parseConfig(rawOverride));
15
29
  return {
16
30
  ...base,
17
31
  ...patch,
@@ -209,7 +209,7 @@ export function handleExplain(params: {
209
209
  return result("explain requires runId", { action: "explain", status: "error" }, true);
210
210
  }
211
211
 
212
- const loaded = loadRunManifestById(cwd, params.runId);
212
+ const loaded = loadRunManifestById(cwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
213
213
  if (!loaded) {
214
214
  return result(`Run '${params.runId}' not found.`, { action: "explain", status: "error" }, true);
215
215
  }
@@ -163,6 +163,43 @@ function persistScheduledJob(cwd: string, job: import("../../runtime/scheduler.t
163
163
  }
164
164
  }
165
165
 
166
+ /** Update an existing scheduled job in persistent settings. */
167
+ export function persistScheduledJobUpdate(cwd: string, job: import("../../runtime/scheduler.ts").ScheduledJob): void {
168
+ try {
169
+ const settings = loadCrewSettings(cwd);
170
+ const existingJobs: import("../../runtime/scheduler.ts").ScheduledJob[] = Array.isArray(
171
+ (settings as Record<string, unknown>).scheduledJobs,
172
+ )
173
+ ? ((settings as Record<string, unknown>).scheduledJobs as import("../../runtime/scheduler.ts").ScheduledJob[])
174
+ : [];
175
+ const updated = existingJobs.map((j) => j.id === job.id ? job : j);
176
+ saveCrewSettings(
177
+ { ...settings, scheduledJobs: updated } as Parameters<typeof saveCrewSettings>[0],
178
+ cwd,
179
+ );
180
+ } catch {
181
+ /* best-effort persistence */
182
+ }
183
+ }
184
+
185
+ /** Remove a scheduled job from persistent settings. */
186
+ function persistScheduledJobRemove(cwd: string, jobId: string): void {
187
+ try {
188
+ const settings = loadCrewSettings(cwd);
189
+ const existingJobs: import("../../runtime/scheduler.ts").ScheduledJob[] = Array.isArray(
190
+ (settings as Record<string, unknown>).scheduledJobs,
191
+ )
192
+ ? ((settings as Record<string, unknown>).scheduledJobs as import("../../runtime/scheduler.ts").ScheduledJob[])
193
+ : [];
194
+ saveCrewSettings(
195
+ { ...settings, scheduledJobs: existingJobs.filter((j) => j.id !== jobId) } as Parameters<typeof saveCrewSettings>[0],
196
+ cwd,
197
+ );
198
+ } catch {
199
+ /* best-effort persistence */
200
+ }
201
+ }
202
+
166
203
  export function handleListScheduled(_params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
167
204
  const scheduler = getCrewScheduler();
168
205
  if (!scheduler) return result("Scheduler not running.", { action: "scheduled", status: "error" }, true);
@@ -177,6 +214,9 @@ export function handleListScheduled(_params: TeamToolParamsValue, ctx: TeamConte
177
214
  ` Next run: ${job.nextRun ?? "(unscheduled)"}`,
178
215
  ` Runs: ${job.runCount}, Last: ${job.lastRun ?? "(never)"} [${job.lastStatus ?? "?"}]`,
179
216
  );
217
+ if (job.spawnedRunIds && job.spawnedRunIds.length > 0) {
218
+ lines.push(` Spawned runs: ${job.spawnedRunIds.join(", ")}`);
219
+ }
180
220
  }
181
221
  return result(lines.join("\n"), { action: "scheduled", status: "ok" });
182
222
  }
@@ -10,7 +10,7 @@ export function handleEvents(params: TeamToolParamsValue, ctx: TeamContext): PiT
10
10
  if (!params.runId) return result("Events requires runId.", { action: "events", status: "error" }, true);
11
11
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
12
12
  if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "events", status: "error" }, true);
13
- const loaded = loadRunManifestById(runCwd, params.runId);
13
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
14
14
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "events", status: "error" }, true);
15
15
  const events = readEvents(loaded.manifest.eventsPath);
16
16
  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)"])];
@@ -21,7 +21,7 @@ export function handleArtifacts(params: TeamToolParamsValue, ctx: TeamContext):
21
21
  if (!params.runId) return result("Artifacts requires runId.", { action: "artifacts", status: "error" }, true);
22
22
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
23
23
  if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "artifacts", status: "error" }, true);
24
- const loaded = loadRunManifestById(runCwd, params.runId);
24
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
25
25
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "artifacts", status: "error" }, true);
26
26
  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
27
  return result(lines.join("\n"), { action: "artifacts", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
@@ -31,7 +31,7 @@ export function handleSummary(params: TeamToolParamsValue, ctx: TeamContext): Pi
31
31
  if (!params.runId) return result("Summary requires runId.", { action: "summary", status: "error" }, true);
32
32
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
33
33
  if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "summary", status: "error" }, true);
34
- const loaded = loadRunManifestById(runCwd, params.runId);
34
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
35
35
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "summary", status: "error" }, true);
36
36
  const usage = aggregateUsage(loaded.tasks);
37
37
  const lines = [
@@ -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;