pi-crew 0.6.1 → 0.6.4

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 (119) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +81 -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/docs/ui-optimization-plan.md +447 -0
  6. package/package.json +2 -2
  7. package/src/config/config.ts +106 -15
  8. package/src/errors.ts +107 -0
  9. package/src/extension/async-notifier.ts +6 -2
  10. package/src/extension/crew-cleanup.ts +8 -5
  11. package/src/extension/management.ts +464 -109
  12. package/src/extension/register.ts +213 -35
  13. package/src/extension/registration/brief-tool-overrides.ts +400 -0
  14. package/src/extension/registration/commands.ts +27 -2
  15. package/src/extension/registration/subagent-helpers.ts +2 -2
  16. package/src/extension/registration/subagent-tools.ts +9 -4
  17. package/src/extension/registration/team-tool.ts +17 -10
  18. package/src/extension/registration/viewers.ts +2 -2
  19. package/src/extension/team-tool/api.ts +3 -3
  20. package/src/extension/team-tool/cancel.ts +3 -3
  21. package/src/extension/team-tool/explain.ts +1 -1
  22. package/src/extension/team-tool/handle-schedule.ts +40 -0
  23. package/src/extension/team-tool/inspect.ts +3 -3
  24. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  25. package/src/extension/team-tool/respond.ts +2 -2
  26. package/src/extension/team-tool/run.ts +64 -14
  27. package/src/extension/team-tool/status.ts +1 -1
  28. package/src/extension/team-tool-types.ts +2 -0
  29. package/src/extension/team-tool.ts +173 -46
  30. package/src/hooks/registry.ts +77 -13
  31. package/src/hooks/types.ts +9 -0
  32. package/src/plugins/plugin-define.ts +6 -0
  33. package/src/plugins/plugin-registry.ts +32 -0
  34. package/src/plugins/plugins/index.ts +3 -0
  35. package/src/plugins/plugins/nextjs.ts +19 -0
  36. package/src/plugins/plugins/vite.ts +14 -0
  37. package/src/plugins/plugins/vitest.ts +14 -0
  38. package/src/runtime/async-runner.ts +35 -7
  39. package/src/runtime/background-runner.ts +529 -144
  40. package/src/runtime/chain-parser.ts +5 -1
  41. package/src/runtime/chain-runner.ts +9 -3
  42. package/src/runtime/checkpoint.ts +262 -180
  43. package/src/runtime/child-pi.ts +164 -37
  44. package/src/runtime/crash-recovery.ts +25 -14
  45. package/src/runtime/crew-agent-records.ts +2 -0
  46. package/src/runtime/diagnostic-export.ts +1 -1
  47. package/src/runtime/dynamic-script-runner.ts +10 -7
  48. package/src/runtime/foreground-watchdog.ts +1 -1
  49. package/src/runtime/heartbeat-watcher.ts +19 -2
  50. package/src/runtime/intercom-bridge.ts +7 -0
  51. package/src/runtime/iteration-hooks.ts +1 -1
  52. package/src/runtime/manifest-cache.ts +4 -0
  53. package/src/runtime/orphan-worker-registry.ts +444 -0
  54. package/src/runtime/parent-guard.ts +70 -16
  55. package/src/runtime/pi-args.ts +437 -14
  56. package/src/runtime/pi-spawn.ts +1 -0
  57. package/src/runtime/post-checks.ts +1 -1
  58. package/src/runtime/retry-runner.ts +9 -3
  59. package/src/runtime/run-tracker.ts +38 -10
  60. package/src/runtime/sandbox.ts +17 -2
  61. package/src/runtime/scheduler.ts +25 -2
  62. package/src/runtime/skill-effectiveness.ts +105 -62
  63. package/src/runtime/skill-instructions.ts +110 -32
  64. package/src/runtime/stale-reconciler.ts +310 -69
  65. package/src/runtime/subagent-manager.ts +251 -57
  66. package/src/runtime/task-health.ts +76 -0
  67. package/src/runtime/task-id.ts +20 -0
  68. package/src/runtime/task-runner/live-executor.ts +1 -1
  69. package/src/runtime/task-runner/progress.ts +1 -1
  70. package/src/runtime/task-runner/state-helpers.ts +110 -6
  71. package/src/runtime/task-runner.ts +92 -70
  72. package/src/runtime/team-runner.ts +186 -20
  73. package/src/schema/team-tool-schema.ts +27 -9
  74. package/src/skills/discover-skills.ts +9 -3
  75. package/src/state/active-run-registry.ts +170 -38
  76. package/src/state/artifact-store.ts +25 -13
  77. package/src/state/atomic-write-v2.ts +86 -0
  78. package/src/state/atomic-write.ts +346 -55
  79. package/src/state/blob-store.ts +178 -10
  80. package/src/state/crew-init.ts +161 -28
  81. package/src/state/decision-ledger.ts +172 -111
  82. package/src/state/event-log-rotation.ts +82 -52
  83. package/src/state/event-log.ts +254 -70
  84. package/src/state/health-store.ts +71 -0
  85. package/src/state/locks.ts +102 -20
  86. package/src/state/mailbox.ts +45 -7
  87. package/src/state/observation-store.ts +4 -1
  88. package/src/state/run-graph.ts +141 -130
  89. package/src/state/run-metrics.ts +24 -8
  90. package/src/state/session-state-map.ts +51 -0
  91. package/src/state/state-store.ts +330 -43
  92. package/src/ui/live-run-sidebar.ts +1 -1
  93. package/src/ui/loaders.ts +4 -0
  94. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  95. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  96. package/src/ui/powerbar-publisher.ts +1 -1
  97. package/src/ui/run-action-dispatcher.ts +2 -2
  98. package/src/ui/run-snapshot-cache.ts +1 -1
  99. package/src/ui/status-colors.ts +5 -1
  100. package/src/ui/theme-adapter.ts +80 -1
  101. package/src/ui/tool-progress-formatter.ts +9 -5
  102. package/src/ui/tool-render.ts +4 -0
  103. package/src/ui/tool-renderers/brief-mode.ts +207 -0
  104. package/src/ui/tool-renderers/index.ts +627 -0
  105. package/src/ui/widget/index.ts +224 -0
  106. package/src/ui/widget/widget-formatters.ts +148 -0
  107. package/src/ui/widget/widget-model.ts +90 -0
  108. package/src/ui/widget/widget-renderer.ts +130 -0
  109. package/src/ui/widget/widget-types.ts +37 -0
  110. package/src/utils/env-filter.ts +66 -0
  111. package/src/utils/file-coalescer.ts +9 -1
  112. package/src/utils/guards.ts +110 -0
  113. package/src/utils/paths.ts +80 -5
  114. package/src/utils/redaction.ts +6 -1
  115. package/src/utils/safe-paths.ts +281 -10
  116. package/src/worktree/cleanup.ts +112 -24
  117. package/src/worktree/worktree-manager.ts +128 -15
  118. package/test-bugs-all.mjs +10 -6
  119. package/test-integration-check.ts +4 -0
@@ -4,6 +4,7 @@ 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;
@@ -106,18 +132,34 @@ function readLockToken(filePath: string): string | undefined {
106
132
  function timingSafeTokenMatch(a: string, b: string): boolean {
107
133
  const bufA = Buffer.from(String(a));
108
134
  const bufB = Buffer.from(String(b));
109
- if (bufA.length !== bufB.length) return false;
110
- return timingSafeEqual(bufA, bufB);
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);
111
141
  }
112
142
 
113
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
+
114
153
  const stored = readLockToken(filePath);
115
154
  if (stored === undefined || timingSafeTokenMatch(stored, token)) {
116
155
  try {
117
156
  fs.rmSync(filePath, { force: true });
118
- } catch {
119
- // Best-effort cleanup. Either someone else with the same token got
120
- // 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.
121
163
  }
122
164
  }
123
165
  // If the stored token does not match, our lock has been stolen
@@ -138,17 +180,12 @@ function acquireLockWithRetry(filePath: string, staleMs: number, kind: LockKind
138
180
  if (Date.now() > deadline) {
139
181
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
140
182
  }
141
- // FIX: Use both staleness AND PID liveness to decide if we can steal
142
- // a lock. Previously only staleness was checked, so a process whose
143
- // PID was recently reused by another process could have its lock
144
- // stolen even while still active. Now: fresh+alive = fail, else = clear.
145
183
  const isStale = isLockStale(filePath, staleMs);
146
184
  const isHolderAlive = isLockHolderAlive(filePath);
147
185
  if (!isStale && isHolderAlive) {
148
- // Lock is fresh AND holder is alive — fail fast
149
186
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
150
187
  }
151
- // Lock is stale OR holder is dead safe to clear
188
+ // Stale or dead holder forcibly remove the lock.
152
189
  try {
153
190
  fs.rmSync(filePath, { force: true });
154
191
  } catch { /* race — let loop retry */ }
@@ -176,16 +213,12 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number, kind
176
213
  if (Date.now() > deadline) {
177
214
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
178
215
  }
179
- // FIX (Round 14, locks-async): Mirror the sync path's staleness AND
180
- // PID liveness check. Previously the async path only checked
181
- // staleness, so a recently-reused PID could have its lock stolen
182
- // even while still running. Now: fresh + alive holder = fail.
183
216
  const isStale = isLockStale(filePath, staleMs);
184
217
  const isHolderAlive = isLockHolderAlive(filePath);
185
218
  if (!isStale && isHolderAlive) {
186
219
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
187
220
  }
188
- // Lock is stale OR holder is dead safe to clear
221
+ // Stale or dead holder forcibly remove the lock.
189
222
  try {
190
223
  fs.rmSync(filePath, { force: true });
191
224
  } catch { /* race — let loop retry */ }
@@ -207,8 +240,38 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
207
240
  // append, or even the lock acquisition itself) would race with the lock.
208
241
  const lockFile = `${filePath}.lock`;
209
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");
210
246
  fs.mkdirSync(path.dirname(lockFile), { recursive: true });
211
- 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.`);
212
275
  try {
213
276
  return fn();
214
277
  } finally {
@@ -220,23 +283,42 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
220
283
  export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, options: RunLockOptions = {}): T {
221
284
  const filePath = lockPath(manifest);
222
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
+ }
223
291
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
224
292
  const token = acquireLockWithRetry(filePath, staleMs, "run");
293
+ runLockHeldByUs.set(filePath, token);
225
294
  try {
226
295
  return fn();
227
296
  } finally {
297
+ runLockHeldByUs.delete(filePath);
228
298
  releaseLock(filePath, token);
229
299
  }
230
300
  }
231
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
+
232
307
  export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promise<T>, options: RunLockOptions = {}): Promise<T> {
233
308
  const filePath = lockPath(manifest);
234
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
+ }
235
315
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
236
316
  const token = await acquireLockWithRetryAsync(filePath, staleMs, "run");
317
+ runLockHeldByUs.set(filePath, token);
237
318
  try {
238
319
  return await fn();
239
320
  } finally {
321
+ runLockHeldByUs.delete(filePath);
240
322
  releaseLock(filePath, token);
241
323
  }
242
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
  }