taskplane 0.28.8 → 0.29.1
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/bin/get-version.mjs +50 -0
- package/bin/taskplane.mjs +5 -8
- package/extensions/taskplane/agent-bridge-extension.ts +58 -3
- package/extensions/taskplane/agent-host.ts +11 -17
- package/extensions/taskplane/config-schema.ts +17 -12
- package/extensions/taskplane/diagnostics.ts +9 -0
- package/extensions/taskplane/engine-worker.ts +27 -0
- package/extensions/taskplane/engine.ts +235 -1
- package/extensions/taskplane/execution.ts +115 -6
- package/extensions/taskplane/extension.ts +303 -0
- package/extensions/taskplane/lane-runner.ts +74 -3
- package/extensions/taskplane/mailbox.ts +83 -0
- package/extensions/taskplane/messages.ts +19 -0
- package/extensions/taskplane/path-resolver.ts +63 -24
- package/extensions/taskplane/persistence.ts +378 -3
- package/extensions/taskplane/resume.ts +64 -7
- package/extensions/taskplane/tool-allowlist-constants.ts +37 -0
- package/extensions/taskplane/types.ts +60 -7
- package/extensions/taskplane/worktree.ts +5 -1
- package/package.json +1 -1
- package/skills/create-taskplane-task/SKILL.md +37 -0
- package/templates/agents/supervisor.md +62 -1
- package/templates/agents/task-worker.md +58 -6
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
sessionInboxDir,
|
|
54
54
|
ackOutboxMessage,
|
|
55
55
|
appendMailboxAuditEvent,
|
|
56
|
+
drainAgentOutbox,
|
|
56
57
|
} from "./mailbox.ts";
|
|
57
58
|
|
|
58
59
|
import {
|
|
@@ -245,6 +246,14 @@ export interface LaneRunnerConfig {
|
|
|
245
246
|
killPercent: number;
|
|
246
247
|
/** Optional callback for surfacing runtime mailbox replies/escalations to supervisor */
|
|
247
248
|
onSupervisorAlert?: SupervisorAlertCallback;
|
|
249
|
+
/**
|
|
250
|
+
* Optional callback fired when the lane reaches a terminal state (no-progress
|
|
251
|
+
* kill or hard-fail). The supervisor process uses this to suppress any
|
|
252
|
+
* subsequent zombie alerts queued for the now-dead lane.
|
|
253
|
+
*
|
|
254
|
+
* @since TP-187 (#538)
|
|
255
|
+
*/
|
|
256
|
+
onLaneTerminated?: (info: import("./types.ts").LaneTerminatedInfo) => void;
|
|
248
257
|
}
|
|
249
258
|
|
|
250
259
|
/**
|
|
@@ -656,8 +665,41 @@ export async function executeTaskV2(
|
|
|
656
665
|
}
|
|
657
666
|
} catch { /* If we can't read STATUS.md, proceed with escalation */ }
|
|
658
667
|
|
|
659
|
-
// No visible progress — compose escalation message
|
|
660
|
-
|
|
668
|
+
// No visible progress — compose escalation message.
|
|
669
|
+
// TP-187 (#540): when the worker exits silently, fall back to the most
|
|
670
|
+
// recent `assistant_message` event in events.jsonl so the supervisor
|
|
671
|
+
// has SOMETHING to act on instead of `Worker said: ""`.
|
|
672
|
+
let workerSaid = (assistantMessage ?? "").trim();
|
|
673
|
+
let workerSaidSource: "current-turn" | "events-jsonl-fallback" | "empty-sentinel" = "current-turn";
|
|
674
|
+
if (!workerSaid) {
|
|
675
|
+
workerSaidSource = "empty-sentinel";
|
|
676
|
+
try {
|
|
677
|
+
const raw = readFileSync(eventsPath, "utf-8");
|
|
678
|
+
const lines = raw.split("\n");
|
|
679
|
+
// Walk backward to find the most recent assistant_message with non-empty text.
|
|
680
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
681
|
+
const line = lines[i].trim();
|
|
682
|
+
if (!line) continue;
|
|
683
|
+
try {
|
|
684
|
+
const evt = JSON.parse(line) as Record<string, unknown>;
|
|
685
|
+
if (evt.type === "assistant_message") {
|
|
686
|
+
const payload = evt.payload as Record<string, unknown> | undefined;
|
|
687
|
+
const text = typeof payload?.text === "string" ? payload.text.trim() : "";
|
|
688
|
+
if (text) {
|
|
689
|
+
workerSaid = text;
|
|
690
|
+
workerSaidSource = "events-jsonl-fallback";
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
} catch { /* skip malformed line */ }
|
|
695
|
+
}
|
|
696
|
+
} catch { /* events.jsonl unreadable; sentinel will be used */ }
|
|
697
|
+
}
|
|
698
|
+
if (!workerSaid) {
|
|
699
|
+
workerSaid = "(no assistant message captured — worker exited without producing visible output)";
|
|
700
|
+
workerSaidSource = "empty-sentinel";
|
|
701
|
+
}
|
|
702
|
+
const truncatedMsg = workerSaid.slice(0, 500);
|
|
661
703
|
const uncheckedItems: string[] = [];
|
|
662
704
|
try {
|
|
663
705
|
const statusContent = readFileSync(statusPath, "utf-8");
|
|
@@ -693,7 +735,12 @@ export async function executeTaskV2(
|
|
|
693
735
|
` Current step: ${currentStepInfo}\n` +
|
|
694
736
|
` Iteration: ${totalIterations}, No-progress count: ${noProgressCount + 1}\n` +
|
|
695
737
|
` Unchecked items: ${uncheckedItems.length > 0 ? uncheckedItems.join("; ") : "(none found)"}\n` +
|
|
696
|
-
` Worker said: "${truncatedMsg}"
|
|
738
|
+
` Worker said: "${truncatedMsg}"` +
|
|
739
|
+
(workerSaidSource === "events-jsonl-fallback"
|
|
740
|
+
? ` (fallback: most-recent assistant_message from events.jsonl)\n`
|
|
741
|
+
: workerSaidSource === "empty-sentinel"
|
|
742
|
+
? ` (no assistant message captured this iteration)\n`
|
|
743
|
+
: "\n") +
|
|
697
744
|
`\nSend a steering message to ${workerAgentId} with targeted instructions,` +
|
|
698
745
|
` or reply "skip" / "let it fail" to close the session.`,
|
|
699
746
|
context: {
|
|
@@ -978,6 +1025,30 @@ export async function executeTaskV2(
|
|
|
978
1025
|
`Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`);
|
|
979
1026
|
if (noProgressCount >= config.noProgressLimit) {
|
|
980
1027
|
logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
|
|
1028
|
+
// TP-187 (#538): synchronous outbox drain at lane-termination decision
|
|
1029
|
+
// point. Purges any pending escalations/replies/segment-expansions the
|
|
1030
|
+
// worker emitted just before termination so they are not later re-
|
|
1031
|
+
// discovered and re-forwarded as zombie supervisor alerts.
|
|
1032
|
+
try {
|
|
1033
|
+
const drained = drainAgentOutbox(config.stateRoot, config.batchId, workerAgentId);
|
|
1034
|
+
if (drained > 0) {
|
|
1035
|
+
logExecution(statusPath, "Outbox drained",
|
|
1036
|
+
`No-progress kill: drained ${drained} pending outbox entr${drained === 1 ? "y" : "ies"} for ${workerAgentId}`);
|
|
1037
|
+
}
|
|
1038
|
+
} catch { /* best effort — do not block termination */ }
|
|
1039
|
+
// TP-187 (#538): notify the supervisor process so it can suppress any
|
|
1040
|
+
// further alerts queued for this lane (zombie-alert filter).
|
|
1041
|
+
if (config.onLaneTerminated) {
|
|
1042
|
+
try {
|
|
1043
|
+
config.onLaneTerminated({
|
|
1044
|
+
laneNumber: config.laneNumber,
|
|
1045
|
+
agentId: workerAgentId,
|
|
1046
|
+
batchId: config.batchId,
|
|
1047
|
+
terminatedAt: Date.now(),
|
|
1048
|
+
reason: "no-progress-kill",
|
|
1049
|
+
});
|
|
1050
|
+
} catch { /* best effort */ }
|
|
1051
|
+
}
|
|
981
1052
|
return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
|
|
982
1053
|
`No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
|
|
983
1054
|
}
|
|
@@ -538,6 +538,89 @@ export function ackOutboxMessage(
|
|
|
538
538
|
}
|
|
539
539
|
}
|
|
540
540
|
|
|
541
|
+
/**
|
|
542
|
+
* Drain (purge to processed/) all pending outbox messages for an agent.
|
|
543
|
+
*
|
|
544
|
+
* Used at lane-termination decision points to ensure stale escalations or
|
|
545
|
+
* replies that the worker emitted just before termination don't get later
|
|
546
|
+
* re-discovered and re-forwarded as zombie supervisor alerts. The drain
|
|
547
|
+
* mirrors {@link ackOutboxMessage} — each pending `*.msg.json` file is
|
|
548
|
+
* moved to `outbox/processed/` so it remains in the durable history (for
|
|
549
|
+
* `read_agent_replies`) but is no longer pending.
|
|
550
|
+
*
|
|
551
|
+
* Best-effort: any per-file failure is logged but does not abort the drain.
|
|
552
|
+
* Returns the number of messages successfully drained.
|
|
553
|
+
*
|
|
554
|
+
* Also drains any non-message pending files in the outbox (e.g.,
|
|
555
|
+
* `segment-expansion-*.json` requests) by renaming them to a `.drained`
|
|
556
|
+
* sibling so the engine's discovery scans don't re-pick them up.
|
|
557
|
+
*
|
|
558
|
+
* @since TP-187 (#538)
|
|
559
|
+
*/
|
|
560
|
+
export function drainAgentOutbox(
|
|
561
|
+
stateRoot: string,
|
|
562
|
+
batchId: string,
|
|
563
|
+
agentId: string,
|
|
564
|
+
): number {
|
|
565
|
+
const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId);
|
|
566
|
+
if (!existsSync(outboxDir)) return 0;
|
|
567
|
+
|
|
568
|
+
let entries: string[] = [];
|
|
569
|
+
try {
|
|
570
|
+
entries = readdirSync(outboxDir);
|
|
571
|
+
} catch (err) {
|
|
572
|
+
process.stderr.write(
|
|
573
|
+
`[mailbox] WARNING: drainAgentOutbox failed to read ${outboxDir}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
574
|
+
);
|
|
575
|
+
return 0;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
let drained = 0;
|
|
579
|
+
const processedDir = join(outboxDir, "processed");
|
|
580
|
+
let processedDirEnsured = false;
|
|
581
|
+
|
|
582
|
+
for (const entry of entries) {
|
|
583
|
+
// Skip the processed/ subdirectory itself and any in-flight temp writes.
|
|
584
|
+
if (entry === "processed" || entry.endsWith(".tmp")) continue;
|
|
585
|
+
|
|
586
|
+
const srcPath = join(outboxDir, entry);
|
|
587
|
+
|
|
588
|
+
if (entry.endsWith(".msg.json")) {
|
|
589
|
+
if (!processedDirEnsured) {
|
|
590
|
+
try { mkdirSync(processedDir, { recursive: true }); } catch { /* fall through to rename error handling */ }
|
|
591
|
+
processedDirEnsured = true;
|
|
592
|
+
}
|
|
593
|
+
const dstPath = join(processedDir, entry);
|
|
594
|
+
try {
|
|
595
|
+
renameSync(srcPath, dstPath);
|
|
596
|
+
drained++;
|
|
597
|
+
} catch (err: unknown) {
|
|
598
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
599
|
+
if (code === "ENOENT") continue; // already gone — race-safe
|
|
600
|
+
process.stderr.write(
|
|
601
|
+
`[mailbox] WARNING: drainAgentOutbox failed to rename ${entry}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Non-message pending files (e.g., segment-expansion-*.json). Rename in
|
|
608
|
+
// place to a `.drained` suffix so engine.ts discovery scans skip them.
|
|
609
|
+
try {
|
|
610
|
+
renameSync(srcPath, `${srcPath}.drained`);
|
|
611
|
+
drained++;
|
|
612
|
+
} catch (err: unknown) {
|
|
613
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
614
|
+
if (code === "ENOENT") continue;
|
|
615
|
+
process.stderr.write(
|
|
616
|
+
`[mailbox] WARNING: drainAgentOutbox failed to mark ${entry} drained: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return drained;
|
|
622
|
+
}
|
|
623
|
+
|
|
541
624
|
/**
|
|
542
625
|
* Discover all agent IDs that have mailbox directories for a batch.
|
|
543
626
|
* Returns directory names under .pi/mailbox/{batchId}/ excluding _broadcast.
|
|
@@ -104,6 +104,25 @@ export const ORCH_MESSAGES = {
|
|
|
104
104
|
resumeNoState: () =>
|
|
105
105
|
`❌ No batch to resume. No batch-state.json file found.\n` +
|
|
106
106
|
` Use /orch <areas|all> to start a new batch.`,
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* TP-187 (#539): Successful reconstruction from .pi/runtime/<batchId>/
|
|
110
|
+
* runtime artifacts during force-resume after `orch_abort()`.
|
|
111
|
+
*/
|
|
112
|
+
resumeReconstructed: (batchId: string, selectionNote: string) =>
|
|
113
|
+
`🔨 Reconstructed batch ${batchId} from .pi/runtime/ artifacts (${selectionNote}).\n` +
|
|
114
|
+
` Force-resume will proceed with a fresh wave-zero pass; the existing\n` +
|
|
115
|
+
` reconciliation logic will re-detect succeeded tasks via .DONE markers.`,
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* TP-187 (#539): Fail-loud message when force-resume can't reconstruct
|
|
119
|
+
* after `orch_abort()` because required runtime artifacts are missing.
|
|
120
|
+
*/
|
|
121
|
+
resumeNoStateAfterAbort: (missingArtifact: string, batchId: string | null) =>
|
|
122
|
+
`❌ Cannot resume after abort: ${missingArtifact}.\n` +
|
|
123
|
+
(batchId ? ` Last known batch: ${batchId}.\n` : "") +
|
|
124
|
+
` To start fresh from the preserved worktree state, run\n` +
|
|
125
|
+
` \`orch_start <PROMPT.md>\` (or \`/orch <areas|all>\`).`,
|
|
107
126
|
resumeInvalidState: (error: string) =>
|
|
108
127
|
`❌ Cannot resume: batch state file is invalid.\n` +
|
|
109
128
|
` Error: ${error}\n` +
|
|
@@ -86,15 +86,38 @@ export function getNpmGlobalRoot(): string {
|
|
|
86
86
|
return _npmGlobalRoot;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Pi CLI npm package scopes that taskplane resolves at runtime, ordered with
|
|
91
|
+
* the canonical (current) scope FIRST and legacy scopes after for backward
|
|
92
|
+
* compatibility. Issue #560: the Pi coding agent was renamed from
|
|
93
|
+
* `@mariozechner/pi-coding-agent` to `@earendil-works/pi-coding-agent` in
|
|
94
|
+
* Pi v0.74.0. Pi's own extension loader bundles BOTH scope aliases at runtime
|
|
95
|
+
* for in-process module imports, but spawn-side path resolution (this file)
|
|
96
|
+
* has to look on disk under whichever scope was actually installed.
|
|
97
|
+
*
|
|
98
|
+
* Order matters: the new scope is preferred so a system that has BOTH
|
|
99
|
+
* installed (e.g., during a transition window) picks up the current Pi.
|
|
100
|
+
*/
|
|
101
|
+
const PI_PACKAGE_SCOPES = ["@earendil-works", "@mariozechner"] as const;
|
|
102
|
+
|
|
89
103
|
/**
|
|
90
104
|
* Resolve the absolute path to the Pi coding agent CLI entrypoint (`cli.js`).
|
|
91
105
|
*
|
|
92
|
-
* The Pi CLI is installed
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* `node` directly, without a shell intermediary.
|
|
106
|
+
* The Pi CLI is installed under one of two npm scopes:
|
|
107
|
+
* - `@earendil-works/pi-coding-agent` (current, as of Pi v0.74.0)
|
|
108
|
+
* - `@mariozechner/pi-coding-agent` (legacy)
|
|
96
109
|
*
|
|
97
|
-
*
|
|
110
|
+
* On Windows, invoking `pi` directly executes a `.CMD` shim that cannot be
|
|
111
|
+
* spawned with `shell: false`. This function locates the underlying
|
|
112
|
+
* `dist/cli.js` so callers can spawn it with `node` directly, without a shell
|
|
113
|
+
* intermediary.
|
|
114
|
+
*
|
|
115
|
+
* Resolution order: the cross product of base directories × package scopes,
|
|
116
|
+
* with each base directory tried for the new scope before any base directory
|
|
117
|
+
* is tried for the legacy scope. (Equivalently: scope is the inner loop, base
|
|
118
|
+
* is the outer loop.)
|
|
119
|
+
*
|
|
120
|
+
* Base directories (outer loop):
|
|
98
121
|
* 1. `npm root -g` result (dynamic — covers all setups: nvm, Homebrew, volta, etc.)
|
|
99
122
|
* 2. `%APPDATA%\npm\node_modules\...` (Windows, APPDATA env var)
|
|
100
123
|
* 3. `%USERPROFILE%\AppData\Roaming\npm\node_modules\...` (Windows, HOME-relative)
|
|
@@ -102,40 +125,53 @@ export function getNpmGlobalRoot(): string {
|
|
|
102
125
|
* 5. `/usr/local/lib/node_modules/...` (macOS system Node, Linux)
|
|
103
126
|
* 6. `/opt/homebrew/lib/node_modules/...` (macOS Homebrew)
|
|
104
127
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
128
|
+
* Scopes per base (inner loop):
|
|
129
|
+
* a. `@earendil-works/pi-coding-agent/dist/cli.js`
|
|
130
|
+
* b. `@mariozechner/pi-coding-agent/dist/cli.js`
|
|
131
|
+
*
|
|
132
|
+
* @returns Absolute path to a Pi CLI `dist/cli.js` (under whichever scope was found).
|
|
133
|
+
* @throws {Error} If the CLI entrypoint cannot be found under any base × scope
|
|
134
|
+
* combination. The error message includes the `npm root -g` value
|
|
135
|
+
* AND lists both scopes searched, for operator diagnosis.
|
|
108
136
|
*/
|
|
109
137
|
export function resolvePiCliPath(): string {
|
|
110
|
-
const
|
|
111
|
-
const candidates: string[] = [];
|
|
138
|
+
const bases: string[] = [];
|
|
112
139
|
|
|
113
140
|
// 1. Dynamic: npm root -g (covers nvm, Homebrew, volta, custom npm prefix, etc.)
|
|
114
141
|
const npmRoot = getNpmGlobalRoot();
|
|
115
|
-
if (npmRoot)
|
|
142
|
+
if (npmRoot) bases.push(npmRoot);
|
|
116
143
|
|
|
117
144
|
// 2-3. Static Windows fallbacks
|
|
118
145
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
119
146
|
if (process.env.APPDATA) {
|
|
120
|
-
|
|
147
|
+
bases.push(join(process.env.APPDATA, "npm", "node_modules"));
|
|
121
148
|
}
|
|
122
149
|
if (home) {
|
|
123
|
-
|
|
150
|
+
bases.push(join(home, "AppData", "Roaming", "npm", "node_modules"));
|
|
124
151
|
// 4. macOS/Linux custom global prefix
|
|
125
|
-
|
|
152
|
+
bases.push(join(home, ".npm-global", "lib", "node_modules"));
|
|
126
153
|
}
|
|
127
154
|
// 5. macOS system Node / Linux
|
|
128
|
-
|
|
155
|
+
bases.push(join("/usr", "local", "lib", "node_modules"));
|
|
129
156
|
// 6. macOS Homebrew
|
|
130
|
-
|
|
157
|
+
bases.push(join("/opt", "homebrew", "lib", "node_modules"));
|
|
131
158
|
|
|
132
|
-
|
|
133
|
-
|
|
159
|
+
// Cross product: scope is the inner loop so a single base directory is
|
|
160
|
+
// fully exhausted (new scope, then legacy scope) before falling back to
|
|
161
|
+
// the next base. This matches operator intuition ("check the most likely
|
|
162
|
+
// install location for either scope first").
|
|
163
|
+
for (const base of bases) {
|
|
164
|
+
for (const scope of PI_PACKAGE_SCOPES) {
|
|
165
|
+
const candidate = join(base, scope, "pi-coding-agent", "dist", "cli.js");
|
|
166
|
+
if (existsSync(candidate)) return candidate;
|
|
167
|
+
}
|
|
134
168
|
}
|
|
135
169
|
|
|
136
170
|
throw new Error(
|
|
137
|
-
"Cannot find Pi CLI entrypoint (
|
|
138
|
-
"
|
|
171
|
+
"Cannot find Pi CLI entrypoint (pi-coding-agent/dist/cli.js) under any known npm scope " +
|
|
172
|
+
`(${PI_PACKAGE_SCOPES.join(" or ")}). ` +
|
|
173
|
+
"Install via 'npm install -g @earendil-works/pi-coding-agent' " +
|
|
174
|
+
"(or, for legacy installs, 'npm install -g @mariozechner/pi-coding-agent'). " +
|
|
139
175
|
`npm root -g returned: ${npmRoot || "(empty — npm may not be on PATH)"}`,
|
|
140
176
|
);
|
|
141
177
|
}
|
|
@@ -189,12 +225,15 @@ export function resolveTaskplanePackageFile(repoRoot: string, relPath: string):
|
|
|
189
225
|
candidates.push(join("/opt", "homebrew", "lib", "node_modules", "taskplane", relPath));
|
|
190
226
|
|
|
191
227
|
// 8. Peer of pi's package (look adjacent to pi's CLI entrypoint).
|
|
192
|
-
// pi is at: <npmRoot
|
|
193
|
-
//
|
|
194
|
-
//
|
|
228
|
+
// pi is at: <npmRoot>/<scope>/pi-coding-agent/dist/cli.js (where <scope> is
|
|
229
|
+
// @earendil-works (current) or @mariozechner (legacy)).
|
|
230
|
+
// so piPkgDir = <npmRoot>/<scope>/pi-coding-agent (resolve up 2 levels from cli.js).
|
|
231
|
+
// Then go up TWO more levels to reach <npmRoot>, then into taskplane/.
|
|
232
|
+
// This works regardless of which scope Pi is installed under because we
|
|
233
|
+
// only walk up the directory tree — we never name the scope explicitly.
|
|
195
234
|
try {
|
|
196
235
|
const piPath = process.argv[1] || "";
|
|
197
|
-
const piPkgDir = resolve(piPath, "..", ".."); // <npmRoot
|
|
236
|
+
const piPkgDir = resolve(piPath, "..", ".."); // <npmRoot>/<scope>/pi-coding-agent
|
|
198
237
|
const npmRootFromPi = resolve(piPkgDir, "..", ".."); // <npmRoot>
|
|
199
238
|
candidates.push(join(npmRootFromPi, "taskplane", relPath));
|
|
200
239
|
} catch { /* ignore — process.argv[1] may be undefined in test contexts */ }
|