mirror of
https://github.com/wassname/pi-goals.git
synced 2026-09-12 12:50:58 +08:00
Recover supervisor identity and messages across lifecycle changes
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Generated
+523
-158
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+20
@@ -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 } };
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 }); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user