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.
- package/CHANGELOG.md +225 -0
- package/README.md +70 -31
- package/docs/issue-29-analysis.md +189 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
- package/package.json +2 -2
- package/src/agents/agent-config.ts +2 -1
- package/src/benchmark/feedback-loop.ts +4 -2
- package/src/config/config.ts +106 -15
- package/src/errors.ts +107 -0
- package/src/extension/async-notifier.ts +6 -2
- package/src/extension/crew-cleanup.ts +8 -5
- package/src/extension/cross-extension-rpc.ts +48 -0
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +194 -34
- package/src/extension/registration/commands.ts +4 -3
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +3 -1
- package/src/extension/registration/team-tool.ts +3 -1
- package/src/extension/registration/viewers.ts +3 -2
- package/src/extension/run-export.ts +16 -1
- package/src/extension/run-import.ts +16 -0
- package/src/extension/team-tool/anchor.ts +5 -1
- package/src/extension/team-tool/api.ts +12 -7
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/config-patch.ts +15 -1
- package/src/extension/team-tool/explain.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +40 -0
- package/src/extension/team-tool/inspect.ts +3 -3
- package/src/extension/team-tool/lifecycle-actions.ts +4 -4
- package/src/extension/team-tool/respond.ts +2 -2
- package/src/extension/team-tool/run.ts +60 -11
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool.ts +175 -47
- package/src/hooks/registry.ts +84 -12
- package/src/hooks/types.ts +12 -3
- package/src/i18n.ts +15 -2
- package/src/observability/exporters/otlp-exporter.ts +73 -0
- package/src/plugins/plugin-define.ts +6 -0
- package/src/plugins/plugin-registry.ts +32 -0
- package/src/plugins/plugins/index.ts +3 -0
- package/src/plugins/plugins/nextjs.ts +19 -0
- package/src/plugins/plugins/vite.ts +14 -0
- package/src/plugins/plugins/vitest.ts +14 -0
- package/src/runtime/adaptive-plan.ts +24 -0
- package/src/runtime/agent-control.ts +6 -3
- package/src/runtime/async-runner.ts +86 -3
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +67 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +165 -38
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +6 -3
- package/src/runtime/cross-extension-rpc.ts +34 -8
- package/src/runtime/diagnostic-export.ts +4 -5
- package/src/runtime/dynamic-script-runner.ts +9 -6
- package/src/runtime/foreground-watchdog.ts +3 -3
- package/src/runtime/heartbeat-watcher.ts +19 -2
- package/src/runtime/intercom-bridge.ts +7 -0
- package/src/runtime/iteration-hooks.ts +1 -1
- package/src/runtime/live-agent-manager.ts +6 -3
- package/src/runtime/live-irc.ts +4 -2
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parallel-utils.ts +2 -1
- package/src/runtime/parent-guard.ts +70 -16
- package/src/runtime/pi-args.ts +437 -14
- package/src/runtime/pi-spawn.ts +1 -0
- package/src/runtime/post-checks.ts +11 -4
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +42 -21
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/semaphore.ts +2 -1
- package/src/runtime/settings-store.ts +14 -2
- package/src/runtime/skill-effectiveness.ts +109 -64
- package/src/runtime/skill-instructions.ts +114 -33
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +265 -53
- package/src/runtime/subprocess-tool-registry.ts +2 -2
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-packet.ts +13 -1
- package/src/runtime/task-runner/live-executor.ts +1 -1
- package/src/runtime/task-runner/progress.ts +1 -1
- package/src/runtime/task-runner/state-helpers.ts +110 -6
- package/src/runtime/task-runner.ts +98 -67
- package/src/runtime/team-runner.ts +186 -20
- package/src/runtime/usage-tracker.ts +4 -2
- package/src/runtime/verification-gates.ts +36 -9
- package/src/schema/team-tool-schema.ts +27 -9
- package/src/skills/discover-skills.ts +9 -3
- package/src/state/active-run-registry.ts +170 -38
- package/src/state/artifact-store.ts +25 -13
- package/src/state/atomic-write-v2.ts +86 -0
- package/src/state/atomic-write.ts +346 -55
- package/src/state/blob-store.ts +178 -10
- package/src/state/contracts.ts +2 -1
- package/src/state/crew-init.ts +161 -28
- package/src/state/decision-ledger.ts +172 -111
- package/src/state/event-log-rotation.ts +82 -52
- package/src/state/event-log.ts +270 -75
- package/src/state/health-store.ts +71 -0
- package/src/state/hook-instinct-bridge.ts +2 -1
- package/src/state/locks.ts +109 -20
- package/src/state/mailbox.ts +45 -7
- package/src/state/observation-store.ts +4 -1
- package/src/state/run-graph.ts +141 -130
- package/src/state/run-metrics.ts +24 -8
- package/src/state/state-store.ts +333 -44
- package/src/state/task-claims.ts +9 -2
- package/src/tools/safe-bash.ts +69 -20
- package/src/types/new-api-types.ts +10 -5
- package/src/ui/keybinding-map.ts +2 -1
- package/src/ui/live-run-sidebar.ts +1 -1
- package/src/ui/loaders.ts +4 -0
- package/src/ui/overlays/agent-picker-overlay.ts +1 -1
- package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
- package/src/ui/run-action-dispatcher.ts +4 -3
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/ui/status-colors.ts +2 -1
- package/src/ui/syntax-highlight.ts +2 -1
- package/src/ui/tool-render.ts +13 -3
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/fs-watch.ts +4 -2
- package/src/utils/gh-protocol.ts +2 -1
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +287 -10
- package/src/worktree/cleanup.ts +117 -26
- package/src/worktree/worktree-manager.ts +128 -15
- package/test-bugs-all.mjs +10 -6
- package/test-integration-check.ts +4 -0
package/src/state/blob-store.ts
CHANGED
|
@@ -1,18 +1,57 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
5
|
+
import { atomicWriteFile, renameWithRetry } from "./atomic-write.ts";
|
|
6
|
+
import { withFileLockSync } from "./locks.ts";
|
|
7
|
+
import { sleepSync } from "../utils/sleep.ts";
|
|
5
8
|
|
|
6
9
|
const SHA256_HEX = /^[a-f0-9]{64}$/i;
|
|
7
10
|
|
|
8
|
-
function validateBlobHash(hash: string): void {
|
|
11
|
+
function validateBlobHash(hash: string, algorithm?: string): void {
|
|
9
12
|
if (!SHA256_HEX.test(hash)) throw new Error(`Invalid blob hash: ${hash}`);
|
|
13
|
+
if (algorithm !== undefined && algorithm !== SHA256_PREFIX) {
|
|
14
|
+
throw new Error(`Invalid blob algorithm: ${algorithm} (expected ${SHA256_PREFIX})`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Atomically write a Buffer to a file using temp-file + rename pattern.
|
|
20
|
+
* Prevents partial writes on crash and handles concurrent writes safely.
|
|
21
|
+
*/
|
|
22
|
+
function atomicWriteBuffer(filePath: string, content: Buffer): void {
|
|
23
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
24
|
+
const tempPath = `${filePath}.${randomUUID()}.tmp`;
|
|
25
|
+
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
|
|
26
|
+
const fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o600);
|
|
27
|
+
try {
|
|
28
|
+
const openedStat = fs.fstatSync(fd);
|
|
29
|
+
if (!openedStat.isFile()) {
|
|
30
|
+
throw new Error(`Refusing to write: opened path is not a regular file: ${tempPath}`);
|
|
31
|
+
}
|
|
32
|
+
fs.writeSync(fd, content, 0, content.length);
|
|
33
|
+
renameWithRetry(tempPath, filePath);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
try { fs.rmSync(tempPath, { force: true }); } catch { /* best-effort */ }
|
|
36
|
+
throw error;
|
|
37
|
+
} finally {
|
|
38
|
+
// Always close fd; closeSync is safe to call even if fd was already closed
|
|
39
|
+
try { fs.closeSync(fd); } catch { /* best-effort */ }
|
|
40
|
+
}
|
|
10
41
|
}
|
|
11
42
|
|
|
12
43
|
const BLOBS_DIR = "blobs";
|
|
13
44
|
const BLOB_META_DIR = "blob-metadata";
|
|
14
45
|
const SHA256_PREFIX = "sha256";
|
|
15
46
|
|
|
47
|
+
/**
|
|
48
|
+
* File-based lock for cross-process metadata write protection.
|
|
49
|
+
* Uses withFileLockSync which provides O_EXCL atomic create + token-guarded release.
|
|
50
|
+
*/
|
|
51
|
+
function withMetadataLock<T>(metadataPath: string, fn: () => T): T {
|
|
52
|
+
return withFileLockSync(metadataPath, fn);
|
|
53
|
+
}
|
|
54
|
+
|
|
16
55
|
export interface BlobMetadata {
|
|
17
56
|
blobHash: string;
|
|
18
57
|
blobAlgorithm: string;
|
|
@@ -42,6 +81,9 @@ function sha256Of(content: string | Buffer): string {
|
|
|
42
81
|
/**
|
|
43
82
|
* Write content-addressed blob to the blobs directory under artifactsRoot.
|
|
44
83
|
* Content is deduplicated by hash; metadata sidecar is always written.
|
|
84
|
+
* FIX: Both content and metadata writes now use atomicWriteFile to prevent
|
|
85
|
+
* partial writes on crash. The deduplication check is now advisory (the atomic
|
|
86
|
+
* write handles concurrent writes correctly via O_EXCL temp file pattern).
|
|
45
87
|
*/
|
|
46
88
|
export function writeBlob(artifactsRoot: string, input: {
|
|
47
89
|
content: string | Buffer;
|
|
@@ -58,14 +100,19 @@ export function writeBlob(artifactsRoot: string, input: {
|
|
|
58
100
|
const algorithm = SHA256_PREFIX;
|
|
59
101
|
const blobDir = path.join(artifactsRoot, BLOBS_DIR, algorithm);
|
|
60
102
|
const metaDir = path.join(artifactsRoot, BLOB_META_DIR);
|
|
103
|
+
// Issue 2 fix: Validate parent directories are not symlinks before mkdirSync.
|
|
104
|
+
// An attacker could plant a symlink before we create the directory, causing
|
|
105
|
+
// blob content to be written to an attacker-controlled location.
|
|
106
|
+
resolveRealContainedPath(artifactsRoot, blobDir);
|
|
107
|
+
resolveRealContainedPath(artifactsRoot, metaDir);
|
|
61
108
|
fs.mkdirSync(blobDir, { recursive: true });
|
|
62
109
|
fs.mkdirSync(metaDir, { recursive: true });
|
|
63
110
|
|
|
64
111
|
const blobPath = path.join(blobDir, hash);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
112
|
+
// Issue 6 fix: Validate blobPath BEFORE writing to ensure the path is safe.
|
|
113
|
+
// If resolveRealContainedPath throws after the write, the blob may already
|
|
114
|
+
// exist at an unexpected location. Validate first to prevent this.
|
|
115
|
+
resolveRealContainedPath(artifactsRoot, blobPath);
|
|
69
116
|
const metadata: BlobMetadata = {
|
|
70
117
|
blobHash: hash,
|
|
71
118
|
blobAlgorithm: algorithm,
|
|
@@ -79,11 +126,90 @@ export function writeBlob(artifactsRoot: string, input: {
|
|
|
79
126
|
retention: input.retention ?? "run",
|
|
80
127
|
createdAt: new Date().toISOString(),
|
|
81
128
|
};
|
|
82
|
-
|
|
83
129
|
const metadataPath = path.join(metaDir, `${hash}.json`);
|
|
84
|
-
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), "utf-8");
|
|
85
130
|
|
|
86
|
-
|
|
131
|
+
// Both content and metadata use atomic writes to prevent partial writes on crash.
|
|
132
|
+
// Content is immutable and content-addressed (same hash = same content), so
|
|
133
|
+
// concurrent writes to the same hash are safe. Metadata (mime, retention, etc.)
|
|
134
|
+
// is protected by a per-hash lock to make the check-then-write atomic, preventing
|
|
135
|
+
// concurrent writers from racing to write different metadata for the same hash.
|
|
136
|
+
// Issue 3 fix: wrap metadata check-and-write in a lock to make it atomic.
|
|
137
|
+
// Without the lock, two processes could read the same metadata concurrently,
|
|
138
|
+
// both pass the check, and the second write would silently overwrite the first.
|
|
139
|
+
// FIX: Write blob content FIRST, then metadata. If blob write fails, no orphan metadata.
|
|
140
|
+
// Previous order was metadata first, then blob - causing orphan metadata on blob failure.
|
|
141
|
+
let blobContentWritten = false;
|
|
142
|
+
try {
|
|
143
|
+
// Write blob content first (immutable, content-addressed)
|
|
144
|
+
atomicWriteBuffer(blobPath, Buffer.isBuffer(content) ? content : Buffer.from(content, "utf-8"));
|
|
145
|
+
blobContentWritten = true;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
// Blob write failed - no metadata to clean up since we haven't written it yet
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Metadata only after blob content is successfully written
|
|
152
|
+
// Issue 3 fix: Use two-phase commit for metadata - write to temp file first,
|
|
153
|
+
// then atomically rename to final location. This prevents orphan blobs if
|
|
154
|
+
// the process crashes after blob write but before metadata rename.
|
|
155
|
+
let metadataWritten = false;
|
|
156
|
+
try {
|
|
157
|
+
withMetadataLock(metadataPath, () => {
|
|
158
|
+
try {
|
|
159
|
+
const existingMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8")) as BlobMetadata;
|
|
160
|
+
// Compare fields that indicate concurrent write with different metadata.
|
|
161
|
+
// Note: taskId is intentionally NOT part of the conflict check because
|
|
162
|
+
// it represents which task produced the blob and may legitimately differ
|
|
163
|
+
// across processes (last-write-wins for taskId). If concurrent writes
|
|
164
|
+
// have identical mime/retention/producer/originalPath but different
|
|
165
|
+
// taskId, the conflict check passes and both writes succeed — the
|
|
166
|
+
// final taskId reflects whichever write completed last.
|
|
167
|
+
// Callers should not rely on taskId for correctness-critical decisions.
|
|
168
|
+
if (existingMeta.mime !== metadata.mime ||
|
|
169
|
+
existingMeta.retention !== metadata.retention ||
|
|
170
|
+
existingMeta.producer !== metadata.producer ||
|
|
171
|
+
existingMeta.originalPath !== metadata.originalPath) {
|
|
172
|
+
throw new Error(`Concurrent metadata write conflict for blob ${hash}: different metadata values detected. Existing: ${JSON.stringify(existingMeta)}, New: ${JSON.stringify(metadata)}`);
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (err instanceof Error && err.message.includes("ENOENT")) {
|
|
176
|
+
// OK - metadata doesn't exist yet, proceed to write
|
|
177
|
+
} else {
|
|
178
|
+
// Re-throw non-ENOENT errors (e.g., permission denied, corrupt file)
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Issue 3 fix: Two-phase commit for metadata
|
|
183
|
+
// Write to temp file first, then atomically rename to final location.
|
|
184
|
+
// Issue 1 fix: Use atomicWriteFile instead of plain writeSync for the temp file
|
|
185
|
+
// to prevent partial writes on crash. atomicWriteFile uses O_EXCL + write + rename
|
|
186
|
+
// pattern which is consistent with how blob content is written.
|
|
187
|
+
const tempMetaPath = `${metadataPath}.${randomUUID()}.tmp`;
|
|
188
|
+
atomicWriteFile(tempMetaPath, JSON.stringify(metadata, null, 2));
|
|
189
|
+
renameWithRetry(tempMetaPath, metadataPath);
|
|
190
|
+
metadataWritten = true;
|
|
191
|
+
});
|
|
192
|
+
} catch (error) {
|
|
193
|
+
// Issue 4 fix: Clean up orphaned blob if metadata write fails.
|
|
194
|
+
// If metadata write fails (e.g., concurrent conflict), the blob content
|
|
195
|
+
// is orphaned since no metadata references it. Clean it up to reclaim space.
|
|
196
|
+
// Issue 8 fix: Do NOT delete blob content on metadata failure.
|
|
197
|
+
// If metadata write fails due to concurrent conflict (different values),
|
|
198
|
+
// the blob content is still valid. Another process has written metadata
|
|
199
|
+
// referencing this blob - deleting the blob would orphan their metadata.
|
|
200
|
+
// The caller can retry the metadata write if needed.
|
|
201
|
+
// However, if metadata was never written (metadataWritten === false),
|
|
202
|
+
// the blob is orphaned and should be cleaned up.
|
|
203
|
+
if (!blobContentWritten) {
|
|
204
|
+
try { fs.rmSync(blobPath, { force: true }); } catch { /* best-effort */ }
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Issue 2 fix: resolve paths before writes and return cached values
|
|
210
|
+
const resolvedBlobPath = resolveRealContainedPath(artifactsRoot, blobPath);
|
|
211
|
+
const resolvedMetadataPath = resolveRealContainedPath(artifactsRoot, metadataPath);
|
|
212
|
+
return { hash, algorithm, blobPath: resolvedBlobPath, metadataPath: resolvedMetadataPath, sizeBytes: metadata.sizeBytes };
|
|
87
213
|
}
|
|
88
214
|
|
|
89
215
|
/**
|
|
@@ -114,4 +240,46 @@ export function readBlobMetadata(artifactsRoot: string, hash: string): BlobMetad
|
|
|
114
240
|
} catch {
|
|
115
241
|
return undefined;
|
|
116
242
|
}
|
|
117
|
-
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Cleanup orphaned blobs - blobs without corresponding metadata entries.
|
|
246
|
+
* Called periodically to reclaim storage from blobs that were written but whose
|
|
247
|
+
* metadata write failed (e.g., crash between content and metadata write).
|
|
248
|
+
* Returns the number of orphaned blobs cleaned up.
|
|
249
|
+
*/
|
|
250
|
+
export function cleanupOrphanedBlobs(artifactsRoot: string): number {
|
|
251
|
+
const blobDir = path.join(artifactsRoot, BLOBS_DIR, SHA256_PREFIX);
|
|
252
|
+
const metaDir = path.join(artifactsRoot, BLOB_META_DIR);
|
|
253
|
+
|
|
254
|
+
let cleaned = 0;
|
|
255
|
+
try {
|
|
256
|
+
const blobFiles = fs.readdirSync(blobDir);
|
|
257
|
+
for (const blobFile of blobFiles) {
|
|
258
|
+
// Skip non-hash files (e.g., temp files from atomicWriteBuffer)
|
|
259
|
+
if (!SHA256_HEX.test(blobFile)) continue;
|
|
260
|
+
|
|
261
|
+
const metaPath = path.join(metaDir, `${blobFile}.json`);
|
|
262
|
+
// Issue 4 fix: Use lock to prevent race between stat and delete.
|
|
263
|
+
// A concurrent process could write metadata between stat and delete,
|
|
264
|
+
// causing valid blob to be deleted while metadata remains.
|
|
265
|
+
withMetadataLock(metaPath, () => {
|
|
266
|
+
try {
|
|
267
|
+
fs.statSync(metaPath);
|
|
268
|
+
// Metadata exists - blob is not orphaned
|
|
269
|
+
} catch {
|
|
270
|
+
// Metadata does not exist - blob is orphaned, delete it
|
|
271
|
+
const blobPath = path.join(blobDir, blobFile);
|
|
272
|
+
try {
|
|
273
|
+
fs.rmSync(blobPath, { force: true });
|
|
274
|
+
cleaned++;
|
|
275
|
+
} catch {
|
|
276
|
+
// Best-effort cleanup - continue to next blob
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
// Blobs directory doesn't exist or inaccessible - nothing to clean
|
|
283
|
+
}
|
|
284
|
+
return cleaned;
|
|
285
|
+
}
|
package/src/state/contracts.ts
CHANGED
|
@@ -28,7 +28,8 @@ export const TEAM_TASK_STATUS_TRANSITIONS: Readonly<Record<TeamTaskStatus, reado
|
|
|
28
28
|
needs_attention: ["queued", "running"],
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/** @internal */
|
|
32
|
+
const TEAM_EVENT_TYPES = [
|
|
32
33
|
"run.created",
|
|
33
34
|
"run.queued",
|
|
34
35
|
"run.planning",
|
package/src/state/crew-init.ts
CHANGED
|
@@ -3,10 +3,17 @@
|
|
|
3
3
|
* Called on first team run in a workspace to ensure all required
|
|
4
4
|
* directories and files exist.
|
|
5
5
|
*
|
|
6
|
-
* IMPORTANT: This module
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* IMPORTANT: This module is dynamically `import()`'d from concurrent child
|
|
7
|
+
* Pi subprocesses (3+ parallel subagents). Under load, the `path` namespace
|
|
8
|
+
* binding can intermittently arrive as `undefined` in jiti's ESM/CJS interop
|
|
9
|
+
* layer. We therefore use the inline helpers `parseRoot`, `safeJoin`,
|
|
10
|
+
* `safeDirname`, and `safeResolve` so that critical path operations do not
|
|
11
|
+
* depend on the `path` namespace binding.
|
|
12
|
+
*
|
|
13
|
+
* The `node:path` import is retained as a *fallback* (only used when the
|
|
14
|
+
* binding is healthy). Don't add new dependencies on other pi-crew modules.
|
|
15
|
+
*
|
|
16
|
+
* See: https://github.com/baphuongna/pi-crew/issues/28
|
|
10
17
|
*/
|
|
11
18
|
import * as fs from "node:fs";
|
|
12
19
|
import * as path from "node:path";
|
|
@@ -49,25 +56,139 @@ team action='cache' action='clear'
|
|
|
49
56
|
* Inline implementation to avoid module dependency on paths.ts.
|
|
50
57
|
* Matches the logic in src/utils/paths.ts:computeRepoRoot().
|
|
51
58
|
*/
|
|
52
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Detect filesystem root for `start` without relying on `path.parse()`.
|
|
61
|
+
*
|
|
62
|
+
* **Why this exists**: This module is dynamically `import()`'d from concurrent
|
|
63
|
+
* child Pi subprocesses (3+ parallel subagents). Under load, the `path` namespace
|
|
64
|
+
* binding can intermittently arrive as `undefined` in jiti's ESM/CJS interop layer,
|
|
65
|
+
* crashing `findProjectRoot` with `TypeError: Cannot read properties of undefined
|
|
66
|
+
* (reading 'parse')` — see https://github.com/baphuongna/pi-crew/issues/28.
|
|
67
|
+
*
|
|
68
|
+
* Inlining `parse` for the termination root eliminates the dependency on the
|
|
69
|
+
* `path` binding for that critical call path.
|
|
70
|
+
*/
|
|
71
|
+
function parseRoot(start: string): string {
|
|
72
|
+
if (!start) return "/";
|
|
73
|
+
if (start[0] === "/") return "/";
|
|
74
|
+
// Windows: "C:\\" or "C:/" -> "C:\\"
|
|
75
|
+
if (/^[A-Za-z]:[\\/]/.test(start)) return start.slice(0, 3);
|
|
76
|
+
// UNC: "\\\\server\\share" — find the second path separator.
|
|
77
|
+
if (start.startsWith("\\\\") || start.startsWith("//")) {
|
|
78
|
+
const rest = start.slice(2);
|
|
79
|
+
const firstSep = Math.max(rest.indexOf("\\"), rest.indexOf("/"));
|
|
80
|
+
if (firstSep === -1) return start;
|
|
81
|
+
const secondSep = Math.max(
|
|
82
|
+
rest.indexOf("\\", firstSep + 1),
|
|
83
|
+
rest.indexOf("/", firstSep + 1),
|
|
84
|
+
);
|
|
85
|
+
if (secondSep === -1) return start;
|
|
86
|
+
// secondSep is an index into `rest`; add 2 to map back to `start`.
|
|
87
|
+
return start.slice(0, 2 + secondSep);
|
|
88
|
+
}
|
|
89
|
+
// Relative path — no fixed root, use start itself as terminator.
|
|
90
|
+
return start;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Defensive wrappers around `path` for use in dynamic-import contexts.
|
|
95
|
+
*
|
|
96
|
+
* **Why these exist**: This module is dynamically `import()`'d from concurrent
|
|
97
|
+
* child Pi subprocesses (3+ parallel subagents). Under load, the `path` namespace
|
|
98
|
+
* binding can intermittently arrive as `undefined` in jiti's ESM/CJS interop layer,
|
|
99
|
+
* crashing `findProjectRoot` with `TypeError: Cannot read properties of undefined
|
|
100
|
+
* (reading 'parse')` — see https://github.com/baphuongna/pi-crew/issues/28.
|
|
101
|
+
*
|
|
102
|
+
* Each helper checks that the corresponding `path` function exists before
|
|
103
|
+
* calling it, falling back to an inline implementation. This keeps the file
|
|
104
|
+
* self-contained even if the namespace binding is missing.
|
|
105
|
+
*/
|
|
106
|
+
function safeJoin(...parts: string[]): string {
|
|
107
|
+
// Cross-platform join — picks the separator based on the parts.
|
|
108
|
+
// Don't delegate to `path.join` because POSIX/Windows disagree on which
|
|
109
|
+
// separator is the path separator, and the dynamic-import context (issue
|
|
110
|
+
// #28) may have a partially-initialized `path` namespace.
|
|
111
|
+
const filtered = parts.filter(Boolean);
|
|
112
|
+
if (filtered.length === 0) return "";
|
|
113
|
+
const sep = filtered.some((p) => p.includes("\\")) ? "\\" : "/";
|
|
114
|
+
// Detect if the first part begins with a leading separator (or UNC "\\\\")
|
|
115
|
+
// so we can preserve it. F-8: collapses runs of the separator everywhere
|
|
116
|
+
// (including the body), but re-prepends the leading separator that the
|
|
117
|
+
// collapse regex would otherwise eat.
|
|
118
|
+
const firstPart = filtered[0];
|
|
119
|
+
let leading = "";
|
|
120
|
+
if (sep === "\\") {
|
|
121
|
+
if (firstPart.startsWith("\\\\")) leading = "\\\\";
|
|
122
|
+
else if (firstPart.startsWith("\\")) leading = "\\";
|
|
123
|
+
} else if (firstPart.startsWith("/")) {
|
|
124
|
+
leading = "/";
|
|
125
|
+
}
|
|
126
|
+
// Strip the leading separator(s) from the first part before joining, so
|
|
127
|
+
// the collapse regex doesn't re-collapse them.
|
|
128
|
+
const firstPartStripped =
|
|
129
|
+
sep === "\\"
|
|
130
|
+
? firstPart.replace(/^\\{1,2}/, "")
|
|
131
|
+
: firstPart.replace(/^\/+/, "");
|
|
132
|
+
const rest = filtered.slice(1);
|
|
133
|
+
const joined = [firstPartStripped, ...rest].filter(Boolean).join(sep);
|
|
134
|
+
// Collapse internal runs of the separator.
|
|
135
|
+
const collapsed = joined.replace(
|
|
136
|
+
new RegExp(`${sep === "\\" ? "\\\\" : "/"}{2,}`, "g"),
|
|
137
|
+
sep,
|
|
138
|
+
);
|
|
139
|
+
return leading + collapsed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function safeDirname(p: string): string {
|
|
143
|
+
// Cross-platform dirname — handles BOTH `/` and `\` separators.
|
|
144
|
+
// Note: we don't delegate to `path.dirname` here because on POSIX it treats
|
|
145
|
+
// backslashes as part of a filename, and on Windows it treats forward
|
|
146
|
+
// slashes the same way. The dynamic-import context (issue #28) may also
|
|
147
|
+
// have a partially-initialized `path` namespace. Using a unified inline
|
|
148
|
+
// implementation ensures consistent behavior across all platforms.
|
|
149
|
+
const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
|
|
150
|
+
if (idx === -1) return p; // No separator at all
|
|
151
|
+
if (idx === 0) return p[0] === "/" || p[0] === "\\" ? p[0] : p; // Root: "/" or "\"
|
|
152
|
+
// Preserve drive letter roots like "C:\"
|
|
153
|
+
if (idx === 2 && /[A-Za-z]:/.test(p.slice(0, 2))) return p.slice(0, 3);
|
|
154
|
+
return p.slice(0, idx);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function safeResolve(p: string, pathDep?: typeof path): string {
|
|
158
|
+
const dep = pathDep ?? path;
|
|
159
|
+
if (dep && typeof dep.resolve === "function") return dep.resolve(p);
|
|
160
|
+
return p;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function findProjectRoot(
|
|
164
|
+
start: string,
|
|
165
|
+
pathDep?: typeof path,
|
|
166
|
+
): string | undefined {
|
|
53
167
|
const dirMarkers = [".git", ".hg", ".svn"];
|
|
54
|
-
const fileMarkers = [
|
|
55
|
-
|
|
56
|
-
|
|
168
|
+
const fileMarkers = [
|
|
169
|
+
"package.json",
|
|
170
|
+
"pyproject.toml",
|
|
171
|
+
"Cargo.toml",
|
|
172
|
+
"go.mod",
|
|
173
|
+
];
|
|
174
|
+
// Use `parseRoot` (inlined above) to avoid `path.parse` for the critical
|
|
175
|
+
// termination root — fixes the jiti namespace race in issue #28.
|
|
176
|
+
const root = parseRoot(start);
|
|
177
|
+
let current = safeResolve(start, pathDep);
|
|
57
178
|
// Walk up to find project root
|
|
58
179
|
while (current !== root) {
|
|
59
180
|
for (const marker of dirMarkers) {
|
|
60
|
-
if (fs.existsSync(
|
|
181
|
+
if (fs.existsSync(safeJoin(current, marker))) return current;
|
|
61
182
|
}
|
|
62
183
|
for (const marker of fileMarkers) {
|
|
63
|
-
if (fs.existsSync(
|
|
184
|
+
if (fs.existsSync(safeJoin(current, marker))) return current;
|
|
64
185
|
}
|
|
65
|
-
const parent =
|
|
186
|
+
const parent = safeDirname(current);
|
|
66
187
|
if (parent === current) break;
|
|
67
188
|
current = parent;
|
|
68
189
|
}
|
|
69
190
|
// Check root as fallback
|
|
70
|
-
if (dirMarkers.some((m) => fs.existsSync(
|
|
191
|
+
if (dirMarkers.some((m) => fs.existsSync(safeJoin(root, m)))) return root;
|
|
71
192
|
return undefined;
|
|
72
193
|
}
|
|
73
194
|
|
|
@@ -77,12 +198,12 @@ function findProjectRoot(start: string): string | undefined {
|
|
|
77
198
|
*/
|
|
78
199
|
function computeCrewRoot(cwd: string): string {
|
|
79
200
|
const repoRoot = findProjectRoot(cwd) ?? cwd;
|
|
80
|
-
const crewDir =
|
|
201
|
+
const crewDir = safeJoin(repoRoot, ".crew");
|
|
81
202
|
// Keep existing .crew/ stable even when .pi/ exists for project config.
|
|
82
203
|
if (fs.existsSync(crewDir)) return crewDir;
|
|
83
204
|
// Legacy reuse: if .pi/ already exists, namespace under .pi/teams/
|
|
84
|
-
const piDir =
|
|
85
|
-
return fs.existsSync(piDir) ?
|
|
205
|
+
const piDir = safeJoin(repoRoot, ".pi");
|
|
206
|
+
return fs.existsSync(piDir) ? safeJoin(piDir, "teams") : crewDir;
|
|
86
207
|
}
|
|
87
208
|
|
|
88
209
|
/**
|
|
@@ -99,12 +220,12 @@ export async function ensureCrewDirectory(cwd: string): Promise<void> {
|
|
|
99
220
|
// 1. Create directory structure
|
|
100
221
|
const dirs = [
|
|
101
222
|
crewRoot,
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
223
|
+
safeJoin(crewRoot, "state", "runs"),
|
|
224
|
+
safeJoin(crewRoot, "state", "subagents"),
|
|
225
|
+
safeJoin(crewRoot, "artifacts"),
|
|
226
|
+
safeJoin(crewRoot, "cache"),
|
|
227
|
+
safeJoin(crewRoot, "graphs"),
|
|
228
|
+
safeJoin(crewRoot, "audit"),
|
|
108
229
|
];
|
|
109
230
|
|
|
110
231
|
for (const dir of dirs) {
|
|
@@ -115,10 +236,10 @@ export async function ensureCrewDirectory(cwd: string): Promise<void> {
|
|
|
115
236
|
|
|
116
237
|
// 2. Create .gitkeep placeholders in directories that should be tracked
|
|
117
238
|
const placeholders = [
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
239
|
+
safeJoin(crewRoot, "artifacts", ".gitkeep"),
|
|
240
|
+
safeJoin(crewRoot, "cache", ".gitkeep"),
|
|
241
|
+
safeJoin(crewRoot, "graphs", ".gitkeep"),
|
|
242
|
+
safeJoin(crewRoot, "audit", ".gitkeep"),
|
|
122
243
|
];
|
|
123
244
|
|
|
124
245
|
for (const placeholder of placeholders) {
|
|
@@ -128,12 +249,24 @@ export async function ensureCrewDirectory(cwd: string): Promise<void> {
|
|
|
128
249
|
}
|
|
129
250
|
|
|
130
251
|
// 3. Write README.md (always overwrite to keep it current)
|
|
131
|
-
fs.writeFileSync(
|
|
252
|
+
fs.writeFileSync(safeJoin(crewRoot, "README.md"), CREW_README, "utf-8");
|
|
132
253
|
|
|
133
254
|
// 4. Update .gitignore at project root
|
|
134
255
|
const repoRoot = findProjectRoot(cwd);
|
|
135
256
|
if (repoRoot) {
|
|
136
|
-
const gitignorePath =
|
|
257
|
+
const gitignorePath = safeJoin(repoRoot, ".gitignore");
|
|
137
258
|
await updateGitignore(gitignorePath);
|
|
138
259
|
}
|
|
139
|
-
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Exported only for regression tests of issue #28.
|
|
263
|
+
// NOT part of the public API — the `__test__` prefix follows the project
|
|
264
|
+
// convention used in atomic-write.ts, state-store.ts, team-runner.ts, etc.
|
|
265
|
+
// See F-4 in the post-fix review for the convention rationale.
|
|
266
|
+
export const __test__internals = {
|
|
267
|
+
parseRoot,
|
|
268
|
+
safeJoin,
|
|
269
|
+
safeDirname,
|
|
270
|
+
safeResolve,
|
|
271
|
+
findProjectRoot,
|
|
272
|
+
};
|