taskplane 0.30.1 → 0.30.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/bin/taskplane.mjs +154 -8
- package/dashboard/public/app.js +24 -0
- package/dashboard/server.cjs +25 -4
- package/extensions/taskplane/engine.ts +11 -2
- package/extensions/taskplane/merge.ts +4 -4
- package/extensions/taskplane/path-resolver.ts +52 -15
- package/extensions/taskplane/process-registry.ts +11 -4
- package/extensions/taskplane/types.ts +15 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -61,6 +61,26 @@ IMPORTANT: If you just installed pi, make sure you've configured at least one mo
|
|
|
61
61
|
pi install npm:taskplane
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
As of pi `0.75.0`, this installs Taskplane into pi's private extension directory (`~/.pi/agent/npm/node_modules/`) rather than the system npm-global root. To make the `taskplane` CLI available on your shell PATH, add pi's bin dir once:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# bash / zsh
|
|
68
|
+
echo 'export PATH="$HOME/.pi/agent/npm/node_modules/.bin:$PATH"' >> ~/.bashrc # or ~/.zshrc
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```powershell
|
|
72
|
+
# PowerShell
|
|
73
|
+
[Environment]::SetEnvironmentVariable(
|
|
74
|
+
"PATH",
|
|
75
|
+
"$HOME\.pi\agent\npm\node_modules\.bin;" + [Environment]::GetEnvironmentVariable("PATH", "User"),
|
|
76
|
+
"User"
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
With Pi's bin dir on PATH, `pi update` keeps Taskplane current automatically — there's no second update command to remember.
|
|
81
|
+
|
|
82
|
+
> **Note for users who previously ran `npm install -g taskplane`:** That puts a second copy of Taskplane in your system npm-global, separate from pi's private copy. The two copies drift independently (`pi update` only refreshes the pi-private one), and your shell will resolve `taskplane` to whichever is earlier on PATH. Run `taskplane doctor` — it now detects this duplication and prints a remediation. The fix is `npm uninstall -g taskplane` plus the PATH change above.
|
|
83
|
+
|
|
64
84
|
### Option B: Single Project-Local Install
|
|
65
85
|
|
|
66
86
|
```bash
|
|
@@ -68,6 +88,8 @@ cd my-project
|
|
|
68
88
|
pi install -l npm:taskplane
|
|
69
89
|
```
|
|
70
90
|
|
|
91
|
+
Project-local installs land in `.pi/npm/node_modules/`. Either invoke via `npx taskplane <cmd>`, run the binary directly at `.pi/npm/node_modules/.bin/taskplane`, or add that dir to PATH (project-scoped — e.g. via a `.envrc` if you use direnv).
|
|
92
|
+
|
|
71
93
|
## Quickstart
|
|
72
94
|
|
|
73
95
|
### 1. Initialize a project (to scaffold settings)
|
package/bin/taskplane.mjs
CHANGED
|
@@ -2789,6 +2789,53 @@ function cmdDoctor() {
|
|
|
2789
2789
|
` ${OK} taskplane package installed ${c.dim}(v${pkgVersion}, ${installType})${c.reset}`,
|
|
2790
2790
|
);
|
|
2791
2791
|
|
|
2792
|
+
// Duplication check: when both the Pi-private and the npm-global copies
|
|
2793
|
+
// exist with different versions, `pi update` only refreshes the Pi-private
|
|
2794
|
+
// one and the system-wide CLI silently drifts behind. Surface this clearly
|
|
2795
|
+
// so operators don't run a stale CLI thinking `pi update` covered it.
|
|
2796
|
+
// Background: Pi 0.75.0 (2026-05-17) moved user-scoped pi packages from
|
|
2797
|
+
// npm's global root to `~/.pi/agent/npm/` to avoid system-Node permission
|
|
2798
|
+
// errors. Users who installed via `npm install -g taskplane` before that
|
|
2799
|
+
// landed (or who installed via both paths) end up with two on-disk copies.
|
|
2800
|
+
let duplicationDetected = false;
|
|
2801
|
+
const duplication = detectDuplicateTaskplaneInstall();
|
|
2802
|
+
if (duplication) {
|
|
2803
|
+
duplicationDetected = true;
|
|
2804
|
+
console.log();
|
|
2805
|
+
console.log(` ${WARN} taskplane is installed in TWO locations with different versions:`);
|
|
2806
|
+
for (const loc of duplication.locations) {
|
|
2807
|
+
console.log(
|
|
2808
|
+
` ${c.dim}${loc.label}:${c.reset} v${loc.version} ${c.dim}(${loc.path})${c.reset}`,
|
|
2809
|
+
);
|
|
2810
|
+
}
|
|
2811
|
+
console.log(
|
|
2812
|
+
` ${c.dim}→ ${c.reset}\`pi update\`${c.dim} only refreshes the Pi-private copy.${c.reset}`,
|
|
2813
|
+
);
|
|
2814
|
+
console.log(
|
|
2815
|
+
` ${c.dim}→ Recommended fix: drop the npm-global copy and put Pi's bin dir on PATH:${c.reset}`,
|
|
2816
|
+
);
|
|
2817
|
+
console.log(` ${c.cyan} npm uninstall -g taskplane${c.reset}`);
|
|
2818
|
+
// Platform-aware PATH guidance: PowerShell on Windows, bash/zsh elsewhere.
|
|
2819
|
+
// Linux & macOS users running PowerShell can still copy the bash form;
|
|
2820
|
+
// Windows users running Git Bash can still copy the PowerShell form. The
|
|
2821
|
+
// platform check picks the line most likely to match a fresh shell on
|
|
2822
|
+
// the host OS so the inline copy/paste path is one-line on the common
|
|
2823
|
+
// case.
|
|
2824
|
+
if (process.platform === "win32") {
|
|
2825
|
+
console.log(
|
|
2826
|
+
` ${c.cyan} $env:PATH = "$HOME\\.pi\\agent\\npm\\node_modules\\.bin;" + $env:PATH${c.reset} ${c.dim}# PowerShell, add to $PROFILE${c.reset}`,
|
|
2827
|
+
);
|
|
2828
|
+
console.log(
|
|
2829
|
+
` ${c.dim} or, for Git Bash:${c.reset} ${c.cyan}export PATH="$HOME/.pi/agent/npm/node_modules/.bin:$PATH"${c.reset} ${c.dim}# ~/.bashrc${c.reset}`,
|
|
2830
|
+
);
|
|
2831
|
+
} else {
|
|
2832
|
+
console.log(
|
|
2833
|
+
` ${c.cyan} export PATH="$HOME/.pi/agent/npm/node_modules/.bin:$PATH"${c.reset} ${c.dim}# add to ~/.bashrc / ~/.zshrc${c.reset}`,
|
|
2834
|
+
);
|
|
2835
|
+
}
|
|
2836
|
+
issues++;
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2792
2839
|
if (isWorkspaceMode) {
|
|
2793
2840
|
console.log();
|
|
2794
2841
|
if (wsResult.error) {
|
|
@@ -3315,9 +3362,21 @@ function cmdDoctor() {
|
|
|
3315
3362
|
if (issues === 0) {
|
|
3316
3363
|
console.log(`${OK} ${c.green}All checks passed!${c.reset}\n`);
|
|
3317
3364
|
} else {
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3365
|
+
// Each check above prints its own inline remediation hint, so the
|
|
3366
|
+
// summary points back to those rather than recommending a single
|
|
3367
|
+
// generic fix. The previous summary always recommended
|
|
3368
|
+
// `taskplane init`, which was misleading when the underlying issue
|
|
3369
|
+
// was a duplicate install (whose remediation is
|
|
3370
|
+
// `npm uninstall -g taskplane`, not init).
|
|
3371
|
+
if (duplicationDetected) {
|
|
3372
|
+
console.log(
|
|
3373
|
+
`${FAIL} ${issues} issue(s) found. See remediation hints above (${c.cyan}npm uninstall -g taskplane${c.reset} for the duplicate install).\n`,
|
|
3374
|
+
);
|
|
3375
|
+
} else {
|
|
3376
|
+
console.log(
|
|
3377
|
+
`${FAIL} ${issues} issue(s) found. See remediation hints above; run ${c.cyan}taskplane init${c.reset} to fix config issues.\n`,
|
|
3378
|
+
);
|
|
3379
|
+
}
|
|
3321
3380
|
process.exit(1);
|
|
3322
3381
|
}
|
|
3323
3382
|
}
|
|
@@ -3332,17 +3391,28 @@ function cmdVersion() {
|
|
|
3332
3391
|
console.log(`\ntaskplane ${c.bold}v${pkgVersion}${c.reset}`);
|
|
3333
3392
|
console.log(` Package: ${installType}`);
|
|
3334
3393
|
|
|
3335
|
-
// Check for project config
|
|
3394
|
+
// Check for project config. Only render the `version` / `installedAt`
|
|
3395
|
+
// fields when they're actually present and non-empty — some marker files
|
|
3396
|
+
// (e.g. taskplane's own source repo, where `.pi/taskplane.json` carries
|
|
3397
|
+
// only migration history rather than init metadata) would otherwise
|
|
3398
|
+
// produce the user-visible "vundefined, initialized unknown" placeholders.
|
|
3336
3399
|
const projectRoot = process.cwd();
|
|
3337
3400
|
const tpJson = path.join(projectRoot, ".pi", "taskplane.json");
|
|
3338
3401
|
if (fs.existsSync(tpJson)) {
|
|
3339
3402
|
try {
|
|
3340
3403
|
const info = JSON.parse(fs.readFileSync(tpJson, "utf-8"));
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3404
|
+
const parts = [];
|
|
3405
|
+
if (typeof info.version === "string" && info.version.length > 0) {
|
|
3406
|
+
parts.push(`v${info.version}`);
|
|
3407
|
+
}
|
|
3408
|
+
if (typeof info.installedAt === "string" && info.installedAt.length > 0) {
|
|
3409
|
+
parts.push(`initialized ${info.installedAt.slice(0, 10)}`);
|
|
3410
|
+
}
|
|
3411
|
+
const suffix =
|
|
3412
|
+
parts.length > 0 ? ` (${parts.join(", ")})` : ` ${c.dim}(metadata only)${c.reset}`;
|
|
3413
|
+
console.log(` Config: .pi/taskplane.json${suffix}`);
|
|
3344
3414
|
} catch {
|
|
3345
|
-
console.log(` Config: .pi/taskplane.json (unreadable)`);
|
|
3415
|
+
console.log(` Config: .pi/taskplane.json ${c.dim}(unreadable)${c.reset}`);
|
|
3346
3416
|
}
|
|
3347
3417
|
} else {
|
|
3348
3418
|
console.log(` Config: ${c.dim}not initialized (run taskplane init)${c.reset}`);
|
|
@@ -3366,6 +3436,82 @@ function getPackageVersion() {
|
|
|
3366
3436
|
}
|
|
3367
3437
|
}
|
|
3368
3438
|
|
|
3439
|
+
/**
|
|
3440
|
+
* Detect a duplicate taskplane install: one copy at Pi's private extension
|
|
3441
|
+
* directory (`~/.pi/agent/npm/node_modules/taskplane/`) and another at the
|
|
3442
|
+
* system npm-global root, with DIFFERENT versions. Same-version installs
|
|
3443
|
+
* are harmless and intentionally not flagged (they may drift later but
|
|
3444
|
+
* aren't a problem in the present tense).
|
|
3445
|
+
*
|
|
3446
|
+
* Returns null when there's no detectable duplication, or an object
|
|
3447
|
+
* describing the two locations + their versions when there is.
|
|
3448
|
+
*
|
|
3449
|
+
* Best-effort: silently returns null if `npm root -g` is unreachable or
|
|
3450
|
+
* either package.json is unreadable, since the rest of `taskplane doctor`
|
|
3451
|
+
* should never crash on a side check.
|
|
3452
|
+
*
|
|
3453
|
+
* Background: Pi 0.75.0 (2026-05-17) moved user-scoped pi packages from
|
|
3454
|
+
* npm's global root to `~/.pi/agent/npm/` to avoid permission errors with
|
|
3455
|
+
* system-managed Node installs (Pi changelog: "Fixed user-scoped npm pi
|
|
3456
|
+
* packages to install under `~/.pi/agent/npm/` instead of npm's global
|
|
3457
|
+
* package root"). Users who had previously run `npm install -g taskplane`
|
|
3458
|
+
* (or who continue to do so per older docs) now have two on-disk copies
|
|
3459
|
+
* that drift independently: `pi update` only refreshes the Pi-private one.
|
|
3460
|
+
*/
|
|
3461
|
+
function detectDuplicateTaskplaneInstall() {
|
|
3462
|
+
const piPrivatePath = path.join(homedir(), ".pi", "agent", "npm", "node_modules", "taskplane");
|
|
3463
|
+
|
|
3464
|
+
let npmRootGlobal = null;
|
|
3465
|
+
try {
|
|
3466
|
+
// 5s timeout guards against the rare case where `npm` is on PATH but
|
|
3467
|
+
// hangs (slow registry probe, misconfigured custom prefix, etc.).
|
|
3468
|
+
// `taskplane doctor` is a diagnostic — it should never make the CLI
|
|
3469
|
+
// itself feel hung. On timeout, execSync throws and we fall back to
|
|
3470
|
+
// the catch branch, returning null and silently skipping the
|
|
3471
|
+
// duplication check (best-effort by design).
|
|
3472
|
+
npmRootGlobal = execSync("npm root -g", {
|
|
3473
|
+
encoding: "utf-8",
|
|
3474
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3475
|
+
timeout: 5000,
|
|
3476
|
+
})
|
|
3477
|
+
.toString()
|
|
3478
|
+
.trim();
|
|
3479
|
+
} catch {
|
|
3480
|
+
return null;
|
|
3481
|
+
}
|
|
3482
|
+
if (!npmRootGlobal) return null;
|
|
3483
|
+
const npmGlobalPath = path.join(npmRootGlobal, "taskplane");
|
|
3484
|
+
|
|
3485
|
+
// Skip if Pi-private and npm-global resolve to the same path on disk.
|
|
3486
|
+
// This can happen on installs where Pi hasn't (yet) migrated to its
|
|
3487
|
+
// private directory or where the two paths point at the same install.
|
|
3488
|
+
if (path.resolve(piPrivatePath) === path.resolve(npmGlobalPath)) return null;
|
|
3489
|
+
|
|
3490
|
+
const candidates = [
|
|
3491
|
+
{ path: piPrivatePath, label: "Pi-private" },
|
|
3492
|
+
{ path: npmGlobalPath, label: "npm-global" },
|
|
3493
|
+
];
|
|
3494
|
+
|
|
3495
|
+
const found = [];
|
|
3496
|
+
for (const candidate of candidates) {
|
|
3497
|
+
try {
|
|
3498
|
+
const pkgJsonPath = path.join(candidate.path, "package.json");
|
|
3499
|
+
if (!fs.existsSync(pkgJsonPath)) continue;
|
|
3500
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
|
|
3501
|
+
if (typeof pkg.version !== "string" || pkg.version.length === 0) continue;
|
|
3502
|
+
found.push({ path: candidate.path, label: candidate.label, version: pkg.version });
|
|
3503
|
+
} catch {
|
|
3504
|
+
/* unreadable — skip this candidate */
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
// Need both copies to be a duplication. Same-version pair is not flagged.
|
|
3509
|
+
if (found.length < 2) return null;
|
|
3510
|
+
if (found[0].version === found[1].version) return null;
|
|
3511
|
+
|
|
3512
|
+
return { locations: found };
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3369
3515
|
// ─── dashboard ──────────────────────────────────────────────────────────────
|
|
3370
3516
|
|
|
3371
3517
|
function cmdDashboard(args) {
|
package/dashboard/public/app.js
CHANGED
|
@@ -243,6 +243,17 @@ let viewerMode = null; // "conversation" | "status-md" | null
|
|
|
243
243
|
let viewerTarget = null; // session name (conversation) or taskId (status-md)
|
|
244
244
|
let lastBatchId = null; // TP-178: track batchId for stale viewer detection (#487)
|
|
245
245
|
|
|
246
|
+
// #507: Debounce the no-batch transition. A single missed poll happens
|
|
247
|
+
// transiently during batch-state.json writes at batch startup, and was
|
|
248
|
+
// causing the dashboard to flash the previous batch's history view before
|
|
249
|
+
// switching to the new live batch. Require N consecutive no-batch polls
|
|
250
|
+
// before clearing the viewer / showing history. With the server's 2s
|
|
251
|
+
// POLL_INTERVAL, a threshold of 3 corresponds to ~6s of confirmed silence —
|
|
252
|
+
// well past the typical batch-state.json write window (sub-second) while
|
|
253
|
+
// still cleaning up promptly when a batch genuinely ends.
|
|
254
|
+
let consecutiveNoBatchPolls = 0;
|
|
255
|
+
const NO_BATCH_DEBOUNCE_THRESHOLD = 3;
|
|
256
|
+
|
|
246
257
|
// ─── Repo Helpers ───────────────────────────────────────────────────────────
|
|
247
258
|
|
|
248
259
|
/**
|
|
@@ -1818,6 +1829,16 @@ function render(data) {
|
|
|
1818
1829
|
$lastUpdate.textContent = new Date().toLocaleTimeString();
|
|
1819
1830
|
|
|
1820
1831
|
if (!batch) {
|
|
1832
|
+
// #507: A single missed poll during batch startup (batch-state.json being
|
|
1833
|
+
// written) is not a real "batch disappeared" signal. Only act on no-batch
|
|
1834
|
+
// after N consecutive polls confirm it, so we don't flash the history
|
|
1835
|
+
// view between two live batches.
|
|
1836
|
+
consecutiveNoBatchPolls += 1;
|
|
1837
|
+
if (consecutiveNoBatchPolls < NO_BATCH_DEBOUNCE_THRESHOLD) {
|
|
1838
|
+
// Hold the previous render in place. Still tick the timestamp so the
|
|
1839
|
+
// user knows the SSE stream is alive.
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1821
1842
|
// TP-178: Clear viewer when batch disappears (#487)
|
|
1822
1843
|
if (lastBatchId && viewerMode) closeViewer();
|
|
1823
1844
|
lastBatchId = null;
|
|
@@ -1830,6 +1851,9 @@ function render(data) {
|
|
|
1830
1851
|
return;
|
|
1831
1852
|
}
|
|
1832
1853
|
|
|
1854
|
+
// Batch present — reset the no-batch debounce counter (#507).
|
|
1855
|
+
consecutiveNoBatchPolls = 0;
|
|
1856
|
+
|
|
1833
1857
|
// TP-178: Detect batchId change — clear stale viewer state (#487)
|
|
1834
1858
|
if (batch.batchId && lastBatchId && batch.batchId !== lastBatchId && viewerMode) {
|
|
1835
1859
|
closeViewer();
|
package/dashboard/server.cjs
CHANGED
|
@@ -467,12 +467,26 @@ function loadRuntimeLaneSnapshots(batchId) {
|
|
|
467
467
|
/**
|
|
468
468
|
* Load Runtime V2 merge agent snapshots for the current batch.
|
|
469
469
|
*
|
|
470
|
-
* Reads all `merge
|
|
471
|
-
* Returns a map of
|
|
470
|
+
* Reads all `merge-*.json` files from `.pi/runtime/{batchId}/lanes/`.
|
|
471
|
+
* Returns a map of unique key → snapshot data, where the key is a composite
|
|
472
|
+
* of waveIndex and mergeNumber.
|
|
473
|
+
*
|
|
474
|
+
* The composite key is essential because lane numbers (and therefore
|
|
475
|
+
* `mergeNumber`) repeat across waves — keying solely by `mergeNumber` caused
|
|
476
|
+
* wave N+1's snapshots to silently overwrite wave N's in the intermediate
|
|
477
|
+
* map, which is the root cause of #509 ('merge agent telemetry missing for
|
|
478
|
+
* some waves').
|
|
472
479
|
*
|
|
473
480
|
* Follows the same pattern as {@link loadRuntimeLaneSnapshots}.
|
|
474
481
|
*
|
|
475
|
-
*
|
|
482
|
+
* Filename accepted patterns (back-compat-tolerant):
|
|
483
|
+
* merge-w{waveIndex}-{mergeNumber}.json (current, post-#509)
|
|
484
|
+
* merge-{mergeNumber}.json (legacy, pre-#509)
|
|
485
|
+
*
|
|
486
|
+
* Both patterns embed waveIndex inside the snapshot JSON itself, so the key
|
|
487
|
+
* derivation works for either filename.
|
|
488
|
+
*
|
|
489
|
+
* @since TP-164 (composite key added in #509 remediation)
|
|
476
490
|
*/
|
|
477
491
|
function loadRuntimeMergeSnapshots(batchId) {
|
|
478
492
|
if (!batchId) return {};
|
|
@@ -484,7 +498,14 @@ function loadRuntimeMergeSnapshots(batchId) {
|
|
|
484
498
|
for (const file of files) {
|
|
485
499
|
try {
|
|
486
500
|
const data = JSON.parse(fs.readFileSync(path.join(lanesDir, file), "utf-8"));
|
|
487
|
-
if (data.mergeNumber
|
|
501
|
+
if (data.mergeNumber == null) continue;
|
|
502
|
+
// Composite key keeps cross-wave snapshots from colliding in this map.
|
|
503
|
+
// Falls back to mergeNumber-only for legacy snapshots that pre-date
|
|
504
|
+
// the waveIndex-in-filename change.
|
|
505
|
+
const key = data.waveIndex != null
|
|
506
|
+
? `w${data.waveIndex}-${data.mergeNumber}`
|
|
507
|
+
: String(data.mergeNumber);
|
|
508
|
+
snapshots[key] = data;
|
|
488
509
|
} catch { continue; }
|
|
489
510
|
}
|
|
490
511
|
} catch { /* dir missing */ }
|
|
@@ -4225,14 +4225,23 @@ export async function executeOrchBatch(
|
|
|
4225
4225
|
"info",
|
|
4226
4226
|
);
|
|
4227
4227
|
|
|
4228
|
-
// TP-040: Emit merge_success event
|
|
4228
|
+
// TP-040: Emit merge_success event.
|
|
4229
|
+
//
|
|
4230
|
+
// `waveIndex` is the segment-round index (0-based), and the supervisor
|
|
4231
|
+
// formatter renders the (N/M) counter using `waveIndex + 1` for N.
|
|
4232
|
+
// For unit consistency we therefore pair it with the segment-level
|
|
4233
|
+
// `batchState.totalWaves` (segment-expanded round count), not
|
|
4234
|
+
// `taskLevelWaveCount` (pre-expansion). Using the task-level count as
|
|
4235
|
+
// the denominator while the numerator counts segment rounds produced
|
|
4236
|
+
// `(4/3)`, `(5/3)`, `(6/3)` style overflow once segments expanded —
|
|
4237
|
+
// see issue #562.
|
|
4229
4238
|
emitEvent(
|
|
4230
4239
|
stateRoot,
|
|
4231
4240
|
{
|
|
4232
4241
|
...buildEngineEventBase("merge_success", batchState.batchId, waveIdx, batchState.phase),
|
|
4233
4242
|
laneCount: mergedCount,
|
|
4234
4243
|
durationMs: mergeResult.totalDurationMs,
|
|
4235
|
-
totalWaves:
|
|
4244
|
+
totalWaves: batchState.totalWaves,
|
|
4236
4245
|
},
|
|
4237
4246
|
onEngineEvent,
|
|
4238
4247
|
);
|
|
@@ -895,7 +895,7 @@ export async function spawnMergeAgentV2(
|
|
|
895
895
|
agent: buildAgentSnap(tel, "running"),
|
|
896
896
|
updatedAt: Date.now(),
|
|
897
897
|
};
|
|
898
|
-
writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
|
|
898
|
+
writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, snap);
|
|
899
899
|
} catch {
|
|
900
900
|
/* non-fatal */
|
|
901
901
|
}
|
|
@@ -915,7 +915,7 @@ export async function spawnMergeAgentV2(
|
|
|
915
915
|
agent: buildAgentSnap({}, "running"),
|
|
916
916
|
updatedAt: Date.now(),
|
|
917
917
|
};
|
|
918
|
-
writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, initialSnap);
|
|
918
|
+
writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, initialSnap);
|
|
919
919
|
} catch {
|
|
920
920
|
/* non-fatal */
|
|
921
921
|
}
|
|
@@ -964,7 +964,7 @@ export async function spawnMergeAgentV2(
|
|
|
964
964
|
agent: buildAgentSnap(result, terminalStatus === "complete" ? "exited" : "crashed"),
|
|
965
965
|
updatedAt: Date.now(),
|
|
966
966
|
};
|
|
967
|
-
writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
|
|
967
|
+
writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, snap);
|
|
968
968
|
} catch {
|
|
969
969
|
/* non-fatal */
|
|
970
970
|
}
|
|
@@ -987,7 +987,7 @@ export async function spawnMergeAgentV2(
|
|
|
987
987
|
agent: buildAgentSnap({}, "crashed"),
|
|
988
988
|
updatedAt: Date.now(),
|
|
989
989
|
};
|
|
990
|
-
writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
|
|
990
|
+
writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, snap);
|
|
991
991
|
} catch {
|
|
992
992
|
/* non-fatal */
|
|
993
993
|
}
|
|
@@ -112,32 +112,53 @@ const PI_PACKAGE_SCOPES = ["@earendil-works", "@mariozechner"] as const;
|
|
|
112
112
|
* `dist/cli.js` so callers can spawn it with `node` directly, without a shell
|
|
113
113
|
* intermediary.
|
|
114
114
|
*
|
|
115
|
-
* Resolution order:
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* is the
|
|
115
|
+
* Resolution order:
|
|
116
|
+
*
|
|
117
|
+
* 0. **AUTHORITATIVE** — `process.argv[1]` when it points at a Pi `cli.js`.
|
|
118
|
+
* When Taskplane is running as a Pi extension, the parent process IS
|
|
119
|
+
* Pi, and Node sets `process.argv[1]` to the path of the file used to
|
|
120
|
+
* start it. This is the single most reliable resolution path: it works
|
|
121
|
+
* for npm-global, mise, asdf, NVM (Windows + Unix), Nix, Bun-installed
|
|
122
|
+
* Pi, and any future install method we can't enumerate. Issues #519
|
|
123
|
+
* and #598 both stem from this signal being ignored in favor of a
|
|
124
|
+
* static-path search that misses non-canonical install layouts.
|
|
125
|
+
*
|
|
126
|
+
* If `process.argv[1]` isn't a Pi `cli.js` (e.g. running standalone in tests,
|
|
127
|
+
* or invoked through an indirect wrapper), the function falls through to a
|
|
128
|
+
* cross product of base directories × package scopes:
|
|
119
129
|
*
|
|
120
|
-
*
|
|
121
|
-
* 1. `npm root -g` result (dynamic — covers all setups: nvm, Homebrew, volta, etc.)
|
|
130
|
+
* 1. `npm root -g` result (dynamic — covers npm-global, Homebrew, volta, etc.)
|
|
122
131
|
* 2. `%APPDATA%\npm\node_modules\...` (Windows, APPDATA env var)
|
|
123
132
|
* 3. `%USERPROFILE%\AppData\Roaming\npm\node_modules\...` (Windows, HOME-relative)
|
|
124
133
|
* 4. `~/.npm-global/lib/node_modules/...` (macOS/Linux custom global prefix)
|
|
125
|
-
* 5.
|
|
126
|
-
* 6.
|
|
134
|
+
* 5. `$NVM_SYMLINK\node_modules` (NVM-for-Windows, when the env var is set)
|
|
135
|
+
* 6. `dirname($NVM_BIN)/../lib/node_modules` (NVM-for-Unix, when the env var is set)
|
|
136
|
+
* 7. `/usr/local/lib/node_modules/...` (macOS system Node, Linux)
|
|
137
|
+
* 8. `/opt/homebrew/lib/node_modules/...` (macOS Homebrew)
|
|
127
138
|
*
|
|
128
139
|
* Scopes per base (inner loop):
|
|
129
140
|
* a. `@earendil-works/pi-coding-agent/dist/cli.js`
|
|
130
141
|
* b. `@mariozechner/pi-coding-agent/dist/cli.js`
|
|
131
142
|
*
|
|
132
|
-
* @returns Absolute path to a Pi CLI `dist/cli.js
|
|
133
|
-
* @throws {Error} If the CLI entrypoint cannot be found
|
|
134
|
-
*
|
|
135
|
-
*
|
|
143
|
+
* @returns Absolute path to a Pi CLI `dist/cli.js`.
|
|
144
|
+
* @throws {Error} If the CLI entrypoint cannot be found by any strategy.
|
|
145
|
+
* The error message includes the `npm root -g` value AND lists
|
|
146
|
+
* both scopes searched, for operator diagnosis.
|
|
136
147
|
*/
|
|
137
148
|
export function resolvePiCliPath(): string {
|
|
149
|
+
// 0. AUTHORITATIVE: trust process.argv[1] when it points at a Pi cli.js.
|
|
150
|
+
// Pi's package.json declares `"bin": { "pi": "dist/cli.js" }`, so the
|
|
151
|
+
// `endsWith("cli.js")` guard is a tight sanity check that rejects e.g.
|
|
152
|
+
// test runners or wrapper scripts that happen to leave argv[1] pointing
|
|
153
|
+
// somewhere else. existsSync() guards against stale argv state in mocks.
|
|
154
|
+
const piEntry = process.argv[1] || "";
|
|
155
|
+
if (piEntry.endsWith("cli.js") && existsSync(piEntry)) {
|
|
156
|
+
return piEntry;
|
|
157
|
+
}
|
|
158
|
+
|
|
138
159
|
const bases: string[] = [];
|
|
139
160
|
|
|
140
|
-
// 1. Dynamic: npm root -g (covers
|
|
161
|
+
// 1. Dynamic: npm root -g (covers npm-global, Homebrew, volta, custom npm prefix, etc.)
|
|
141
162
|
const npmRoot = getNpmGlobalRoot();
|
|
142
163
|
if (npmRoot) bases.push(npmRoot);
|
|
143
164
|
|
|
@@ -151,9 +172,25 @@ export function resolvePiCliPath(): string {
|
|
|
151
172
|
// 4. macOS/Linux custom global prefix
|
|
152
173
|
bases.push(join(home, ".npm-global", "lib", "node_modules"));
|
|
153
174
|
}
|
|
154
|
-
|
|
175
|
+
|
|
176
|
+
// 5. NVM-for-Windows defense in depth: NVM_SYMLINK points at the active
|
|
177
|
+
// Node install (typically C:\Program Files\nodejs as a junction), and the
|
|
178
|
+
// global packages live under <symlink>\node_modules. Child processes
|
|
179
|
+
// inherit this env var even when PATH is stripped of npm.
|
|
180
|
+
if (process.env.NVM_SYMLINK) {
|
|
181
|
+
bases.push(join(process.env.NVM_SYMLINK, "node_modules"));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 6. NVM-for-Unix defense in depth: NVM_BIN points at the active version's
|
|
185
|
+
// bin directory, and the corresponding node_modules sit alongside it at
|
|
186
|
+
// `../lib/node_modules`. Same inheritance properties as NVM_SYMLINK.
|
|
187
|
+
if (process.env.NVM_BIN) {
|
|
188
|
+
bases.push(join(process.env.NVM_BIN, "..", "lib", "node_modules"));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 7. macOS system Node / Linux
|
|
155
192
|
bases.push(join("/usr", "local", "lib", "node_modules"));
|
|
156
|
-
//
|
|
193
|
+
// 8. macOS Homebrew
|
|
157
194
|
bases.push(join("/opt", "homebrew", "lib", "node_modules"));
|
|
158
195
|
|
|
159
196
|
// Cross product: scope is the inner loop so a single base directory is
|
|
@@ -400,20 +400,25 @@ export function readLaneSnapshot(
|
|
|
400
400
|
* Stored in the `lanes/` directory alongside lane snapshots so the dashboard
|
|
401
401
|
* server picks it up with the same scan that reads lane-N.json files.
|
|
402
402
|
*
|
|
403
|
+
* Filename includes BOTH waveIndex and mergeNumber so wave-N+1's merges
|
|
404
|
+
* cannot overwrite wave-N's snapshots before the dashboard polls them (#509).
|
|
405
|
+
*
|
|
403
406
|
* @param stateRoot - Repository root (where `.pi/` lives)
|
|
404
407
|
* @param batchId - Current batch identifier
|
|
408
|
+
* @param waveIndex - 0-based wave index for the merge
|
|
405
409
|
* @param mergeNumber - 1-indexed merge agent number
|
|
406
410
|
* @param snapshot - Snapshot data to persist
|
|
407
411
|
*
|
|
408
|
-
* @since TP-164
|
|
412
|
+
* @since TP-164 (waveIndex parameter added in #509 remediation)
|
|
409
413
|
*/
|
|
410
414
|
export function writeMergeSnapshot(
|
|
411
415
|
stateRoot: string,
|
|
412
416
|
batchId: string,
|
|
417
|
+
waveIndex: number,
|
|
413
418
|
mergeNumber: number,
|
|
414
419
|
snapshot: RuntimeMergeSnapshot,
|
|
415
420
|
): void {
|
|
416
|
-
const path = runtimeMergeSnapshotPath(stateRoot, batchId, mergeNumber);
|
|
421
|
+
const path = runtimeMergeSnapshotPath(stateRoot, batchId, waveIndex, mergeNumber);
|
|
417
422
|
mkdirSync(dirname(path), { recursive: true });
|
|
418
423
|
const tmpPath = path + ".tmp";
|
|
419
424
|
writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
|
|
@@ -426,17 +431,19 @@ export function writeMergeSnapshot(
|
|
|
426
431
|
*
|
|
427
432
|
* @param stateRoot - Repository root (where `.pi/` lives)
|
|
428
433
|
* @param batchId - Current batch identifier
|
|
434
|
+
* @param waveIndex - 0-based wave index for the merge
|
|
429
435
|
* @param mergeNumber - 1-indexed merge agent number
|
|
430
436
|
*
|
|
431
|
-
* @since TP-164
|
|
437
|
+
* @since TP-164 (waveIndex parameter added in #509 remediation)
|
|
432
438
|
*/
|
|
433
439
|
export function readMergeSnapshot(
|
|
434
440
|
stateRoot: string,
|
|
435
441
|
batchId: string,
|
|
442
|
+
waveIndex: number,
|
|
436
443
|
mergeNumber: number,
|
|
437
444
|
): RuntimeMergeSnapshot | null {
|
|
438
445
|
try {
|
|
439
|
-
const p = runtimeMergeSnapshotPath(stateRoot, batchId, mergeNumber);
|
|
446
|
+
const p = runtimeMergeSnapshotPath(stateRoot, batchId, waveIndex, mergeNumber);
|
|
440
447
|
if (!existsSync(p)) return null;
|
|
441
448
|
return JSON.parse(readFileSync(p, "utf-8")) as RuntimeMergeSnapshot;
|
|
442
449
|
} catch {
|
|
@@ -4323,12 +4323,26 @@ export interface RuntimeMergeSnapshot {
|
|
|
4323
4323
|
*
|
|
4324
4324
|
* @since TP-164
|
|
4325
4325
|
*/
|
|
4326
|
+
/**
|
|
4327
|
+
* Path to a merge agent snapshot file.
|
|
4328
|
+
*
|
|
4329
|
+
* The filename includes BOTH `waveIndex` and `mergeNumber` because lane
|
|
4330
|
+
* numbers (and therefore the legacy `mergeNumber`-only filename) repeat
|
|
4331
|
+
* across waves — a wave-2 lane-1 merge would overwrite the wave-1 lane-1
|
|
4332
|
+
* snapshot before the dashboard's next poll could read it. Per-wave
|
|
4333
|
+
* namespacing keeps each merge's snapshot durable until the runtime
|
|
4334
|
+
* directory itself is cleaned up at end-of-batch. See #509.
|
|
4335
|
+
*
|
|
4336
|
+
* @param waveIndex 0-based wave index for the merge
|
|
4337
|
+
* @param mergeNumber 1-based merge agent number (derived from lane number)
|
|
4338
|
+
*/
|
|
4326
4339
|
export function runtimeMergeSnapshotPath(
|
|
4327
4340
|
stateRoot: string,
|
|
4328
4341
|
batchId: string,
|
|
4342
|
+
waveIndex: number,
|
|
4329
4343
|
mergeNumber: number,
|
|
4330
4344
|
): string {
|
|
4331
|
-
return `${stateRoot}/.pi/runtime/${batchId}/lanes/merge-${mergeNumber}.json`;
|
|
4345
|
+
return `${stateRoot}/.pi/runtime/${batchId}/lanes/merge-w${waveIndex}-${mergeNumber}.json`;
|
|
4332
4346
|
}
|
|
4333
4347
|
|
|
4334
4348
|
/**
|