Recover supervisor identity and messages across lifecycle changes

This commit is contained in:
wassname
2026-09-09 13:09:43 +08:00
parent 138bde57f4
commit 15dd7f0222
19 changed files with 1150 additions and 222 deletions
+10 -7
View File
@@ -15,17 +15,17 @@ Plan in one Pi session, then do the work there while a stronger visible Pi sessi
The two Pi sessions are visible. You can switch to the supervisor pane and talk to it directly. Supervisor instructions are shown in full, including in collapsed tool rows; ordinary messages and emitted thinking use Pi's display settings. The supervisor is prompted to give brief progress assessments and use judgment about when to intervene.
On same-process reload, monitoring is restored without removing normal or custom tools. Views include the latest human direction, source-session path, worker model, and new messages since the last acknowledged view. They report Pi idleness and tracked process/subagent activity separately. Unavailable trackers stay unknown; unregistered detached jobs are not tracked. Approval is blocked while tracked work is active or unknown. Intercom disconnects are reported; unsent current views and unacknowledged instructions are retained in Pi session history for reconnect. A receipt confirms adapter handling only—not durable queue persistence, model receipt, or execution. Pi's void message API can fail asynchronously after that acknowledgement; crashes can also cause duplicate handoffs. End-to-end exactly-once or durable delivery is not guaranteed. Reviews stop after all goals are completed or cancelled, and both panes remain available. These mechanics are tested; useful judgment and savings from a cheaper worker still require a representative two-model run. -- Pi/OpenAI
On same-process reload, monitoring is restored without removing normal or custom tools. Views include the latest human direction, source-session path, worker model, and new messages since the last acknowledged view. They report Pi idleness and tracked process/subagent activity separately. Unavailable trackers stay unknown; unregistered detached jobs are not tracked. Approval is blocked while tracked work is active or unknown. Intercom disconnects are reported; unsent current views and unacknowledged instructions are retained in Pi session history for reconnect. Incoming review/instruction payloads are saved in a bounded inbox before presentation. A receipt is sent only when Pi emits the corresponding user `message_start`: this confirms session acceptance, not model judgment or tool execution. Pending payloads survive reload, and manual-compaction success, failure or cancellation resumes delivery after Pi is idle. Unpresented incremental views are kept separately, not overwritten. The inbox limit is 64 messages; overflow is reported without acknowledging the new message. Crashes around acceptance/persistence or extensions that transform/consume input can still require inspection and explicit reconnect; end-to-end exactly-once execution is not guaranteed. Reviews stop after all goals are completed or cancelled, and both panes remain available. These mechanics are tested; useful judgment and savings from a cheaper worker still require a representative two-model run. -- Pi/OpenAI
## Install
This branch requires Herdr 0.7.5 or newer and one Pi package. It reuses installed pi-intercom or loads its pi-intercom dependency when none is registered:
This branch requires Pi 0.85.1 or newer (before 1.0) and Herdr 0.7.5 or newer. Pi 0.85.1 supplies the public compaction-failure event and compaction-aware idle state used for delivery recovery. It reuses installed pi-intercom or loads its pi-intercom dependency when none is registered:
```bash
pi install npm:@wassname2/pi-goals
```
The supervisor launcher uses the normal Pi profile: it inherits the agent directory/environment and discovers configured extensions, skills, prompt templates, themes and authentication. It explicitly loads this pi-goals source and forks the planning session with the supervisor role/model. Existing Intercom is reused when registered. The repeated role instruction says to inspect and diagnose directly, but delegate changes through `SteerWorker` rather than alter shared state. **This is not an enforced sandbox:** bash, edit, write and extension actions remain available; other extensions may have their own hooks or restrictions. Planning-mode restrictions and approval checks are unchanged. Fresh-shell role recovery and the broader reload/compaction lifecycle remain separate acceptance work.
The supervisor launcher uses the normal Pi profile: it inherits the agent directory/environment and discovers configured extensions, skills, prompt templates, themes and authentication. It explicitly loads this pi-goals source and forks the planning session with the supervisor role/model. Existing Intercom is reused when registered. The repeated role instruction says to inspect and diagnose directly, but delegate changes through `SteerWorker` rather than alter shared state. **This is not an enforced sandbox:** bash, edit, write and extension actions remain available; other extensions may have their own hooks or restrictions. Planning-mode restrictions and approval checks are unchanged. A complete supervisor role/binding is saved before startup compaction and restored before worker handlers can run, including fresh-shell `pi --session <saved-file>` and stopped supervisor forks. Older bootstrap markers are migrated only when their saved pairing is recoverable; incomplete identity fails visibly rather than selecting worker mode. Full-profile Herdr behavior still needs parent-owned functional acceptance.
For a local checkout:
@@ -41,18 +41,21 @@ Run Pi from the Git repository that the plan will change. **Ready** fails if the
/goals <objective> create a new plan
/goals model <model> select the visible supervisor model
/goals model use the remembered supervisor model
/goals work reconnect the existing approved worker pairing
/goals supervise reconnect from the saved supervisor session
/goals noplan exit planning, preserving the draft without approving work
/goals clear close the supervisor pane and disconnect the plan
```
`/goals clear` keeps the plan file. Starting another plan also keeps older versions.
`work` and `supervise` are role-aware recovery commands, not role conversion or new-pairing commands. Wrong or missing identities are rejected. `noplan` preserves the draft/history, leaves planning restrictions, and does not select Ready, start implementation or launch a supervisor. `/goals clear` closes the tracked pane and keeps the plan file. Starting another plan also keeps older versions.
If a required model or supervisor is unavailable, the widget says **goals paused** and implementation/sign-off tools are gated. Human input, read-only diagnosis, `/model`, and recovery commands remain available:
- `/goals reconnect` retries the remembered role model and existing supervisor binding. Reconnect waits five seconds and never replaces a slow or missing pane automatically. A returning peer clears the connection pause automatically.
- `/goals reconnect` retries the remembered role model and existing supervisor binding. Worker readiness/reconnect waits allow five minutes, including an ordinary 60-second supervisor compaction, and never replace a slow or missing pane automatically. A returning peer clears the connection pause automatically; an established active worker pairing publishes a fresh current view so supervisor-only reload can resume review even when its previous view was already accepted.
- `/goals restart` explicitly closes only the tracked supervisor pane and starts a replacement for a working plan, preserving its file/version but invalidating old approvals. During planning it clears the failed pane so Ready can launch again.
- In the supervisor pane, use `/model` then `/goals reconnect` to recover an unavailable supervisor model.
- In the supervisor pane, use `/model` then `/goals supervise` (or `/goals reconnect`) to recover an unavailable supervisor model. Startup failure is reported to the waiting worker; it need not wait for the timeout to learn the cause.
Both sessions must load the updated transport for the request/reply reconnect fix; mixed-version peers are not a supported recovery configuration. Ready announces worker readiness only after its model is restored, and clearing a plan cancels its pending readiness wait.
Both sessions must load the updated transport for the request/reply reconnect fix; mixed-version peers are not a supported recovery configuration. Ready announces worker readiness only after its model is restored. Plan content is rechecked across startup/model-restore waits; changed content returns to review using the existing pane instead of starting different work. Clearing or leaving planning cancels its pending Ready attempt. `CompleteGoal` checks cancellation and the original binding/version after its asynchronous status lookup and before recording completion.
A new supervisor may still need up to five minutes for initial compaction. Recovery does not terminate background jobs. Planning/diagnostic command checks are guardrails, not an OS sandbox; loaded extensions and repository Git configuration must be trusted.
+523 -158
View File
@@ -14,7 +14,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"@earendil-works/pi-coding-agent": "^0.84.1",
"@earendil-works/pi-coding-agent": "0.85.1",
"@earendil-works/pi-tui": "^0.85.1",
"@types/node": "^20.0.0",
"typebox": "^1.3.7",
@@ -22,7 +22,7 @@
"vitest": "^4.0.18"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-coding-agent": ">=0.85.1 <1.0.0",
"@earendil-works/pi-tui": "*",
"typebox": "*"
}
@@ -707,22 +707,20 @@
}
},
"node_modules/@earendil-works/pi-coding-agent": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.1.tgz",
"integrity": "sha512-ncAqFrG+iybuPGOhMiZoEHkEzTpJgz3guYD32pD+M7ucc0WeHmauP6wa7qwP8V/KWvsZDVNa5XGsdZ7fkC7w7A==",
"version": "0.85.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.85.1.tgz",
"integrity": "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==",
"hasShrinkwrap": true,
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.1",
"@earendil-works/pi-ai": "^0.84.1",
"@earendil-works/pi-client": "^0.84.1",
"@earendil-works/pi-protocol": "^0.84.1",
"@earendil-works/pi-tui": "^0.84.1",
"@earendil-works/chord": "^0.85.1",
"@earendil-works/pi-agent-core": "^0.85.1",
"@earendil-works/pi-ai": "^0.85.1",
"@earendil-works/pi-tui": "^0.85.1",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
"diff": "8.0.4",
"glob": "13.0.6",
"grok-mermaid": "0.2.2",
"highlight.js": "10.7.3",
"hosted-git-info": "9.0.3",
@@ -736,7 +734,7 @@
"yaml": "2.9.0"
},
"bin": {
"pi": "dist/cli.js"
"pi": "dist/bundle/cli.js"
},
"engines": {
"node": ">=22.19.0"
@@ -746,12 +744,13 @@
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": {
"version": "0.91.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz",
"integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==",
"version": "0.123.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.123.0.tgz",
"integrity": "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
@@ -1180,13 +1179,25 @@
"node": ">=6.9.0"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.1.tgz",
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/chord": {
"version": "0.85.1",
"resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.85.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.84.1",
"@earendil-works/pi-telemetry": "^0.84.1",
"esbuild": "0.28.1"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": {
"version": "0.85.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.85.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^0.85.1",
"@earendil-works/pi-ai": "^0.85.1",
"@earendil-works/pi-telemetry": "^0.85.1",
"diff": "8.0.4",
"ignore": "7.0.5",
"typebox": "1.3.7",
@@ -1197,20 +1208,18 @@
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.1.tgz",
"version": "0.85.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.85.1.tgz",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.91.1",
"@anthropic-ai/sdk": "0.123.0",
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
"@earendil-works/pi-telemetry": "^0.84.1",
"@earendil-works/pi-telemetry": "^0.85.1",
"@google/genai": "1.52.0",
"@mistralai/mistralai": "2.2.6",
"@opentelemetry/api": "1.9.0",
"@smithy/node-http-handler": "4.7.3",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
"openai": "6.26.0",
"openai": "6.40.0",
"partial-json": "0.1.7",
"typebox": "1.3.7"
},
@@ -1221,39 +1230,17 @@
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-client": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-protocol": "^0.84.1"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-protocol": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.1.tgz",
"license": "MIT",
"dependencies": {
"typebox": "1.3.7"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.1.tgz",
"version": "0.85.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.85.1.tgz",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.1.tgz",
"version": "0.85.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.85.1.tgz",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "1.6.0",
@@ -1263,6 +1250,422 @@
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz",
@@ -1466,26 +1869,6 @@
"node": ">= 10"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz",
"integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.40.0",
"ws": "^8.18.0",
"zod": "^3.25.0 || ^4.0.0",
"zod-to-json-schema": "^3.25.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.9.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
@@ -1498,24 +1881,6 @@
],
"license": "MIT"
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": {
"version": "1.41.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz",
"integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -1699,6 +2064,12 @@
"node": ">=14.0.0"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
@@ -1849,12 +2220,59 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
@@ -1967,23 +2385,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
@@ -2181,15 +2582,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2235,13 +2627,10 @@
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/openai": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz",
"integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==",
"version": "6.40.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz",
"integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
@@ -2304,22 +2693,6 @@
"node": ">=8"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
@@ -2431,6 +2804,16 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/standardwebhooks": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
"integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
@@ -2551,24 +2934,6 @@
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/zod-to-json-schema": {
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
},
"node_modules/@earendil-works/pi-telemetry": {
"version": "0.85.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.85.1.tgz",
+2 -2
View File
@@ -22,7 +22,7 @@
"herdr"
],
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-coding-agent": ">=0.85.1 <1.0.0",
"@earendil-works/pi-tui": "*",
"typebox": "*"
},
@@ -49,7 +49,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"@earendil-works/pi-coding-agent": "^0.84.1",
"@earendil-works/pi-coding-agent": "0.85.1",
"@earendil-works/pi-tui": "^0.85.1",
"@types/node": "^20.0.0",
"typebox": "^1.3.7",
@@ -0,0 +1,33 @@
RUN v4.1.9 /home/code/.pi/agent/git/github.com/wassname/pi-goals
stdout | test/native-compaction-delivery.test.ts > real Pi preserves worker delivery through compaction success, failure and cancellation
real Pi worker/success: retained message presented once and saved, no extension errors
stdout | test/native-compaction-delivery.test.ts > real Pi preserves worker delivery through compaction success, failure and cancellation
real Pi worker/failure: retained message presented once and saved, no extension errors
stdout | test/native-intercom.test.ts > runs a forked Pi supervisor and receives its exact instruction in another Pi session
Native Pi pair: fork retained planning context; SteerWorker delivered exactly: Read the real outputs before declaring completion.
✓ test/native-intercom.test.ts > runs a forked Pi supervisor and receives its exact instruction in another Pi session 2071ms
stdout | test/native-compaction-delivery.test.ts > real Pi preserves worker delivery through compaction success, failure and cancellation
real Pi worker/cancel: retained message presented once and saved, no extension errors
✓ test/native-compaction-delivery.test.ts > real Pi preserves worker delivery through compaction success, failure and cancellation 2239ms
stdout | test/native-compaction-delivery.test.ts > real Pi preserves supervisor delivery through compaction success, failure and cancellation
real Pi supervisor/success: retained message presented once and saved, no extension errors
stdout | test/native-compaction-delivery.test.ts > real Pi preserves supervisor delivery through compaction success, failure and cancellation
real Pi supervisor/failure: retained message presented once and saved, no extension errors
stdout | test/native-compaction-delivery.test.ts > real Pi preserves supervisor delivery through compaction success, failure and cancellation
real Pi supervisor/cancel: retained message presented once and saved, no extension errors
✓ test/native-compaction-delivery.test.ts > real Pi preserves supervisor delivery through compaction success, failure and cancellation 2176ms
Test Files 2 passed (2)
Tests 3 passed (3)
Start at 12:59:23
Duration 4.60s (transform 52ms, setup 0ms, import 133ms, tests 6.49s, environment 0ms)
+44
View File
@@ -0,0 +1,44 @@
# Lifecycle recovery and role-aware commands
## Scope and decisions
Continue issue #6 and the independent lifecycle review after full-profile commit `1d52857`. Keep normal extensions/tools; do not alter Git/evidence approval policy. The supervisor approved delayed role selection at session_start, a durable inbound-message record, and requiring Pi 0.85.1 instead of maintaining a 0.84 compatibility retry layer. The user additionally authorized `work`, `supervise`, and `noplan` recovery commands.
`138bde5` already committed/pushed the first increment: CompleteGoal cancellation and Ready content stability. Remaining changes build on it, not replace it.
## Fixes
- CompleteGoal checks its signal before/after the asynchronous background lookup and before persistence; stale binding/version/phase or a disposed runtime also rejects. Cancelling one call leaves the plan active. `cancellation-before.txt` records a regression failure against the earlier implementation (it returned success after abort); the updated test verifies no tick/sign-off marker is written and a later valid call still works.
- Ready checks the displayed plan content before launch, after launch, after worker-model restoration and after the first view. A changed plan returns to planning using the existing pane; it does not start the changed implementation. Tests mutate content at launch and model restoration, then select Ready again without another pane.
- Real supervisor and worker sessions now select their role when session entries are available, before registering either role's handlers. A complete supervisor marker (worker, owner, plan path, approval binding) is written before model restoration/compaction. Persisted identity wins over launch environment. Legacy bootstrap markers migrate only with an existing pairing; incomplete records throw visibly without enabling worker mode. Stopped supervisor forks retain their identity. No tool denylist was reintroduced.
- Intercom saves each incoming non-started view/steer in a bounded inbox before calling Pi. It acknowledges only a matching user message_start. That is **session acceptance**, not model judgment or action execution. Unconfirmed payloads are restored from the active branch on reload; separate incremental views are retained rather than overwritten. A pending Pi queue is not re-enqueued while waiting for presentation. Completed/detached bindings and shutdown cancel delivery timers.
- Pi 0.85.1 has a public session_compact_failed event and fixes isIdle to include manual compaction; repo 0.84.1 did not. The minimum peer and exact dev version are now 0.85.1. Installation used `--ignore-scripts --no-audit --no-fund`; no global Pi change. Success/failure/cancel events resume retained context. Since session_compact precedes final controller cleanup, delivery waits for compaction-aware idle state; it never probes by sending a prompt during compaction. Waiting is bounded to 300 one-second idle checks with retained payload and a visible reconnect instruction on exhaustion, not a deadline that interrupts the model.
- Readiness/reconnect paths allow five minutes rather than five seconds. Reload while an inherited compaction is active waits for it rather than starting a second one. An existing last compaction or Pi's Already compacted/Nothing to compact result can proceed to bootstrap. Startup model/compaction failure is communicated through the existing hello so the worker sees the cause promptly. A later ready hello clears the failure.
- An established worker pairing republishes one fresh current view when disconnected→connected, including after supervisor-only reload where the old stopped view had already been accepted. This reuses the connection callback; Ready retains its own initial publication and session_start/reconnect no longer separately publish duplicates. Cleared/completed sessions do not restart monitoring.
- A started-worker view now reports that work is running instead of falsely claiming a newer review is queued for delivery.
## Command meanings
- `/goals work`: existing approved worker session reconnects its saved pairing/model; no new plan, pairing or model fallback. Missing/unapproved pairing is rejected.
- `/goals supervise`: existing saved supervisor reconnects its role/model/pairing. Running it in a worker session is rejected rather than converting the role.
- `/goals noplan`: leave planning restrictions and preserve the draft/history without Ready, implementation, supervisor launch or file deletion. In-flight Ready is invalidated. It does not claim the retained draft was approved.
- `/goals reconnect` remains generic recovery; `/goals restart` explicitly replaces only the tracked pane and invalidates the prior binding; `/goals clear` closes/disconnects while retaining the plan file.
## Runtime validation actually observed
`native-validation.txt` is fresh verbose output from installed Pi 0.85.1 with a local deterministic HTTP model; no credentials or model credits used.
1. Real Pi worker and supervisor delivery during manual compaction: success, local model failure and cancellation, six cases total. Each retained payload is presented exactly once and saved in the session; no extension_error events. The transport in this fixture is deterministic, while Pi owns the real compaction and prompt lifecycle. Success uses an extension-provided summary; failure exercises Pi's HTTP summarization failure. These are not rendered Herdr sessions or 60-second real-model runs.
2. Real native Pi/Intercom pair: full-profile discovery in an isolated agent directory, exact steering delivery, then supervisor termination and fresh-shell `--session` resume with role/binding launcher environment removed. The resumed model sees SteerWorker/ApproveGoal and the discovered profile tool, not CompleteGoal, and retains the supervisor opening. No second supervisor pairing is constructed.
Hook tests additionally cover a simulated 60-second inherited compaction without competing compaction, five-minute Ready/reconnect patience, immediate reported failure plus rejoin, retained distinct deltas through reload, delayed presentation without duplicate enqueue, role migration/incomplete identity, cancelled completion, Ready content drift, and command semantics. Existing paired tests cover symmetric reconnect/model restoration. The accepted-view reconnect regression checks one new view ID and unchanged-payload replay deduplication separately; cleared/completed pairings produce no new view. The tests model `/reload` with new extension instances or saved state; no real interactive `/reload` command was exercised in this task.
Final `validation.txt`: 123 tests in 22 files, typecheck, lint, build, and diff check pass. Native fixture initially could not compact a single retained turn; it now seeds two sufficiently sized turns. This corrected fixture setup is not counted as a product failure. No test processes from earlier runs remained when resuming after timeout; all processes started by these tests were shut down.
## Limits and remaining acceptance
The parent must still run full-profile Herdr acceptance: actual reloads in both orders, drafting/Ready/checkpoint interruption, stopped pair resume, real-model long compaction, and an unmet-outcome correction followed by both sign-offs. Automated session acceptance does not establish judgment quality or cheaper-worker success.
The inbox holds at most 64 messages; overflow is visible and unacknowledged. Arbitrary extensions that rewrite or consume injected user messages can defeat exact-text acceptance matching; crashes between message_start and message persistence can require review/replay. This is not an exactly-once execution guarantee or a general durable model queue. Role instructions remain the only prohibition on supervisor writes. Existing all-cancelled completion behavior and Git-tracked verification policy are unchanged.
No user or test Herdr panes, research sessions, human journal, or pre-existing dirty native evidence logs were operated/read/edited by this task. Changes to package-lock reflect the approved local Pi dependency upgrade. This report does not claim all issue #6 behavioral acceptance is complete.
@@ -0,0 +1,26 @@
> @wassname2/pi-goals@0.2.2 test
> vitest run
RUN v4.1.9 /home/code/.pi/agent/git/github.com/wassname/pi-goals
Test Files 22 passed (22)
Tests 123 passed (123)
Start at 13:08:28
Duration 4.90s (transform 5.07s, setup 0ms, import 11.10s, tests 11.14s, environment 4ms)
> @wassname2/pi-goals@0.2.2 typecheck
> tsc --noEmit
> @wassname2/pi-goals@0.2.2 lint
> biome check src/ test/
Checked 41 files in 77ms. No fixes applied.
> @wassname2/pi-goals@0.2.2 build
> tsc
+49 -13
View File
@@ -27,7 +27,7 @@ import { GoalIntercom } from "./intercom.js";
import { FOLD_LINE, foldPlan, GOAL_LINE } from "./plan.js";
import { completeGoalDescription, completeGoalParamDescription, planDrafting, planningState, resync, supervisorPlanReview } from "./prompts.js";
import { RoleModels } from "./role-models.js";
import { isVisibleSupervisor, registerVisibleSupervisor } from "./supervisor-session.js";
import { isVisibleSupervisor, registerVisibleSupervisor, restoredSupervisor } from "./supervisor-session.js";
import { workerView } from "./worker-view.js";
export { foldPlan } from "./plan.js";
@@ -99,11 +99,18 @@ interface PlanState {
}
export default function piGoalsExtension(pi: ExtensionAPI): void {
if (isVisibleSupervisor()) {
registerVisibleSupervisor(pi);
return;
}
if (!isMainSession()) return;
let initialized = false;
pi.on("session_start", async (_event, ctx) => {
if (initialized) return;
initialized = true;
const saved = restoredSupervisor(ctx.sessionManager.getEntries());
if (saved || isVisibleSupervisor()) registerVisibleSupervisor(pi, saved);
else if (isMainSession()) registerWorker(pi);
// Pi's dispatcher iterates its live handler list, including those just registered.
});
}
export function registerWorker(pi: ExtensionAPI): void {
const intercom = new GoalIntercom(pi);
const models = new RoleModels(pi);
intercom.onSteer = (instruction) => {
@@ -122,7 +129,17 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
};
let modelError: string | null = null;
let readyAttempt: object | undefined;
intercom.onConnectionChange = (ctx) => updateWidget(ctx);
let wasConnected = false;
intercom.onConnectionChange = (ctx) => {
const connected = intercom.connected;
const rejoined = connected && !wasConnected;
wasConnected = connected;
updateWidget(ctx);
// Ready publishes its own first view. Subsequent rejoins need a new ID even if the old view was accepted.
if (rejoined && !readyAttempt && state.phase === "working" && !modelError) {
void publishWorkerView(ctx, "settled").catch(error => { if (!intercom.ended) ctx.ui.notify(`Recovery view failed: ${String(error)}`, "error"); });
}
};
let planningContextPending = false;
let resyncReason: string | null = "New session.";
@@ -210,7 +227,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
if (!sourceSessionFile) throw new Error("The current session is not persisted, so it cannot be forked.");
if (state.supervisorPaneId && state.approvalId) {
intercom.configure(state.approvalId, "worker", ctx, false);
await intercom.waitReady(5000, { peerOnly: true });
await intercom.waitReady(undefined, { peerOnly: true });
return;
}
beginReview(ctx);
@@ -372,9 +389,28 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
// --- /goals: enter plan mode or configure supervision -- Pi/Codex -----------------------------
pi.registerCommand("goals", {
description: `Plan goals, then open a visible supervisor session. /goals <objective> | reconnect | restart | clear | model <supervisor>`,
description: `Plan goals, then open a visible supervisor session. /goals <objective> | work | supervise | noplan | reconnect | restart | clear | model <supervisor>`,
handler: async (args, ctx) => {
const arg = args.trim();
let arg = args.trim();
if (arg === "supervise") { ctx.ui.notify("This is the worker session. Run /goals supervise in the saved supervisor session; no new pairing was created.", "warning"); return; }
if (arg === "work") {
if (state.phase !== "working" || !state.approvalId || !state.supervisorPaneId) { ctx.ui.notify("No approved worker pairing to reconnect. A retained draft still needs Ready.", "warning"); return; }
arg = "reconnect";
}
if (arg === "noplan") {
if (state.phase !== "planning") { ctx.ui.notify("Not in planning mode; the current plan is unchanged.", "info"); return; }
readyAttempt = undefined;
planningContextPending = false;
resyncReason = null;
stopWorkerTimers();
intercom.detach();
models.leave();
state = { ...state, phase: null };
persist();
updateWidget(ctx);
ctx.ui.notify(`Planning exited. Draft preserved at ${planRel(ctx)}; no implementation was approved or started.`, "info");
return;
}
if (arg === "reconnect" || arg === "restart") {
if (!state.phase) { ctx.ui.notify("No active plan to recover.", "info"); return; }
if (!ctx.isIdle()) { ctx.ui.notify("Stop the current turn before recovering goal supervision.", "warning"); return; }
@@ -390,14 +426,13 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
if (arg === "reconnect") {
if (!state.approvalId) throw new Error("No saved supervision binding. Use /goals restart.");
intercom.configure(state.approvalId, "worker", ctx, false);
await intercom.waitReady(5000, { peerOnly: true });
await intercom.waitReady(undefined, { peerOnly: true });
} else await startSupervisor(ctx);
}
if (intercom.ended) return;
if (state.phase === "working") {
intercom.markReady();
startWorkerTimers(ctx);
await publishWorkerView(ctx, "settled");
}
ctx.ui.notify(state.phase === "planning" ? "Planning model restored. Choose Ready when the plan is agreed." : "Goal supervision reconnected; the current plan is unchanged.", "info");
} catch (error) {
@@ -620,6 +655,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
updateWidget(ctx);
ctx.ui.notify(`Visible supervisor opened in Herdr pane ${state.supervisorPaneId}.`, "info");
pi.sendUserMessage("The plan is approved. Begin implementation as the worker.");
readyAttempt = undefined;
} catch (error) {
if (!current()) return;
intercom.markNotReady();
@@ -663,7 +699,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
if (intercom.ended) return;
if (state.phase === "working" && state.approvalId && !modelError) {
intercom.markReady();
void intercom.waitReady(5000).then(() => publishWorkerView(ctx, "settled")).catch(error => {
void intercom.waitReady().catch(error => {
if (!intercom.ended && state.phase === "working") ctx.ui.notify(`Goal work paused: ${String(error)} Use /goals reconnect or /goals restart.`, "warning");
});
}
+94 -20
View File
@@ -4,7 +4,7 @@ import type { IntercomExtensionChannel, IntercomExtensionEvent } from "pi-interc
export type Role = "worker" | "supervisor";
export interface View { id: string; text: string; reason: string; through?: string; backgroundQuiet: boolean }
interface Message { binding: string; role: Role; kind: "hello" | "view" | "steer" | "received"; id: string; text?: string; reason?: string; ready?: boolean; reply?: boolean; through?: string; backgroundQuiet?: boolean }
interface Message { binding: string; role: Role; kind: "hello" | "view" | "steer" | "received"; id: string; text?: string; reason?: string; failure?: string; ready?: boolean; reply?: boolean; through?: string; backgroundQuiet?: boolean }
const STATE = "pi-goals-intercom";
export class GoalIntercom {
@@ -15,10 +15,17 @@ export class GoalIntercom {
private binding = "";
private role: Role = "worker";
private ready = false;
private failure?: string;
private peer?: string;
private peerReady = false;
private peerFailure?: string;
private pending = new Map<string, Message>();
private received = new Set<string>();
private inbox = new Map<string, Message>();
private deliveryTimer?: ReturnType<typeof setTimeout>;
private idleChecks = 0;
private delivering?: string;
private compacting = false;
private waiters = new Set<(error?: Error) => void>();
latestView?: View;
acknowledgedEntry?: string;
@@ -33,7 +40,28 @@ export class GoalIntercom {
this.ctx = ctx;
if (!this.channel) await this.loadIntercom(event, ctx);
});
pi.on("message_start", async event => {
if (event.message.role !== "user") return;
const content = event.message.content;
const text = typeof content === "string" ? content : content.filter(part => part.type === "text").map(part => part.text).join("\n");
for (const message of this.inbox.values()) {
if (text !== this.deliveryText(message)) continue;
this.inbox.delete(message.id);
this.received.add(message.id);
this.record("in", message);
if (this.connected) this.publish({ binding: this.binding, role: this.role, kind: "received", id: message.id });
this.delivering = undefined;
this.idleChecks = 0;
this.scheduleDelivery(0);
break;
}
});
pi.on("session_before_compact", async () => { this.compacting = true; });
pi.on("session_compact", async () => { this.compacting = true; this.resumeDelivery(); });
pi.on("session_compact_failed", async () => { this.compacting = true; this.resumeDelivery(); });
pi.on("agent_settled", async () => this.resumeDelivery());
pi.on("session_shutdown", async () => {
if (this.deliveryTimer) clearTimeout(this.deliveryTimer);
this.stopped = true;
this.peerReady = false;
for (const wake of this.waiters) wake();
@@ -46,13 +74,20 @@ export class GoalIntercom {
this.role = role;
this.ctx = ctx;
this.ready = ready;
this.failure = undefined;
this.peer = undefined;
this.peerReady = false;
this.peerFailure = undefined;
this.pending.clear();
this.received.clear();
this.inbox.clear();
this.delivering = undefined;
this.idleChecks = 0;
if (this.deliveryTimer) clearTimeout(this.deliveryTimer);
this.deliveryTimer = undefined;
this.latestView = undefined;
this.acknowledgedEntry = undefined;
for (const entry of ctx.sessionManager.getEntries()) {
for (const entry of ctx.sessionManager.getBranch?.() ?? ctx.sessionManager.getEntries()) {
if (entry.type !== "custom" || entry.customType !== STATE) continue;
const record = entry.data as { direction: string; message: Message };
const message = record.message;
@@ -62,14 +97,20 @@ export class GoalIntercom {
this.pending.delete(message.id);
if (message.through) this.acknowledgedEntry = message.through;
}
if (record.direction === "in") this.received.add(message.id);
if (record.direction === "queued") this.inbox.set(message.id, message);
if (record.direction === "in") { this.received.add(message.id); this.inbox.delete(message.id); }
if (message.kind === "view") this.latestView = { id: message.id, text: message.text!, reason: message.reason!, through: message.through, backgroundQuiet: message.backgroundQuiet === true };
}
this.hello();
this.scheduleDelivery(0);
}
// End this plan's binding without disposing the session's transport.
detach(): void {
if (this.deliveryTimer) clearTimeout(this.deliveryTimer);
this.deliveryTimer = undefined;
this.inbox.clear();
this.delivering = undefined;
this.ready = false;
this.hello();
this.binding = "";
@@ -81,7 +122,8 @@ export class GoalIntercom {
if (this.ctx) this.onConnectionChange(this.ctx);
}
markReady(): void { this.setReady(true); }
failReady(reason: string): void { this.failure = reason; this.setReady(false); }
markReady(): void { this.failure = undefined; this.setReady(true); this.resumeDelivery(); }
markNotReady(): void { this.setReady(false); }
private setReady(ready: boolean): void {
if (this.stopped) return;
@@ -97,6 +139,7 @@ export class GoalIntercom {
// Startup can wait for the supervisor while the worker is still in planning/model recovery.
async waitReady(timeoutMs = 300_000, { peerOnly = false } = {}): Promise<void> {
const ready = () => this.connected || (peerOnly && this.peerPresent && this.peerReady);
if (this.peerFailure) throw new Error(this.peerFailure);
if (ready()) return;
await new Promise<void>((resolve, reject) => {
const finish = (error?: Error) => {
@@ -131,6 +174,38 @@ export class GoalIntercom {
return message.id;
}
// The inbox is persisted before handoff. Receipt means Pi started the user message, not model judgment or execution.
private deliveryText(message: Message): string { return message.kind === "view" ? message.text! : `[supervisor] ${message.text!}`; }
resumeDelivery(): void {
if (!this.ctx?.hasPendingMessages?.()) this.delivering = undefined;
this.idleChecks = 0;
this.scheduleDelivery(0); // Pi 0.85.1 isIdle includes compaction; check it after the success/failure hook.
}
private scheduleDelivery(delay: number): void {
if (this.deliveryTimer) clearTimeout(this.deliveryTimer);
this.deliveryTimer = undefined;
if (!this.bound || !this.inbox.size) return;
this.deliveryTimer = setTimeout(() => { this.deliveryTimer = undefined; this.deliverNext(); }, delay);
}
private deliverNext(): void {
if (!this.bound || !this.ready || !this.inbox.size) return;
// Accepted-but-not-yet-presented messages must not be submitted again behind a running turn.
if (this.ctx?.hasPendingMessages?.()) return;
if (this.compacting && !this.ctx?.isIdle?.()) {
if (++this.idleChecks <= 300) this.scheduleDelivery(1000);
else this.ctx?.ui.notify("Supervision message retained while Pi is busy. Use /goals reconnect when ready to retry delivery.", "warning");
return;
}
if (this.delivering && !this.compacting) return;
this.compacting = false;
const message = this.inbox.values().next().value!;
this.delivering = message.id;
try {
if (message.kind === "view") this.onView({ id: message.id, text: message.text!, reason: message.reason!, through: message.through, backgroundQuiet: message.backgroundQuiet === true });
else this.onSteer(message.text!);
} catch (error) { this.delivering = undefined; this.ctx?.ui.notify(`Supervision message retained: ${String(error)} Use /goals reconnect to retry.`, "warning"); }
}
private record(direction: string, message: Message): void { this.pi.appendEntry(STATE, { direction, message }); }
private publish(message: Message): void {
if (this.stopped) throw new Error("Intercom session ended.");
@@ -139,7 +214,7 @@ export class GoalIntercom {
this.channel.publish(message, { audience: "capable" });
}
private hello(reply = false): void {
if (!this.stopped && this.binding && this.channel?.snapshot().connected) this.publish({ binding: this.binding, role: this.role, kind: "hello", id: "hello", ready: this.ready, reply });
if (!this.stopped && this.binding && this.channel?.snapshot().connected) this.publish({ binding: this.binding, role: this.role, kind: "hello", id: "hello", ready: this.ready, failure: this.failure, reply });
}
private receive(event: IntercomExtensionEvent): void {
if (this.stopped) return;
@@ -167,6 +242,11 @@ export class GoalIntercom {
const changed = !this.peer || this.peerReady !== Boolean(message.ready);
this.peer = event.fromSessionId;
this.peerReady = Boolean(message.ready);
this.peerFailure = message.failure;
if (message.failure) {
this.ctx?.ui.notify(message.failure, "error");
for (const wake of this.waiters) wake(new Error(message.failure));
}
// Every request gets one reply, even if only the sender forgot its peer.
// Replies never elicit hellos; own-ready transitions also trigger replay here.
if (!message.reply) this.hello(true);
@@ -174,6 +254,7 @@ export class GoalIntercom {
if (this.role === "worker" && this.latestView) this.publish({ binding: this.binding, role: this.role, kind: "view", ...this.latestView });
for (const pending of this.pending.values()) this.publish(pending);
}
if (this.connected && this.inbox.size && !this.deliveryTimer) this.scheduleDelivery(0);
if (changed && this.ctx) this.onConnectionChange(this.ctx);
for (const wake of this.waiters) wake();
return;
@@ -192,21 +273,14 @@ export class GoalIntercom {
}
if (message.kind === "view" && this.role === "supervisor") {
this.latestView = { id: message.id, text: message.text!, reason: message.reason!, through: message.through, backgroundQuiet: message.backgroundQuiet === true };
if (message.reason !== "started") {
this.onView(this.latestView);
this.publish({ binding: this.binding, role: this.role, kind: "received", id: message.id });
}
} else if (message.kind === "steer" && this.role === "worker") {
// Pi's void message API provides synchronous handoff, not a durable queue receipt.
// Ack only after that handoff; asynchronous enqueue errors are not observable here.
this.onSteer(message.text!);
this.received.add(message.id);
this.record("in", message);
this.publish({ binding: this.binding, role: this.role, kind: "received", id: message.id });
return;
} else return;
this.received.add(message.id);
this.record("in", message);
if (message.reason === "started") { this.record("in", message); return; }
} else if (message.kind !== "steer" || this.role !== "worker") return;
if (this.inbox.has(message.id)) return;
if (this.inbox.size >= 64) { this.ctx?.ui.notify("Supervision inbox is full; message was not acknowledged. Use /goals reconnect after pending review finishes.", "error"); return; }
this.record("queued", message);
this.inbox.set(message.id, message);
if (this.compacting) this.ctx?.ui.notify("Supervision message retained during compaction; delivery will retry automatically.", "info");
if (!this.delivering) this.deliverNext();
}
private register(): void {
if (this.stopped || this.registered) return;
+52 -7
View File
@@ -12,7 +12,9 @@ import { RoleModels } from "./role-models.js";
const BOOTSTRAPPED = "pi-goals-visible-supervisor-v2";
const COMPACT_AT_TOKENS = 100_000;
interface SupervisorConfig {
const ROLE_STATE = "pi-goals-supervisor-binding";
export interface SupervisorConfig {
workerSessionId: string;
ownerSessionId: string;
planPath: string;
@@ -71,9 +73,27 @@ export function isVisibleSupervisor(): boolean {
return process.env.PI_GOALS_ROLE === "supervisor";
}
export function registerVisibleSupervisor(pi: ExtensionAPI): void {
const settings = config();
export function restoredSupervisor(entries: readonly { type: string; customType?: string; data?: unknown }[]): SupervisorConfig | undefined {
const savedEntry = entries.filter(entry => entry.type === "custom" && entry.customType === ROLE_STATE).at(-1);
if (savedEntry) {
const saved = savedEntry.data as SupervisorConfig | undefined;
if (!saved || ![saved.workerSessionId, saved.ownerSessionId, saved.planPath, saved.approvalId].every(value => typeof value === "string" && value.trim())) throw new Error("Saved supervisor binding is incomplete. Restore its original session metadata before resuming; worker mode was not enabled.");
return saved;
}
const legacyEntry = entries.filter(entry => entry.type === "custom" && entry.customType === BOOTSTRAPPED).at(-1);
if (!legacyEntry) return;
const legacy = legacyEntry.data as Partial<SupervisorConfig> | undefined;
const binding = entries.filter(entry => entry.type === "custom" && entry.customType === "pi-goals-state").map(entry => entry.data as { approvalId?: string }).filter(state => state?.approvalId).at(-1)?.approvalId;
if (!legacy || ![legacy.workerSessionId, legacy.planPath, binding].every(value => typeof value === "string" && value.trim())) throw new Error("Saved supervisor role has no complete binding. Restore its original session metadata before resuming. Worker mode was not enabled.");
return { workerSessionId: legacy.workerSessionId!, ownerSessionId: legacy.workerSessionId!, planPath: legacy.planPath!, approvalId: binding! };
}
export function registerVisibleSupervisor(pi: ExtensionAPI, restored?: SupervisorConfig): void {
const settings = restored ?? config();
let compacting = false;
let startupTimer: ReturnType<typeof setTimeout> | undefined;
let startupChecks = 0;
pi.on("session_shutdown", async () => { if (startupTimer) clearTimeout(startupTimer); });
let repeatFullPrompt = true;
pi.on("session_compact", async () => { repeatFullPrompt = true; });
let bootstrapping = false;
@@ -82,6 +102,12 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void {
const intercom = new GoalIntercom(pi);
const models = new RoleModels(pi);
intercom.onView = (view) => pi.sendUserMessage(view.text, { deliverAs: "followUp" });
pi.on("session_compact_failed", async event => {
if (!startupTimer) return;
clearTimeout(startupTimer);
startupTimer = undefined;
intercom.failReady(`${event.aborted ? "Supervisor compaction was cancelled" : event.errorMessage ?? "Supervisor compaction failed"}. Use /goals supervise in its session to retry.`);
});
const bootstrap = async (ctx: ExtensionContext): Promise<void> => {
if (bootstrapping || intercom.ended) return;
@@ -98,9 +124,18 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void {
};
const bootstrapAfterInitialCompaction = (ctx: ExtensionContext): void => {
if (intercom.ended) return;
// A reload does not terminate Pi's in-flight compaction. Never start a competing one.
if (!ctx.isIdle()) {
if (++startupChecks > 300) { intercom.failReady("Supervisor startup is still busy. Inspect its session, then use /goals supervise to retry."); return; }
startupTimer = setTimeout(() => bootstrapAfterInitialCompaction(ctx), 1000);
return;
}
startupTimer = undefined;
const tokens = ctx.getContextUsage()?.tokens;
const resumed = ctx.sessionManager.getEntries().some((entry: { type?: string; customType?: string }) => entry.type === "custom" && entry.customType === BOOTSTRAPPED);
if (resumed || (typeof tokens === "number" && tokens < COMPACT_AT_TOKENS)) {
const latestContext = ctx.sessionManager.getEntries().filter(entry => entry.type === "message" || entry.type === "compaction").at(-1);
if (resumed || latestContext?.type === "compaction" || (typeof tokens === "number" && tokens < COMPACT_AT_TOKENS)) {
void bootstrap(ctx);
return;
}
@@ -116,13 +151,19 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void {
onError: (error) => {
compacting = false;
if (intercom.ended) return;
if (/^(Already compacted|Nothing to compact)/.test(error.message)) { void bootstrap(ctx); return; }
intercom.failReady(`Supervisor startup compaction failed: ${error.message}. Use /goals reconnect in the supervisor pane.`);
ctx.ui.notify(`Supervisor startup compaction failed: ${error.message}`, "error");
},
});
};
const start = async (ctx: ExtensionContext): Promise<void> => {
if (startupTimer) clearTimeout(startupTimer);
startupTimer = undefined;
startupChecks = 0;
modelError = "Supervisor model restoration is pending.";
if (!ctx.sessionManager.getEntries().some(entry => entry.type === "custom" && entry.customType === ROLE_STATE)) pi.appendEntry(ROLE_STATE, settings);
intercom.configure(settings.approvalId, "supervisor", ctx);
try {
await models.enter("supervisor", ctx, process.env.PI_GOALS_MODEL_EXPLICIT === "1");
@@ -130,14 +171,17 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void {
setImmediate(() => { if (!intercom.ended) bootstrapAfterInitialCompaction(ctx); });
} catch (error) {
modelError = String(error);
if (!intercom.ended) ctx.ui.notify(`Supervisor paused: ${modelError} Select /model, then /goals reconnect.`, "error");
if (!intercom.ended) {
intercom.failReady(`Supervisor paused: ${modelError}. Select /model in its session, then /goals supervise.`);
ctx.ui.notify(`Supervisor paused: ${modelError} Select /model, then /goals reconnect.`, "error");
}
}
};
pi.on("session_start", async (_event, ctx) => start(ctx));
pi.registerCommand("goals", {
description: "Retry supervisor model restoration and readiness: /goals reconnect",
description: "Reconnect this saved supervisor role and pairing: /goals supervise (or reconnect)",
handler: async (args, ctx) => {
if (args.trim() !== "reconnect") { ctx.ui.notify("Use /goals reconnect here; manage the plan or restart the pane from the worker session.", "info"); return; }
if (!["reconnect", "supervise"].includes(args.trim())) { ctx.ui.notify("This is the supervisor session. Use /goals supervise here; /goals work and /goals noplan belong to the worker. No role or plan was changed.", "info"); return; }
if (!ctx.isIdle() || compacting) { ctx.ui.notify("Wait for the supervisor to settle before reconnecting.", "warning"); return; }
await start(ctx);
},
@@ -214,6 +258,7 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void {
const newest = intercom.latestView;
if (!intercom.connected) return result("Cannot approve: worker supervision is disconnected or not ready. Restore the existing connection before review.", true);
if (!newest) return result("Cannot approve: no worker view has arrived.", true);
if (newest.reason === "started") return result("Cannot approve while the worker is starting or running. Wait for its stopped-worker view.", true);
if (view !== newest.text) return result("Cannot approve this older worker view. A newer view is queued for you; finish this response to receive it. Do not ask the worker to generate another handoff merely to refresh this review.", true);
if (!view?.startsWith("The worker stopped.")) return result("Cannot approve without a current stopped-worker view.", true);
if (!newest.backgroundQuiet) return result("Cannot approve while tracked background work is active or unknown.", true);
+20
View File
@@ -0,0 +1,20 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { GoalIntercom } from "../../src/intercom.js";
import { intercomFixture } from "../intercom-fixture.js";
// Real Pi owns compaction; only the external transport is deterministic in this fixture.
export default function compactionDelivery(pi: ExtensionAPI) {
const wire = intercomFixture();
const link = new GoalIntercom({ ...pi, events: wire.events } as ExtensionAPI);
const role = process.env.PI_GOALS_TEST_DELIVERY_ROLE === "worker" ? "worker" : "supervisor";
link.onSteer = text => pi.sendUserMessage(`[supervisor] ${text}`, { deliverAs: "steer" });
link.onView = view => pi.sendUserMessage(view.text, { deliverAs: "followUp" });
pi.on("session_start", async (_event, ctx) => link.configure("compaction-pair", role, ctx, true));
pi.on("session_before_compact", async event => {
wire.receive({ binding: "compaction-pair", role: role === "worker" ? "supervisor" : "worker", kind: role === "worker" ? "steer" : "view", id: "retained-evidence", text: "Retained evidence arrived during manual compaction.", reason: "settled" });
await new Promise(resolve => setTimeout(resolve, 100));
if (process.env.PI_GOALS_TEST_COMPACTION === "cancel") return { cancel: true };
if (process.env.PI_GOALS_TEST_COMPACTION === "failure") return; // Local model fails only its summarization request.
return { compaction: { summary: "Previous task and result preserved.", firstKeptEntryId: event.preparation.firstKeptEntryId, tokensBefore: event.preparation.tokensBefore } };
});
}
+7 -3
View File
@@ -5,7 +5,7 @@ import { join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { expect, it, vi } from "vitest";
import { approvalPath, readApproval } from "../src/approval.js";
import goals from "../src/index.js";
import { registerWorker as goals } from "../src/index.js";
import { registerVisibleSupervisor } from "../src/supervisor-session.js";
import { pairedIntercomFixture } from "./paired-intercom-fixture.js";
@@ -35,7 +35,7 @@ async function setup() {
cwd, hasUI: true, isIdle: vi.fn(() => true), model: { provider: "test", id: "model" },
modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) },
getSystemPrompt: () => "base", getContextUsage: () => ({ tokens: 10 }),
sessionManager: { getSessionId: () => role, getSessionFile: () => join(cwd, `${role}.jsonl`), getEntries: () => entries, getBranch: () => branch },
sessionManager: { getSessionId: () => role, getSessionFile: () => join(cwd, `${role}.jsonl`), getEntries: () => entries, getBranch: () => [...entries, ...branch] },
ui: { notify: vi.fn(), setWidget: vi.fn(), setStatus: vi.fn(), theme: { fg: (_kind: string, text: string) => text } },
};
const pi = {
@@ -43,7 +43,11 @@ async function setup() {
on: (name: string, fn: any) => { const prior = hooks.get(name); hooks.set(name, async (...args: any[]) => { await prior?.(...args); return fn(...args); }); },
registerTool: (tool: any) => tools.set(tool.name, tool), registerCommand: () => {}, setModel: async () => true,
appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }),
sendUserMessage: (text: string) => branch.push({ type: "message", message: { role: "user", content: [{ type: "text", text }] } }),
sendUserMessage: (text: string) => {
const message = { role: "user", content: [{ type: "text", text }] };
branch.push({ type: "message", message });
void hooks.get("message_start")?.({ message });
},
};
(role === "worker" ? goals : registerVisibleSupervisor)(pi as unknown as ExtensionAPI);
return { pi, ctx, hooks, branch, tools };
+71 -4
View File
@@ -13,7 +13,7 @@ const openSupervisorPane = vi.fn(async () => "pane-2");
const closeSupervisorPane = vi.fn(async () => undefined);
const shutdowns: Array<() => Promise<void>> = [];
vi.mock("../src/herdr.js", () => ({ openSupervisorPane, closeSupervisorPane }));
const { default: piGoalsExtension, isMainSession } = await import("../src/index.js");
const { registerWorker: piGoalsExtension, isMainSession } = await import("../src/index.js");
function setup(selectChoices: Array<string | undefined>, editorChoices: Array<string | undefined> = [], events?: ExtensionAPI["events"]) {
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-flow-"));
@@ -328,6 +328,10 @@ describe("/goals flow", () => {
expect(flow.ctx.ui.setWidget).toHaveBeenLastCalledWith("pi-goals-widget", ["✔ complete"]);
await flow.hooks.get("session_start")({}, flow.ctx);
expect(flow.ctx.ui.setWidget).toHaveBeenLastCalledWith("pi-goals-widget", ["✔ complete"]);
const completedViews = flow.transport.sent.filter(message => message.kind === "view").length;
flow.transport.receive({ binding: approvalId, role: "supervisor", kind: "hello", id: "hello", ready: true });
await new Promise(resolve => setImmediate(resolve));
expect(flow.transport.sent.filter(message => message.kind === "view")).toHaveLength(completedViews);
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
@@ -390,7 +394,9 @@ it("shows a missing resumed supervisor, pauses writes, and automatically unpause
flow.transport.replyToHello(false);
await flow.hooks.get("session_start")({}, flow.ctx);
expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", "goals paused");
await vi.advanceTimersByTimeAsync(5000);
await vi.advanceTimersByTimeAsync(60_000);
expect(flow.notifications.some(text => text.includes("/goals restart"))).toBe(false);
await vi.advanceTimersByTimeAsync(240_000);
expect(flow.notifications.some(text => text.includes("/goals restart"))).toBe(true);
expect((await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "code.ts" } }, flow.ctx)).terminate).toBe(true);
expect(await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "git status" } }, flow.ctx)).toBeUndefined();
@@ -401,6 +407,65 @@ it("shows a missing resumed supervisor, pauses writes, and automatically unpause
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("exits planning without deleting the draft or approving implementation", async () => {
const flow = setup([]);
try {
await flow.commands.get("goals").handler("draft", flow.ctx);
const path = approvedPlan(flow.cwd);
const before = readFileSync(path, "utf8");
await flow.commands.get("goals").handler("noplan", flow.ctx);
expect(readFileSync(path, "utf8")).toBe(before);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null, planVersion: 1 });
expect(openSupervisorPane).not.toHaveBeenCalled();
expect(flow.messages.some(message => message.content.includes("Begin implementation"))).toBe(false);
expect(await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "arbitrary.txt" } }, flow.ctx)).toBeUndefined();
await flow.commands.get("goals").handler("work", flow.ctx);
expect(flow.notifications.at(-1)).toContain("No approved worker pairing");
await flow.commands.get("goals").handler("supervise", flow.ctx);
expect(flow.notifications.at(-1)).toContain("worker session");
expect(openSupervisorPane).not.toHaveBeenCalled();
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("publishes one fresh view after an accepted-view peer reload, but never after clear", async () => {
const flow = setup(["Ready"]);
try {
await flow.commands.get("goals").handler("draft", flow.ctx);
const path = approvedPlan(flow.cwd);
await flow.hooks.get("agent_settled")({}, flow.ctx);
const views = () => flow.transport.sent.filter(message => message.kind === "view");
const original = views().at(-1)!;
flow.transport.receive({ binding: original.binding, role: "supervisor", kind: "received", id: original.id });
flow.transport.event({ type: "session_left", sessionId: "peer" });
writeFileSync(path, readFileSync(path, "utf8").replace("output exists", "current result must exist"));
const previousIds = new Set(views().map(view => view.id));
flow.transport.receive({ binding: original.binding, role: "supervisor", kind: "hello", id: "hello", ready: true, reply: true });
await new Promise(resolve => setImmediate(resolve));
expect(views().filter(view => !previousIds.has(view.id))).toHaveLength(1);
expect(views().at(-1)!.id).not.toBe(original.id);
expect(views().at(-1)!.text).toContain("The worker stopped.");
expect(views().at(-1)!.text).toContain("current result must exist");
await flow.commands.get("goals").handler("clear", flow.ctx);
const afterClear = views().length;
flow.transport.receive({ binding: original.binding, role: "supervisor", kind: "hello", id: "hello", ready: true });
await new Promise(resolve => setImmediate(resolve));
expect(views()).toHaveLength(afterClear);
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("reconnects an approved worker with work without making a new pairing", async () => {
const flow = setup(["Ready"]);
try {
await flow.commands.get("goals").handler("draft", flow.ctx);
approvedPlan(flow.cwd);
await flow.hooks.get("agent_settled")({}, flow.ctx);
const state = flow.entries.at(-1)?.data as any;
await flow.commands.get("goals").handler("work", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ approvalId: state.approvalId, phase: "working" });
expect(openSupervisorPane).toHaveBeenCalledTimes(1);
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
});
it.each(["launch", "model"])("rejects plan content changes during Ready %s without replacing its pane", async (stage) => {
const flow = setup(["Ready", "Ready"]);
try {
@@ -419,7 +484,7 @@ it.each(["launch", "model"])("rejects plan content changes during Ready %s witho
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("times out stale Ready retries in five seconds, without replacing the pane automatically", async () => {
it("allows five minutes for stale Ready retries, without replacing the pane automatically", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval"] });
const flow = setup(["Ready", "Ready"]);
try {
@@ -430,7 +495,9 @@ it("times out stale Ready retries in five seconds, without replacing the pane au
await flow.hooks.get("agent_settled")({}, flow.ctx);
expect(flow.notifications.at(-1)).toContain("failed-pane");
const retry = flow.hooks.get("agent_settled")({}, flow.ctx);
await vi.advanceTimersByTimeAsync(5000);
await vi.advanceTimersByTimeAsync(60_000);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning" });
await vi.advanceTimersByTimeAsync(240_000);
await retry;
expect(openSupervisorPane).toHaveBeenCalledTimes(1);
expect(closeSupervisorPane).not.toHaveBeenCalled();
+68
View File
@@ -0,0 +1,68 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { afterEach, expect, it, vi } from "vitest";
import { GoalIntercom } from "../src/intercom.js";
import { intercomFixture } from "./intercom-fixture.js";
const shutdowns: Array<() => void> = [];
function setup(role: "worker" | "supervisor", entries: any[] = []) {
const fixture = intercomFixture();
const hooks = new Map<string, any>();
const ctx = { isIdle: vi.fn(() => true), hasPendingMessages: vi.fn(() => false), sessionManager: { getEntries: () => entries }, ui: { notify: vi.fn() } };
const pi = { events: fixture.events, on: (name: string, hook: any) => hooks.set(name, hook), appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }) };
const link = new GoalIntercom(pi as unknown as ExtensionAPI);
link.configure("binding", role, ctx as any, true);
const delivered = vi.fn();
link.onSteer = delivered;
link.onView = view => delivered(view.text);
shutdowns.push(() => hooks.get("session_shutdown")());
const receive = (id: string, text: string) => fixture.receive({ binding: "binding", role: role === "worker" ? "supervisor" : "worker", kind: role === "worker" ? "steer" : "view", reason: "settled", id, text });
const accept = (text: string) => hooks.get("message_start")({ message: { role: "user", content: role === "worker" ? `[supervisor] ${text}` : text } });
return { link, hooks, ctx, entries, fixture, delivered, receive, accept };
}
afterEach(() => { for (const stop of shutdowns.splice(0)) stop(); vi.useRealTimers(); });
it.each(["worker", "supervisor"] as const)("retains %s messages across successful, failed and cancelled manual compaction", async role => {
vi.useFakeTimers();
const r = setup(role);
for (const [id, event] of [["success", "session_compact"], ["failure", "session_compact_failed"], ["cancelled", "session_compact_failed"]]) {
r.ctx.isIdle.mockReturnValue(false);
await r.hooks.get("session_before_compact")({});
r.receive(id, `evidence-${id}`);
await vi.advanceTimersByTimeAsync(60_000);
expect(r.delivered).not.toHaveBeenCalledWith(`evidence-${id}`);
expect(r.fixture.sent.some(m => m.kind === "received" && m.id === id)).toBe(false);
await r.hooks.get(event)({ aborted: id === "cancelled" });
await vi.advanceTimersByTimeAsync(0); // Another extension may still be handling session_compact.
expect(r.delivered).not.toHaveBeenCalledWith(`evidence-${id}`);
r.ctx.isIdle.mockReturnValue(true);
await vi.advanceTimersByTimeAsync(1000);
expect(r.delivered).toHaveBeenCalledWith(`evidence-${id}`);
await r.accept(`evidence-${id}`);
expect(r.fixture.sent.some(m => m.kind === "received" && m.id === id)).toBe(true);
}
});
it("retains distinct deltas through reload and does not duplicate delayed presentation", async () => {
vi.useFakeTimers();
const first = setup("supervisor");
first.ctx.isIdle.mockReturnValue(false);
await first.hooks.get("session_before_compact")({});
first.receive("one", "first independent evidence");
first.receive("two", "second independent evidence");
await first.hooks.get("session_shutdown")();
const resumed = setup("supervisor", [...first.entries]);
await vi.advanceTimersByTimeAsync(0);
expect(resumed.delivered.mock.calls).toEqual([["first independent evidence"]]);
resumed.ctx.hasPendingMessages.mockReturnValue(true);
resumed.link.resumeDelivery();
resumed.receive("one", "first independent evidence");
await vi.advanceTimersByTimeAsync(60_000);
expect(resumed.delivered).toHaveBeenCalledTimes(1);
resumed.ctx.hasPendingMessages.mockReturnValue(false);
await resumed.accept("first independent evidence");
await vi.advanceTimersByTimeAsync(0);
expect(resumed.delivered.mock.calls).toEqual([["first independent evidence"], ["second independent evidence"]]);
await resumed.accept("second independent evidence");
resumed.receive("two", "second independent evidence");
expect(resumed.delivered).toHaveBeenCalledTimes(2);
});
+5 -2
View File
@@ -6,8 +6,9 @@ import { pairedIntercomFixture } from "./paired-intercom-fixture.js";
function endpoint(transport: ReturnType<typeof pairedIntercomFixture>["worker"]) {
const entries: any[] = [];
const ctx = { sessionManager: { getEntries: () => entries }, ui: { notify: vi.fn() } };
const link = new GoalIntercom({ events: transport.events, on: () => {}, appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }) } as unknown as ExtensionAPI);
return { link, ctx, entries };
const hooks = new Map<string, any>();
const link = new GoalIntercom({ events: transport.events, on: (name: string, hook: any) => hooks.set(name, hook), appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }) } as unknown as ExtensionAPI);
return { link, ctx, entries, accept: (text: string) => hooks.get("message_start")({ message: { role: "user", content: text } }) };
}
const settle = () => new Promise(resolve => setImmediate(resolve));
@@ -56,12 +57,14 @@ it("replays pending advice and views across either role's own readiness transiti
expect(worker.link.connected).toBe(false);
supervisor.link.markReady(); await settle();
expect(deliver).toHaveBeenCalledExactlyOnceWith("Pending advice.");
await worker.accept("[supervisor] Pending advice.");
worker.link.markNotReady(); await settle();
const onView = vi.fn(); supervisor.link.onView = onView;
worker.link.view("Fresh view.", "settled");
expect(onView).not.toHaveBeenCalled();
worker.link.markReady(); await settle();
expect(onView).toHaveBeenCalledTimes(1);
await supervisor.accept(onView.mock.calls[0][0].text);
expect(worker.link.connected && supervisor.link.connected).toBe(true);
wire.worker.connect(false); wire.worker.connect(true); await settle();
expect(worker.link.connected && supervisor.link.connected).toBe(true);
+20 -2
View File
@@ -6,7 +6,7 @@ import { intercomFixture } from "./intercom-fixture.js";
function setup(role: "worker" | "supervisor", entries: any[] = []) {
const fixture = intercomFixture();
const hooks = new Map<string, any>();
const ctx = { sessionManager: { getEntries: () => entries }, ui: { notify: vi.fn() } };
const ctx = { isIdle: vi.fn(() => true), hasPendingMessages: vi.fn(() => false), sessionManager: { getEntries: () => entries }, ui: { notify: vi.fn() } };
const api = { events: fixture.events, on: (name: string, hook: any) => hooks.set(name, hook), appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }) };
const link = new GoalIntercom(api as unknown as ExtensionAPI);
link.configure("binding", role, ctx as any);
@@ -25,6 +25,8 @@ describe("pi-intercom transport", () => {
runtime.fixture.receive(message, "wrong-peer");
expect(delivered).not.toHaveBeenCalled();
runtime.fixture.receive(message);
expect(runtime.fixture.sent.filter(message => message.kind === "received")).toHaveLength(0);
await runtime.hooks.get("message_start")({ message: { role: "user", content: `[supervisor] ${instruction}` } });
runtime.fixture.receive(message);
expect(delivered).toHaveBeenCalledExactlyOnceWith(instruction);
expect(runtime.fixture.sent.filter(message => message.kind === "received")).toHaveLength(2);
@@ -77,6 +79,7 @@ describe("pi-intercom transport", () => {
});
it("does not acknowledge a synchronous handoff failure, and retries the instruction", async () => {
vi.useFakeTimers();
const runtime = setup("worker");
await runtime.link.waitReady();
const delivery = vi.fn().mockImplementationOnce(() => { throw new Error("Delivery unavailable"); });
@@ -85,9 +88,24 @@ it("does not acknowledge a synchronous handoff failure, and retries the instruct
runtime.fixture.receive(message);
expect(runtime.fixture.sent.filter(m => m.kind === "received")).toHaveLength(0);
expect(runtime.entries.filter(e => e.data.direction === "in")).toHaveLength(0);
runtime.fixture.receive(message);
runtime.link.resumeDelivery();
await vi.advanceTimersByTimeAsync(0);
expect(delivery).toHaveBeenCalledTimes(2);
await runtime.hooks.get("message_start")({ message: { role: "user", content: "[supervisor] Inspect evidence." } });
expect(runtime.fixture.sent.filter(m => m.kind === "received")).toHaveLength(1);
await runtime.hooks.get("session_shutdown")();
vi.useRealTimers();
});
it("reports a peer startup failure immediately and recovers on its next ready hello", async () => {
const runtime = setup("worker");
await runtime.link.waitReady();
runtime.fixture.receive({ binding: "binding", role: "supervisor", kind: "hello", id: "hello", reply: true, ready: false, failure: "Compaction cancelled; use /goals supervise." });
await expect(runtime.link.waitReady()).rejects.toThrow("Compaction cancelled");
runtime.fixture.receive({ binding: "binding", role: "supervisor", kind: "hello", id: "hello", reply: true, ready: true });
await runtime.link.waitReady();
expect(runtime.link.connected).toBe(true);
await runtime.hooks.get("session_shutdown")();
});
it("detaches a completed binding and ignores its late advice without replay errors or false acceptance", async () => {
+64
View File
@@ -0,0 +1,64 @@
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { expect, it } from "vitest";
it.each(["worker", "supervisor"] as const)("real Pi preserves %s delivery through compaction success, failure and cancellation", async role => {
for (const outcome of ["success", "failure", "cancel"]) {
const cwd = mkdtempSync(join(tmpdir(), "goals-native-compaction-"));
const agentDir = join(cwd, "agent");
mkdirSync(agentDir);
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ compaction: { enabled: false, keepRecentTokens: 1000, reserveTokens: 1000 }, retry: { enabled: false } }));
let requests = 0;
const server = createServer(async (req, res) => {
for await (const _chunk of req) { /* consume local request */ }
requests++;
if (outcome === "failure" && requests === 3) { res.writeHead(400); res.end(JSON.stringify({ error: { message: "deterministic compaction failure" } })); return; }
res.writeHead(200, { "content-type": "text/event-stream" });
res.end(`data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: "Inspected the supplied context." }, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 8000, completion_tokens: 10, total_tokens: 8010 } })}\n\ndata: [DONE]\n\n`);
});
await new Promise<void>(done => server.listen(0, "127.0.0.1", done));
const address = server.address();
if (!address || typeof address === "string") throw new Error("No local model port");
const child = spawn(resolve("node_modules/.bin/pi"), ["--mode", "rpc", "--no-extensions", "--model", "offline/test", "-e", resolve("test/fixtures/offline-model.ts"), "-e", resolve("test/fixtures/compaction-delivery.ts")], { cwd, env: { ...process.env, PI_CODING_AGENT_DIR: agentDir, PI_GOALS_OFFLINE_MODEL_URL: `http://127.0.0.1:${address.port}`, PI_GOALS_TEST_DELIVERY_ROLE: role, PI_GOALS_TEST_COMPACTION: outcome } });
const messages: any[] = [];
let buffer = "", stderr = "";
child.stdout.on("data", data => {
buffer += data;
while (buffer.includes("\n")) { const n = buffer.indexOf("\n"); const line = buffer.slice(0, n); buffer = buffer.slice(n + 1); if (line.trim()) messages.push(JSON.parse(line)); }
});
child.stderr.on("data", data => { stderr += data; });
const send = (message: object) => child.stdin.write(`${JSON.stringify(message)}\n`);
const wait = async (predicate: (message: any) => boolean) => {
for (let i = 0; i < 500; i++) { const found = messages.find(predicate); if (found) return found; await new Promise(done => setTimeout(done, 20)); }
throw new Error(`Timed out ${role}/${outcome}: ${stderr}\n${JSON.stringify(messages.slice(-8))}`);
};
try {
send({ type: "prompt", id: "seed", message: "Inspect this bounded test context. " + "Saved observation. ".repeat(2000) });
await wait(m => m.type === "agent_settled");
messages.length = 0;
send({ type: "prompt", id: "second-turn", message: "Keep this most recent turn for continued work. " + "Recent context. ".repeat(500) });
await wait(m => m.type === "agent_settled");
send({ type: "compact", id: "compact" });
const completion = await wait(m => m.type === "response" && m.id === "compact");
expect(completion.success, JSON.stringify(completion)).toBe(outcome === "success");
await wait(m => m.type === "message_start" && m.message?.role === "user" && JSON.stringify(m.message.content).includes("Retained evidence arrived"));
await wait(m => m.type === "message_end" && m.message?.role === "user" && JSON.stringify(m.message.content).includes("Retained evidence arrived"));
send({ type: "get_state", id: "state" });
const state = await wait(m => m.type === "response" && m.id === "state");
const transcript = readFileSync(state.data.sessionFile, "utf8");
expect(transcript).toContain('"direction":"queued"');
expect(transcript).toContain('"direction":"in"');
expect(messages.filter(m => m.type === "message_start" && m.message?.role === "user" && JSON.stringify(m.message.content).includes("Retained evidence arrived"))).toHaveLength(1);
expect(messages.filter(m => m.type === "extension_error")).toEqual([]);
console.log(`real Pi ${role}/${outcome}: retained message presented once and saved, no extension errors`);
} finally {
child.kill("SIGTERM"); await once(child, "exit");
await new Promise<void>(done => server.close(() => done()));
rmSync(cwd, { recursive: true, force: true });
}
}
}, 45_000);
+17
View File
@@ -50,10 +50,12 @@ it("runs a forked Pi supervisor and receives its exact instruction in another Pi
let supervisorFile: string | undefined;
let supervisorTools: string[] = [];
let supervisorRequest: any;
let latestRequest: any;
const server = createServer(async (request, response) => {
let body = "";
for await (const chunk of request) body += chunk;
const input = JSON.parse(body);
latestRequest = input;
const latest = input.messages.filter((message: any) => !JSON.stringify(message.content).includes("Full active plan:")).at(-1);
const steer = latest.role === "user" && JSON.stringify(latest.content).includes("The worker stopped.");
if (steer) {
@@ -117,6 +119,21 @@ it("runs a forked Pi supervisor and receives its exact instruction in another Pi
supervisorFile = supervisorState.data.sessionFile;
expect(supervisorFile).not.toBe(workerFile);
expect(readFileSync(supervisorFile!, "utf8")).toContain("Retain this planning context");
await supervisor.wait(message => message.type === "agent_settled");
supervisorProcess.kill("SIGTERM");
await once(supervisorProcess, "exit");
const freshEnv = Object.fromEntries(Object.entries(env).filter(([name]) => !/^PI_GOALS_(ROLE|WORKER_ID|OWNER_SESSION_ID|PLAN_PATH|APPROVAL_ID|MODEL_EXPLICIT)$/.test(name)));
const resumedProcess = spawn(resolve("node_modules/.bin/pi"), [...common.filter(arg => arg !== "--no-extensions"), "-e", resolve("src/index.ts"), "--session", supervisorFile!], { cwd, env: freshEnv });
children.push(resumedProcess);
supervisor = new Driver(resumedProcess);
supervisor.send({ type: "prompt", id: "resumed-review", message: "Confirm resumed identity without sending another instruction." });
await supervisor.wait(message => message.type === "agent_settled");
const resumedTools = latestRequest.tools.map((tool: any) => tool.function.name);
expect(resumedTools).toContain("ApproveGoal");
expect(resumedTools).toContain("SteerWorker");
expect(resumedTools).toContain("profile_inspection");
expect(resumedTools).not.toContain("CompleteGoal");
expect(JSON.stringify(latestRequest.messages)).toContain("autonomously extending the user's agency");
console.log(`Native Pi pair: fork retained planning context; SteerWorker delivered exactly: ${advice}`);
} finally {
if (process.env.PI_GOALS_EVIDENCE_DIR) {
+19
View File
@@ -0,0 +1,19 @@
import { expect, it } from "vitest";
import { restoredSupervisor } from "../src/supervisor-session.js";
const binding = { workerSessionId: "worker", ownerSessionId: "worker", planPath: "/repo/plan.md", approvalId: "pairing" };
it("restores complete supervisor identity even for a stopped fork without worker state", () => {
expect(restoredSupervisor([{ type: "custom", customType: "pi-goals-supervisor-binding", data: binding }])).toEqual(binding);
});
it("migrates an older supervisor marker without inventing a pairing", () => {
expect(restoredSupervisor([
{ type: "custom", customType: "pi-goals-state", data: { approvalId: "pairing", phase: null } },
{ type: "custom", customType: "pi-goals-visible-supervisor-v2", data: { workerSessionId: "worker", planPath: "/repo/plan.md" } },
])).toEqual(binding);
});
it("refuses incomplete supervisor identity instead of returning worker mode", () => {
expect(() => restoredSupervisor([{ type: "custom", customType: "pi-goals-supervisor-binding", data: { workerSessionId: "worker" } }])).toThrow("incomplete");
expect(() => restoredSupervisor([{ type: "custom", customType: "pi-goals-visible-supervisor-v2", data: {} }])).toThrow("Worker mode was not enabled");
for (const customType of ["pi-goals-supervisor-binding", "pi-goals-visible-supervisor-v2"]) expect(() => restoredSupervisor([{ type: "custom", customType, data: null }])).toThrow(/worker mode was not enabled/i);
expect(restoredSupervisor([{ type: "custom", customType: "pi-goals-state", data: { phase: "working" } }])).toBeUndefined();
});
+26 -4
View File
@@ -33,7 +33,7 @@ function setup(cwd: string, planPath: string, tokens: number | null = 10, onComp
modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) },
getContextUsage: () => tokens === null ? undefined : ({ tokens }),
compact: vi.fn(onCompact),
sessionManager: { getEntries: () => entries, getBranch: () => branch, getSessionId: () => "supervisor-session" },
sessionManager: { getEntries: () => entries, getBranch: () => [...entries, ...branch], getSessionId: () => "supervisor-session" },
ui: { notify: vi.fn() },
};
const pi = {
@@ -45,7 +45,7 @@ function setup(cwd: string, planPath: string, tokens: number | null = 10, onComp
registerTool: (tool: any) => tools.set(tool.name, tool),
registerCommand: (name: string, command: any) => commands.set(name, command),
appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }),
sendUserMessage: (message: string) => messages.push(message),
sendUserMessage: (text: string) => { messages.push(text); void hooks.get("message_start")?.({ message: { role: "user", content: [{ type: "text", text }] } }); },
getActiveTools: () => activeTools,
setModel: vi.fn(async () => true),
setActiveTools: (next: string[]) => { activeTools = next; },
@@ -185,6 +185,24 @@ describe("visible supervisor session", () => {
} finally { rmSync(cwd, { recursive: true, force: true }); }
});
it("waits for an inherited 60-second compaction without starting a competing compaction", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-reload-"));
try {
const runtime = setup(cwd, join(cwd, "plan.md"), null);
runtime.ctx.isIdle = () => false;
await runtime.start();
await vi.advanceTimersByTimeAsync(60_000);
expect(runtime.ctx.compact).not.toHaveBeenCalled();
expect(runtime.ready()).toBe(false);
runtime.entries.push({ type: "compaction", summary: "inherited compaction finished" });
runtime.ctx.isIdle = () => true;
await vi.advanceTimersByTimeAsync(1000);
expect(runtime.ctx.compact).not.toHaveBeenCalled();
expect(runtime.ready()).toBe(true);
} finally { rmSync(cwd, { recursive: true, force: true }); }
});
it("compacts a large planning fork before writing readiness", async () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-"));
try {
@@ -257,7 +275,7 @@ describe("visible supervisor session", () => {
runtime.view("second", "The worker is still working.", "started");
const stale = await runtime.tools.get("ApproveGoal").execute("id", { goal: "make the file", verifyOutputPath: "verify.txt" }, undefined, undefined, runtime.ctx);
expect(stale.isError).toBe(true);
expect(stale.content[0].text).toContain("A newer view is queued for you");
expect(stale.content[0].text).toContain("worker is starting or running");
const unknown = runtime.view("third", "The worker stopped.\ntracked background work: unknown", "settled", false);
runtime.branch([{ type: "message", message: { role: "user", content: [{ type: "text", text: unknown.text }] } }]);
const blocked = await runtime.tools.get("ApproveGoal").execute("id", { goal: "make the file", verifyOutputPath: "verify.txt" }, undefined, undefined, runtime.ctx);
@@ -275,8 +293,12 @@ it("does not enforce a supervisor tool-call denylist or reset extension tool sel
expect(runtime.activeTools()).toContain("intercom");
const selection = ["intercom", "SteerWorker", "bash", "write", "edit", "custom_action"];
runtime.pi.setActiveTools(selection);
await runtime.commands.get("goals").handler("reconnect", runtime.ctx);
await runtime.commands.get("goals").handler("supervise", runtime.ctx);
expect(runtime.activeTools()).toEqual(selection);
const restorations = runtime.pi.setModel.mock.calls.length;
await runtime.commands.get("goals").handler("work", runtime.ctx);
expect(runtime.pi.setModel).toHaveBeenCalledTimes(restorations);
expect(runtime.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("supervisor session"), "info");
expect(runtime.hooks.has("tool_call")).toBe(false);
} finally { rmSync(cwd, { recursive: true, force: true }); }
});