taskplane 0.25.4 → 0.25.5
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
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const raw = readFileSync(
|
|
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)
|
|
424
|
-
}
|
|
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 =
|
|
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,
|
|
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 } 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
|
-
|
|
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,30 @@ 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
|
+
markOrphansCrashed(stateRootForRegistry ?? repoRoot, batchId, orphans);
|
|
1142
|
+
// Refresh cache so this poll cycle sees the updated crashed status
|
|
1143
|
+
setV2LivenessRegistryCache(
|
|
1144
|
+
readRegistrySnapshot(stateRootForRegistry ?? repoRoot, batchId)
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
} catch {
|
|
1149
|
+
// Non-fatal — monitor loop must never throw
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1181
1153
|
// Check pause signal
|
|
1182
1154
|
if (pauseSignal.paused) {
|
|
1183
1155
|
execLog("monitor", "ALL", "pause signal detected — stopping monitoring");
|
|
@@ -2126,15 +2098,11 @@ function parseAgentFile(filePath: string): { fm: Record<string, string>; body: s
|
|
|
2126
2098
|
* @since TP-117
|
|
2127
2099
|
*/
|
|
2128
2100
|
function loadBaseAgentPrompt(agentName: string): string {
|
|
2129
|
-
//
|
|
2130
|
-
//
|
|
2131
|
-
//
|
|
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`);
|
|
2101
|
+
// resolveTaskplaneAgentTemplate handles all npm setups (nvm, Homebrew, volta, Windows, etc.)
|
|
2102
|
+
// via npm root -g caching and well-known fallback paths (see path-resolver.ts, TP-157).
|
|
2103
|
+
// This avoids silently returning "" which would cause the worker to skip reviews.
|
|
2136
2104
|
try {
|
|
2137
|
-
const resolved =
|
|
2105
|
+
const resolved = resolveTaskplaneAgentTemplate(agentName);
|
|
2138
2106
|
if (existsSync(resolved)) {
|
|
2139
2107
|
const def = parseAgentFile(resolved);
|
|
2140
2108
|
if (def?.body) return def.body;
|
|
@@ -1914,6 +1914,39 @@ 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
|
+
const _isActiveBatch = _activePhase === "executing" || _activePhase === "launching"
|
|
1924
|
+
|| _activePhase === "merging" || _activePhase === "planning";
|
|
1925
|
+
if (!_isActiveBatch) {
|
|
1926
|
+
try {
|
|
1927
|
+
// Build everything into temporaries first, then commit atomically
|
|
1928
|
+
// so a partial failure doesn't leave mixed-generation state.
|
|
1929
|
+
const freshCtx = buildExecutionContext(ctx.cwd, loadOrchestratorConfig, loadTaskRunnerConfig);
|
|
1930
|
+
let freshSupervisor: SupervisorConfig;
|
|
1931
|
+
try {
|
|
1932
|
+
freshSupervisor = loadSupervisorConfig(
|
|
1933
|
+
freshCtx.repoRoot,
|
|
1934
|
+
freshCtx.pointer?.configRoot,
|
|
1935
|
+
);
|
|
1936
|
+
} catch {
|
|
1937
|
+
freshSupervisor = { ...DEFAULT_SUPERVISOR_CONFIG };
|
|
1938
|
+
}
|
|
1939
|
+
// Atomic commit — all or nothing
|
|
1940
|
+
execCtx = freshCtx;
|
|
1941
|
+
orchConfig = freshCtx.orchestratorConfig;
|
|
1942
|
+
runnerConfig = freshCtx.taskRunnerConfig;
|
|
1943
|
+
supervisorConfig = freshSupervisor;
|
|
1944
|
+
} catch {
|
|
1945
|
+
// Non-fatal — if reload fails, proceed with existing config.
|
|
1946
|
+
// The existing config guard below will handle a null execCtx.
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1917
1950
|
if (!execCtx) {
|
|
1918
1951
|
return {
|
|
1919
1952
|
message: getExecCtxInitErrorMessage(),
|
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
try {
|
|
193
|
+
const piPath = process.argv[1] || "";
|
|
194
|
+
const piPkgDir = resolve(piPath, "..", "..");
|
|
195
|
+
candidates.push(join(piPkgDir, "..", "taskplane", relPath));
|
|
196
|
+
} catch { /* ignore — process.argv[1] may be undefined in test contexts */ }
|
|
197
|
+
|
|
198
|
+
for (const candidate of candidates) {
|
|
199
|
+
if (existsSync(candidate)) return candidate;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Fallback: return the local path. Callers will fail with a clear error at use time.
|
|
203
|
+
return localPath;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolve the path to a taskplane agent template file.
|
|
208
|
+
*
|
|
209
|
+
* Convenience wrapper around {@link resolveTaskplanePackageFile} for the
|
|
210
|
+
* common case of locating a file in `templates/agents/`.
|
|
211
|
+
*
|
|
212
|
+
* Used by `loadBaseAgentPrompt` (execution.ts) and `loadReviewerPrompt`
|
|
213
|
+
* (agent-bridge-extension.ts) to locate the base agent prompt templates
|
|
214
|
+
* that ship with the taskplane package.
|
|
215
|
+
*
|
|
216
|
+
* @param agentName - Agent template name without extension, e.g. `"task-worker"`,
|
|
217
|
+
* `"task-reviewer"`, `"task-merger"`
|
|
218
|
+
* @returns Absolute path to `templates/agents/{agentName}.md` within the
|
|
219
|
+
* resolved taskplane package root.
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```ts
|
|
223
|
+
* const templatePath = resolveTaskplaneAgentTemplate("task-worker");
|
|
224
|
+
* // → "/usr/local/lib/node_modules/taskplane/templates/agents/task-worker.md"
|
|
225
|
+
* // (or local dev path, or any other resolved location)
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
export function resolveTaskplaneAgentTemplate(agentName: string): string {
|
|
229
|
+
return resolveTaskplanePackageFile(
|
|
230
|
+
process.cwd(),
|
|
231
|
+
join("templates", "agents", `${agentName}.md`),
|
|
232
|
+
);
|
|
233
|
+
}
|