taskplane 0.25.4 → 0.25.6

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.
@@ -25,7 +25,8 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
25
25
  import { Type } from "@mariozechner/pi-ai";
26
26
  import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "fs";
27
27
  import { join, dirname } from "path";
28
- import { spawn as nodeSpawn, spawnSync } from "child_process";
28
+ import { spawn as nodeSpawn } from "child_process";
29
+ import { resolvePiCliPath, resolveTaskplaneAgentTemplate } from "./path-resolver.ts";
29
30
  import { randomBytes } from "crypto";
30
31
  import { buildExpansionRequestId, type SegmentExpansionRequest } from "./types.ts";
31
32
 
@@ -356,73 +357,22 @@ export default function (pi: ExtensionAPI) {
356
357
  // The reviewer runs as a separate Pi process, writes feedback to
357
358
  // .reviews/, and this tool returns the verdict to the worker.
358
359
 
359
- /**
360
- * Resolve the Pi CLI entrypoint path (same logic as agent-host.ts).
361
- */
362
- let _npmRootCache: string | null = null;
363
- function getNpmGlobalRoot(): string {
364
- if (_npmRootCache !== null) return _npmRootCache;
365
- try {
366
- const result = spawnSync("npm", ["root", "-g"], { encoding: "utf-8", timeout: 5000, shell: true });
367
- _npmRootCache = result.stdout?.trim() || "";
368
- } catch { _npmRootCache = ""; }
369
- return _npmRootCache;
370
- }
371
-
372
- function resolvePiCli(): string {
373
- const relPath = join("@mariozechner", "pi-coding-agent", "dist", "cli.js");
374
- const candidates: string[] = [];
375
360
 
376
- // Dynamic: npm root -g (covers nvm, Homebrew, volta, and all custom prefixes)
377
- const npmRoot = getNpmGlobalRoot();
378
- if (npmRoot) candidates.push(join(npmRoot, relPath));
379
-
380
- // Well-known static paths
381
- const home = process.env.HOME || process.env.USERPROFILE || "";
382
- if (process.env.APPDATA) candidates.push(join(process.env.APPDATA, "npm", "node_modules", relPath));
383
- if (home) {
384
- candidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", relPath));
385
- candidates.push(join(home, ".npm-global", "lib", "node_modules", relPath));
386
- }
387
- candidates.push(join("/usr", "local", "lib", "node_modules", relPath));
388
- candidates.push(join("/opt", "homebrew", "lib", "node_modules", relPath));
389
-
390
- for (const c of candidates) {
391
- if (existsSync(c)) return c;
392
- }
393
- throw new Error("Cannot find Pi CLI entrypoint. Run: npm root -g to verify your npm global path.");
394
- }
395
361
 
396
362
  /**
397
363
  * Load the reviewer system prompt from base template + local override.
364
+ * Uses resolveTaskplaneAgentTemplate (path-resolver.ts) for all platform support (TP-157).
398
365
  */
399
366
  function loadReviewerPrompt(): string {
400
- const relPath = join("taskplane", "templates", "agents", "task-reviewer.md");
401
- const candidates: string[] = [];
402
-
403
- // Dynamic: npm root -g
404
- const npmRoot = getNpmGlobalRoot();
405
- if (npmRoot) candidates.push(join(npmRoot, relPath));
406
-
407
- // Well-known static paths
408
- const home = process.env.HOME || process.env.USERPROFILE || "";
409
- if (process.env.APPDATA) candidates.push(join(process.env.APPDATA, "npm", "node_modules", relPath));
410
- if (home) {
411
- candidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", relPath));
412
- candidates.push(join(home, ".npm-global", "lib", "node_modules", relPath));
413
- }
414
- candidates.push(join("/usr", "local", "lib", "node_modules", relPath));
415
- candidates.push(join("/opt", "homebrew", "lib", "node_modules", relPath));
416
-
417
367
  let basePrompt = "You are a code reviewer. Read the request and write your review to the specified output file.";
418
- for (const p of candidates) {
419
- try {
420
- if (!existsSync(p)) continue;
421
- const raw = readFileSync(p, "utf-8");
368
+ try {
369
+ const templatePath = resolveTaskplaneAgentTemplate("task-reviewer");
370
+ if (existsSync(templatePath)) {
371
+ const raw = readFileSync(templatePath, "utf-8");
422
372
  const fmEnd = raw.indexOf("---", 4);
423
- if (fmEnd > 0) { basePrompt = raw.slice(fmEnd + 3).trim(); break; }
424
- } catch { continue; }
425
- }
373
+ if (fmEnd > 0) basePrompt = raw.slice(fmEnd + 3).trim();
374
+ }
375
+ } catch { /* fall through to default */ }
426
376
  // Local override
427
377
  const localPaths = [join(process.cwd(), ".pi", "agents", "task-reviewer.md"), join(process.cwd(), "agents", "task-reviewer.md")];
428
378
  for (const p of localPaths) {
@@ -479,7 +429,7 @@ export default function (pi: ExtensionAPI) {
479
429
  // Pre-clean stale reviewer state from prior interrupted review
480
430
  removeReviewerState(taskFolder);
481
431
  return new Promise((resolve) => {
482
- const cliPath = resolvePiCli();
432
+ const cliPath = resolvePiCliPath();
483
433
  const args = [
484
434
  cliPath, "--mode", "rpc", "--no-session", "--no-extensions", "--no-skills",
485
435
  "--tools", "read,write,edit,bash,grep,find,ls",
@@ -20,7 +20,7 @@
20
20
  * @since TP-104
21
21
  */
22
22
 
23
- import { spawn, spawnSync, type ChildProcess } from "child_process";
23
+ import { spawn, type ChildProcess } from "child_process";
24
24
  import {
25
25
  readFileSync, writeFileSync, appendFileSync, mkdirSync,
26
26
  existsSync, readdirSync, renameSync,
@@ -45,67 +45,12 @@ import {
45
45
  writeRegistrySnapshot,
46
46
  } from "./process-registry.ts";
47
47
  import { appendMailboxAuditEvent } from "./mailbox.ts";
48
+ import { resolvePiCliPath } from "./path-resolver.ts";
48
49
 
49
50
  // ── Pi CLI Resolution ────────────────────────────────────────────────
51
+ // resolvePiCliPath() is imported from path-resolver.ts and re-exported below (TP-157)
50
52
 
51
- /**
52
- * Resolve the Pi CLI JS entrypoint for direct spawning.
53
- *
54
- * On Windows, `pi` resolves to a .CMD shim which cannot be spawned
55
- * with `shell: false`. This function resolves the underlying JS file.
56
- *
57
- * Resolution order:
58
- * 1. npm root -g (dynamic — covers nvm, Homebrew, volta, custom prefix)
59
- * 2. APPDATA/npm/node_modules/... (Windows)
60
- * 3. HOME/.npm-global/lib/node_modules/...
61
- * 4. /usr/local/lib/node_modules/...
62
- * 5. /opt/homebrew/lib/node_modules/... (macOS Homebrew)
63
- *
64
- * @returns Absolute path to the Pi CLI JS entrypoint
65
- * @throws Error if Pi CLI cannot be found
66
- *
67
- * @since TP-104
68
- */
69
- let _npmGlobalRootCache: string | null = null;
70
- function getNpmGlobalRoot(): string {
71
- if (_npmGlobalRootCache !== null) return _npmGlobalRootCache;
72
- try {
73
- const result = spawnSync("npm", ["root", "-g"], { encoding: "utf-8", timeout: 5000, shell: true });
74
- _npmGlobalRootCache = result.stdout?.trim() || "";
75
- } catch {
76
- _npmGlobalRootCache = "";
77
- }
78
- return _npmGlobalRootCache;
79
- }
80
-
81
- export function resolvePiCliPath(): string {
82
- const relPath = join("@mariozechner", "pi-coding-agent", "dist", "cli.js");
83
- const candidates: string[] = [];
84
-
85
- // Dynamic first: covers nvm, Homebrew, volta, custom npm prefix
86
- const npmRoot = getNpmGlobalRoot();
87
- if (npmRoot) candidates.push(join(npmRoot, relPath));
88
-
89
- // Well-known static fallbacks
90
- const home = process.env.HOME || process.env.USERPROFILE || "";
91
- if (process.env.APPDATA) candidates.push(join(process.env.APPDATA, "npm", "node_modules", relPath));
92
- if (home) {
93
- candidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", relPath));
94
- candidates.push(join(home, ".npm-global", "lib", "node_modules", relPath));
95
- }
96
- candidates.push(join("/usr", "local", "lib", "node_modules", relPath));
97
- candidates.push(join("/opt", "homebrew", "lib", "node_modules", relPath));
98
-
99
- for (const candidate of candidates) {
100
- if (existsSync(candidate)) return candidate;
101
- }
102
-
103
- throw new Error(
104
- "Cannot find Pi CLI entrypoint (cli.js). Ensure pi is installed globally via 'pi install'. " +
105
- `npm root -g: ${npmRoot || "(not found)"}`,
106
- );
107
- }
108
-
53
+ export { resolvePiCliPath };
109
54
  // ── Conversation Payload Helpers (TP-111) ───────────────────────────────
110
55
 
111
56
  /** Maximum characters for conversation event text payloads. */
@@ -4,102 +4,20 @@
4
4
  */
5
5
  import { readFileSync, existsSync, statSync, unlinkSync, mkdirSync, writeFileSync, copyFileSync } from "fs";
6
6
  import { access as fsAccess, readFile as fsReadFile, stat as fsStat } from "fs/promises";
7
- import { spawnSync } from "child_process";
8
7
  import { join, dirname, basename, resolve, relative, delimiter as pathDelimiter } from "path";
9
8
  import { userInfo } from "os";
10
9
 
11
10
  import { DONE_GRACE_MS, EXECUTION_POLL_INTERVAL_MS, ExecutionError, SESSION_SPAWN_RETRY_MAX } from "./types.ts";
12
11
  import type { AllocatedLane, AllocatedTask, DependencyGraph, LaneExecutionResult, LaneMonitorSnapshot, LaneTaskOutcome, LaneTaskStatus, MonitorState, MtimeTracker, OrchestratorConfig, ParsedTask, TaskMonitorSnapshot, WaveExecutionResult, WorkspaceConfig, ExecutionUnit, PacketPaths, RuntimeAgentId, RuntimeAgentRole, SupervisorAlertCallback } from "./types.ts";
13
12
  import { resolvePacketPaths, buildRuntimeAgentId } from "./types.ts";
14
- import { readRegistrySnapshot, readLaneSnapshot, isTerminalStatus, isProcessAlive } from "./process-registry.ts";
13
+ import { readRegistrySnapshot, readLaneSnapshot, isTerminalStatus, isProcessAlive, detectOrphans, markOrphansCrashed, buildRegistrySnapshot, writeRegistrySnapshot } from "./process-registry.ts";
15
14
  import { allocateLanes } from "./waves.ts";
16
15
  import { resolveOperatorId } from "./naming.ts";
17
16
  import { runGit } from "./git.ts";
17
+ import { resolveTaskplanePackageFile, resolveTaskplaneAgentTemplate } from "./path-resolver.ts";
18
18
 
19
19
  // ── Taskplane Package File Resolution ────────────────────────────────
20
-
21
- /**
22
- * Cached result of `npm root -g` to avoid repeated child process spawns.
23
- * null = not yet resolved, "" = resolution failed.
24
- */
25
- let _npmGlobalRoot: string | null = null;
26
-
27
- /**
28
- * Get the global npm root directory via `npm root -g`.
29
- * Result is cached for the process lifetime.
30
- */
31
- function getNpmGlobalRoot(): string {
32
- if (_npmGlobalRoot !== null) return _npmGlobalRoot;
33
- try {
34
- const result = spawnSync("npm", ["root", "-g"], {
35
- encoding: "utf-8",
36
- timeout: 5000,
37
- shell: true,
38
- });
39
- _npmGlobalRoot = result.stdout?.trim() || "";
40
- } catch {
41
- _npmGlobalRoot = "";
42
- }
43
- return _npmGlobalRoot;
44
- }
45
-
46
- /**
47
- * Resolve a file path within the taskplane package.
48
- *
49
- * Resolution order:
50
- * 1. Local project: {repoRoot}/{relPath} (for taskplane development)
51
- * 2. `npm root -g` based: {npmGlobalRoot}/taskplane/{relPath}
52
- * (covers Homebrew, nvm, volta, pnpm, and any custom npm prefix)
53
- * 3. Well-known global npm paths (Windows/macOS/Linux):
54
- * - {APPDATA}/npm/node_modules/taskplane/{relPath}
55
- * - {HOME}/.npm-global/lib/node_modules/taskplane/{relPath}
56
- * - /usr/local/lib/node_modules/taskplane/{relPath}
57
- * - /opt/homebrew/lib/node_modules/taskplane/{relPath}
58
- * 4. Peer of pi's package: resolve from pi's binary location
59
- *
60
- * @param repoRoot - Absolute path to the project root
61
- * @param relPath - Relative path within the taskplane package (e.g., "bin/rpc-wrapper.mjs")
62
- * @returns Absolute path to the resolved file
63
- */
64
- function resolveTaskplanePackageFile(repoRoot: string, relPath: string): string {
65
- // 1. Local project (taskplane development)
66
- const localPath = join(resolve(repoRoot), relPath);
67
- if (existsSync(localPath)) return localPath;
68
-
69
- const candidates: string[] = [];
70
-
71
- // 2. Dynamic: `npm root -g` (covers ALL npm setups: nvm, Homebrew, volta, etc.)
72
- const npmRoot = getNpmGlobalRoot();
73
- if (npmRoot) {
74
- candidates.push(join(npmRoot, "taskplane", relPath));
75
- }
76
-
77
- // 3. Well-known static paths
78
- const home = process.env.HOME || process.env.USERPROFILE || "";
79
- if (process.env.APPDATA) {
80
- candidates.push(join(process.env.APPDATA, "npm", "node_modules", "taskplane", relPath));
81
- }
82
- if (home) {
83
- candidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", "taskplane", relPath));
84
- candidates.push(join(home, ".npm-global", "lib", "node_modules", "taskplane", relPath));
85
- }
86
- candidates.push(join("/usr", "local", "lib", "node_modules", "taskplane", relPath));
87
- candidates.push(join("/opt", "homebrew", "lib", "node_modules", "taskplane", relPath));
88
-
89
- // 4. Peer of pi's package
90
- try {
91
- const piPath = process.argv[1] || "";
92
- const piPkgDir = resolve(piPath, "..", "..");
93
- candidates.push(join(piPkgDir, "..", "taskplane", relPath));
94
- } catch { /* ignore */ }
95
-
96
- for (const candidate of candidates) {
97
- if (existsSync(candidate)) return candidate;
98
- }
99
-
100
- // Fallback: return the local path (will fail at spawn time with a clear error)
101
- return localPath;
102
- }
20
+ // getNpmGlobalRoot() and resolveTaskplanePackageFile() consolidated in path-resolver.ts (TP-157)
103
21
 
104
22
  // ── Task Runner Extension Path Resolution ────────────────────────────
105
23
 
@@ -911,7 +829,37 @@ export async function resolveTaskMonitorState(
911
829
  sessionAlive = true;
912
830
  }
913
831
  } else {
914
- sessionAlive = snap.status === "running";
832
+ // TP-159: Fast-fail path for ghost workers (issue #461).
833
+ // When the snapshot belongs to the current task but the lane-runner
834
+ // has stopped updating it and the agent's PID is confirmed dead,
835
+ // immediately set sessionAlive=false instead of waiting for the full
836
+ // stall timeout. This handles the case where a worker dies silently
837
+ // (OOM, segfault, parent crash) after writing its first snapshot:
838
+ // snap.status stays "running", stallTimerStart stays null
839
+ // (STATUS.md never written), so Priority 2 never fires without
840
+ // this explicit dead-PID check.
841
+ // Conditions:
842
+ // 1. snap.updatedAt is stale beyond stallTimeoutMs/2
843
+ // 2. startup grace has elapsed (trackerAgeMs >= 60s)
844
+ // 3. agent is confirmed dead (registry marked crashed by orphan scan)
845
+ const trackerAgeMs = now - tracker.firstObservedAt;
846
+ if (
847
+ snap.updatedAt &&
848
+ (now - snap.updatedAt) > stallTimeoutMs / 2 &&
849
+ trackerAgeMs >= 60_000 &&
850
+ !isV2AgentAlive(sessionName, runtimeBackend, v2Context?.laneNumber)
851
+ ) {
852
+ // Ghost worker confirmed: PID dead, snapshot stale beyond half the stall timeout
853
+ execLog("monitor", taskId, "ghost worker fast-fail — dead PID + stale snapshot", {
854
+ session: sessionName,
855
+ snapStaleMs: now - snap.updatedAt,
856
+ trackerAgeMs,
857
+ halfStallTimeoutMs: stallTimeoutMs / 2,
858
+ });
859
+ sessionAlive = false;
860
+ } else {
861
+ sessionAlive = snap.status === "running";
862
+ }
915
863
  }
916
864
  } else {
917
865
  sessionAlive = isV2AgentAlive(sessionName, "v2", v2Context?.laneNumber);
@@ -1178,6 +1126,34 @@ export async function monitorLanes(
1178
1126
  setV2LivenessRegistryCache(null);
1179
1127
  }
1180
1128
 
1129
+ // TP-159: Detect and mark orphaned workers each poll cycle.
1130
+ // When a worker subprocess dies silently (OOM kill, segfault, parent
1131
+ // crash) without going through the normal completion handshake, its
1132
+ // registry manifest stays in a non-terminal status indefinitely.
1133
+ // Scanning for dead PIDs here ensures list_active_agents, read_agent_status,
1134
+ // and the dashboard all reflect reality within one poll interval.
1135
+ if (runtimeBackend === "v2" && batchId) {
1136
+ try {
1137
+ const registry = readRegistrySnapshot(stateRootForRegistry ?? repoRoot, batchId);
1138
+ if (registry) {
1139
+ const orphans = detectOrphans(registry);
1140
+ if (orphans.length > 0) {
1141
+ // Mark individual agent manifests as crashed
1142
+ markOrphansCrashed(stateRootForRegistry ?? repoRoot, batchId, orphans);
1143
+ // Rebuild and write registry.json from the updated individual manifests.
1144
+ // markOrphansCrashed only updates per-agent files; registry.json is a
1145
+ // cached aggregate that must be explicitly rebuilt so readRegistrySnapshot()
1146
+ // and the dashboard see the crashed status within this poll cycle.
1147
+ const freshRegistry = buildRegistrySnapshot(stateRootForRegistry ?? repoRoot, batchId);
1148
+ writeRegistrySnapshot(stateRootForRegistry ?? repoRoot, freshRegistry);
1149
+ setV2LivenessRegistryCache(freshRegistry);
1150
+ }
1151
+ }
1152
+ } catch {
1153
+ // Non-fatal — monitor loop must never throw
1154
+ }
1155
+ }
1156
+
1181
1157
  // Check pause signal
1182
1158
  if (pauseSignal.paused) {
1183
1159
  execLog("monitor", "ALL", "pause signal detected — stopping monitoring");
@@ -2126,15 +2102,11 @@ function parseAgentFile(filePath: string): { fm: Record<string, string>; body: s
2126
2102
  * @since TP-117
2127
2103
  */
2128
2104
  function loadBaseAgentPrompt(agentName: string): string {
2129
- // Use the same robust resolution as resolveTaskplanePackageFile, which
2130
- // handles all npm setups (nvm, Homebrew, volta, Windows, etc.) via
2131
- // npm root -g caching and well-known fallback paths.
2132
- // This avoids loadBaseAgentPrompt silently returning "" (which would
2133
- // cause the worker to skip reviews because the review_step instructions
2134
- // live in the base template, not the local .pi/agents/ override).
2135
- const relPath = join("templates", "agents", `${agentName}.md`);
2105
+ // resolveTaskplaneAgentTemplate handles all npm setups (nvm, Homebrew, volta, Windows, etc.)
2106
+ // via npm root -g caching and well-known fallback paths (see path-resolver.ts, TP-157).
2107
+ // This avoids silently returning "" which would cause the worker to skip reviews.
2136
2108
  try {
2137
- const resolved = resolveTaskplanePackageFile(process.cwd(), relPath);
2109
+ const resolved = resolveTaskplaneAgentTemplate(agentName);
2138
2110
  if (existsSync(resolved)) {
2139
2111
  const def = parseAgentFile(resolved);
2140
2112
  if (def?.body) return def.body;
@@ -1914,6 +1914,40 @@ export default function (pi: ExtensionAPI) {
1914
1914
  };
1915
1915
  }
1916
1916
 
1917
+ // TP-158: Reload config from disk so changes made after session start
1918
+ // (e.g. creating .pi/taskplane-config.json mid-session) take effect
1919
+ // immediately — fixes issue #460 where stale task_areas caused
1920
+ // "Discovery had fatal errors" on first /orch run after config creation.
1921
+ // Skip if a batch is already active to avoid swapping config mid-run.
1922
+ const _activePhase = orchBatchState.phase;
1923
+ // Treat paused as active — config must not change for a resumable batch
1924
+ const _isActiveBatch = _activePhase === "executing" || _activePhase === "launching"
1925
+ || _activePhase === "merging" || _activePhase === "planning" || _activePhase === "paused";
1926
+ if (!_isActiveBatch) {
1927
+ try {
1928
+ // Build everything into temporaries first, then commit atomically
1929
+ // so a partial failure doesn't leave mixed-generation state.
1930
+ const freshCtx = buildExecutionContext(ctx.cwd, loadOrchestratorConfig, loadTaskRunnerConfig);
1931
+ let freshSupervisor: SupervisorConfig;
1932
+ try {
1933
+ freshSupervisor = loadSupervisorConfig(
1934
+ freshCtx.repoRoot,
1935
+ freshCtx.pointer?.configRoot,
1936
+ );
1937
+ } catch {
1938
+ freshSupervisor = { ...DEFAULT_SUPERVISOR_CONFIG };
1939
+ }
1940
+ // Atomic commit — all or nothing
1941
+ execCtx = freshCtx;
1942
+ orchConfig = freshCtx.orchestratorConfig;
1943
+ runnerConfig = freshCtx.taskRunnerConfig;
1944
+ supervisorConfig = freshSupervisor;
1945
+ } catch {
1946
+ // Non-fatal — if reload fails, proceed with existing config.
1947
+ // The existing config guard below will handle a null execCtx.
1948
+ }
1949
+ }
1950
+
1917
1951
  if (!execCtx) {
1918
1952
  return {
1919
1953
  message: getExecCtxInitErrorMessage(),
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Path Resolver — Consolidated npm global root detection and package/tool path resolution.
3
+ *
4
+ * This module is the single source of truth for resolving paths to globally-installed
5
+ * npm packages (taskplane and the pi coding agent CLI). It was created to eliminate
6
+ * three duplicate implementations that previously existed in execution.ts, agent-host.ts,
7
+ * and agent-bridge-extension.ts.
8
+ *
9
+ * ## Why this module exists
10
+ *
11
+ * macOS-specific bugs (#472, #474) were caused by hardcoded path lists that missed
12
+ * Homebrew (`/opt/homebrew`) and contained ESM-unsafe `require()` calls. Each fix had
13
+ * to be applied to multiple files, risking future drift. A single module eliminates that.
14
+ *
15
+ * ## Platform coverage
16
+ *
17
+ * - **Windows** — npm global root is typically `%APPDATA%\npm\node_modules` or a custom
18
+ * prefix. Static fallbacks cover both the APPDATA env var path and the HOME-relative
19
+ * equivalent (`AppData\Roaming\npm\node_modules`).
20
+ *
21
+ * - **macOS** — Multiple valid npm setups are covered:
22
+ * - System Node via Homebrew: `/opt/homebrew/lib/node_modules`
23
+ * - System Node (legacy): `/usr/local/lib/node_modules`
24
+ * - nvm, volta, or custom prefix: resolved dynamically via `npm root -g`
25
+ * - Custom global prefix: `~/.npm-global/lib/node_modules`
26
+ *
27
+ * - **Linux** — System Node (`/usr/local/lib/node_modules`), nvm, volta, and custom
28
+ * prefixes are all covered dynamically via `npm root -g`.
29
+ *
30
+ * ## Resolution strategy
31
+ *
32
+ * `npm root -g` is the **primary** resolution path because it covers every npm setup
33
+ * (nvm, Homebrew, volta, pnpm global, and any custom `--prefix`) with a single call.
34
+ * Static fallbacks exist only for environments where `npm` is not on PATH, which is
35
+ * uncommon but can happen in certain CI containers or restricted environments.
36
+ *
37
+ * The `npm root -g` result is module-level cached because:
38
+ * 1. It is called from multiple callsites per process.
39
+ * 2. The result never changes within a process lifetime.
40
+ * 3. Spawning a subprocess on every call would be expensive.
41
+ *
42
+ * @module taskplane/path-resolver
43
+ * @since TP-157
44
+ */
45
+
46
+ import { spawnSync } from "child_process";
47
+ import { existsSync } from "fs";
48
+ import { join, resolve } from "path";
49
+
50
+ // ── Module-level cache ──────────────────────────────────────────────
51
+
52
+ /**
53
+ * Cached result of `npm root -g`.
54
+ * `null` = not yet resolved; `""` = resolution failed.
55
+ */
56
+ let _npmGlobalRoot: string | null = null;
57
+
58
+ // ── Exported functions ──────────────────────────────────────────────
59
+
60
+ /**
61
+ * Get the global npm root directory via `npm root -g`.
62
+ *
63
+ * The result is cached at module level for the process lifetime, so repeated
64
+ * calls are free after the first.
65
+ *
66
+ * @returns Absolute path to the npm global `node_modules` directory,
67
+ * or `""` if the call fails (npm not on PATH, subprocess error, etc.).
68
+ * Never throws.
69
+ *
70
+ * @platform Windows — `shell: true` is required because `npm` resolves to
71
+ * `npm.cmd`, a Windows batch script that cannot be spawned without a shell.
72
+ */
73
+ export function getNpmGlobalRoot(): string {
74
+ if (_npmGlobalRoot !== null) return _npmGlobalRoot;
75
+ try {
76
+ const result = spawnSync("npm", ["root", "-g"], {
77
+ encoding: "utf-8",
78
+ timeout: 5000,
79
+ // shell: true is mandatory on Windows — npm resolves to npm.cmd
80
+ shell: true,
81
+ });
82
+ _npmGlobalRoot = result.stdout?.trim() || "";
83
+ } catch {
84
+ _npmGlobalRoot = "";
85
+ }
86
+ return _npmGlobalRoot;
87
+ }
88
+
89
+ /**
90
+ * Resolve the absolute path to the Pi coding agent CLI entrypoint (`cli.js`).
91
+ *
92
+ * The Pi CLI is installed as `@mariozechner/pi-coding-agent`. On Windows, invoking
93
+ * `pi` directly executes a `.CMD` shim that cannot be spawned with `shell: false`.
94
+ * This function locates the underlying `dist/cli.js` so callers can spawn it with
95
+ * `node` directly, without a shell intermediary.
96
+ *
97
+ * Resolution order:
98
+ * 1. `npm root -g` result (dynamic — covers all setups: nvm, Homebrew, volta, etc.)
99
+ * 2. `%APPDATA%\npm\node_modules\...` (Windows, APPDATA env var)
100
+ * 3. `%USERPROFILE%\AppData\Roaming\npm\node_modules\...` (Windows, HOME-relative)
101
+ * 4. `~/.npm-global/lib/node_modules/...` (macOS/Linux custom global prefix)
102
+ * 5. `/usr/local/lib/node_modules/...` (macOS system Node, Linux)
103
+ * 6. `/opt/homebrew/lib/node_modules/...` (macOS Homebrew)
104
+ *
105
+ * @returns Absolute path to `@mariozechner/pi-coding-agent/dist/cli.js`
106
+ * @throws {Error} If the CLI entrypoint cannot be found in any known location.
107
+ * The error message includes the `npm root -g` value for diagnosis.
108
+ */
109
+ export function resolvePiCliPath(): string {
110
+ const relPath = join("@mariozechner", "pi-coding-agent", "dist", "cli.js");
111
+ const candidates: string[] = [];
112
+
113
+ // 1. Dynamic: npm root -g (covers nvm, Homebrew, volta, custom npm prefix, etc.)
114
+ const npmRoot = getNpmGlobalRoot();
115
+ if (npmRoot) candidates.push(join(npmRoot, relPath));
116
+
117
+ // 2-3. Static Windows fallbacks
118
+ const home = process.env.HOME || process.env.USERPROFILE || "";
119
+ if (process.env.APPDATA) {
120
+ candidates.push(join(process.env.APPDATA, "npm", "node_modules", relPath));
121
+ }
122
+ if (home) {
123
+ candidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", relPath));
124
+ // 4. macOS/Linux custom global prefix
125
+ candidates.push(join(home, ".npm-global", "lib", "node_modules", relPath));
126
+ }
127
+ // 5. macOS system Node / Linux
128
+ candidates.push(join("/usr", "local", "lib", "node_modules", relPath));
129
+ // 6. macOS Homebrew
130
+ candidates.push(join("/opt", "homebrew", "lib", "node_modules", relPath));
131
+
132
+ for (const candidate of candidates) {
133
+ if (existsSync(candidate)) return candidate;
134
+ }
135
+
136
+ throw new Error(
137
+ "Cannot find Pi CLI entrypoint (@mariozechner/pi-coding-agent/dist/cli.js). " +
138
+ "Ensure the pi coding agent is installed globally via 'npm install -g @mariozechner/pi-coding-agent'. " +
139
+ `npm root -g returned: ${npmRoot || "(empty — npm may not be on PATH)"}`,
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Resolve the path to a file within the taskplane npm package.
145
+ *
146
+ * This handles both local development (running from the taskplane repo itself)
147
+ * and the installed-package case (taskplane installed globally via npm).
148
+ *
149
+ * Resolution order:
150
+ * 1. `join(repoRoot, relPath)` — local development (taskplane's own repo)
151
+ * 2. `npm root -g` result: `{npmGlobalRoot}/taskplane/{relPath}` (dynamic, all setups)
152
+ * 3. `{APPDATA}/npm/node_modules/taskplane/{relPath}` (Windows)
153
+ * 4. `{HOME}/AppData/Roaming/npm/node_modules/taskplane/{relPath}` (Windows alt)
154
+ * 5. `{HOME}/.npm-global/lib/node_modules/taskplane/{relPath}` (macOS/Linux custom prefix)
155
+ * 6. `/usr/local/lib/node_modules/taskplane/{relPath}` (macOS system Node, Linux)
156
+ * 7. `/opt/homebrew/lib/node_modules/taskplane/{relPath}` (macOS Homebrew)
157
+ * 8. Peer of pi's package (adjacent to `process.argv[1]`)
158
+ *
159
+ * @param repoRoot - Absolute path to the project root (used for local dev check)
160
+ * @param relPath - Relative path within the taskplane package, e.g.
161
+ * `"extensions/task-runner.ts"` or `"templates/agents/task-worker.md"`
162
+ * @returns Absolute path to the resolved file. If not found in any location,
163
+ * returns the local path (`join(repoRoot, relPath)`) as a fallback — callers
164
+ * will fail at use time with a clear "file not found" error.
165
+ */
166
+ export function resolveTaskplanePackageFile(repoRoot: string, relPath: string): string {
167
+ // 1. Local development — taskplane's own repo
168
+ const localPath = join(resolve(repoRoot), relPath);
169
+ if (existsSync(localPath)) return localPath;
170
+
171
+ const candidates: string[] = [];
172
+
173
+ // 2. Dynamic: npm root -g (covers ALL npm setups: nvm, Homebrew, volta, etc.)
174
+ const npmRoot = getNpmGlobalRoot();
175
+ if (npmRoot) {
176
+ candidates.push(join(npmRoot, "taskplane", relPath));
177
+ }
178
+
179
+ // 3-7. Well-known static paths
180
+ const home = process.env.HOME || process.env.USERPROFILE || "";
181
+ if (process.env.APPDATA) {
182
+ candidates.push(join(process.env.APPDATA, "npm", "node_modules", "taskplane", relPath));
183
+ }
184
+ if (home) {
185
+ candidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", "taskplane", relPath));
186
+ candidates.push(join(home, ".npm-global", "lib", "node_modules", "taskplane", relPath));
187
+ }
188
+ candidates.push(join("/usr", "local", "lib", "node_modules", "taskplane", relPath));
189
+ candidates.push(join("/opt", "homebrew", "lib", "node_modules", "taskplane", relPath));
190
+
191
+ // 8. Peer of pi's package (look adjacent to pi's CLI entrypoint).
192
+ // pi is at: <npmRoot>/@mariozechner/pi-coding-agent/dist/cli.js
193
+ // so piPkgDir = <npmRoot>/@mariozechner/pi-coding-agent (resolve up 2 levels from cli.js)
194
+ // then go up TWO more levels to reach <npmRoot>, then into taskplane/
195
+ try {
196
+ const piPath = process.argv[1] || "";
197
+ const piPkgDir = resolve(piPath, "..", ".."); // <npmRoot>/@mariozechner/pi-coding-agent
198
+ const npmRootFromPi = resolve(piPkgDir, "..", ".."); // <npmRoot>
199
+ candidates.push(join(npmRootFromPi, "taskplane", relPath));
200
+ } catch { /* ignore — process.argv[1] may be undefined in test contexts */ }
201
+
202
+ for (const candidate of candidates) {
203
+ if (existsSync(candidate)) return candidate;
204
+ }
205
+
206
+ // Fallback: return the local path. Callers will fail with a clear error at use time.
207
+ return localPath;
208
+ }
209
+
210
+ /**
211
+ * Resolve the path to a taskplane agent template file.
212
+ *
213
+ * Convenience wrapper around {@link resolveTaskplanePackageFile} for the
214
+ * common case of locating a file in `templates/agents/`.
215
+ *
216
+ * Used by `loadBaseAgentPrompt` (execution.ts) and `loadReviewerPrompt`
217
+ * (agent-bridge-extension.ts) to locate the base agent prompt templates
218
+ * that ship with the taskplane package.
219
+ *
220
+ * @param agentName - Agent template name without extension, e.g. `"task-worker"`,
221
+ * `"task-reviewer"`, `"task-merger"`
222
+ * @returns Absolute path to `templates/agents/{agentName}.md` within the
223
+ * resolved taskplane package root.
224
+ *
225
+ * @example
226
+ * ```ts
227
+ * const templatePath = resolveTaskplaneAgentTemplate("task-worker");
228
+ * // → "/usr/local/lib/node_modules/taskplane/templates/agents/task-worker.md"
229
+ * // (or local dev path, or any other resolved location)
230
+ * ```
231
+ */
232
+ export function resolveTaskplaneAgentTemplate(agentName: string): string {
233
+ return resolveTaskplanePackageFile(
234
+ process.cwd(),
235
+ join("templates", "agents", `${agentName}.md`),
236
+ );
237
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.25.4",
3
+ "version": "0.25.6",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",