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
@@ -1,9 +1,10 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { randomUUID } from "node:crypto";
3
+ import { randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import type { TeamRunManifest } from "./types.ts";
5
5
  import { DEFAULT_LOCKS } from "../config/defaults.ts";
6
6
  import { sleepSync } from "../utils/sleep.ts";
7
+ import { isSymlinkSafePath } from "./atomic-write.ts";
7
8
 
8
9
  export interface RunLockOptions {
9
10
  staleMs?: number;
@@ -52,8 +53,13 @@ function isLockHolderAlive(filePath: string): boolean {
52
53
  return true; // Signal 0 succeeded — process is alive
53
54
  } catch (error) {
54
55
  const code = (error as NodeJS.ErrnoException).code;
55
- // EPERM: process exists but we don't have permission to signal it
56
- return code === "EPERM";
56
+ // EPERM: process exists but we don't have permission to signal it.
57
+ // Since we cannot verify liveness, treat the holder as potentially
58
+ // stale so the lock can be stolen rather than blocking indefinitely.
59
+ // This is an acceptable trade-off: EPERM requires elevated privileges,
60
+ // and blocking indefinitely would be worse. The risk is low.
61
+ // Other errors (ESRCH — process doesn't exist) also mean holder is dead.
62
+ return false;
57
63
  }
58
64
  } catch {
59
65
  return true; // Can't read — assume alive to be safe
@@ -72,7 +78,24 @@ function isLockHolderAlive(filePath: string): boolean {
72
78
  export type LockKind = "run" | "file";
73
79
 
74
80
  function writeLockFile(filePath: string, token: string, kind: LockKind = "file"): void {
75
- const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o644);
81
+ // Reject a pre-existing symlink at the lock path before O_CREAT|O_EXCL.
82
+ // O_EXCL fails with EEXIST if the path exists (including as a symlink),
83
+ // but the failure mode is confusing — an explicit lstatSync gives a
84
+ // clean, distinguishable error instead.
85
+ try {
86
+ const stat = fs.lstatSync(filePath);
87
+ if (stat.isSymbolicLink()) {
88
+ throw new Error(`Refusing to create lock file over symlink: ${filePath}`);
89
+ }
90
+ } catch (error) {
91
+ const code = (error as NodeJS.ErrnoException).code;
92
+ // ENOENT means the path doesn't exist yet — that's fine, proceed.
93
+ // Anything else (EACCES, EPERM, etc.) should surface immediately.
94
+ if (code !== "ENOENT") throw error;
95
+ }
96
+ // Ensure parent directory exists (may have been cleaned up by a concurrent process)
97
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
98
+ const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
76
99
  try {
77
100
  fs.writeSync(fd, JSON.stringify({ kind, pid: process.pid, createdAt: new Date().toISOString(), token }));
78
101
  } finally {
@@ -86,6 +109,9 @@ function writeLockFile(filePath: string, token: string, kind: LockKind = "file")
86
109
  */
87
110
  function readLockToken(filePath: string): string | undefined {
88
111
  try {
112
+ // Refuse to read a symlink — prevents reading a target an attacker placed
113
+ const stat = fs.lstatSync(filePath);
114
+ if (stat.isSymbolicLink()) return undefined;
89
115
  const raw = fs.readFileSync(filePath, "utf-8");
90
116
  const parsed = JSON.parse(raw) as { token?: unknown };
91
117
  return typeof parsed.token === "string" ? parsed.token : undefined;
@@ -103,14 +129,37 @@ function readLockToken(filePath: string): string | undefined {
103
129
  *
104
130
  * With token matching, A's release is a no-op for B's lock.
105
131
  */
132
+ function timingSafeTokenMatch(a: string, b: string): boolean {
133
+ const bufA = Buffer.from(String(a));
134
+ const bufB = Buffer.from(String(b));
135
+ const len = Math.max(bufA.length, bufB.length);
136
+ const safeA = Buffer.alloc(len);
137
+ const safeB = Buffer.alloc(len);
138
+ bufA.copy(safeA);
139
+ bufB.copy(safeB);
140
+ return timingSafeEqual(safeA, safeB);
141
+ }
142
+
106
143
  function releaseLock(filePath: string, token: string): void {
144
+ // FIX: Do not delete a symlink — it may have been planted by an attacker
145
+ // after our lock was released. A legitimate lock file should never be a
146
+ // symlink since writeLockFile uses O_CREAT|O_EXCL which fails on symlinks.
147
+ let isSymlink = false;
148
+ try {
149
+ isSymlink = fs.lstatSync(filePath).isSymbolicLink();
150
+ } catch { /* file doesn't exist — that's fine, we'll handle ENOENT below */ }
151
+ if (isSymlink) return;
152
+
107
153
  const stored = readLockToken(filePath);
108
- if (stored === undefined || stored === token) {
154
+ if (stored === undefined || timingSafeTokenMatch(stored, token)) {
109
155
  try {
110
156
  fs.rmSync(filePath, { force: true });
111
- } catch {
112
- // Best-effort cleanup. Either someone else with the same token got
113
- // there first, or the lock is already gone both are fine.
157
+ } catch (error) {
158
+ // FIX: Only ignore ENOENT (lock already gone). Other errors (EACCES,
159
+ // EPERM, EBUSY) indicate a real problem and should be surfaced.
160
+ const code = (error as NodeJS.ErrnoException).code;
161
+ if (code !== "ENOENT") throw error;
162
+ // Lock already gone — that's fine, we wanted to release it anyway.
114
163
  }
115
164
  }
116
165
  // If the stored token does not match, our lock has been stolen
@@ -131,17 +180,12 @@ function acquireLockWithRetry(filePath: string, staleMs: number, kind: LockKind
131
180
  if (Date.now() > deadline) {
132
181
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
133
182
  }
134
- // FIX: Use both staleness AND PID liveness to decide if we can steal
135
- // a lock. Previously only staleness was checked, so a process whose
136
- // PID was recently reused by another process could have its lock
137
- // stolen even while still active. Now: fresh+alive = fail, else = clear.
138
183
  const isStale = isLockStale(filePath, staleMs);
139
184
  const isHolderAlive = isLockHolderAlive(filePath);
140
185
  if (!isStale && isHolderAlive) {
141
- // Lock is fresh AND holder is alive — fail fast
142
186
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
143
187
  }
144
- // Lock is stale OR holder is dead safe to clear
188
+ // Stale or dead holder forcibly remove the lock.
145
189
  try {
146
190
  fs.rmSync(filePath, { force: true });
147
191
  } catch { /* race — let loop retry */ }
@@ -169,16 +213,12 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number, kind
169
213
  if (Date.now() > deadline) {
170
214
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
171
215
  }
172
- // FIX (Round 14, locks-async): Mirror the sync path's staleness AND
173
- // PID liveness check. Previously the async path only checked
174
- // staleness, so a recently-reused PID could have its lock stolen
175
- // even while still running. Now: fresh + alive holder = fail.
176
216
  const isStale = isLockStale(filePath, staleMs);
177
217
  const isHolderAlive = isLockHolderAlive(filePath);
178
218
  if (!isStale && isHolderAlive) {
179
219
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
180
220
  }
181
- // Lock is stale OR holder is dead safe to clear
221
+ // Stale or dead holder forcibly remove the lock.
182
222
  try {
183
223
  fs.rmSync(filePath, { force: true });
184
224
  } catch { /* race — let loop retry */ }
@@ -200,8 +240,38 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
200
240
  // append, or even the lock acquisition itself) would race with the lock.
201
241
  const lockFile = `${filePath}.lock`;
202
242
  const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
243
+ // FIX: Validate the parent directory is not a symlink BEFORE calling mkdirSync.
244
+ // Between mkdir and lock acquisition, an attacker could plant a symlink.
245
+ if (!isSymlinkSafePath(path.dirname(lockFile))) throw new Error("Refusing: parent of lock directory is a symlink");
203
246
  fs.mkdirSync(path.dirname(lockFile), { recursive: true });
204
- const token = acquireLockWithRetry(lockFile, staleMs, "file");
247
+ // FIX: Validate that the target file still exists. If it was deleted and
248
+ // recreated since the last lock cycle, the old .lock file may be orphaned
249
+ // and should not block the new cycle. Clean it up if the target is missing.
250
+ try {
251
+ fs.statSync(filePath);
252
+ } catch {
253
+ // Target file doesn't exist — clean up any stale .lock file and proceed.
254
+ // The lock will be acquired fresh for the new file (if fn creates it).
255
+ try { fs.rmSync(lockFile, { force: true }); } catch { /* ignore */ }
256
+ }
257
+ // FIX (TOCTOU): Re-validate symlink safety before each lock acquisition
258
+ // attempt. Between our initial check and the acquisition (and between
259
+ // acquireLockWithRetry's internal retries), an attacker could plant a
260
+ // symlink. We must re-check on each iteration to catch TOCTOU races.
261
+ let token = "";
262
+ let attempt = 0;
263
+ const deadline = Date.now() + staleMs * 2;
264
+ while (Date.now() <= deadline) {
265
+ if (!isSymlinkSafePath(path.dirname(lockFile))) throw new Error("Refusing: parent of lock directory is a symlink");
266
+ try {
267
+ token = acquireLockWithRetry(lockFile, staleMs, "file");
268
+ break;
269
+ } catch {
270
+ sleepSync(Math.min(250, 25 * 2 ** attempt));
271
+ attempt++;
272
+ }
273
+ }
274
+ if (token === "") throw new Error(`Run '${path.basename(lockFile)}' is locked by another operation.`);
205
275
  try {
206
276
  return fn();
207
277
  } finally {
@@ -213,23 +283,42 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
213
283
  export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, options: RunLockOptions = {}): T {
214
284
  const filePath = lockPath(manifest);
215
285
  const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
286
+ const existingToken = runLockHeldByUs.get(filePath);
287
+ if (existingToken) {
288
+ // Re-entrant: already hold this lock, just run the callback.
289
+ return fn();
290
+ }
216
291
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
217
292
  const token = acquireLockWithRetry(filePath, staleMs, "run");
293
+ runLockHeldByUs.set(filePath, token);
218
294
  try {
219
295
  return fn();
220
296
  } finally {
297
+ runLockHeldByUs.delete(filePath);
221
298
  releaseLock(filePath, token);
222
299
  }
223
300
  }
224
301
 
302
+ // Track re-entrant lock acquisitions per lock file path. When a lock is
303
+ // already held by this call stack (handleResume -> executeTeamRun ->
304
+ // executeTeamRunCore), we skip re-acquisition to avoid deadlock.
305
+ const runLockHeldByUs = new Map<string, string>(); // filePath -> token
306
+
225
307
  export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promise<T>, options: RunLockOptions = {}): Promise<T> {
226
308
  const filePath = lockPath(manifest);
227
309
  const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
310
+ const existingToken = runLockHeldByUs.get(filePath);
311
+ if (existingToken) {
312
+ // Re-entrant: already hold this lock, just run the callback.
313
+ return await fn();
314
+ }
228
315
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
229
316
  const token = await acquireLockWithRetryAsync(filePath, staleMs, "run");
317
+ runLockHeldByUs.set(filePath, token);
230
318
  try {
231
319
  return await fn();
232
320
  } finally {
321
+ runLockHeldByUs.delete(filePath);
233
322
  releaseLock(filePath, token);
234
323
  }
235
324
  }
@@ -69,7 +69,24 @@ function mailboxDir(manifest: TeamRunManifest): string {
69
69
 
70
70
  function safeMailboxDir(manifest: TeamRunManifest, create = false): string {
71
71
  const dir = mailboxDir(manifest);
72
- if (create) fs.mkdirSync(dir, { recursive: true });
72
+ if (create) {
73
+ try {
74
+ fs.mkdirSync(dir, { recursive: true });
75
+ } catch (error) {
76
+ // Windows EPERM: can occur when dir uses long-name form (runneradmin)
77
+ // but filesystem parent only exists in short-name form (RUNNER~1).
78
+ // Retry with realpathSync-resolved form.
79
+ if (process.platform === "win32" && (error as NodeJS.ErrnoException).code === "EPERM") {
80
+ try {
81
+ const realDir = fs.realpathSync(path.dirname(dir));
82
+ const correctedDir = path.join(realDir, path.basename(dir));
83
+ fs.mkdirSync(correctedDir, { recursive: true });
84
+ } catch { throw error; }
85
+ } else {
86
+ throw error;
87
+ }
88
+ }
89
+ }
73
90
  // SECURITY: When create=true, dir now exists and must be validated via
74
91
  // resolveRealContainedPath. When create=false, missing dir must throw —
75
92
  // never return an unvalidated bare path (bypasses containment checks).
@@ -78,7 +95,12 @@ function safeMailboxDir(manifest: TeamRunManifest, create = false): string {
78
95
  return path.join(dir); // will throw in callers via resolveRealContainedPath on read
79
96
  }
80
97
  if (fs.lstatSync(dir).isSymbolicLink()) throw new Error(`Invalid mailbox directory: ${dir}`);
81
- return resolveRealContainedPath(manifest.stateRoot, "mailbox");
98
+ // Return dir as-is. The containment guarantee is provided by
99
+ // manifest.stateRoot which was validated by createRunPaths. Re-resolving
100
+ // through resolveRealContainedPath can change the path form on Windows
101
+ // (short-name vs long-name), causing subsequent operations to see a
102
+ // different filesystem entry.
103
+ return dir;
82
104
  }
83
105
 
84
106
  function safeTaskId(taskId: string): string {
@@ -91,7 +113,8 @@ function safeMailboxTasksRoot(manifest: TeamRunManifest, create = false): string
91
113
  if (create) fs.mkdirSync(root, { recursive: true });
92
114
  if (!fs.existsSync(root)) return root;
93
115
  if (fs.lstatSync(root).isSymbolicLink()) throw new Error(`Invalid mailbox tasks directory: ${root}`);
94
- return resolveRealContainedPath(safeMailboxDir(manifest), "tasks");
116
+ // Return root as-is (see safeMailboxDir comment about path form consistency)
117
+ return root;
95
118
  }
96
119
 
97
120
  function taskMailboxDir(manifest: TeamRunManifest, taskId: string, create = false): string {
@@ -101,7 +124,9 @@ function taskMailboxDir(manifest: TeamRunManifest, taskId: string, create = fals
101
124
  const relative = path.relative(tasksRoot, resolved);
102
125
  if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Invalid mailbox task id: ${taskId}`);
103
126
  if (create) fs.mkdirSync(resolved, { recursive: true });
104
- return resolveRealContainedPath(tasksRoot, normalizedTaskId);
127
+ if (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink()) throw new Error(`Invalid mailbox task directory: ${resolved}`);
128
+ // Return resolved as-is (see safeMailboxDir comment about path form consistency)
129
+ return resolved;
105
130
  }
106
131
 
107
132
  function mailboxPath(manifest: TeamRunManifest, direction: MailboxDirection, taskId?: string, create = false): string {
@@ -115,7 +140,12 @@ function deliveryPath(manifest: TeamRunManifest, create = false): string {
115
140
  function safeMailboxFile(filePath: string, parentDir: string): string {
116
141
  if (!fs.existsSync(filePath)) return filePath;
117
142
  if (fs.lstatSync(filePath).isSymbolicLink()) throw new Error(`Invalid mailbox file: ${filePath}`);
118
- return resolveRealContainedPath(parentDir, path.basename(filePath));
143
+ // Return filePath as-is instead of calling resolveRealContainedPath().
144
+ // The containment guarantee is already provided by safeMailboxDir/taskMailboxDir
145
+ // when the parent directory is created. Re-resolving can change the path
146
+ // form (e.g. short-name vs long-name on Windows), causing subsequent
147
+ // existsSync/appendFileSync to see a different file.
148
+ return filePath;
119
149
  }
120
150
 
121
151
  function mailboxFile(manifest: TeamRunManifest, direction: MailboxDirection, taskId?: string, create = false): string {
@@ -145,10 +175,18 @@ function ensureRunMailbox(manifest: TeamRunManifest): void {
145
175
  safeMailboxDir(manifest, true);
146
176
  for (const direction of ["inbox", "outbox"] as const) {
147
177
  const filePath = mailboxFile(manifest, direction, undefined, true);
148
- if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, "", "utf-8");
178
+ if (!fs.existsSync(filePath)) {
179
+ // Ensure parent dir exists (may have been lost due to race or
180
+ // Windows path normalization mismatch)
181
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
182
+ fs.writeFileSync(filePath, "", "utf-8");
183
+ }
149
184
  }
150
185
  const delivery = deliveryFile(manifest, true);
151
- if (!fs.existsSync(delivery)) fs.writeFileSync(delivery, `${JSON.stringify({ messages: {}, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf-8");
186
+ if (!fs.existsSync(delivery)) {
187
+ fs.mkdirSync(path.dirname(delivery), { recursive: true });
188
+ fs.writeFileSync(delivery, `${JSON.stringify({ messages: {}, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf-8");
189
+ }
152
190
  }
153
191
 
154
192
  function ensureTaskMailbox(manifest: TeamRunManifest, taskId: string): void {
@@ -9,6 +9,8 @@
9
9
  */
10
10
 
11
11
  import { mkdirSync, readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
12
14
  import { logInternalError } from "../utils/internal-error.ts";
13
15
 
14
16
  // ── Types ────────────────────────────────────────────────────────────────
@@ -151,7 +153,8 @@ export class ObservationStore {
151
153
  */
152
154
  save(): void {
153
155
  try {
154
- mkdirSync(this.storePath.substring(0, this.storePath.lastIndexOf("/")), { recursive: true });
156
+ // Use path.dirname for cross-platform support (handles both \ and /)
157
+ mkdirSync(path.dirname(this.storePath), { recursive: true });
155
158
  writeFileSync(this.storePath, JSON.stringify({
156
159
  observations: this.observations,
157
160
  compressed: this.compressed,
@@ -1,37 +1,39 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { projectCrewRoot } from "../utils/paths.ts";
4
+ import { assertSafePathId } from "../utils/safe-paths.ts";
3
5
  import type { TeamRunManifest, TeamTaskState } from "./types.ts";
4
6
 
5
7
  export interface RunGraphNode {
6
- id: string;
7
- type: "run" | "task" | "agent" | "artifact" | "file";
8
- name: string;
9
- metadata?: Record<string, unknown>;
8
+ id: string;
9
+ type: "run" | "task" | "agent" | "artifact" | "file";
10
+ name: string;
11
+ metadata?: Record<string, unknown>;
10
12
  }
11
13
 
12
14
  export interface RunGraphEdge {
13
- source: string;
14
- target: string;
15
- type: "dependsOn" | "produces" | "runs" | "contains";
16
- weight?: number;
15
+ source: string;
16
+ target: string;
17
+ type: "dependsOn" | "produces" | "runs" | "contains";
18
+ weight?: number;
17
19
  }
18
20
 
19
21
  export interface RunGraphLayer {
20
- name: string;
21
- nodeIds: string[];
22
+ name: string;
23
+ nodeIds: string[];
22
24
  }
23
25
 
24
26
  export interface RunGraph {
25
- version: "1.0.0";
26
- runId: string;
27
- team: string;
28
- workflow: string;
29
- createdAt: string;
30
- completedAt?: string;
31
- status: string;
32
- nodes: RunGraphNode[];
33
- edges: RunGraphEdge[];
34
- layers: RunGraphLayer[];
27
+ version: "1.0.0";
28
+ runId: string;
29
+ team: string;
30
+ workflow: string;
31
+ createdAt: string;
32
+ completedAt?: string;
33
+ status: string;
34
+ nodes: RunGraphNode[];
35
+ edges: RunGraphEdge[];
36
+ layers: RunGraphLayer[];
35
37
  }
36
38
 
37
39
  /**
@@ -39,142 +41,151 @@ export interface RunGraph {
39
41
  * Consolidates state into a single graph JSON for dashboard/API use.
40
42
  */
41
43
  export function buildRunGraph(
42
- manifest: TeamRunManifest,
43
- tasks: TeamTaskState[],
44
+ manifest: TeamRunManifest,
45
+ tasks: TeamTaskState[],
44
46
  ): RunGraph {
45
- const nodes: RunGraphNode[] = [];
46
- const edges: RunGraphEdge[] = [];
47
- const nodeIds = new Set<string>();
48
-
49
- // Add run node
50
- const runId = manifest.runId;
51
- nodes.push({
52
- id: `run:${runId}`,
53
- type: "run",
54
- name: manifest.goal ?? runId,
55
- metadata: {
56
- team: manifest.team,
57
- workflow: manifest.workflow,
58
- status: manifest.status,
59
- createdAt: manifest.createdAt,
60
- },
61
- });
62
- nodeIds.add(`run:${runId}`);
63
-
64
- // Add task nodes
65
- for (const task of tasks) {
66
- const taskId = `task:${task.id}`;
67
- if (nodeIds.has(taskId)) continue;
68
- nodeIds.add(taskId);
69
-
70
- nodes.push({
71
- id: taskId,
72
- type: "task",
73
- name: task.role,
74
- metadata: {
75
- status: task.status,
76
- startedAt: task.startedAt,
77
- finishedAt: task.finishedAt,
78
- },
79
- });
80
-
81
- // Edge from run to task
82
- edges.push({
83
- source: `run:${runId}`,
84
- target: taskId,
85
- type: "contains",
86
- });
87
-
88
- // Edges from dependencies
89
- for (const dep of task.dependsOn ?? []) {
90
- edges.push({
91
- source: `task:${dep}`,
92
- target: taskId,
93
- type: "dependsOn",
94
- weight: 1.0,
95
- });
96
- }
97
-
98
- }
99
-
100
- // Group by layer (based on phase or role)
101
- const layerMap = new Map<string, string[]>();
102
- for (const task of tasks) {
103
- const layerName = task.adaptive?.phase ?? task.role;
104
- if (!layerMap.has(layerName)) layerMap.set(layerName, []);
105
- layerMap.get(layerName)!.push(`task:${task.id}`);
106
- }
107
-
108
- const layers: RunGraphLayer[] = [...layerMap.entries()].map(([name, nodeIdList]) => ({
109
- name,
110
- nodeIds: nodeIdList,
111
- }));
112
-
113
- return {
114
- version: "1.0.0",
115
- runId,
116
- team: manifest.team ?? "unknown",
117
- workflow: manifest.workflow ?? "unknown",
118
- createdAt: manifest.createdAt,
119
- completedAt: manifest.updatedAt,
120
- status: manifest.status,
121
- nodes,
122
- edges,
123
- layers,
124
- };
47
+ const nodes: RunGraphNode[] = [];
48
+ const edges: RunGraphEdge[] = [];
49
+ const nodeIds = new Set<string>();
50
+
51
+ // Add run node
52
+ const runId = manifest.runId;
53
+ nodes.push({
54
+ id: `run:${runId}`,
55
+ type: "run",
56
+ name: manifest.goal ?? runId,
57
+ metadata: {
58
+ team: manifest.team,
59
+ workflow: manifest.workflow,
60
+ status: manifest.status,
61
+ createdAt: manifest.createdAt,
62
+ },
63
+ });
64
+ nodeIds.add(`run:${runId}`);
65
+
66
+ // Add task nodes
67
+ for (const task of tasks) {
68
+ const taskId = `task:${task.id}`;
69
+ if (nodeIds.has(taskId)) continue;
70
+ nodeIds.add(taskId);
71
+
72
+ nodes.push({
73
+ id: taskId,
74
+ type: "task",
75
+ name: task.role,
76
+ metadata: {
77
+ status: task.status,
78
+ startedAt: task.startedAt,
79
+ finishedAt: task.finishedAt,
80
+ },
81
+ });
82
+
83
+ // Edge from run to task
84
+ edges.push({
85
+ source: `run:${runId}`,
86
+ target: taskId,
87
+ type: "contains",
88
+ });
89
+
90
+ // Edges from dependencies
91
+ for (const dep of task.dependsOn ?? []) {
92
+ edges.push({
93
+ source: `task:${dep}`,
94
+ target: taskId,
95
+ type: "dependsOn",
96
+ weight: 1.0,
97
+ });
98
+ }
99
+ }
100
+
101
+ // Group by layer (based on phase or role)
102
+ const layerMap = new Map<string, string[]>();
103
+ for (const task of tasks) {
104
+ const layerName = task.adaptive?.phase ?? task.role;
105
+ if (!layerMap.has(layerName)) layerMap.set(layerName, []);
106
+ layerMap.get(layerName)!.push(`task:${task.id}`);
107
+ }
108
+
109
+ const layers: RunGraphLayer[] = [...layerMap.entries()].map(
110
+ ([name, nodeIdList]) => ({
111
+ name,
112
+ nodeIds: nodeIdList,
113
+ }),
114
+ );
115
+
116
+ return {
117
+ version: "1.0.0",
118
+ runId,
119
+ team: manifest.team ?? "unknown",
120
+ workflow: manifest.workflow ?? "unknown",
121
+ createdAt: manifest.createdAt,
122
+ completedAt: manifest.updatedAt,
123
+ status: manifest.status,
124
+ nodes,
125
+ edges,
126
+ layers,
127
+ };
125
128
  }
126
129
 
127
130
  /**
128
- * Save run graph to disk in .crew/graphs/
131
+ * Save run graph to disk.
132
+ * Uses projectCrewRoot() to honour the .pi/teams/ fallback for .pi-based
133
+ * projects (issue #29 follow-up). Graph files now live alongside the
134
+ * manifest at `<crewRoot>/graphs/<runId>.json`.
129
135
  */
130
136
  export function saveRunGraph(graph: RunGraph, cwd: string): string {
131
- const crewRoot = path.join(cwd, ".crew");
132
- const graphsDir = path.join(crewRoot, "graphs");
137
+ assertSafePathId("runId", graph.runId);
138
+ const crewRoot = projectCrewRoot(cwd);
139
+ const graphsDir = path.join(crewRoot, "graphs");
133
140
 
134
- if (!fs.existsSync(graphsDir)) {
135
- fs.mkdirSync(graphsDir, { recursive: true });
136
- }
141
+ if (!fs.existsSync(graphsDir)) {
142
+ fs.mkdirSync(graphsDir, { recursive: true });
143
+ }
137
144
 
138
- const graphPath = path.join(graphsDir, `${graph.runId}.json`);
139
- fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2), "utf-8");
145
+ const graphPath = path.join(graphsDir, `${graph.runId}.json`);
146
+ fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2), "utf-8");
140
147
 
141
- return graphPath;
148
+ return graphPath;
142
149
  }
143
150
 
144
151
  /**
145
152
  * Load run graph from disk.
146
153
  */
147
154
  export function loadRunGraph(cwd: string, runId: string): RunGraph | null {
148
- const graphPath = path.join(cwd, ".crew", "graphs", `${runId}.json`);
149
- if (!fs.existsSync(graphPath)) return null;
150
-
151
- try {
152
- return JSON.parse(fs.readFileSync(graphPath, "utf-8")) as RunGraph;
153
- } catch {
154
- return null;
155
- }
155
+ assertSafePathId("runId", runId);
156
+ const crewRoot = projectCrewRoot(cwd);
157
+ const graphPath = path.join(crewRoot, "graphs", `${runId}.json`);
158
+ if (!fs.existsSync(graphPath)) return null;
159
+
160
+ try {
161
+ return JSON.parse(fs.readFileSync(graphPath, "utf-8")) as RunGraph;
162
+ } catch {
163
+ return null;
164
+ }
156
165
  }
157
166
 
158
167
  /**
159
168
  * List all archived run graphs.
160
169
  */
161
170
  export function listRunGraphs(cwd: string): string[] {
162
- const graphsDir = path.join(cwd, ".crew", "graphs");
163
- if (!fs.existsSync(graphsDir)) return [];
164
-
165
- return fs.readdirSync(graphsDir)
166
- .filter((f) => f.endsWith(".json"))
167
- .map((f) => f.replace(/\.json$/, ""));
171
+ const crewRoot = projectCrewRoot(cwd);
172
+ const graphsDir = path.join(crewRoot, "graphs");
173
+ if (!fs.existsSync(graphsDir)) return [];
174
+
175
+ return fs
176
+ .readdirSync(graphsDir)
177
+ .filter((f) => f.endsWith(".json"))
178
+ .map((f) => f.replace(/\.json$/, ""));
168
179
  }
169
180
 
170
181
  /**
171
182
  * Build and save run graph from manifest + tasks.
172
183
  */
173
184
  export function buildAndSaveRunGraph(
174
- manifest: TeamRunManifest,
175
- tasks: TeamTaskState[],
176
- cwd: string,
185
+ manifest: TeamRunManifest,
186
+ tasks: TeamTaskState[],
187
+ cwd: string,
177
188
  ): string {
178
- const graph = buildRunGraph(manifest, tasks);
179
- return saveRunGraph(graph, cwd);
189
+ const graph = buildRunGraph(manifest, tasks);
190
+ return saveRunGraph(graph, cwd);
180
191
  }