pi-crew 0.9.26 → 0.9.27

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 (59) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/NOTICE.md +14 -0
  3. package/assets/crew-vibes.ttf +0 -0
  4. package/assets/runner-spritesheet.png +0 -0
  5. package/dist/build-meta.json +255 -40
  6. package/dist/index.mjs +1564 -241
  7. package/dist/index.mjs.map +4 -4
  8. package/docs/perf/optimization-plan-2026-07-verified.md +396 -0
  9. package/package.json +9 -2
  10. package/scripts/bench-check.mjs +96 -0
  11. package/scripts/bench-cold-start.mjs +216 -0
  12. package/scripts/build-bundle.mjs +84 -0
  13. package/scripts/build-crew-vibes-font.py +328 -0
  14. package/scripts/check-all-skills.ts +294 -0
  15. package/scripts/check-bundle-staleness.mjs +97 -0
  16. package/scripts/check-conflict-markers.mjs +91 -0
  17. package/scripts/check-lazy-imports.mjs +28 -0
  18. package/scripts/install-crew-vibes-font.mjs +91 -0
  19. package/scripts/postinstall.mjs +47 -0
  20. package/scripts/profile-startup.mjs +125 -0
  21. package/scripts/release-smoke.mjs +74 -0
  22. package/scripts/run-bench.mjs +47 -0
  23. package/scripts/run-real-chain.ts +41 -0
  24. package/scripts/test-issue-29-crash.ts +111 -0
  25. package/scripts/test-issue-29-e2e.ts +183 -0
  26. package/scripts/test-issue-29-real-runtime.ts +330 -0
  27. package/scripts/test-issue-29-real-tasks.ts +387 -0
  28. package/scripts/test-issue-29-team-tool.ts +105 -0
  29. package/scripts/test-runner.mjs +74 -0
  30. package/scripts/verify-flicker-fix.ts +109 -0
  31. package/scripts/verify-skill.ts +550 -0
  32. package/scripts/watch-bundle.mjs +259 -0
  33. package/scripts/watchdog-harness.ts +119 -0
  34. package/src/agents/discover-agents.ts +6 -1
  35. package/src/config/defaults.ts +6 -0
  36. package/src/extension/crew-vibes/cat-frames.ts +18 -0
  37. package/src/extension/crew-vibes/config.ts +195 -0
  38. package/src/extension/crew-vibes/figures.ts +94 -0
  39. package/src/extension/crew-vibes/font-detect.ts +58 -0
  40. package/src/extension/crew-vibes/index.ts +396 -0
  41. package/src/extension/crew-vibes/provider-usage.ts +330 -0
  42. package/src/extension/crew-vibes/render.ts +206 -0
  43. package/src/extension/crew-vibes/speed.ts +286 -0
  44. package/src/extension/knowledge-injection.ts +12 -3
  45. package/src/extension/management.ts +23 -3
  46. package/src/extension/register.ts +7 -0
  47. package/src/runtime/child-pi.ts +117 -10
  48. package/src/runtime/crew-agent-records.ts +10 -3
  49. package/src/runtime/retry-executor.ts +4 -1
  50. package/src/runtime/task-runner/state-helpers.ts +9 -30
  51. package/src/runtime/team-runner.ts +5 -3
  52. package/src/state/atomic-write.ts +153 -49
  53. package/src/state/event-log.ts +16 -10
  54. package/src/state/locks.ts +7 -8
  55. package/src/state/mailbox.ts +15 -4
  56. package/src/state/state-store.ts +39 -10
  57. package/src/teams/discover-teams.ts +56 -1
  58. package/src/utils/safe-paths.ts +2 -1
  59. package/src/workflows/discover-workflows.ts +72 -1
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Profile pi-crew register cold load.
4
+ *
5
+ * Usage:
6
+ * node scripts/profile-startup.mjs # default 5 iters
7
+ * node scripts/profile-startup.mjs --iters 10
8
+ *
9
+ * Outputs:
10
+ * .profile/startup-<timestamp>.cpuprofile (open in Chrome DevTools)
11
+ * .profile/summary.json (top frames JSON)
12
+ *
13
+ * Implementation note: spawns a child Node with --cpu-prof so the profile
14
+ * captures only the cold-load path (no measurement noise from this driver).
15
+ */
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { execFileSync, spawnSync } from "node:child_process";
19
+ import { pathToFileURL } from "node:url";
20
+
21
+ const root = path.resolve(import.meta.dirname, "..");
22
+ const profileDir = path.join(root, ".profile");
23
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
24
+
25
+ const args = process.argv.slice(2);
26
+ let iters = 5;
27
+ for (let i = 0; i < args.length; i++) {
28
+ if (args[i] === "--iters" && args[i + 1]) {
29
+ iters = Number(args[i + 1]);
30
+ i++;
31
+ }
32
+ }
33
+
34
+ fs.mkdirSync(profileDir, { recursive: true });
35
+
36
+ const driverPath = path.join(profileDir, `_driver-${timestamp}.mjs`);
37
+ const registerImportSpec = pathToFileURL(path.join(root, "src/extension/register.ts")).href;
38
+ const driverSrc = `
39
+ import { performance } from "node:perf_hooks";
40
+ const start = performance.now();
41
+ const { registerPiTeams } = await import(${JSON.stringify(registerImportSpec)});
42
+ const importMs = performance.now() - start;
43
+
44
+ function createEvents() {
45
+ const handlers = new Map();
46
+ return {
47
+ on(event, handler) {
48
+ const set = handlers.get(event) ?? new Set();
49
+ set.add(handler);
50
+ handlers.set(event, set);
51
+ return () => set.delete(handler);
52
+ },
53
+ emit(event, payload) {
54
+ for (const h of handlers.get(event) ?? []) h(payload);
55
+ },
56
+ };
57
+ }
58
+ function createPi(events) {
59
+ return {
60
+ events,
61
+ on(_e, _h) {},
62
+ registerCommand() {},
63
+ registerTool() {},
64
+ appendEntry() {},
65
+ getSessionName() { return undefined; },
66
+ setSessionName() {},
67
+ };
68
+ }
69
+
70
+ const samples = [];
71
+ for (let i = 0; i < ${iters}; i++) {
72
+ const t0 = performance.now();
73
+ const events = createEvents();
74
+ const pi = createPi(events);
75
+ registerPiTeams(pi);
76
+ samples.push(performance.now() - t0);
77
+ }
78
+ process.stdout.write(JSON.stringify({ importMs, samples }) + "\\n");
79
+ `;
80
+ fs.writeFileSync(driverPath, driverSrc, "utf-8");
81
+
82
+ const cpuProfilePath = path.join(profileDir, `startup-${timestamp}.cpuprofile`);
83
+ console.log(`[profile] Running ${iters} iterations with --cpu-prof...`);
84
+
85
+ const result = spawnSync(process.execPath, [
86
+ "--cpu-prof",
87
+ `--cpu-prof-dir=${profileDir}`,
88
+ `--cpu-prof-name=startup-${timestamp}.cpuprofile`,
89
+ "--experimental-strip-types",
90
+ "--no-warnings",
91
+ driverPath,
92
+ ], { encoding: "utf-8", cwd: root });
93
+
94
+ fs.unlinkSync(driverPath);
95
+
96
+ if (result.status !== 0) {
97
+ console.error("[profile] driver failed:", result.stderr || result.stdout);
98
+ process.exit(result.status ?? 1);
99
+ }
100
+
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(result.stdout.trim().split("\n").pop());
104
+ } catch (error) {
105
+ console.error("[profile] could not parse driver output:", result.stdout);
106
+ process.exit(2);
107
+ }
108
+
109
+ const samples = parsed.samples;
110
+ samples.sort((a, b) => a - b);
111
+ const p = (q) => samples[Math.min(samples.length - 1, Math.floor((samples.length - 1) * q))];
112
+ const summary = {
113
+ timestamp,
114
+ iters,
115
+ importMs: round(parsed.importMs),
116
+ registerMs: { min: round(samples[0]), p50: round(p(0.5)), p95: round(p(0.95)), p99: round(p(0.99)), max: round(samples[samples.length - 1]) },
117
+ cpuProfile: path.relative(root, cpuProfilePath).replace(/\\/g, "/"),
118
+ };
119
+ fs.writeFileSync(path.join(profileDir, "summary.json"), JSON.stringify(summary, null, 2) + "\n", "utf-8");
120
+
121
+ console.log("[profile] Summary:", JSON.stringify(summary, null, 2));
122
+ console.log("[profile] CPU profile:", cpuProfilePath);
123
+ console.log("[profile] Open in Chrome DevTools → Performance → Load profile.");
124
+
125
+ function round(n) { return Math.round(n * 100) / 100; }
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Release smoke test — verifies packed tarball loads correctly in a temp project.
4
+ * Run: node scripts/release-smoke.mjs
5
+ */
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import { execSync } from "node:child_process";
10
+
11
+ const root = path.resolve(import.meta.dirname, "..");
12
+
13
+ function log(msg) {
14
+ console.log(`[release-smoke] ${msg}`);
15
+ }
16
+
17
+ function run(cmd, cwd) {
18
+ log(` $ ${cmd}`);
19
+ execSync(cmd, { cwd, stdio: "pipe", timeout: 60_000 });
20
+ }
21
+
22
+ try {
23
+ // 1. Read version from package.json
24
+ const rootPkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf-8"));
25
+ const version = rootPkg.version;
26
+ log(`Package version: ${version}`);
27
+
28
+ // 2. Pack tarball
29
+ log("Packing tarball...");
30
+ execSync("npm pack", { cwd: root, stdio: "pipe", timeout: 60_000 });
31
+ const tarballName = `pi-crew-${version}.tgz`;
32
+ const tarballPath = path.join(root, tarballName);
33
+ if (!fs.existsSync(tarballPath)) throw new Error(`Tarball not found: ${tarballPath}`);
34
+ log(` Tarball: ${tarballName}`);
35
+
36
+ // 3. Create temp project
37
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-smoke-"));
38
+ log(`Temp project: ${tmpDir}`);
39
+ try {
40
+ run("npm init -y", tmpDir);
41
+
42
+ // 4. Install packed tarball
43
+ run(`npm install ${tarballPath}`, tmpDir);
44
+
45
+ // 5. Verify extension loads
46
+ const pkgPath = path.join(tmpDir, "node_modules", "pi-crew", "package.json");
47
+ if (!fs.existsSync(pkgPath)) throw new Error("pi-crew package not found in node_modules");
48
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
49
+ log(` Installed version: ${pkg.version}`);
50
+
51
+ // 6. Verify key entrypoints exist
52
+ const srcRegister = path.join(tmpDir, "node_modules", "pi-crew", "src", "extension", "register.ts");
53
+ if (fs.existsSync(srcRegister)) {
54
+ log(` Extension register entrypoint found: ${srcRegister}`);
55
+ } else {
56
+ throw new Error("Could not find extension register entrypoint");
57
+ }
58
+
59
+ // 7. Verify version consistency
60
+ if (pkg.version !== version) {
61
+ throw new Error(`Version mismatch: root=${version} installed=${pkg.version}`);
62
+ }
63
+ log(` Version consistency: OK ${pkg.version}`);
64
+
65
+ log("Release smoke test PASSED!");
66
+ } finally {
67
+ fs.rmSync(tmpDir, { recursive: true, force: true });
68
+ // Clean up tarball
69
+ try { fs.unlinkSync(tarballPath); } catch {}
70
+ }
71
+ } catch (error) {
72
+ console.error("Release smoke test FAILED:", error instanceof Error ? error.message : String(error));
73
+ process.exit(1);
74
+ }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Run all benches, collect JSON output, write to test/bench/results.json.
4
+ *
5
+ * Each bench prints a single JSON line on stdout (NDJSON). Earlier lines may
6
+ * be ignored. Failures abort the run.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+
12
+ const root = path.resolve(import.meta.dirname, "..");
13
+ const benchDir = path.join(root, "test", "bench");
14
+ const benches = fs.readdirSync(benchDir).filter((f) => f.endsWith(".bench.ts"));
15
+
16
+ const results = {};
17
+ for (const bench of benches) {
18
+ const benchPath = path.join(benchDir, bench);
19
+ console.log(`[bench] running ${bench}...`);
20
+ const t0 = Date.now();
21
+ const result = spawnSync(process.execPath, [
22
+ "--experimental-strip-types",
23
+ "--no-warnings",
24
+ benchPath,
25
+ ], { encoding: "utf-8", cwd: root, timeout: 600_000 });
26
+ if (result.status !== 0) {
27
+ console.error(result.stderr || result.stdout);
28
+ process.exit(result.status ?? 1);
29
+ }
30
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
31
+ const lines = result.stdout.trim().split("\n").filter(Boolean);
32
+ let parsed;
33
+ for (const line of lines.reverse()) {
34
+ try { parsed = JSON.parse(line); break; } catch { /* skip non-JSON */ }
35
+ }
36
+ if (!parsed?.name) {
37
+ console.error(`[bench] could not parse JSON output from ${bench}\n${result.stdout}`);
38
+ process.exit(2);
39
+ }
40
+ results[parsed.name] = parsed;
41
+ console.log(`[bench] ${parsed.name} done in ${elapsed}s`);
42
+ }
43
+
44
+ const outPath = path.join(benchDir, "results.json");
45
+ const payload = { capturedAt: new Date().toISOString(), node: process.version, platform: process.platform, results };
46
+ fs.writeFileSync(outPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
47
+ console.log(`[bench] wrote ${outPath}`);
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Real end-to-end chain execution test.
3
+ *
4
+ * Runs two ACTUAL team runs sequentially via the chain feature, with step 2's
5
+ * worker receiving step 1's handoff context (including output text). Prints
6
+ * both step runIds for inspection.
7
+ *
8
+ * Usage: npx tsx scripts/run-real-chain.ts
9
+ */
10
+
11
+ import type { TeamContext } from "../src/extension/team-tool/context.ts";
12
+ import { handleRun } from "../src/extension/team-tool/run.ts";
13
+
14
+ const chain = '"Say the numbers 1, 2, 3" -> "What was the last number in the previous step? Add 2 to it."';
15
+ const ctx: TeamContext = { cwd: process.cwd() };
16
+
17
+ console.log("[run-real-chain] Executing chain:", chain);
18
+ console.log("[run-real-chain] Working directory:", process.cwd());
19
+ console.log("[run-real-chain] This will spawn REAL team runs. Please wait...\n");
20
+
21
+ const result = await handleRun({ action: "run", chain }, ctx);
22
+
23
+ console.log("\n=== CHAIN RESULT (details) ===");
24
+ console.log(JSON.stringify(result.details, null, 2));
25
+
26
+ const text = result.content?.[0];
27
+ if (text && "text" in text) {
28
+ console.log("\n=== SUMMARY ===");
29
+ console.log(text.text);
30
+ }
31
+
32
+ // Extract runIds for easy inspection.
33
+ const runIds = (result.details as { data?: { runIds?: string[] } }).data?.runIds;
34
+ if (runIds && runIds.length > 0) {
35
+ console.log("\n=== STEP RUN IDS ===");
36
+ for (let i = 0; i < runIds.length; i++) {
37
+ console.log(`Step ${i + 1}: ${runIds[i]}`);
38
+ }
39
+ } else {
40
+ console.log("\n[run-real-chain] WARNING: no runIds found in result.");
41
+ }
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ /**
3
+ * Crash reproduction test for issue #29: verifies the defense-in-depth fix
4
+ * in subagent-manager.ts:start() prevents unhandled rejections from
5
+ * crashing the host process.
6
+ *
7
+ * Strategy: spawn a subagent whose runner throws, then do NOT await
8
+ * record.promise. Without the fix, this triggers unhandledRejection which
9
+ * Node.js converts to uncaughtException. With the fix, the .catch inside
10
+ * start() absorbs the rejection.
11
+ *
12
+ * Run with: npx tsx scripts/test-issue-29-crash.ts
13
+ */
14
+
15
+ import * as fs from "node:fs";
16
+ import * as os from "node:os";
17
+ import * as path from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-issue-29-crash-"));
21
+ fs.mkdirSync(path.join(tmpDir, ".pi"), { recursive: true });
22
+
23
+ console.log("Issue #29 CRASH reproduction test");
24
+ console.log(`Project: ${tmpDir}`);
25
+ console.log();
26
+
27
+ let crashed = false;
28
+ let crashError: Error | undefined;
29
+ const crashEvents: string[] = [];
30
+
31
+ process.on("uncaughtException", (error) => {
32
+ crashed = true;
33
+ crashError = error;
34
+ crashEvents.push(`uncaughtException: ${error.message}`);
35
+ console.error(`[CRASH] uncaughtException: ${error.message}`);
36
+ });
37
+
38
+ process.on("unhandledRejection", (reason) => {
39
+ crashed = true;
40
+ const err = reason instanceof Error ? reason : new Error(String(reason));
41
+ crashError = err;
42
+ crashEvents.push(`unhandledRejection: ${err.message}`);
43
+ console.error(`[CRASH] unhandledRejection: ${err.message}`);
44
+ });
45
+
46
+ async function main(): Promise<void> {
47
+ const piCrewRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
48
+ const { SubagentManager } = await import(path.join(piCrewRoot, "src/runtime/subagent-manager.ts"));
49
+
50
+ const mgr = new SubagentManager();
51
+
52
+ const runnerThatThrows = async (): Promise<never> => {
53
+ throw new Error("simulated subagent failure (issue #29 crash test)");
54
+ };
55
+
56
+ const record = mgr.spawn(
57
+ {
58
+ cwd: tmpDir,
59
+ type: "test",
60
+ description: "issue-29 crash test",
61
+ prompt: "throw",
62
+ background: false,
63
+ },
64
+ runnerThatThrows as Parameters<typeof mgr.spawn>[1],
65
+ );
66
+
67
+ console.log(`Spawned subagent ${record.id}`);
68
+
69
+ // Do NOT await — this is the scenario that crashes pi.
70
+ console.log("Waiting 300ms without awaiting record.promise...");
71
+ await new Promise((resolve) => setTimeout(resolve, 300));
72
+
73
+ console.log();
74
+ console.log(`Results:`);
75
+ console.log(` record.status = ${record.status}`);
76
+ console.log(` process.crashed = ${crashed}`);
77
+ console.log(` crashEvents = ${JSON.stringify(crashEvents)}`);
78
+ console.log();
79
+
80
+ fs.rmSync(tmpDir, { recursive: true, force: true });
81
+
82
+ if (crashed) {
83
+ console.error("✗ FAIL: process crashed (uncaughtException/unhandledRejection fired)");
84
+ console.error(" The defense-in-depth fix in subagent-manager.ts did NOT prevent the crash.");
85
+ if (crashError?.stack) {
86
+ console.error(" Stack trace:");
87
+ console.error(
88
+ crashError.stack
89
+ .split("\n")
90
+ .slice(0, 10)
91
+ .map((l) => ` ${l}`)
92
+ .join("\n"),
93
+ );
94
+ }
95
+ process.exit(1);
96
+ }
97
+
98
+ if (record.status !== "error") {
99
+ console.error(`✗ FAIL: record.status should be 'error', got '${record.status}'`);
100
+ process.exit(1);
101
+ }
102
+
103
+ console.log("✓ PASS: no crash, record marked 'error'");
104
+ process.exit(0);
105
+ }
106
+
107
+ main().catch((error) => {
108
+ console.error("Test script itself crashed:");
109
+ console.error(error);
110
+ process.exit(1);
111
+ });
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ /**
3
+ * End-to-end test for issue #29: "Hardcoded .crew/state/runs path crashes pi
4
+ * (uncaughtException) in .pi-based projects".
5
+ *
6
+ * This script reproduces the EXACT scenario from the issue:
7
+ * 1. Create a .pi/-only project (no .crew/ directory).
8
+ * 2. Set up an uncaughtException + unhandledRejection detector.
9
+ * 3. Trigger the bug path: start a subagent whose runner throws, then
10
+ * NOT await record.promise (mimics the indirect caller pattern
11
+ * described in the issue).
12
+ * 4. Exit 0 if no crash, exit 1 if crash detected.
13
+ *
14
+ * Run with: npx tsx scripts/test-issue-29-e2e.ts
15
+ *
16
+ * The script must be run via `tsx` so that the dynamic ESM import of
17
+ * subagent-manager resolves correctly.
18
+ */
19
+
20
+ import * as fs from "node:fs";
21
+ import * as os from "node:os";
22
+ import * as path from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ // ── Step 1: Set up a .pi/-only project (no .crew/) ──────────────────────
26
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-issue-29-e2e-"));
27
+ fs.mkdirSync(path.join(tmpDir, ".pi"), { recursive: true });
28
+ // CRITICALLY: no .crew/ directory
29
+ const projectRoot = tmpDir;
30
+
31
+ console.log("=".repeat(72));
32
+ console.log("Issue #29 end-to-end reproduction test");
33
+ console.log("=".repeat(72));
34
+ console.log(`Project root: ${projectRoot}`);
35
+ console.log(`Has .pi/: ${fs.existsSync(path.join(projectRoot, ".pi"))}`);
36
+ console.log(`Has .crew/: ${fs.existsSync(path.join(projectRoot, ".crew"))} (should be false)`);
37
+ console.log();
38
+
39
+ // ── Step 2: Install crash detectors ──────────────────────────────────────
40
+ let crashed = false;
41
+ let crashError: Error | undefined;
42
+
43
+ process.on("uncaughtException", (error) => {
44
+ crashed = true;
45
+ crashError = error;
46
+ console.error("✗ FATAL: uncaughtException fired");
47
+ console.error(" This is the bug described in issue #29.");
48
+ console.error(` Error: ${error.message}`);
49
+ if (error.stack) console.error(` Stack: ${error.stack.split("\n").slice(0, 8).join("\n")}`);
50
+ });
51
+
52
+ process.on("unhandledRejection", (reason) => {
53
+ crashed = true;
54
+ const reasonErr = reason instanceof Error ? reason : new Error(String(reason));
55
+ crashError = reasonErr;
56
+ console.error("✗ FATAL: unhandledRejection fired");
57
+ console.error(" This is the bug described in issue #29.");
58
+ console.error(` Reason: ${reasonErr.message}`);
59
+ if (reasonErr.stack) console.error(` Stack: ${reasonErr.stack.split("\n").slice(0, 8).join("\n")}`);
60
+ });
61
+
62
+ // ── Step 3: Trigger the bug path ─────────────────────────────────────────
63
+
64
+ async function main(): Promise<void> {
65
+ const piCrewRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
66
+
67
+ // Dynamic import so the .ts files are loaded through tsx, not require.
68
+ const { SubagentManager } = await import(path.join(piCrewRoot, "src/runtime/subagent-manager.ts"));
69
+ const { waitForRun } = await import(path.join(piCrewRoot, "src/runtime/run-tracker.ts"));
70
+ const { projectCrewRoot } = await import(path.join(piCrewRoot, "src/utils/paths.ts"));
71
+
72
+ // Verify the resolver returns the .pi/teams/ path for our project.
73
+ const resolvedRoot = projectCrewRoot(projectRoot);
74
+ console.log(`projectCrewRoot(projectRoot) = ${resolvedRoot}`);
75
+ const expectedRoot = path.join(projectRoot, ".pi", "teams");
76
+ if (resolvedRoot !== expectedRoot) {
77
+ console.error(`✗ projectCrewRoot returned ${resolvedRoot}, expected ${expectedRoot}`);
78
+ process.exit(1);
79
+ }
80
+ console.log("✓ projectCrewRoot correctly resolves to .pi/teams/");
81
+ console.log();
82
+
83
+ // ── Test 3a: Direct waitForRun() crash path ─────────────────────────
84
+ console.log("Test 3a: Direct waitForRun() with non-existent runId");
85
+ console.log("-".repeat(72));
86
+ const startA = Date.now();
87
+ try {
88
+ await waitForRun("team_e2e_never_exists_xyz", projectRoot, {
89
+ timeoutMs: 500,
90
+ pollIntervalMs: 50,
91
+ });
92
+ console.error("✗ waitForRun should have thrown");
93
+ } catch (error) {
94
+ const msg = (error as Error).message;
95
+ const elapsed = Date.now() - startA;
96
+ console.log(`✓ waitForRun threw after ${elapsed}ms (as expected)`);
97
+ console.log(` Message: ${msg}`);
98
+ if (msg.includes(".pi/teams")) {
99
+ console.log("✓ Error message references .pi/teams/ (resolver applied)");
100
+ } else if (msg.includes(".crew/state/runs")) {
101
+ console.error(`✗ Error message still references .crew/state/runs/ (BUG NOT FIXED)`);
102
+ process.exit(1);
103
+ } else {
104
+ console.error(`✗ Error message doesn't reference expected path: ${msg}`);
105
+ process.exit(1);
106
+ }
107
+ }
108
+ console.log();
109
+
110
+ // ── Test 3b: Indirect crash path via SubagentManager ────────────────
111
+ // This is the ACTUAL crash path: subagent fails → record.promise rejects
112
+ // → no caller awaits → unhandled rejection → pi crashes.
113
+ console.log("Test 3b: SubagentManager with runner that throws (no awaiter)");
114
+ console.log("-".repeat(72));
115
+ const mgr = new SubagentManager();
116
+
117
+ const runnerThatThrows = async (): Promise<never> => {
118
+ // Simulate a failed run lookup inside the subagent — this is what
119
+ // waitForRun() throws when the run directory doesn't exist.
120
+ // The runner returns a synthetic result, and then pollRunToTerminal
121
+ // would call waitForRun internally, but to keep the test focused we
122
+ // throw directly from the runner (mimics any unhandled subagent failure).
123
+ throw new Error(
124
+ `Run team_e2e_simulated not found. No run directory at ${path.join(projectRoot, ".crew", "state", "runs", "team_e2e_simulated")}`,
125
+ );
126
+ };
127
+
128
+ const record = mgr.spawn(
129
+ {
130
+ cwd: projectRoot,
131
+ type: "test",
132
+ description: "issue-29-e2e test",
133
+ prompt: "simulate failure",
134
+ background: false,
135
+ },
136
+ runnerThatThrows as Parameters<typeof mgr.spawn>[1],
137
+ );
138
+
139
+ console.log(`Spawned subagent ${record.id} (status: ${record.status})`);
140
+
141
+ // CRITICALLY: do NOT await record.promise — this is the scenario
142
+ // described in the issue where the throw escapes as an unhandled
143
+ // rejection because no caller awaits.
144
+ console.log("Waiting 500ms without awaiting record.promise...");
145
+ await new Promise((resolve) => setTimeout(resolve, 500));
146
+
147
+ console.log(`After 500ms:`);
148
+ console.log(` record.status = ${record.status}`);
149
+ console.log(` process.crashed = ${crashed}`);
150
+ console.log();
151
+
152
+ // ── Step 4: Final verdict ───────────────────────────────────────────
153
+ if (crashed) {
154
+ console.error("=".repeat(72));
155
+ console.error("✗ TEST FAILED: process crashed (uncaughtException or unhandledRejection)");
156
+ console.error("=".repeat(72));
157
+ console.error("The fix in commit a80fe6c did NOT prevent the crash.");
158
+ process.exit(1);
159
+ }
160
+
161
+ if (record.status !== "error") {
162
+ console.error(`✗ TEST FAILED: record.status should be 'error', got '${record.status}'`);
163
+ process.exit(1);
164
+ }
165
+
166
+ console.log("=".repeat(72));
167
+ console.log("✓ ALL TESTS PASSED");
168
+ console.log(" - projectCrewRoot correctly resolves .pi/teams/");
169
+ console.log(" - waitForRun() throws with .pi/teams/ in error message");
170
+ console.log(" - SubagentManager rejection is caught by defense-in-depth .catch");
171
+ console.log(" - No uncaughtException or unhandledRejection fired");
172
+ console.log("=".repeat(72));
173
+
174
+ // Cleanup
175
+ fs.rmSync(tmpDir, { recursive: true, force: true });
176
+ process.exit(0);
177
+ }
178
+
179
+ main().catch((error) => {
180
+ console.error("Test script itself crashed:");
181
+ console.error(error);
182
+ process.exit(1);
183
+ });