taskplane 0.30.2 → 0.30.4
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 +24 -2
- package/bin/taskplane.mjs +154 -8
- package/extensions/taskplane/supervisor.ts +124 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Taskplane
|
|
2
2
|
|
|
3
|
-
Multi-agent AI orchestration for coding with [pi](https://github.com/
|
|
3
|
+
Multi-agent AI orchestration for coding with [pi](https://github.com/earendil-works/pi) — parallel task execution, mono- and poly-repo support, fresh-context worker loops, cross-model reviews, automated merges and a killer dashboard!
|
|
4
4
|
|
|
5
5
|
> **Status:** Initial release.
|
|
6
6
|
|
|
@@ -50,7 +50,7 @@ Taskplane is a pi package. You need Node.js 22+, pi and Git installed first.
|
|
|
50
50
|
| Dependency | Required | Notes |
|
|
51
51
|
|-----------|----------|-------|
|
|
52
52
|
| [Node.js](https://nodejs.org/) ≥ 22 | Yes | Runtime |
|
|
53
|
-
| [pi](https://github.com/
|
|
53
|
+
| [pi](https://github.com/earendil-works/pi) | Yes | Agent framework |
|
|
54
54
|
| [Git](https://git-scm.com/) | Yes | Version control, worktrees |
|
|
55
55
|
|
|
56
56
|
IMPORTANT: If you just installed pi, make sure you've configured at least one model provider and tested before installing Taskplane.
|
|
@@ -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) {
|
|
@@ -2020,7 +2020,16 @@ export function presentBatchSummary(
|
|
|
2020
2020
|
(batchState.failedTasks > 0 ? `- **Failed:** ${batchState.failedTasks} task(s)\n` : "") +
|
|
2021
2021
|
`\nFull summary written to \`.pi/supervisor/${filename}\`.`;
|
|
2022
2022
|
|
|
2023
|
-
|
|
2023
|
+
// #597 (post-Sage-review): `presentBatchSummary` is a terminal best-effort
|
|
2024
|
+
// operation, and it can be reached from a timer-origin call chain via
|
|
2025
|
+
// `startHeartbeat → deactivateSupervisor → (state.pendingSummaryDeps)`.
|
|
2026
|
+
// If the captured `pi` handle has gone stale by the time the heartbeat
|
|
2027
|
+
// fires, an unwrapped `pi.sendMessage` here re-introduces the exact
|
|
2028
|
+
// uncaughtException pattern #597 is meant to prevent. Use the same
|
|
2029
|
+
// never-throw wrapper as the timer call sites: deliver the message if
|
|
2030
|
+
// pi is healthy, drop it silently if pi is stale.
|
|
2031
|
+
safeSendMessageFromTimer(
|
|
2032
|
+
pi,
|
|
2024
2033
|
{
|
|
2025
2034
|
customType: "supervisor-batch-summary",
|
|
2026
2035
|
content: [{ type: "text", text: conciseText }],
|
|
@@ -3037,6 +3046,23 @@ export async function activateSupervisor(
|
|
|
3037
3046
|
};
|
|
3038
3047
|
writeLockfile(stateRoot, lock);
|
|
3039
3048
|
|
|
3049
|
+
// #597: Defensive teardown before installing new timers.
|
|
3050
|
+
//
|
|
3051
|
+
// `state` is a mutable singleton that persists across activate/deactivate
|
|
3052
|
+
// cycles. In re-activation paths (a previous activation that didn't go
|
|
3053
|
+
// through `deactivateSupervisor` cleanly, session churn after takeover,
|
|
3054
|
+
// etc.) `state.heartbeatTimer` and `state.eventTailer` may still reference
|
|
3055
|
+
// running timers that captured a now-stale `pi` handle. If we just
|
|
3056
|
+
// reassign `state.heartbeatTimer = startHeartbeat(...)` the previous
|
|
3057
|
+
// timer is orphaned — still ticking, still holding the stale `pi`, and
|
|
3058
|
+
// at the next tick its `pi.sendMessage()` call throws `assertActive` and
|
|
3059
|
+
// crashes the host process. Tear down explicitly before replacing.
|
|
3060
|
+
stopEventTailer(state.eventTailer);
|
|
3061
|
+
if (state.heartbeatTimer) {
|
|
3062
|
+
clearInterval(state.heartbeatTimer);
|
|
3063
|
+
state.heartbeatTimer = null;
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3040
3066
|
// Start heartbeat timer — updates lockfile every 30s, detects takeover
|
|
3041
3067
|
state.heartbeatTimer = startHeartbeat(stateRoot, state, pi);
|
|
3042
3068
|
|
|
@@ -3710,6 +3736,87 @@ export function buildTakeoverSummary(stateRoot: string, batchState: PersistedBat
|
|
|
3710
3736
|
*
|
|
3711
3737
|
* @since TP-041
|
|
3712
3738
|
*/
|
|
3739
|
+
|
|
3740
|
+
// ── Stale extension-context guard (#597) ──────────────────────────────
|
|
3741
|
+
//
|
|
3742
|
+
// Pi throws "This extension ctx is stale after session replacement or reload."
|
|
3743
|
+
// from `assertActive` when an extension uses a captured `pi` handle after
|
|
3744
|
+
// `ctx.newSession()`, `ctx.fork()`, `ctx.switchSession()`, or `ctx.reload()`.
|
|
3745
|
+
// Background timers in this file (`startHeartbeat`, `startEventTailer`)
|
|
3746
|
+
// capture `pi` in a closure and call `pi.sendMessage()` at arbitrary times;
|
|
3747
|
+
// when the captured handle goes stale between timer ticks, an uncaught
|
|
3748
|
+
// throw from `pi.sendMessage()` becomes a process-fatal `uncaughtException`
|
|
3749
|
+
// that kills the entire Pi process — issue #597.
|
|
3750
|
+
//
|
|
3751
|
+
// `isStaleExtensionCtx` and `safeSendMessageFromTimer` together harden the
|
|
3752
|
+
// timer call sites: stale-ctx errors are recognized and swallowed (the timer
|
|
3753
|
+
// caller then stops itself), other errors are logged to stderr but do not
|
|
3754
|
+
// propagate. The supervisor surrenders its UI surface gracefully instead of
|
|
3755
|
+
// taking Pi down.
|
|
3756
|
+
|
|
3757
|
+
/**
|
|
3758
|
+
* Returns true when `err` is Pi's distinctive stale-extension-ctx error.
|
|
3759
|
+
*
|
|
3760
|
+
* Matched by error-message substring rather than by class identity because
|
|
3761
|
+
* the error class is not exported by `@earendil-works/pi-coding-agent` and
|
|
3762
|
+
* the message text is the stable, documented contract
|
|
3763
|
+
* (`core/extensions/loader.js:assertActive`).
|
|
3764
|
+
*
|
|
3765
|
+
* Defensive against non-Error throws (string throws, null, undefined, etc.)
|
|
3766
|
+
* which are not stale-ctx errors and should propagate to the caller's
|
|
3767
|
+
* normal error path — we only swallow the specific Pi case.
|
|
3768
|
+
*
|
|
3769
|
+
* @since #597
|
|
3770
|
+
*/
|
|
3771
|
+
export function isStaleExtensionCtx(err: unknown): boolean {
|
|
3772
|
+
if (err === null || err === undefined) return false;
|
|
3773
|
+
// Read message off either an Error instance or a plain object with .message
|
|
3774
|
+
const message =
|
|
3775
|
+
typeof err === "object" && err !== null && "message" in err
|
|
3776
|
+
? String((err as { message: unknown }).message ?? "")
|
|
3777
|
+
: typeof err === "string"
|
|
3778
|
+
? err
|
|
3779
|
+
: "";
|
|
3780
|
+
return message.includes("This extension ctx is stale");
|
|
3781
|
+
}
|
|
3782
|
+
|
|
3783
|
+
/**
|
|
3784
|
+
* `pi.sendMessage()` wrapper for timer-context callers (heartbeat / event
|
|
3785
|
+
* tailer / digest timer).
|
|
3786
|
+
*
|
|
3787
|
+
* Returns `true` on success, `false` when the call was swallowed because
|
|
3788
|
+
* the extension context has gone stale (per `isStaleExtensionCtx`). Other
|
|
3789
|
+
* exceptions are logged via `console.error` but also do not propagate —
|
|
3790
|
+
* the timer caller stays alive and continues, since the safe-default for a
|
|
3791
|
+
* background timer in a long-running process is to keep ticking rather
|
|
3792
|
+
* than crash the host. Callers should treat a `false` return as "Pi has
|
|
3793
|
+
* replaced us; stop trying" and clear their own interval.
|
|
3794
|
+
*
|
|
3795
|
+
* @since #597
|
|
3796
|
+
*/
|
|
3797
|
+
export function safeSendMessageFromTimer(
|
|
3798
|
+
pi: ExtensionAPI,
|
|
3799
|
+
message: Parameters<ExtensionAPI["sendMessage"]>[0],
|
|
3800
|
+
options?: Parameters<ExtensionAPI["sendMessage"]>[1],
|
|
3801
|
+
): boolean {
|
|
3802
|
+
try {
|
|
3803
|
+
pi.sendMessage(message, options);
|
|
3804
|
+
return true;
|
|
3805
|
+
} catch (err) {
|
|
3806
|
+
if (isStaleExtensionCtx(err)) {
|
|
3807
|
+
// Pi has replaced us. The timer caller will see `false` and stop.
|
|
3808
|
+
return false;
|
|
3809
|
+
}
|
|
3810
|
+
// Unexpected error — log for diagnosis but do not crash the host.
|
|
3811
|
+
console.error(
|
|
3812
|
+
`[supervisor] pi.sendMessage from timer callback threw: ${
|
|
3813
|
+
err instanceof Error ? err.message : String(err)
|
|
3814
|
+
}`,
|
|
3815
|
+
);
|
|
3816
|
+
return true; // not stale; let the caller continue ticking
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3713
3820
|
export function startHeartbeat(
|
|
3714
3821
|
stateRoot: string,
|
|
3715
3822
|
state: SupervisorState,
|
|
@@ -3731,9 +3838,14 @@ export function startHeartbeat(
|
|
|
3731
3838
|
// Read current lockfile to detect force takeover — async (TP-070)
|
|
3732
3839
|
const currentLock = await readLockfileAsync(stateRoot);
|
|
3733
3840
|
if (currentLock && currentLock.sessionId !== sessionId) {
|
|
3734
|
-
// Another session has taken over — yield gracefully
|
|
3841
|
+
// Another session has taken over — yield gracefully.
|
|
3842
|
+
// #597: the captured `pi` handle may be stale at this point;
|
|
3843
|
+
// use safeSendMessageFromTimer so a stale-ctx throw cannot
|
|
3844
|
+
// escape and become an uncaughtException that kills the
|
|
3845
|
+
// whole Pi process.
|
|
3735
3846
|
clearInterval(timer);
|
|
3736
|
-
|
|
3847
|
+
safeSendMessageFromTimer(
|
|
3848
|
+
pi,
|
|
3737
3849
|
{
|
|
3738
3850
|
customType: "supervisor-yield",
|
|
3739
3851
|
content: [
|
|
@@ -4444,7 +4556,12 @@ export function startEventTailer(
|
|
|
4444
4556
|
setStatus("supervisor", `🔀 ${statusText}`);
|
|
4445
4557
|
}
|
|
4446
4558
|
|
|
4447
|
-
pi.
|
|
4559
|
+
// #597: guard against stale-ctx throws from the captured `pi` handle.
|
|
4560
|
+
// If Pi has replaced us between event-tailer ticks, swallow the throw
|
|
4561
|
+
// and stop the tailer rather than letting an uncaughtException kill
|
|
4562
|
+
// the Pi process.
|
|
4563
|
+
const ok = safeSendMessageFromTimer(
|
|
4564
|
+
pi,
|
|
4448
4565
|
{
|
|
4449
4566
|
customType: "supervisor-event",
|
|
4450
4567
|
content: [{ type: "text", text }],
|
|
@@ -4452,6 +4569,9 @@ export function startEventTailer(
|
|
|
4452
4569
|
},
|
|
4453
4570
|
{ triggerTurn: true },
|
|
4454
4571
|
);
|
|
4572
|
+
if (!ok) {
|
|
4573
|
+
stopEventTailer(tailer);
|
|
4574
|
+
}
|
|
4455
4575
|
};
|
|
4456
4576
|
|
|
4457
4577
|
// ── TP-043: Integration is triggered by triggerSupervisorIntegration() ──
|