Author SHA1 Message Date
wassname2 b386efd8ea fix: persist goal reminders before agent runs 2026-09-09 14:57:55 +08:00
wassname2 d5729ac106 Consolidate supervision and remember models per role
Bundle Intercom/VCC with the internal supervisor. Add default alignment questions and conversational plan review. Cover model recovery, cancellation and packed single-package operation.
2026-09-08 08:45:26 +08:00
wassname2 8e44738773 Use real plan supervisors and enable automation by default 2026-09-07 14:49:25 +08:00
wassname2 1c10a282ac Use goals subcommands without option prefixes 2026-09-07 09:18:56 +08:00
wassname2 3f0aadfffa Add persistent plan steward 2026-09-03 14:17:39 +08:00
45 changed files with 9630 additions and 338 deletions
+5 -3
View File
@@ -5,12 +5,14 @@
Run `npm test` before a commit. It includes unit and flow tests plus the RPC review test.
- `test/*.test.ts` unit and flow tests use a small Pi API mock. They check plan state, tool gates, and plan-file updates.
- `npm run test:rpc` runs `test/rpc-review.test.ts`. It starts the installed Pi executable in RPC mode, uses Pi's real `select` and `editor` protocol, and uses a local deterministic HTTP model. It does not need a credential or spend API credits. This is the closest automated session test.
- `npm run test:rpc` runs `test/rpc-review.test.ts`. It starts the installed Pi executable in RPC mode, uses Pi's real `select` protocol and conversational Discuss flow, and uses a local deterministic HTTP model. It does not need a credential or spend API credits. This is the closest automated session test.
- Use tmux for visual TUI debugging when the RPC test fails or a terminal-only problem is reported:
```bash
tmux new-session -s pi-goals-debug 'cd /path/to/pi-goals && pi -e ./src/index.ts'
tmux new-session -s pi-goals-debug 'cd /path/to/pi-goals && pi -e .'
```
Run `/goals <objective>` in that pane. Tmux checks the rendered menu, editor focus, widget, and keyboard handling. RPC does not render the terminal UI.
- `pi -p` has no UI, so it cannot test `Ready`, `Refine`, `Edit`, or `Cancel`.
- `pi -p` has no UI, so it cannot test `Ready`, `Discuss`, `Edit`, or `Cancel`.
- `npm run test:supervisor` runs the inherited `node:test` supervisor regressions. `npm test` also includes the always-enabled packed-artifact Intercom flow; Linux requires Unix-socket support.
+155 -19
View File
@@ -56,7 +56,7 @@ Or for development:
```bash
git clone https://github.com/wassname/pi-goals && cd pi-goals && npm install
pi -e ./src/index.ts
pi -e .
```
## Use
@@ -67,37 +67,173 @@ pi -e ./src/index.ts
`/goals` enters plan mode and starts a conversation; the objective is an optional seed. From there:
1. Plan. The agent explores read-only and drafts the plan.
2. Review. After Pi settles, the full plan is printed in the transcript. Check that User-visible
result names the final artifact or behavior you expect. The menu offers Ready, Refine, Edit, or
Cancel. Refine collects short notes. Edit opens the full plan in Pi's editor.
1. Align. The agent inspects technical facts read-only, then asks at least three task-specific
questions in one chat round about the expected result, scope/constraints, and success/failure
criteria. It waits for your answers before proposing the final plan. An explicit “no questions”
or “skip questions” clause in the current objective waives this round for that plan only.
“No q's” and “skip q's” are also supported. Negated instructions (“do not skip questions”) and
quoted feature references (“add a 'skip questions' button”) do not waive alignment.
2. Review. When alignment is complete, the agent requests review and the full draft is printed.
Check that User-visible result names the artifact or behavior you expect. The menu offers
**Ready / Discuss / Edit / Cancel**. Discuss returns to normal chat and asks useful alignment
questions, not a refinement-notes editor. Keep talking for as many turns as needed; the old draft
alone cannot reopen the menu. When discussion is finished, the agent calls `RequestPlanReview`,
even if the draft is unchanged. Discussion state survives reload. Edit opens the full plan directly.
Escape also returns to chat and preserves the draft; explicit Cancel discards the current draft.
3. Work. Ready is the only review action that starts work. The agent ticks subtasks, appends to
`## Log` and `## Learnings`, fills `evidence:`, and calls `CompleteGoal` when a discriminator is
satisfied. Every human reply and Refine note in plan mode is saved verbatim under `## Interview`.
After eight turns without a change above `## Log`, the working set is sent back with a short upkeep
reminder.
satisfied. Every human reply in plan mode is saved verbatim under `## Interview`.
After eight turns without a change above `## Log`, the next natural prompt includes a saved
extension message with the working set and a short upkeep reminder.
Other commands: `/goals --clear` disconnects this session from its active plan, preserving the
versioned file on disk; `/goals --auto [minutes|off]` continues active goals after the agent settles
and then on that interval. It pauses after two automatic wakes with no working-plan change; `/goals
--judge <model-ref>` picks a sign-off judge model (default: your current session model, else pi's
default). The `--` prefix
keeps ordinary objectives such as `judge model quality` from being parsed as commands.
Plan reminders are saved in session history before they reach the model, not appended only to an
outgoing request. In working mode, after startup or compaction, the next natural prompt refreshes
the whole plan, including its appendix, from disk. In planning mode, it refreshes the planning-policy
snapshot and plan path instead. Automatic compaction and tool-loop continuations do not start
an extra turn for a reminder: the refresh waits until the next `before_agent_start` (normally the
next user prompt). There is no fresh plan reminder during that ongoing automatic continuation.
## Plan supervisor and auto-continue
Steward supervision and 60-minute auto-continue are enabled by default. A real supervisor starts at
Ready. Auto-continue is the fallback when stewardship is off; it does not run a competing timer while
supervision is enabled. Use `/goals steward off` or `/goals auto off` to opt out. Explicit preferences
survive clear and reload. Cleared legacy sessions adopt the new defaults on reload; active legacy
plans retain their settings so supervision is not attached midway through work.
Install/load **only pi-goals**. Its internal modules contain the supervisor; the package bundles
`pi-intercom` 0.10.0 and `@sting8k/pi-vcc` 0.5.0 as locked runtime dependencies. Pi's manifest loads
the bundled Intercom resources through `node_modules/` paths. VCC is used as a compiler, not loaded
as a separate extension. Pi core stays a peer dependency. **Herdr remains the supported terminal
host** for launching/focusing the supervisor. Load the package directory (`pi -e .`), not only
`src/index.ts`, so the manifest can supply Intercom too.
Ready is the human's plan approval. Pi-goals forks the planning session, initializes the supervisor
with the original plan and supervisor policy, and waits for acknowledged pairing before starting the
worker. The initial supervisor view can steer; it is not another mandatory plan-approval gate.
The supervisor fork is compacted unless its known context is already at most 20k tokens. Only the
supervisor is compacted. Its normal context policy checks near 100k tokens, subject to its model limit.
The internal supervisor provides incremental VCC views and retains its decisions. The plan-aware
policy checks every 50 model turns or 60 minutes, or when the worker settles with no tracked
background work. Process/subagent providers that cannot answer are reported as unknown; they do not
prove the worker is finished. The `SUPERVISOR.md` policy lookup is unchanged: project `.pi/SUPERVISOR.md`, then the Pi agent directory, then the built-in policy. Auto-continue is
suspended while the steward is enabled so there is only one continuation policy.
One `CompleteGoal` call asks this supervisor about direction and scope, then runs the normal fresh
read-only evidence judge. Approving one goal does not finish supervision. Cancelled, stale or
mismatched replies do not sign off goals. `/goals steward off` ends this plan's supervision and
cancels pending goal requests; it does not close the human's terminal pane.
Navigation: `/goals supervisor` focuses the supervisor, `/goals worker` returns to the worker, and
`/goals zoom` toggles supervisor zoom. These use the real Pi panes, not a Fleet inspector. If the
recorded pane is unavailable, its location/liveness is unknown. Locate the existing session first;
only after confirming it is no longer running, reopen the saved `pi --session` path shown in the
error. Pi-goals never starts a duplicate merely because a pane ID is missing.
After completion, keep the plan as a record. Ordinary auto-continue stops when no open goals remain.
The supervisor's `done` ends the pairing and its watch timer; it leaves the terminal and saved session
available for inspection. `/goals clear` is the manual way to disconnect. A later `/goals plan …`
creates a new plan version and starts a new supervisor fork at Ready rather than reusing the completed
plan's pairing. You can close an old supervisor pane after supervision has ended.
Other commands: `/goals clear` disconnects this session, preserving its plan file;
`/goals auto [minutes|off]` controls ordinary auto-continue; `/goals judge <model-ref>` overrides the
fresh judge's model; `/goals steward status` reports supervision. Use `/goals plan <objective>` for
objectives beginning with reserved command words, such as `/goals plan judge the vendor options`.
The old `--clear`, `--auto`, and `--judge` forms remain compatibility aliases.
For a local trial, start inside Herdr with just this checkout:
```bash
pi -e /path/to/pi-goals
```
Then draft a plan and select Ready; no enable command is needed. Initialization failure stays in
planning and names the unavailable component; resolve it in the supervisor pane, or turn the steward
off and retry Ready. Sessions saved with the older checkpoint-only steward need a new Ready handoff;
old pi-subagents reviewer runs are not reused as supervisor sessions.
### Migrating an already-running installation
After validating this package, remove the old standalone supervisor and standalone Intercom entries
from Pi's package list, leaving pi-goals. **Reload existing workers before selecting Ready again**:
an old worker still has old launch arguments in memory and can launch both old and internal copies.
Reload both sides of a retained pairing. Do not add extra `-e` supervisor/Intercom arguments.
Duplicate Intercom registries are diagnosed and plan bootstrap is refused; Pi also reports conflicting
tools from duplicate packages. Diagnose/remove the duplicate rather than starting more panes. No
settings or live panes are changed by this extension's migration.
### Remembered role models
Choose with `/model` or Pi's model-cycle shortcut in planning, the worker, or the supervisor. Each
pi-goals role remembers its own last explicit provider/model. On first use it inherits the current
model; no provider is hardcoded. Planning is restored on `/goals`, worker at Ready **after** the
planning fork is captured and before pairing activation/the work handoff, and supervisor before its initial compaction
or first turn. Reload/resume and later plans restore those choices. The supervisor does not inherit
the worker's goal tools or auto-continue policy.
Storage is under `getAgentDir()/pi-goals/` (normally `~/.pi/agent/pi-goals/`):
`planning-model.json`, `worker-model.json`, and `supervisor-model.json`. Each atomic file contains
only `{"provider":"…","id":"…"}`. Different role processes never rewrite each other's file; competing
explicit choices in the *same* role are last-write-wins. No credentials or thinking-level preferences
are stored. Automatic `setModel` and Pi's restore events do not replace role preferences.
If a remembered model is missing or unauthenticated, the role pauses with an error instead of
silently using a different provider. Configure the saved model and reload, or explicitly select a
different available model with `/model`. Pi does not emit a selection event when you choose the
already-current model; use **`/goals model current`** to explicitly save that current model for the
paused role instead. This command verifies authentication before replacing the saved choice.
A worker-model failure at Ready keeps the attached supervisor inactive and persists **worker** as
the recovery target across reload. Recovery updates the worker preference, not the planner's, and
reoffers the existing Ready menu; Ready retries the same fork without another approval stage.
There is no activation, supervisor review turn or work handoff before the worker model is usable.
`/goals clear` and `/goals steward off` still stop/cancel supervision while a model is unavailable;
old supervisor directives cannot restart the stopped work. A failed restore or recovery never
silently replaces a saved model choice.
`/goals judge <model-ref>` remains a separate override for the fresh evidence judge; it never changes
these role files.
## Prompts
All model-facing text lives in [`src/prompts.ts`](src/prompts.ts), in flow order.
Planning/judge text lives in [`src/prompts.ts`](src/prompts.ts); supervisor policy/text lives in [`src/internal/supervisor/prompts.ts`](src/internal/supervisor/prompts.ts).
## Develop
```bash
pi -e ./src/index.ts # load locally
npm test # all unit, flow, and Pi RPC tests
npm run test:rpc # Pi RPC review flow with a local offline model
pi -e . # package manifest includes bundled Intercom
npm test # unit/flow/RPC + inherited node:test supervisor regressions
npm run test:rpc # real-Pi conversational review, local offline model
npm run test:supervisor # inherited lifecycle/VCC/correlation/recovery regressions
npm run typecheck
npm run lint
npm run build
```
No supervisor checkout or opt-in environment variable is needed. `test/rpc-supervisor.test.ts`
performs `npm pack`, extracts the tarball outside the checkout, and runs real Pi sessions plus the
actual bundled Intercom broker and a fresh offline evidence judge. Only Herdr is mocked: the worker's
exec adapter imports the extracted goals entry, and the supervisor loads the untouched extracted
package manifest. The test checks bundled production resources and excludes bundled Pi core peers.
It requires Unix sockets on Linux and spends no API credits. The hook integration additionally
checks initial supervisor compaction/model order, native planning-fork capture, cancellation, two
goal reviews, and judge isolation without relying on a live terminal.
The moved VCC dependency is source-only and has upstream type incompatibilities with current Pi/Intl
unions. `tsconfig.build.json` maps just its four imported API surfaces to narrow local declarations;
the actual pinned VCC source still runs in tests and production. All pi-goals source is typechecked
and linted; the node:test suite is run separately, not silently collected/skipped by Vitest.
Validation (2026-09-07): **67 Vitest tests and 118 internal supervisor tests passed, with no skips**,
including the packed real-Pi/Intercom flow. Typecheck, lint, build and diff checks passed. Independent
review and targeted recheck are complete. See the [saved validation and review disposition](docs/reviews/2026-09-07_single-package-role-models.md).
Neither automated test proves visual Herdr rendering/navigation or measured token savings. The
previous live trial contained a historical tool call without a saved result, which can still block
supervisor `done`. Use `/goals clear` to explicitly disconnect; genuine outstanding-work checks have
not been weakened. See [the prior validation record](docs/reviews/2026-09-07_supervisor-validation.md).
## License
MIT
MIT. See [third-party provenance and notices](THIRD_PARTY_NOTICES.md).
+47
View File
@@ -0,0 +1,47 @@
# Third-party provenance and notices
## Internal supervisor
`src/internal/supervisor/` and `test/internal-supervisor/` were moved from
[wassname/pi-supervise](https://github.com/wassname/pi-supervise) (formerly
pi-intercom-supervisor), commit `145c2cb081f85c08b0244c4a2c8a2d9aef8debda`.
The source package is `@wassname2/pi-supervise` 0.0.4, author wassname, declared
license MIT. The source comments and attribution are retained. Local changes
integrate package loading, role-model readiness, duplicate-registration diagnostics,
and strict build/lint compatibility. Its synthetic fork fixture is retained; it
contains no user transcript.
The supervisor's `subagents.ts` retains its attribution to
`@monotykamary/pi-supervisor` (MIT), `src/subagent-detector.ts`. Its policy precedence
also follows that project. No separate supervisor package is required at runtime.
### MIT license
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
## Bundled dependencies
- `pi-intercom` 0.10.0: existing Intercom transport and broker; MIT. Its upstream
LICENSE is included under `node_modules/pi-intercom/LICENSE` in the package.
- `@sting8k/pi-vcc` 0.5.0: existing algorithmic worker-view compiler. Its README's
License section declares MIT and is included with the bundled source.
- Intercom's runtime dependencies, including `tsx` and `esbuild`, retain their
upstream package notices in the tarball. Pi core and typebox are peers, not bundled.
No replacement IPC runtime or VCC implementation was written for this move.
@@ -0,0 +1,38 @@
# Single-package and role-model validation
2026-09-07. The parent accepted the implementation after independent review and a targeted recheck. Changes are in the local pi-goals feature worktree; this is not an npm release.
## Delivered
- One pi-goals package: internal supervisor, bundled Intercom/VCC, and one package-root supervisor launch. Herdr remains the terminal host.
- Separate remembered planning, worker and supervisor provider/model choices. Files are under `getAgentDir()/pi-goals/`; the fresh evidence-judge override stays separate.
- At least three task-specific alignment questions by default. An explicit affirmative current-plan waiver skips them; negations and quoted feature names do not.
- Ready / Discuss / Edit / Cancel. Discuss and Escape preserve the draft and return to chat. `RequestPlanReview` reopens review when discussion is finished, including an unchanged draft. Only Ready starts work.
## Observed validation
[Saved full output](evidence/2026-09-07_single-package-final-validation.log) contains:
```text
Test Files 12 passed (12)
Tests 67 passed (67)
...
tests 118
pass 118
fail 0
skipped 0
...
SINGLE_PACKAGE_FINAL_VALIDATION_PASSED
```
`npm test` includes the packed/extracted production artifact running two real Pi sessions, the actual bundled Intercom broker and an offline fresh judge. It checks distinct actual role models and does not load a companion source checkout. The real-Pi conversational test covers Discuss and same-current-model recovery. Herdr is mocked in automated tests. Typecheck, lint, build and diff checks passed. Comparing common entries in the old/new lockfiles found no changed versions of existing locked packages.
The first review found four defects: stop blocked by model unavailability; activation before worker restoration and wrong recovery role; negated waiver matching; and same-model selection not triggering recovery. All were fixed with regressions. Parent inspection also caught a recovery await before cancellation ownership was captured; two more regressions cover clear/replacement during that await. The targeted review read current source and tests and returned `No issues found.` and `Merge verdict: OK`.
## Migration and remaining limits
The parent removed the two old companion entries from the user's Pi package list after the packed path passed. Only pi-goals remains registered for this workflow. Existing workers must reload before Ready: their old launch arguments still name the standalone extensions. The failed supervisor pane was observed at a shell with no Pi process; no duplicate recovery process was started during implementation.
Live Herdr recovery/navigation, the quality of questions from the user's chosen model, and token savings still need a human trial. The old historical tool-call-without-result issue can still block whole-plan `done`; `/goals clear` explicitly disconnects supervision and preserves history. This change did not weaken outstanding-work checks or alter old session transcripts.
<!-- Parent synthesis from observed commands, source inspection and review output, by Pi. -->
@@ -0,0 +1,69 @@
# Supervisor integration options
2026-09-07. Source-based recommendation, not implemented or tested as a live two-pane session. User intent: [Plan-aware persistent supervisor](../spec/2026-09-07_plan-supervisor.md).
## Recommendation
Compose `pi-goals` with `pi-intercom-supervisor` and its existing Intercom channel. Run the supervisor as a second real Pi session in a Herdr split. Use native Pi fork/resume and Herdr launch/focus facilities; keep a small adapter in pi-goals for the plan handoff and navigation.
This reuses the supervisor's policy, incremental views, decisions, and steering. It avoids both copying that runtime into pi-goals and placing a headless subagent behind a second UI. Both sessions remain ordinary Pi sessions that the human can inspect and address.
Herdr owns the terminals. Pi owns each session's history and compaction. The supervisor package owns the supervision relationship and observation cadence. Pi-goals owns the plan and individual goal-completion requests. Intercom transports messages. Pi-subagents can continue running the worker's delegated jobs without owning the supervisor.
## Existing examples and their fit
| Pattern | Fit for this task | Boundary |
|---|---|---|
| Intercom planner/worker + existing supervisor + native Herdr split | Recommended | New fork/bootstrap, plan-signoff, and cadence hooks still needed |
| Intercom `openProjectPaneIfMissing` | Useful launch/discovery example | Starts plain Pi; no explicit fork/session argument; cwd selection is insufficient to identify this supervisor |
| Pi-subagents `project.open` | Real visible Pi session | One binding per canonical cwd; options do not select a session file or supervisor role |
| Pi-subagents retained child + Fleet/Herdr inspector | Retains child context and managed lifecycle | Inspector reads artifacts and offers control; it is not the child's interactive Pi session |
| Messenger Crew/lobby + overlay | Useful for multi-worker task orchestration | Own task state and headless `--mode json --no-session -p` workers; overlay is chat/progress, not two real Pi terminals |
Intercom's README describes its planner/worker example as “one holds the big picture, the other does the hands-on work.” Its extension-channel example is the right transport pattern for non-conversational views and correlated goal requests: channel traffic alone starts no model turn. The supervisor already uses this channel and starts its own review when a view arrives.
The supervisor's `pi-supervise:pair:v1` event accepts an explicit worker Intercom identity and goal, and resolves on worker acknowledgement. It is emitted inside the supervisor process, not across the event bus from the worker. Its companion worker-state and paired events expose the identities needed by a launcher.
## Human interaction
Default to a named worker/supervisor split so both real sessions are visible. Focus either pane to type there; zoom temporarily when more width is needed. Offer direct pi-goals actions for opening/focusing the supervisor and returning to the worker. Exact slash spelling is still a proposal.
Installed help confirms this launch shape. These commands were NOT run:
```bash
herdr pane split --current --direction right --cwd "$PROJECT" --no-focus
# Take SUPERVISOR_PANE from the returned pane.pane_id.
herdr agent start goals-supervisor --kind pi --pane "$SUPERVISOR_PANE" -- --session "$FORK_SESSION_FILE"
herdr agent focus "$SUPERVISOR_PANE"
herdr agent focus "$WORKER_PANE"
herdr pane zoom --pane "$SUPERVISOR_PANE" --toggle
```
`--session` here opens an already-created distinct fork, never the worker's live file. Pi also supports `--fork <path|id>` to create the separate session at launch, so a native CLI fork followed by supervisor-side initialization may eliminate custom fork-file construction. Herdr `agent start` waits for the expected interactive agent to be detected and ready; its pane must initially be at a shell prompt. Use a unique name per pairing and retain the returned pane ID for control.
A separate Herdr tab is an alternative for switching; the split satisfies seeing both at once. Outside Herdr, an ordinary second terminal remains usable, but automatic terminal creation is a separate portability question. Do not build an additional fallback process manager before that need is established.
## Minimal integration work still required
1. **Bootstrap:** fork at the plan handoff, establish a supervisor-only role, compact the fork, provide the worker's explicit plan path and full plan, then pair. Do not restore the fork's inherited pi-goals activation as another worker, and do not truncate the plan into the routine 15 KB view. No supervisor judgement should run before initialization finishes.
2. **One review policy:** expose the proposed 50-model-turn / 60-minute / settled-and-no-background-work triggers in the supervisor package. Replace its stock 30-minute policy rather than running a second timer beside it. Count model turns, not transcript messages. Track the worker's registered processes/subagents and react when they finish; arbitrary unregistered detached work remains a visibility limitation.
3. **Per-goal request/reply:** add correlated goal-signoff and plan-update hooks. Stock `done` ends the entire pairing. A goal decision should return to pi-goals, which owns checkbox updates and the existing fresh evidence judge. A stale or duplicate reply must not approve another goal or plan version. The current two-call CompleteGoal protocol is not a user requirement.
4. **Recovery and navigation:** retain session/plan/pane identity; reconcile live Intercom identity and pairing on restart. Focus or resume the same supervisor instead of creating another. Do not open one session file in two live Pi processes. Preserve the original supervisor's decisions and reestablish policy/plan after compaction.
Keep VCC's incremental views and the supervisor's recent-view pruning initially. Use native compaction for the fork and later supervisor history. Measure token totals, cache reads, compaction cost, and whether useful interventions occur. The supervisor README's reported cost saving is not proof of an equivalent token reduction.
## Evidence and limits
Inspected Pi 0.84.4 and Herdr `0.8.2-preview.2026-09-06-9e9bc8a14466` CLI help. Herdr is installed, but this chat is not a Herdr-managed pane. No panes, agents, packages, or settings were started/changed for the research. Only documentation changed in this branch.
Source anchors:
- [Supervisor pairing and runtime](https://github.com/wassname/pi-intercom-supervisor/blob/409233cd4fb89e9b8c4a027affdedac5c9a8fddc/src/index.ts): pairing at 749794; recent-view pruning at 535556; settled reviews at 710742. [Policy](https://github.com/wassname/pi-intercom-supervisor/blob/409233cd4fb89e9b8c4a027affdedac5c9a8fddc/src/prompts.ts) and [wire protocol](https://github.com/wassname/pi-intercom-supervisor/blob/409233cd4fb89e9b8c4a027affdedac5c9a8fddc/src/protocol.ts) show the reusable prompt and missing per-goal request.
- [Intercom 0.13.0 README](https://github.com/nicobailon/pi-intercom/blob/199279ae861bf53ce014809fb2a03337538ae13e/README.md): Planner-Worker Coordination, Extension channels. [Project launcher](https://github.com/nicobailon/pi-intercom/blob/199279ae861bf53ce014809fb2a03337538ae13e/project-agent.ts) accepts cwd/focus but not a selected session. Inspected the published npm tarball as well. The supervisor README's old `tintinweb/pi-intercom` link returned 404; current Intercom source is under `nicobailon`.
- [Pi-subagents current integration docs](https://github.com/nicobailon/pi-subagents/blob/54df154d1891db6b1a3e755539683335b9f9dbd3/docs/extension-api.md): Inspector panes, Project panes, Background-work provider API. Also inspected installed 0.60.0 source: `project-panes.ts:102107,177179,409412,546564` and `focus.ts:3255`. Registry latest was 0.66.0; no upgrade was performed. Current source still distinguishes project sessions from headless-run inspectors.
- [Messenger 0.15.2 lobby launch](https://github.com/nicobailon/pi-messenger/blob/09937ed647a1b07a3b595bf75943feacb80ff123/crew/lobby.ts): `spawnLobbyWorker` uses print/JSON/no-session mode. [Overlay actions](https://github.com/nicobailon/pi-messenger/blob/09937ed647a1b07a3b595bf75943feacb80ff123/overlay-actions.ts) send messages and manipulate Crew tasks.
- Installed Pi SDK `docs/sdk.md`, `docs/session-format.md`, and `examples/sdk/11-sessions.ts`: session persistence, native branching, compaction, and CLI resume. Installed `herdr pane split --help`, `herdr agent start --help`, `herdr agent focus --help`, and `herdr pane zoom --help`: real interactive launch and navigation.
This is a fit comparison of the requested packages and their actual examples, not a package security audit or a measured reliability ranking. End-to-end fork/compact/pair, reload, background completion, sign-off, and visible switching remain to be tested.
<!-- Research and synthesis by Pi; recommendation pending user approval. -->
@@ -0,0 +1,80 @@
# Plan supervisor: implementation and trial status
2026-09-07. Feature-branch implementation, reviewed fixes and local trial. One live supervised goal completed successfully. Full navigation/reload UAT and token-saving measurements remain open. The user authorized registering the two companion packages in Pi settings and committing/pushing both feature branches. No npm release is part of this change.
## What changed
Pi-goals now forks a real supervisor session and starts it through native Herdr commands. The existing supervisor package supplies its policy, incremental VCC worker views and retained judgement. Ready waits for initialization and acknowledged pairing, then starts work; it is not a second plan-approval gate.
Routine checks use the 50-model-turn / 60-minute / settled-with-no-tracked-work policy. One CompleteGoal call requests a correlated supervisor decision before running the separate fresh evidence judge. Direct supervisor/worker focus and zoom commands replace Fleet navigation for this workflow. Small measured forks can skip compaction; larger/unknown forks use native compaction. Missing-pane recovery is deliberately manual.
## Parent-observed automated validation
[Saved default-on validation](evidence/2026-09-07_default-on-validation.log) records `Tests 48 passed (48)` across 11 files, plus typecheck, lint, build and diff checks. Both the actual-package hook test and real-Pi/Intercom test now initialize supervision without an explicit enable command. New regressions cover default-on migration, explicit-off persistence and preserving active legacy plans.
[Earlier full saved output](evidence/2026-09-07_supervisor-validation.log) covers all seven lifecycle review fixes, before the default-on change. Relevant excerpts:
```text
GOALS: enabled suite after R6/R7
Test Files 11 passed (11)
Tests 45 passed (45)
...
Checked 16 files in 56ms. No fixes applied.
...
tests 116
pass 116
fail 0
skipped 0
...
POST-R6-R7 PARENT VALIDATION PASSED
```
The goals suite ran with `PI_GOALS_SUPERVISOR_SOURCE` pointing to the matching supervisor branch. It included the actual two-Pi RPC / Intercom-broker / fresh-offline-judge test, with Herdr mocked. The hook integration additionally exercises two goals with actual package code and a persisted native fork, but mocks transport, Herdr and the judge. Neither is visual TUI proof. Typecheck, lint, build and both diff checks also passed. The supervisor suite ran through its literal `npm test`, not only the worker's alternate runner.
Reproduce from the goals worktree:
```bash
PI_GOALS_SUPERVISOR_SOURCE=/home/ubuntu/.pi/agent/worktrees/pi-intercom-supervisor-goals-integration/src/index.ts npm test
npm run typecheck && npm run lint && npm run build && git diff --check
cd /home/ubuntu/.pi/agent/worktrees/pi-intercom-supervisor-goals-integration
npm test && git diff --check
```
## Review disposition
All seven implementation findings were accepted and fixed:
- R1: preserve unknown context usage and remove the stale pre-compaction token floor.
- R2: cancel stale Ready handoffs after awaited activation and plan replacement.
- R3: invalidate suspended view/compaction continuations after stop or shutdown.
- R4: persist acknowledged initialization separately from provisional bootstrap state.
- R5: preserve explicit-stop state across cleanup and reload.
- R6: preserve and acknowledge worker activation during same-binding bootstrap replay.
- R7: keep the plan in starting until activation succeeds; steward-off returns it to planning.
The independent final review verified the original R1R5 scenarios and found R6/R7. Its verdict was BLOCK for R6. The parent then authorized the two narrow fixes, inspected their source and regression tests, and ran the final suites above. No fourth independent review was launched: the three-round cap was reached. R6/R7 therefore have parent review and regression evidence, not a subsequent independent approval.
The orchestration script failed after the first fix worker because its progress object included an undefined optional output reference. Completed code/results were retained; only the unlaunched final reviewer was recovered. This did not constitute a code/test failure.
## Local trial
The user has registered all three local packages in Pi settings, so ordinary Pi startup now loads them. For a temporary trial elsewhere, start inside Herdr with the matching goals extension already loaded and pass the companions explicitly:
```bash
base="$HOME/.pi/agent/worktrees/pi-intercom-supervisor-goals-integration"
pi -e "$base/src/index.ts" -e "$base/node_modules/pi-intercom/index.ts"
```
Then use `/goals plan <objective>` and Ready. Steward and 60-minute fallback auto-continue now default to on; explicit off preferences persist. `/goals supervisor`, `/goals worker`, and `/goals zoom` operate on the recorded real panes. `/goals steward off` stops the relationship, not the terminal pane.
Observe both panes, switch and zoom, reload/compact the supervisor, then complete two goals. If a pane ID is lost, locate the existing supervisor before reopening its saved session; a missing pane ID is not evidence that its process exited. Unknown background providers are not evidence that all work finished. Automatic phase-model switching was not implemented.
## Live trial and remaining cleanup issue
The user completed one real Herdr-supervised file-table goal. The supervisor delivered a direction message, and CompleteGoal ultimately accepted the evidence after corrections to the saved command/transcript attribution. This establishes a live pairing, steering and goal-sign-off path; it does not establish two-goal persistence or focus/zoom/reload behavior.
After completion, the supervisor reported that its `done` call was blocked by `Cannot finish: the worker still has work running (write).` Its worker view still reported an unresolved write while the worker was settled and tracked processes/subagents were zero. The origin of this apparently stale tool state has not been diagnosed. Automatic whole-plan cleanup is therefore not verified. `/goals clear` explicitly disconnects the pairing and stops its watch timer while preserving the plan; the supervisor pane/session can remain as history. A new plan creates a new version and pairing.
The managed pi-goals checkout's pre-existing `package-lock.json` modification was left untouched. Development dependencies were installed only in the new supervisor feature checkout. The later user-approved settings change registers the existing local supervisor and Intercom copies; it does not upgrade or download packages.
<!-- Final implementation synthesis and observed validation by Pi. -->
@@ -0,0 +1,27 @@
> @wassname2/pi-goals@0.2.2 test
> vitest run
RUN v4.1.9 /home/ubuntu/.pi/agent/worktrees/pi-goals-persistent-steward
Test Files 11 passed (11)
Tests 48 passed (48)
Start at 14:06:36
Duration 6.78s (transform 1.61s, setup 0ms, import 16.58s, tests 11.71s, environment 2ms)
> @wassname2/pi-goals@0.2.2 typecheck
> tsc --noEmit
> @wassname2/pi-goals@0.2.2 lint
> biome check src/ test/
Checked 16 files in 36ms. No fixes applied.
> @wassname2/pi-goals@0.2.2 build
> tsc
DEFAULT_ON_VALIDATION_PASSED
@@ -0,0 +1,157 @@
> @wassname2/pi-goals@0.2.2 test
> vitest run && npm run test:supervisor
RUN v4.1.9 /home/ubuntu/.pi/agent/worktrees/pi-goals-persistent-steward
Test Files 12 passed (12)
Tests 67 passed (67)
Start at 16:14:44
Duration 15.63s (transform 1.67s, setup 307ms, import 22.12s, tests 28.12s, environment 3ms)
> @wassname2/pi-goals@0.2.2 test:supervisor
> node --import tsx --test test/internal-supervisor/*.test.ts
✔ retries intercom registration when pi-intercom loads after pi-supervise (3.540888ms)
✔ a directive with no text is rejected, so the worker never sees undefined (0.288987ms)
✔ a directive from the paired supervisor becomes a real user message (27.396147ms)
✔ a directive to a busy worker interrupts, instead of waiting for the whole task (13.079025ms)
✔ a directive from an unpaired session is dropped (11.977008ms)
✔ a second pair takes over, and the first supervisor is told it lost the worker (21.508059ms)
✔ only the paired worker can end a run (6.314542ms)
✔ the programmatic pairing API waits for the worker acknowledgement (1.485485ms)
✔ the worker acknowledges a pair, so the supervisor knows it was heard (5.359587ms)
✔ a goal the supervisor inferred reaches the worker, which owns the view header (37.477099ms)
✔ the second view carries only what happened after the first (328.068703ms)
✔ a message addressed to a different session is ignored (11.834377ms)
✔ on settle the worker publishes a view built from the live branch (17.888259ms)
✔ the view is built from the live branch, not from every entry in the session (22.049192ms)
✔ an unpaired session publishes nothing on settle (0.498005ms)
✔ supervision never stops itself: no round limit at all (9.442441ms)
✔ goal, pairing and the steer count all survive a reload together (0.791695ms)
✔ a view that arrives while the supervisor is thinking is queued, not dropped (5.643173ms)
✔ the nudge repeats neither the instructions already sent nor the verdict rules (5.269154ms)
✔ a multi-line goal returns to supervisor context every fifth review and after compaction (31.103788ms)
✔ a one-line goal is not redundantly reinserted (26.640178ms)
✔ a check in and a worker that stopped ask for different things (12.287139ms)
✔ a loop still gets named after the supervisor compacts, from restored state (1.450856ms)
✔ a session that does not answer the roll call is not offered as a worker (501.077007ms)
✔ a child run stays out of the roll call, so it can never be picked (5.337167ms)
✔ a session already paired stays out of the roll call, and a free one answers (15.899876ms)
✔ /supervise look asks the worker for a fresh view, rather than the supervisor guessing (306.105484ms)
✔ let_it_run says the turn is over, so it is not called four times running (0.835308ms)
✔ a sign-off verdict is answered, not aborted, and a runaway is still cut (0.609635ms)
✔ every verdict result names the way to end the turn, steer included (0.449781ms)
✔ an old view is dropped from context once its verdict is in, and the verdict is kept (1.046937ms)
✔ a worker session never has its context rewritten (0.289351ms)
✔ a view that arrives mid-answer starts a fresh look (5.824464ms)
✔ a tool a worker cannot use never aborts its turn (0.339873ms)
✔ a resume onto a session that is gone drops the pairing and says so (6.297894ms)
✔ a resume onto a live worker keeps supervising, and takes the writers back off (5.29537ms)
✔ state written before recentSteers existed still loads (0.171283ms)
✔ done unpairs the worker, so it stops publishing views (320.167743ms)
✔ with no goal the supervisor cannot steer, it must ask the human (0.573651ms)
✔ set_goal binds an inferred goal, and steering then works (501.94885ms)
✔ a goal given at pair time still allows steering (0.665903ms)
✔ done is refused while the worker has an unanswered tool call (12.424782ms)
✔ done is allowed once nothing is outstanding (6.182792ms)
✔ steer refuses when the session is not supervising (0.325609ms)
✔ a reworded repeat of an earlier instruction is sent, and named back to the supervisor (0.510602ms)
✔ overlap scores rewording high and a different instruction low (0.136533ms)
✔ the view of the old worker cannot be used to judge the new one (6.045704ms)
✔ with one other session here, /supervise needs no target and the whole line is the goal (501.381162ms)
✔ naming the worker still works, and the rest of the line is the goal (0.554981ms)
✔ with two free sessions here, /supervise asks which one, and pairs with the choice (501.08053ms)
✔ a goal that is a path is read from the file, so it is not pasted every run (2.297652ms)
✔ a long goal is one short line above the picker, and reaches the worker whole (501.854523ms)
✔ a session that stayed quiet is still on the list, because 0 free is a dead end (501.687356ms)
✔ a cancelled picker pairs with nothing (500.579211ms)
✔ supervising takes the writing tools away, and stopping gives them back (501.255208ms)
✔ stopping gives back the writers without undoing another extension's tools (500.601415ms)
✔ a first word that names no session is refused, rather than folded into the goal (0.611801ms)
✔ a goal with spaces needs no target, and @name takes the rest of the line as the goal (501.893405ms)
✔ the brief starts no turn, so there is no answer before the first view (5.995751ms)
✔ /supervise goal changes the goal without breaking the pairing (0.70475ms)
✔ the footer says which side of a pairing this session is, and clears when it ends (506.432358ms)
✔ a session that is not supervising never sees the supervisor tools (5.830284ms)
✔ worker_view refuses when there is no worker, rather than implying a pairing (0.513982ms)
✔ the view names the worker's model and how full its context is (21.061667ms)
✔ supervising a second session is refused while the first is still paired (0.548942ms)
✔ the supervisor gets a look at a working worker every half hour, without being asked (926.124762ms)
✔ a human message in the worker session is not a reason to stand back (6.127037ms)
✔ letting a stopped worker run says plainly that the worker stays stopped (11.377477ms)
✔ a stopped worker is looked at again, so let_it_run cannot silence the pairing (922.993821ms)
✔ a worker that pairs at the prompt and never takes a turn is still watched (604.616558ms)
✔ a worker that reloads at the prompt starts watching itself again (604.845926ms)
✔ a timer look at a worker that has not moved is not sent, until it has been skipped three times (2425.474247ms)
✔ the worker counts reviews in a row where nothing changed (356.432776ms)
✔ an unacknowledged pair gives up, and a takeover cancels that timer (3.893969ms)
✔ duplicate standalone Intercom registries are diagnosed and cannot bootstrap a plan (1.232918ms)
✔ plan bootstrap compacts only the supervisor and pairing alone never starts a worker or a review (18.935035ms)
✔ goal decisions are correlated, preserve the pair across two goals, and cannot call overall done (5.389797ms)
✔ abort and stop cancel pending requests; late decisions cannot approve a replacement (3.265783ms)
✔ 50 actual model turns trigger one view, independent of the number of messages (3.457965ms)
✔ unknown background providers are not proof of quiescence (0.294658ms)
✔ stale plan content invalidates a pending goal review (3.01992ms)
✔ small forks skip compaction, but real compaction failure prevents pairing (2.471879ms)
✔ the hour timer and a coincident turn checkpoint produce a single view (5.559542ms)
✔ settled checks wait for tracked processes and subagents to finish (2.833087ms)
✔ bootstrap stop cannot resurrect a supervisor after compaction completes (2.15529ms)
✔ a restarted worker reconnects by exact saved session identity without a new supervisor (2.812666ms)
✔ unknown initial context must compact instead of taking the known-small shortcut (1.119486ms)
✔ null post-compaction usage cannot raise the next configured 100k checkpoint (2.180167ms)
✔ stopping a routine view during compaction invalidates its suspended continuation (1.977452ms)
✔ restart of a provisional bootstrap resumes compaction and pairing in the same saved session (2.18079ms)
✔ command preserves a stopped supervisor across reload (2.007763ms)
✔ done preserves a stopped supervisor across reload (2.456918ms)
✔ same-binding replay retains activation when the supervisor lost its acknowledgement (2.599438ms)
✔ model-unavailable stop validates binding, cancels pending reviews and ignores old directives (2.091306ms)
✔ a child process named pi is found by ps, and stops being found when it exits (367.036839ms)
✔ the check is a snapshot, so it cannot hold up the worker's settle (321.13146ms)
✔ a one-line goal stays whole while a multi-line goal has a locator (6.226689ms)
✔ a view carries only the turns the supervisor has not been sent (2.16041ms)
✔ the last two reasoning blocks stay in the narrative, and older ones drop out (1.078426ms)
✔ a compaction restarts the view, so no turn falls into the gap (0.455488ms)
✔ pi-vcc reports the files the worker wrote, and separates them from the ones it read (1.361829ms)
✔ progressKey is unchanged when a review produced no new file or commit (0.506944ms)
✔ progressKey still sees a new file past pi-vcc's ten path display cap (0.575315ms)
✔ a commit counts as progress, even when no file was written since (0.960805ms)
✔ outstandingWork finds tool calls that never got a result (1.43676ms)
✔ buildView reports a tool call with no result, so done can be refused (0.87924ms)
✔ the view says how many reviews in a row changed nothing, and says nothing at zero (0.692566ms)
✔ the view merges the worker's compaction summary with the turns after it (0.470565ms)
✔ a turn the compaction summary already covers is not sent twice (0.336722ms)
✔ pi-vcc's sections and its transcript land on the right sides of the split (2.943ms)
✔ the view does not tell the supervisor to use vcc_recall, a tool it does not have (0.291333ms)
✔ supervisor directives are not sent back as worker evidence (0.488463ms)
✔ bookkeeping tool calls are kept out of the transcript (0.390618ms)
✔ buildView reports the goal, status, and files without historical failures (0.42015ms)
✔ how long the worker has been quiet, measured from its own last entry (0.593754ms)
✔ buildView keeps the newest turns when it has to cut for the channel limit (26.316809ms)
✔ pi's own branch logic drops the abandoned fork, on a session file (1793.809954ms)
✔ a long goal cannot push the view past the broker limit (0.55492ms)
tests 118
suites 0
pass 118
fail 0
cancelled 0
skipped 0
todo 0
duration_ms 20178.623102
> @wassname2/pi-goals@0.2.2 typecheck
> tsc -p tsconfig.build.json --noEmit
> @wassname2/pi-goals@0.2.2 lint
> biome check src/ test/
Checked 32 files in 93ms. No fixes applied.
> @wassname2/pi-goals@0.2.2 build
> tsc -p tsconfig.build.json
SINGLE_PACKAGE_FINAL_VALIDATION_PASSED
@@ -0,0 +1,167 @@
GOALS: enabled suite after R6/R7
> @wassname2/pi-goals@0.2.2 test
> vitest run
RUN v4.1.9 /home/ubuntu/.pi/agent/worktrees/pi-goals-persistent-steward
Test Files 11 passed (11)
Tests 45 passed (45)
Start at 12:09:35
Duration 6.14s (transform 1.20s, setup 0ms, import 14.85s, tests 10.77s, environment 2ms)
GOALS: typecheck
> @wassname2/pi-goals@0.2.2 typecheck
> tsc --noEmit
GOALS: lint
> @wassname2/pi-goals@0.2.2 lint
> biome check src/ test/
Checked 16 files in 56ms. No fixes applied.
GOALS: build
> @wassname2/pi-goals@0.2.2 build
> tsc
SUPERVISOR: literal npm test after R6/R7
> @wassname2/pi-supervise@0.0.4 pretest
> tsx scripts/make-fixture.ts
wrote /home/ubuntu/.pi/agent/worktrees/pi-intercom-supervisor-goals-integration/test/forked-session.jsonl
entries (excluding the session header): 410
abandoned entries: 22
> @wassname2/pi-supervise@0.0.4 test
> tsx --test src/*.test.ts
✔ retries intercom registration when pi-intercom loads after pi-supervise (3.429966ms)
✔ a directive with no text is rejected, so the worker never sees undefined (0.247018ms)
✔ a directive from the paired supervisor becomes a real user message (29.622902ms)
✔ a directive to a busy worker interrupts, instead of waiting for the whole task (13.977563ms)
✔ a directive from an unpaired session is dropped (13.157035ms)
✔ a second pair takes over, and the first supervisor is told it lost the worker (27.141375ms)
✔ only the paired worker can end a run (7.283707ms)
✔ the programmatic pairing API waits for the worker acknowledgement (1.436818ms)
✔ the worker acknowledges a pair, so the supervisor knows it was heard (5.54306ms)
✔ a goal the supervisor inferred reaches the worker, which owns the view header (44.194636ms)
✔ the second view carries only what happened after the first (330.585574ms)
✔ a message addressed to a different session is ignored (10.170908ms)
✔ on settle the worker publishes a view built from the live branch (21.893806ms)
✔ the view is built from the live branch, not from every entry in the session (17.260824ms)
✔ an unpaired session publishes nothing on settle (0.379273ms)
✔ supervision never stops itself: no round limit at all (4.651789ms)
✔ goal, pairing and the steer count all survive a reload together (0.521505ms)
✔ a view that arrives while the supervisor is thinking is queued, not dropped (5.970369ms)
✔ the nudge repeats neither the instructions already sent nor the verdict rules (6.195585ms)
✔ a multi-line goal returns to supervisor context every fifth review and after compaction (32.097966ms)
✔ a one-line goal is not redundantly reinserted (26.327091ms)
✔ a check in and a worker that stopped ask for different things (10.092757ms)
✔ a loop still gets named after the supervisor compacts, from restored state (0.953582ms)
✔ a session that does not answer the roll call is not offered as a worker (502.245612ms)
✔ a child run stays out of the roll call, so it can never be picked (6.095977ms)
✔ a session already paired stays out of the roll call, and a free one answers (16.179354ms)
✔ /supervise look asks the worker for a fresh view, rather than the supervisor guessing (308.118194ms)
✔ let_it_run says the turn is over, so it is not called four times running (0.768638ms)
✔ a sign-off verdict is answered, not aborted, and a runaway is still cut (0.57058ms)
✔ every verdict result names the way to end the turn, steer included (0.39166ms)
✔ an old view is dropped from context once its verdict is in, and the verdict is kept (1.05293ms)
✔ a worker session never has its context rewritten (0.295565ms)
✔ a view that arrives mid-answer starts a fresh look (5.950038ms)
✔ a tool a worker cannot use never aborts its turn (0.408612ms)
✔ a resume onto a session that is gone drops the pairing and says so (5.502016ms)
✔ a resume onto a live worker keeps supervising, and takes the writers back off (6.297883ms)
✔ state written before recentSteers existed still loads (0.188312ms)
✔ done unpairs the worker, so it stops publishing views (321.527785ms)
✔ with no goal the supervisor cannot steer, it must ask the human (0.563499ms)
✔ set_goal binds an inferred goal, and steering then works (501.01427ms)
✔ a goal given at pair time still allows steering (0.523019ms)
✔ done is refused while the worker has an unanswered tool call (11.351204ms)
✔ done is allowed once nothing is outstanding (5.223176ms)
✔ steer refuses when the session is not supervising (0.364627ms)
✔ a reworded repeat of an earlier instruction is sent, and named back to the supervisor (0.483156ms)
✔ overlap scores rewording high and a different instruction low (0.114745ms)
✔ the view of the old worker cannot be used to judge the new one (6.06669ms)
✔ with one other session here, /supervise needs no target and the whole line is the goal (501.412838ms)
✔ naming the worker still works, and the rest of the line is the goal (0.519157ms)
✔ with two free sessions here, /supervise asks which one, and pairs with the choice (501.863353ms)
✔ a goal that is a path is read from the file, so it is not pasted every run (2.227961ms)
✔ a long goal is one short line above the picker, and reaches the worker whole (501.010774ms)
✔ a session that stayed quiet is still on the list, because 0 free is a dead end (501.565666ms)
✔ a cancelled picker pairs with nothing (501.796819ms)
✔ supervising takes the writing tools away, and stopping gives them back (501.55056ms)
✔ stopping gives back the writers without undoing another extension's tools (501.583537ms)
✔ a first word that names no session is refused, rather than folded into the goal (0.653782ms)
✔ a goal with spaces needs no target, and @name takes the rest of the line as the goal (501.035374ms)
✔ the brief starts no turn, so there is no answer before the first view (6.002338ms)
✔ /supervise goal changes the goal without breaking the pairing (0.74172ms)
✔ the footer says which side of a pairing this session is, and clears when it ends (505.756191ms)
✔ a session that is not supervising never sees the supervisor tools (5.764996ms)
✔ worker_view refuses when there is no worker, rather than implying a pairing (0.508317ms)
✔ the view names the worker's model and how full its context is (18.254298ms)
✔ supervising a second session is refused while the first is still paired (0.498834ms)
✔ the supervisor gets a look at a working worker every half hour, without being asked (920.025754ms)
✔ a human message in the worker session is not a reason to stand back (6.229147ms)
✔ letting a stopped worker run says plainly that the worker stays stopped (10.401535ms)
✔ a stopped worker is looked at again, so let_it_run cannot silence the pairing (924.087031ms)
✔ a worker that pairs at the prompt and never takes a turn is still watched (604.883737ms)
✔ a worker that reloads at the prompt starts watching itself again (604.981832ms)
✔ a timer look at a worker that has not moved is not sent, until it has been skipped three times (2426.519261ms)
✔ the worker counts reviews in a row where nothing changed (356.420327ms)
✔ an unacknowledged pair gives up, and a takeover cancels that timer (4.220555ms)
✔ plan bootstrap compacts only the supervisor and pairing alone never starts a worker or a review (23.339027ms)
✔ goal decisions are correlated, preserve the pair across two goals, and cannot call overall done (6.488073ms)
✔ abort and stop cancel pending requests; late decisions cannot approve a replacement (4.950084ms)
✔ 50 actual model turns trigger one view, independent of the number of messages (4.336451ms)
✔ unknown background providers are not proof of quiescence (0.39308ms)
✔ stale plan content invalidates a pending goal review (3.928135ms)
✔ small forks skip compaction, but real compaction failure prevents pairing (2.247287ms)
✔ the hour timer and a coincident turn checkpoint produce a single view (3.504665ms)
✔ settled checks wait for tracked processes and subagents to finish (2.813837ms)
✔ bootstrap stop cannot resurrect a supervisor after compaction completes (1.747705ms)
✔ a restarted worker reconnects by exact saved session identity without a new supervisor (2.135761ms)
✔ unknown initial context must compact instead of taking the known-small shortcut (0.924763ms)
✔ null post-compaction usage cannot raise the next configured 100k checkpoint (1.994091ms)
✔ stopping a routine view during compaction invalidates its suspended continuation (1.671618ms)
✔ restart of a provisional bootstrap resumes compaction and pairing in the same saved session (2.221047ms)
✔ command preserves a stopped supervisor across reload (1.905196ms)
✔ done preserves a stopped supervisor across reload (2.421426ms)
✔ same-binding replay retains activation when the supervisor lost its acknowledgement (2.604633ms)
✔ a child process named pi is found by ps, and stops being found when it exits (374.81872ms)
✔ the check is a snapshot, so it cannot hold up the worker's settle (319.675518ms)
✔ a one-line goal stays whole while a multi-line goal has a locator (8.167075ms)
✔ a view carries only the turns the supervisor has not been sent (2.090138ms)
✔ the last two reasoning blocks stay in the narrative, and older ones drop out (1.13405ms)
✔ a compaction restarts the view, so no turn falls into the gap (0.563394ms)
✔ pi-vcc reports the files the worker wrote, and separates them from the ones it read (1.400011ms)
✔ progressKey is unchanged when a review produced no new file or commit (0.525086ms)
✔ progressKey still sees a new file past pi-vcc's ten path display cap (0.725928ms)
✔ a commit counts as progress, even when no file was written since (0.874443ms)
✔ outstandingWork finds tool calls that never got a result (1.469005ms)
✔ buildView reports a tool call with no result, so done can be refused (0.885647ms)
✔ the view says how many reviews in a row changed nothing, and says nothing at zero (0.644311ms)
✔ the view merges the worker's compaction summary with the turns after it (0.44949ms)
✔ a turn the compaction summary already covers is not sent twice (0.364824ms)
✔ pi-vcc's sections and its transcript land on the right sides of the split (2.422505ms)
✔ the view does not tell the supervisor to use vcc_recall, a tool it does not have (0.315271ms)
✔ supervisor directives are not sent back as worker evidence (0.494375ms)
✔ bookkeeping tool calls are kept out of the transcript (0.412053ms)
✔ buildView reports the goal, status, and files without historical failures (0.403255ms)
✔ how long the worker has been quiet, measured from its own last entry (0.460999ms)
✔ buildView keeps the newest turns when it has to cut for the channel limit (33.622661ms)
✔ pi's own branch logic drops the abandoned fork, on a session file (1712.237778ms)
✔ a long goal cannot push the view past the broker limit (0.489445ms)
tests 116
suites 0
pass 116
fail 0
cancelled 0
skipped 0
todo 0
duration_ms 19908.802286
POST-R6-R7 PARENT VALIDATION PASSED
@@ -0,0 +1,44 @@
# Plan-aware supervisor integration
Approved direction: compose the existing supervisor with native Pi/Herdr sessions. See [intent](../../spec/2026-09-07_plan-supervisor.md) and [final validation](../../reviews/2026-09-07_supervisor-validation.md).
- [ ] goal: Ready creates a real, plan-aware supervisor that the user can open beside the worker
- [x] Replace the checkpoint reviewer with native fork/bootstrap, acknowledged pairing and a Herdr launch/focus adapter.
- [x] Compact only the supervisor; supply the explicit worker plan and isolate the supervisor role.
- [x] Observe live supervisor steering and accepted sign-off for one goal.
- [ ] Verify navigation, reload, two-goal persistence and automatic whole-plan cleanup in Herdr.
- failure modes: dashboard mistaken for a session; inherited worker automation; duplicate startup.
- deliverable: branch implementation and trial route; live visual acceptance remains open.
- evidence: [final log](../../reviews/evidence/2026-09-07_supervisor-validation.log) shows 45 enabled goals tests passing, including two real Pi processes and actual Intercom; Herdr is mocked.
- [x] goal: One supervisor checks progress at the agreed cadence without following every worker turn
- [x] Use one 50-model-turn / 60-minute / settled-and-no-tracked-work policy, VCC views, retained verdicts and supervisor-only compaction.
- failure modes: duplicate timers; message count substituted for model turns; unknown work treated as finished.
- deliverable: supervisor branch with deterministic cadence, background-state and compaction coverage.
- evidence: [final log](../../reviews/evidence/2026-09-07_supervisor-validation.log) includes passing 50-turn, hour/turn coincidence, unknown-provider, background-completion and nullable-compaction regressions. Token savings are not yet measured.
- [x] goal: Goal sign-off asks that supervisor and still checks evidence independently
- [x] Correlate goal requests and replies; preserve pairing between goals; keep the worker and fresh judge as the completion path.
- [x] Cover stale replies, cancellation, interrupted bootstrap, lost acknowledgement and explicit stop/reload.
- failure modes: one goal ends all supervision; an old response approves another goal; disabled-only tests.
- deliverable: single-call sign-off with actual-package and real-Pi integration tests.
- evidence: [final log](../../reviews/evidence/2026-09-07_supervisor-validation.log) records 45 goals and 116 supervisor tests passing without skips; [report](../../reviews/2026-09-07_supervisor-validation.md) distinguishes the real and mocked boundaries.
- [x] goal: The two branches are reviewable and ready for a user trial
- [x] Complete the single-writer implementation, independent review, accepted R1R7 fixes and final parent diff/test checks.
- failure modes: mocks hide the package boundary; undisclosed updates; unrelated settings/lockfiles change.
- deliverable: [review disposition, saved validation and trial commands](../../reviews/2026-09-07_supervisor-validation.md).
- evidence: final log ends `POST-R6-R7 PARENT VALIDATION PASSED`; typecheck/lint/build and both diff checks passed. R6/R7 were parent-reviewed after the independent three-round cap, not independently re-reviewed.
## UAT / Verification
- Success: Ready initializes and pairs; goal review reaches the correct supervisor and fresh judge. Automated enabled-path evidence is saved above.
- Likely failure: unavailable Intercom/Herdr/API produces an actionable error, with no false approval; covered in tests.
- Sneaky failure: pending work, stale replies or restarted identities cause a false finish; targeted lifecycle regressions pass.
- Still required: see/focus/zoom both real Herdr sessions, reload/compact the supervisor and finish two goals. Measure supervisor token use and usefulness on real work.
## Constraints and state
- Goals: `/home/ubuntu/.pi/agent/worktrees/pi-goals-persistent-steward`, branch `feature/persistent-steward`.
- Supervisor: `/home/ubuntu/.pi/agent/worktrees/pi-intercom-supervisor-goals-integration`, branch `feature/pi-goals-integration`.
- State: automated default-on validation passes 48 goals tests; one live supervised goal accepted. Full Herdr UAT remains open, including an apparently stale unresolved-write cleanup blocker. User authorized companion-package settings and feature-branch commit/push; no npm release.
- Manual lost-pane recovery remains explicit. The managed pi-goals lockfile, APC files and personal journals were not edited. No council or automatic phase-model switching.
<!-- Plan and final evidence read by Pi. -->
@@ -0,0 +1,138 @@
# One package, remembered role models, and conversational plan alignment
The user wants supervision inside pi-goals, with a separate remembered model choice for planning,
working and supervising. This replaces the three-extension installation described in the earlier
integration plan. Two follow-up requests require default alignment questions and a clear return to
normal chat from review; the parent supplied these approved additions during implementation.
## User-visible result
Install only pi-goals, discuss a plan and select Ready to open its real supervisor. Each role restores
its last explicitly selected provider/model. Planning asks at least three task-specific alignment
questions by default. Discuss returns the review menu to ordinary conversation without an editor or
an immediately recurring menu; Ready remains the sole handoff to work.
## User voice
- > did you do the part where the last model choice in plan ,vs worker vs supervisor mode is sticky?
- > ok implement. and just put it into pi-goals I guess... seems messy to have a sep package. one clean package
- > note I tried it in another window and it didn't grill me even one quesiton that is bad should be default to at least ask 3 questions to see how far apart in understanding user and agent are
- > and then I wans't sure what to press, edit? no. refine? that's a special prompt. how to go back to chat about plan and ask it to grill. should not have ot
## Goals
1. [x] goal: Install one package for planning and supervision
- scope: Move supervisor code and regression coverage into pi-goals. Bundle the existing Intercom transport through supported Pi packaging; no separate companion registration or source checkout required. Keep Herdr as the terminal host.
- failure modes: A source-checkout test hides missing packed dependencies; duplicate extension registration; lifecycle regressions during the move.
- discriminator: A packed pi-goals artifact alone supplies both real Pi sessions and completes the offline supervised-goal test. Full inherited supervisor regressions run from this repository.
- evidence: `src/internal/supervisor/` imports the implementation from `145c2cb081f85c08b0244c4a2c8a2d9aef8debda`; `THIRD_PARTY_NOTICES.md` and source attribution retain provenance/MIT notices. `package.json` bundles locked Intercom 0.10.0 and VCC 0.5.0; its manifest loads Intercom through `node_modules/`, while VCC remains a compiler rather than a loaded extension. Launch passes only the pi-goals package root. Duplicate registry diagnostics reject ambiguous plan bootstrap. `test/rpc-supervisor.test.ts` packs/extracts outside companion checkouts, runs actual Pi/Intercom and an offline fresh judge, and asserts production resources and no bundled Pi core peers. Only the worker's Herdr exec is adapted; the supervisor loads the untouched extracted manifest. Parent full validation passed 59 Vitest tests and 117 internal tests (116 inherited plus duplicate-registry coverage), zero skipped.
2. [x] goal: Restore the last model selected for each role
- scope: Remember provider/model separately for planning, worker and supervisor in user-scoped pi-goals preferences. First use inherits the current model. Preserve the separate evidence-judge override.
- failure modes: Automatic switching overwrites another role's preference; reload/fork assigns the wrong role; one process overwrites another role's choice; an unavailable model silently changes the saved preference.
- discriminator: Tests select distinct models in each role, enter/re-enter roles, reload and start another plan. Each role restores its own selection; missing models are reported without overwriting preferences.
- evidence: `src/role-models.ts` uses public `model_select` (`set`/`cycle`; ignores `restore` and guarded automatic `setModel` events). Separate atomic `planning-model.json`, `worker-model.json`, and `supervisor-model.json` files under `getAgentDir()/pi-goals` contain only provider/id. Failed authentication or lookup visibly pauses the role, preserving its saved choice. `test/role-models.test.ts` covers deterministic cycling, fresh processes and unavailable/unauthenticated models. `test/goals-flow.test.ts` covers Ready, cancellation during setModel, failed Ready, next plans, fresh-instance resume/reload and inherited supervisor isolation. `test/supervisor-integration.test.ts` proves planning model capture before worker restore, supervisor model before initial compaction, activation cancellation and judge override isolation. The packed RPC flow also verifies actual planning selection and distinct worker/supervisor/judge model IDs.
3. [x] goal: Ask useful alignment questions before the final plan
- scope: Default to at least three distinct task-specific questions in one chat round, about expected result, scope/constraints and success/failure criteria. Resolve technical facts read-only. Wait for answers and use them. Only an explicit current-objective no/skip-questions instruction waives the default.
- failure modes: Generic ritual questions; questions skipped merely because the agent thinks it understands; an old waiver leaking into a new plan.
- discriminator: Prompt and flow regressions require the default, persist the current-plan waiver across resync, reset it on a new plan, and keep the final-review menu closed until review is requested.
- evidence: `src/prompts.ts` defines the default and current-plan policy; `src/index.ts` persists `questionsWaived` per plan. Prompt and flow tests cover default/waiver/next-plan behavior. The deterministic real-Pi `test/rpc-review.test.ts` asks questions before the first review, accepts chat answers, and reaches Ready. This validates protocol/control flow, not semantic question quality from every live model; no semantic question-count framework was added.
4. [x] goal: Return to ordinary plan chat from review
- scope: Ready / Discuss / Edit / Cancel. Discuss preserves the draft and planning role, asks useful questions in chat, and supports multiple answer turns without another modal. Escape also preserves the draft and returns to chat. Edit remains direct editing; explicit Cancel discards the draft.
- failure modes: Refine-notes editor persists under a renamed button; unchanged drafts immediately reopen the menu; discussion is lost on reload; another approval gate starts work.
- discriminator: Discuss -> multiple chat turns without a menu -> completed discussion -> review again (including unchanged draft) -> exactly one Ready handoff. Reload retains discussion state.
- evidence: Planning-only `RequestPlanReview` is the unambiguous signal to offer review; it does not approve work. New plans and Discuss set persisted `reviewRequested: false`. `test/goals-flow.test.ts` proves unchanged-draft re-review and reload. `test/rpc-review.test.ts` uses real Pi select/chat events and asserts no Discuss editor or premature menu. Ready still exclusively starts work.
## Log
Final parent acceptance: [saved validation and review disposition](../../reviews/2026-09-07_single-package-role-models.md). All four review findings and the recovery-cancellation correction passed the targeted recheck (`No issues found.`, `Merge verdict: OK`). Final tests: 67 Vitest and 118 internal tests, no skips. The package-list migration is complete; existing workers need reload before another Ready attempt.
## Verification
Initial (pre-review-fix) parent unsandboxed checkpoint validation, read back from
`/tmp/pi-goals-single-package-parent-validation.log`:
```text
npm test
Test Files 12 passed (12)
Tests 59 passed (59)
internal node:test: tests 117, pass 117, fail 0, skipped 0
npm run typecheck: passed
npm run lint: Checked 32 files. No fixes applied.
npm run build: passed
git diff --check: passed
SINGLE_PACKAGE_PARENT_VALIDATION_PASSED
```
The full test includes production `npm pack` and the real broker/offline supervised flow, without an
optional integration flag or companion checkout. Worker-local validation independently passes 58
non-broker Vitest tests and all 117 internal regressions, plus typecheck/lint/build. Worker full
`npm test` fails only at Intercom startup: a minimal Unix `net.listen()` also returns `EPERM` in this
sandbox. The parent ran the unchanged enabled test outside that restriction and passed it. Do not
confuse the worker environment limitation with a skipped test or a product pass claim.
`tsconfig.build.json` maps only the four used source-only VCC API surfaces to narrow declarations,
because VCC 0.5.0 has upstream Pi-message/Intl type incompatibilities. Pi-goals source remains fully
typechecked/linted; tests execute the actual pinned VCC implementation. The inherited `node:test`
suite has its own package script and is not silently collected or skipped by Vitest.
Final production tarball, file listing, tracked-plus-new-source diff, source copies, status and
validation logs are saved under `/tmp/pi-goals-single-package-review/` for read-only review. No files
are staged. Fresh review and the targeted recheck are complete; parent acceptance is recorded above.
## Accepted review findings and narrow fixes
Parent accepted all four findings in `single-package-review-recovery.md`; the source was frozen
again for unsandboxed validation after this targeted pass:
1. **Stop while a model is unavailable:** the validated `stop` control-plane operation bypasses
model readiness, while binding/channel checks and peer notification remain. AbortSignal review
cancellation also works during the pause. Directives are ignored for paused/inactive or stopped
workers. New actual-module clear/off tests and the internal wrong-binding/abort/stop regression
prove that the old pair cannot revive work.
2. **Restore before activation and recover the right role:** Ready captures/attaches the planning
fork, persists a pending worker recovery target, restores the worker model, then activates and
hands off. Missing lookup/authentication returns to the review UI without activation or a
supervisor review turn. The persisted recovery target remains worker across reload; an explicit
selection writes worker-model.json, not planning-model.json. Both failure paths have supervised
integration regressions; successful recovery reuses the existing fork and hands off once.
3. **Affirmative waivers only:** a small clause recognizer accepts explicit affirmative current-plan
instructions including `no q's` and `skip q's`, but not `do not skip questions`, quoted feature
names, or embedded quoted clauses. No general NLP parser or semantic question-count gate was added.
4. **Already-current model recovery:** `/goals model current` explicitly authenticates and saves the
current model for the paused role. It does not depend on model_select, which Pi suppresses for
an unchanged model. It is never automatic and does nothing if no role is paused. Unit tests
cover failed authentication without preference replacement; real-Pi RPC proves that same-model
selection alone cannot recover, then the explicit command safely recovers and Ready starts once.
The parent identified one cancellation gap in the initial repair: the recovery restore await was
outside Ready's controller/version ownership. It is now inside the captured lifetime/controller and
plan version/hash checks, before any launch call. Deferred recovery -> clear and -> replacement
regressions prove zero additional startup/activation/handoff and preserve the new/null state.
Worker validation on the final fix source: 66 non-broker Vitest tests and 118 internal supervisor tests pass;
`npm run typecheck`, lint (32 files), build and diff checks pass. Worker full `npm test` still fails
only at actual Intercom startup under the same sandbox Unix-socket EPERM limitation. The enabled
packed test has not been skipped or replaced. Parent unsandboxed validation of the four-fix checkpoint passed 65/65 Vitest and 118/118 internal
tests, zero skipped, plus typecheck/lint/build/diff checks; see
`/tmp/pi-goals-single-package-fixed-validation.log`, ending `SINGLE_PACKAGE_FIX_VALIDATION_PASSED`.
That full run predates the final recovery-await cancellation correction and two extra local tests.
Final parent unsandboxed validation of that correction has now passed **67/67 Vitest tests in 12
files and 118/118 internal tests, zero skipped**, plus typecheck, lint (32 files), build and diffcheck.
The read-back log is `/tmp/pi-goals-single-package-final-validation.log`, ending
`SINGLE_PACKAGE_FINAL_VALIDATION_PASSED`. Parent's targeted review subsequently returned `No issues found.` and `Merge verdict: OK`; all named fixes and their immediate regressions were checked.
Fresh complete snapshots are at `/tmp/pi-goals-single-package-fix-review/`, including a fix-only
delta against the previous review snapshot as well as the full tracked-plus-new-source diff. The
normal three-role/Discuss/RequestPlanReview flow is unchanged, and no extra normal-path approval
gate, IPC/package redesign, global setting edit, or legacy orphaned-write fix was made.
## Boundaries and remaining limits
- Sole writer in the goals worktree on `feature/persistent-steward`, based on `8e44738`; the supervisor source checkout was read-only. No commit/push/publish or global settings/auth edits.
- Only pi-goals is loaded after migration. Parent reports that the old standalone supervisor/Intercom global registrations have now been removed; existing workers remain instructed to wait for validated reload/retry guidance before Ready. Old worker code can otherwise launch old `-e` companion arguments. No panes were controlled here.
- Herdr remains the supported host. Automated tests mock its exec adapter; live navigation/rendering/recovery and measured token savings are not established by this change.
- Native fork/compaction, SUPERVISOR.md precedence, incremental VCC memory, correlated review/cancellation/recovery and default-on policy remain. The fresh evidence judge stays separate.
- The previous live trial's apparently stale unresolved-write `done` failure remains pre-existing and undiagnosed, as recorded in `docs/reviews/2026-09-07_supervisor-validation.md`. Outstanding-work safeguards were not weakened and cleanup is not claimed fixed.
- Role files avoid cross-role lost updates; concurrent explicit choices within the same role are intentionally last-write-wins. No thinking-level preferences or credentials are stored.
<!-- Implementation and parent-observed validation by Pi. -->
@@ -0,0 +1,39 @@
# Persistent plan steward
Historical snapshot of the replaced checkpoint-review implementation. The user's clarified objective and proposed real-session supervisor workflow are captured in [Plan-aware persistent supervisor (2026-09-07)](2026-09-07_plan-supervisor.md); the current branch implements that newer composition, with validation limitations recorded in its plan.
## Purpose
Add judgement across a plan without weakening the fresh evidence check. The steward checks intent,
trajectory, goal ordering, and scope. `CompleteGoal`'s fresh judge continues to check artifacts.
## Lifecycle
1. The human opts in with `/goals steward on`.
2. Ready forks one non-writing `oracle` through the public `pi-subagents` RPC. Work does not start
until it returns `approve`.
3. The child process exits. Pi-subagents retains its session and run identity; no model or process
remains active between checkpoints.
4. The first `CompleteGoal` call resumes the same child with bounded approved/current contract views
and the proposed goal. Checkbox state is normalized and evidence detail is omitted because the fresh
judge owns it. The steward checks contract fidelity and whether sign-off is timely.
5. `approve` creates a one-use approval bound to the goal and current working-set hash. The next
`CompleteGoal` call consumes it and runs the existing fresh evidence judge.
6. `revise_plan`, `needs_user`, an invalid response, or a failed child never signs off the goal.
## Authority
The steward's contract forbids edits, goal completion, detailed evidence assessment, and answers to
unresolved human choices. The builtin Oracle retains inspection-only bash; pi-goals rejects a review
when pi-subagents reports a file-mutation effect, but this is not an OS sandbox. Structured decisions
are `approve`, `revise_plan`, and `needs_user`. An `approve` carrying drift or unresolved decisions is
downgraded. A separate fresh judge remains the only evidence sign-off path.
## Integration
Pi-goals uses the process-local `subagents:rpc:v1` event API. Initial execution is async `spawn` with
`context: fork`; later checks use `resume`. The current run id and approved working set persist in the
existing `pi-goals-state` session entry. `pi-intercom` is not involved.
The feature is opt-in and has no hard package dependency. If pi-subagents is absent, RPC startup
fails visibly and the plan remains in planning mode until the user retries or turns the steward off.
+73
View File
@@ -0,0 +1,73 @@
# Plan-aware persistent supervisor
Status: user-confirmed intent and package composition, approved 2026-09-07. The composition is implemented in the two feature worktrees. [Final validation and trial instructions](../reviews/2026-09-07_supervisor-validation.md) record passing enabled suites and real Pi/Intercom integration. Live Herdr UAT and token-saving measurements remain open in [the branch plan](../slop/plans/20260907_plan-supervisor-integration.md).
This updates the direction of [the persistent-steward branch](2026-09-03_persistent-plan-steward.md). That earlier document is a historical snapshot of the replaced checkpoint reviewer.
## User's goal
> the idea is that the supervisor agent can use less tokens, and keep track of the overall goals, keeping the work on trck, and using it's judgment and context to steer or sign off.
The worker carries detailed investigation and implementation context. The supervisor retains the overall goals, user intent, decisions, and enough progress information to judge the direction of the work. It spends its tokens on supervision rather than following every working turn.
The intended result is lower supervisory token use while keeping work on track. A succession of approval gates is not the objective. The supervisor should remember earlier reasoning and its own interventions, recognize ineffective approaches, and steer or sign off using that context.
## Proposed workflow
The user proposed:
> make a plan
> fork this agent and compact
> so supervisor has: compacted state, plan, and supervisor prompt
1. Draft the plan with the user.
2. At the planning-to-working handoff, create a separate supervisor session from the planning session's fork. Compact it unless measured context is already at most 20k tokens; unknown size does not justify skipping. The original session remains the worker.
3. Give the supervisor the compacted planning context, actual plan, and supervisor policy from `pi-intercom-supervisor` (including the existing `SUPERVISOR.md` customization route).
4. Send incremental worker updates. Retain supervisor context between checks; inactivity need not consume model tokens.
5. Let the user open either real Pi session or see both together without manually starting and pairing the second session.
The plan and supervisor policy remain available directly after compaction. Summarization should not become the only surviving copy of the user's goals.
## Two check interfaces
Routine supervision and per-goal sign-off ask different questions.
Routine checks are proposed after 50 completed worker model turns, after 60 minutes, or when the worker settles with no outstanding processes or subagents. These are candidate defaults, not measured optimal settings. Overlapping triggers should produce one check. A busy worker making progress may need no instruction; a stopped worker with unfinished work may need a continuation or a human decision.
A goal-completion attempt is a separate explicit request identifying the goal and its evidence. Approving one goal must not end supervision of the remaining plan. The existing fresh evidence judge remains in the branch; removing it has not been approved.
Use registered background-work state where available. The existing supervisor's child-process snapshot is not a complete test for outstanding work. The idle supervisor itself must not prevent the worker from ever being considered settled.
## Context and token use
- Consider native compaction for the initial supervisor fork and later supervisor-history compaction.
- Compare native summary generation with the existing VCC compiler for incremental worker views. Reporting should not require compacting the worker each time.
- Preserve user decisions and supervisory conclusions; avoid repeatedly sending the whole worker transcript.
- Reuse the existing supervisor's retention approach where useful: recent views remain detailed, older views give way to the supervisor's verdicts.
- The user suggested supervisor compaction around 100k tokens to reduce context rot and cost. Interpret this as current context size, subject to the model's limit, rather than cumulative billed tokens. Exact policy remains to be tested.
- Planning/working model persistence was considered earlier; its settings and switching behaviour are not settled.
## Preferences
Use the simplest robust composition of existing packages and supported APIs. Candidates include `pi-intercom-supervisor`, `pi-intercom`, `pi-subagents`, `pi-messenger`, and Herdr. Mentioning a package does not approve adding it as a dependency or merging its whole codebase.
Reuse the supervisor prompt and working supervision behaviour where possible. Avoid an additional orchestration framework, council, or growing collection of mechanical review gates. Routine implementation judgement belongs to the supervisor; material choices requiring the user's knowledge or preferences still come back to the user.
Keep `/goals` subcommands without required `--` prefixes. The feature should be testable in an ordinary Pi session through this branch.
Make worker/supervisor navigation direct. The user finds Fleet hard to parse. A named supervisor pane, easy switching, and a side-by-side view are preferable to requiring navigation through a fleet dashboard. A headless-run inspector is not the same thing as the real supervisor session.
## Approved implementation qualifications
Compose the existing supervisor through narrow APIs, using native Pi sessions and Herdr panes.
Human Ready is the approval: wait for successful bootstrap and pairing, then start work once. The
initial supervisor view may steer but is not a second mandatory plan-approval gate.
A demonstrably small fork (at most 20k tokens) skips the initial compaction call and reports that fact.
Unknown size does not justify skipping; other compaction failures remain visible.
If a recorded pane is missing, locate the supervisor before reopening its saved session. Missing pane
identity is not proof of process exit. Automatic recreation or an additional confirmation UI is
deferred for the branch trial, as approved by the parent during implementation.
<!-- Written by Pi from the user's conversation; integration proposals are not implementation receipts. -->
+2027 -153
View File
File diff suppressed because it is too large Load Diff
+24 -9
View File
@@ -26,20 +26,22 @@
},
"files": [
"src",
"README.md"
"README.md",
"THIRD_PARTY_NOTICES.md"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsc",
"build": "tsc -p tsconfig.build.json",
"prepublishOnly": "npm run lint && npm run typecheck && npm run test",
"test": "vitest run",
"test": "vitest run && npm run test:supervisor",
"test:rpc": "vitest run test/rpc-review.test.ts",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"typecheck": "tsc -p tsconfig.build.json --noEmit",
"lint": "biome check src/ test/",
"lint:fix": "biome check --fix src/ test/"
"lint:fix": "biome check --fix src/ test/",
"test:supervisor": "node --import tsx --test test/internal-supervisor/*.test.ts"
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "^0.84.1",
@@ -47,12 +49,25 @@
"typebox": "^1.3.7",
"typescript": "^5.0.0",
"@biomejs/biome": "^2.4.8",
"vitest": "^4.0.18"
"vitest": "^4.0.18",
"tsx": "^4.20.0"
},
"pi": {
"extensions": [
"./src/index.ts"
"./src/index.ts",
"./node_modules/pi-intercom/index.ts"
],
"image": "https://cdn.jsdelivr.net/gh/wassname/pi-goals@main/media/screenshot.png"
}
"image": "https://cdn.jsdelivr.net/gh/wassname/pi-goals@main/media/screenshot.png",
"skills": [
"./node_modules/pi-intercom/skills"
]
},
"dependencies": {
"@sting8k/pi-vcc": "0.5.0",
"pi-intercom": "0.10.0"
},
"bundledDependencies": [
"pi-intercom",
"@sting8k/pi-vcc"
]
}
+374 -71
View File
@@ -5,15 +5,15 @@
*
* PI: Each /goals call makes a new plan version, `.pi/plan/<session_id>-vN.md`. The selected version
* stays in session state across resume and compaction. Old plans stay on disk but inert, so a new
* conversation cannot silently edit them. `/goals --clear` only disconnects this session; the filename is the arm switch: a session that never ran
* conversation cannot silently edit them. `/goals clear` only disconnects this session; the filename is the arm switch: a session that never ran
* /goals has no active plan, so the widget, injections, and CompleteGoal all stay silent.
*
* The v1 lesson: the parser existed so TypeScript could read the plan, but almost every reader is a
* model. So v2 has NO parser and no schema. The harness does exactly three things for a
* cooperative-but-confused model:
* 1. memory — a transient re-send of the plan, never persisted, on two triggers: the plan went
* stale for STALE_TURNS turns (send the working set above ## Log), or the session
* started / compacted (send the whole file, appendix included). v2 sent the whole
* 1. memory — a saved extension message on two triggers: the plan went stale for STALE_TURNS
* turns (send the working set above ## Log), or the session started / compacted
* (send the whole file, appendix included). v2 sent the whole
* file every turn; pi-tasks tried that and deleted it as "wallpaper noise that
* trains the model to ignore the task block" (tintinweb/pi-tasks CHANGELOG.md:149),
* and the always-present CompleteGoal description carries the contract instead.
@@ -38,16 +38,33 @@
*/
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { completeGoalDescription, completeGoalParamDescription, judgeSystem, judgeUser, planDrafting, planningState, reminder, resync } from "./prompts.js";
import supervise from "./internal/supervisor/index.js";
import {
alignmentPolicy,
completeGoalDescription,
completeGoalParamDescription,
discussPlan,
judgeSystem,
judgeUser,
planDrafting,
planningState,
reminder,
resync,
waivesAlignment,
} from "./prompts.js";
import { RoleModels } from "./role-models.js";
import { focusSupervisor, initializeSupervisor, planHash, type SupervisorBinding, type SupervisorDecision, startSupervisor, supervisorBootstrap, supervisorRequest } from "./supervisor.js";
const STATE = "pi-goals-state";
const STATUS_KEY = "pi-goals";
const WIDGET_KEY = "pi-goals-widget";
const PLANNING_CONTEXT = "pi-goals-planning-context";
const PLAN_REMINDER = "pi-goals-plan-reminder";
const PLAN_DIR = ".pi/plan";
// For static text (the /goals description) where there is no ctx to resolve the session id.
const PLAN_SHAPE = `${PLAN_DIR}/<session_id>-vN.md`;
@@ -116,21 +133,46 @@ export function nextPlanVersion(planNames: string[], sessionId: string): number
return Math.max(0, ...versions) + 1;
}
type Phase = "planning" | "working" | null;
type Phase = "planning" | "starting" | "working" | null;
interface PlanState {
/** Distinguishes explicit preferences from the old opt-in defaults. */
defaultsVersion: 1;
phase: Phase;
reviewRequested: boolean;
questionsWaived: boolean;
/** Ready captured its fork, but worker model recovery is still pending (also across reload). */
modelRecovery: "worker" | null;
/** Optional model ref for the sign-off judge; unset => current session model, else pi's default. */
judgeModel: string | null;
planVersion: number | null;
/** User-enabled interval for continuing active goals after the agent settles. */
/** Interval for continuing active goals when supervision is disabled. */
autoIntervalMs: number | null;
autoPaused: boolean;
/** Real supervisor session, enabled by default and paired through bundled Intercom. */
stewardEnabled: boolean;
supervisor: SupervisorBinding | null;
}
export default function piGoalsExtension(pi: ExtensionAPI): void {
let state: PlanState = { phase: null, judgeModel: null, planVersion: null, autoIntervalMs: null, autoPaused: false };
const models = new RoleModels(pi);
let state: PlanState = {
defaultsVersion: 1,
phase: null,
reviewRequested: false,
questionsWaived: false,
modelRecovery: null,
judgeModel: null,
planVersion: null,
autoIntervalMs: AUTO_DEFAULT_INTERVAL_MS,
autoPaused: false,
stewardEnabled: true,
supervisor: null,
};
let planningContextPending = false;
let supervisorOnly = false;
let operation: AbortController | null = null;
const lifetime = new AbortController();
// The reminder sees only the working set. A repeated Log line must not look like progress.
let turnsStale = 0;
let lastSeenWorkingSet = "";
@@ -140,9 +182,10 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
let autoLastWorkingSet = "";
let autoImmediateUsed = false;
let runStartedBackgroundWork = false;
// Set on session start and after a compaction; drained by the next LLM call, which then carries
// the WHOLE file (appendix included) instead of just the working set.
// Drained only after a saved reminder is observed. Auto-compaction skips before_agent_start:
// defer refresh until the next natural prompt rather than injecting unsaved context or a turn.
let resyncReason: string | null = "New session.";
let pendingReminder: { id: string; planning: boolean; reason: string | null } | null = null;
const planRel = (ctx: ExtensionContext) => (state.planVersion === null ? PLAN_SHAPE : `${PLAN_DIR}/${ctx.sessionManager.getSessionId()}-v${state.planVersion}.md`);
const planPath = (ctx: ExtensionContext) => {
@@ -161,6 +204,10 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
pi.appendEntry<PlanState>(STATE, state);
}
function workMessage(ctx: ExtensionContext): string {
return `Work the goals in ${planPath(ctx)}. Pick an open goal, mark it active ([/]), work its subtasks, and when its discriminator is satisfied fill its evidence: list, then call CompleteGoal with the goal's text. Keep the plan file current as you go.`;
}
function clearAutoTimer(): void {
if (autoTimer !== null) clearTimeout(autoTimer);
autoTimer = null;
@@ -171,6 +218,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
}
function scheduleAutoContinue(ctx: ExtensionContext, delayMs = state.autoIntervalMs): void {
if (state.stewardEnabled || supervisorOnly || !models.ready) { clearAutoTimer(); return; }
clearAutoTimer();
if (delayMs === null || state.phase !== "working" || state.autoIntervalMs === null || state.autoPaused || !activeGoals(ctx)) return;
autoTimer = setTimeout(() => {
@@ -216,11 +264,18 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
}
function updateWidget(ctx: ExtensionContext): void {
const tools = pi.getActiveTools().filter(tool => tool !== "RequestPlanReview");
pi.setActiveTools(state.phase === "planning" && !supervisorOnly ? [...tools, "RequestPlanReview"] : tools);
if (state.phase === "planning") {
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", "planning"));
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", state.modelRecovery ? models.ready ? "retry Ready" : "worker model paused" : "planning"));
ctx.ui.setWidget(WIDGET_KEY, ["pi-goals: drafting goals"]);
return;
}
if (state.phase === "starting") {
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", "starting supervisor"));
ctx.ui.setWidget(WIDGET_KEY, ["pi-goals: starting the supervisor session"]);
return;
}
const goals = scanGoals(readPlan(ctx));
if (goals.length === 0) {
ctx.ui.setStatus(STATUS_KEY, undefined);
@@ -229,7 +284,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
}
const done = goals.filter((g) => g.status === "done").length;
const auto = state.autoPaused ? " · waiting for user" : state.autoIntervalMs === null ? "" : ` · auto ${state.autoIntervalMs / 60_000}m`;
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `${done}/${goals.length} goals${auto}`));
const steward = state.stewardEnabled ? " · supervisor" : "";
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `${done}/${goals.length} goals${auto}${steward}`));
const mark: Record<GoalStatus, string> = { done: "✔", active: "▸", open: "◻", cancelled: "✗" };
// Only live goals get lines so finished work never pushes current work off screen. The active
// goal also shows its open subtasks: this file is the task list, so the widget is the task list.
@@ -244,27 +300,120 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
ctx.ui.setWidget(WIDGET_KEY, lines);
}
// --- /goals: enter plan mode (or clear / set judge) --------------------------------------------
async function stopSupervisor(ctx: ExtensionContext): Promise<void> {
operation?.abort();
operation = null;
if (state.supervisor) {
try { await supervisorRequest(pi, "stop", { bindingId: state.supervisor.id }); }
catch (error) { ctx.ui.notify(`Could not reach the supervisor to stop it: ${String(error)}. Check its pane.`, "warning"); }
}
state = { ...state, supervisor: null };
}
async function startPlanSupervisor(ctx: ExtensionContext): Promise<void> {
if (operation) return;
const controller = new AbortController();
operation = controller;
const signal = AbortSignal.any([controller.signal, lifetime.signal]);
const version = state.planVersion;
const approvedDraft = planHash(readPlan(ctx));
const handoff = workMessage(ctx);
const recoveringWorker = state.modelRecovery === "worker";
state = { ...state, phase: "starting" };
persist(); updateWidget(ctx);
try {
if (recoveringWorker) {
const workerReady = await models.enter("worker", ctx);
if (signal.aborted || state.planVersion !== version || !state.stewardEnabled) return;
if (planHash(readPlan(ctx)) !== approvedDraft) throw new Error("The plan changed during model recovery; select Ready again");
if (!workerReady) { state = { ...state, phase: "planning" }; persist(); updateWidget(ctx); return; }
}
const binding = await startSupervisor(pi, ctx, planPath(ctx), state.supervisor, supervisor => {
if (signal.aborted) return;
state = { ...state, supervisor }; persist();
}, signal);
if (signal.aborted || state.planVersion !== version || !state.stewardEnabled) return;
if (planHash(readPlan(ctx)) !== approvedDraft) { await supervisorRequest(pi, "stop", { bindingId: binding.id }); state = { ...state, supervisor: null }; throw new Error("The plan changed during initialization; select Ready again"); }
state = { ...state, supervisor: binding };
persist(); updateWidget(ctx);
state = { ...state, modelRecovery: "worker" }; persist();
const workerReady = await models.enter("worker", ctx);
if (signal.aborted || state.planVersion !== version || !state.stewardEnabled) return;
if (!workerReady) {
state = { ...state, phase: "planning" }; persist(); updateWidget(ctx);
return; // Keep the attached pairing inactive and the preference target on worker.
}
if (signal.aborted || state.planVersion !== version || state.supervisor?.id !== binding.id || !state.stewardEnabled) return;
if (planHash(readPlan(ctx)) !== approvedDraft) { await supervisorRequest(pi, "stop", { bindingId: binding.id }); state = { ...state, supervisor: null }; throw new Error("The plan changed during model restoration; select Ready again"); }
state = { ...state, modelRecovery: null }; persist();
await supervisorRequest(pi, "activate", { bindingId: binding.id }, signal);
if (signal.aborted || state.planVersion !== version || state.supervisor?.id !== binding.id || !state.stewardEnabled) return;
if (planHash(readPlan(ctx)) !== approvedDraft) { await supervisorRequest(pi, "stop", { bindingId: binding.id }); state = { ...state, supervisor: null }; throw new Error("The plan changed during activation; select Ready again"); }
state = { ...state, phase: "working" };
persist(); updateWidget(ctx);
pi.sendUserMessage(handoff, { deliverAs: "followUp" });
} catch (error) {
if (signal.aborted) return;
state = { ...state, phase: "planning", modelRecovery: null }; persist(); updateWidget(ctx);
ctx.ui.notify(`Could not initialize the supervisor: ${String(error)}. Use /goals supervisor to inspect startup, or /goals steward off and retry Ready.`, "error");
} finally {
if (!lifetime.signal.aborted && state.phase !== "working" && !state.modelRecovery) {
await models.enter("planning", ctx);
if (!state.phase) models.leave();
}
if (operation === controller) operation = null;
}
}
// --- /goals: enter plan mode (or clear / set judge / set steward) -------------------------------
pi.registerCommand("goals", {
description: `Plan mode: draft goals into ${PLAN_SHAPE}, review, then work them. /goals <objective> | /goals --clear (disconnect) | /goals --auto [minutes|off] | /goals --judge <model>`,
description: `Plan mode: draft goals into ${PLAN_SHAPE}, review, then work them. /goals plan <objective> | /goals model current | /goals supervisor | /goals worker | /goals zoom | /goals <objective> | /goals clear | /goals auto [minutes|off] | /goals judge <model> | /goals steward [on|off|status]`,
handler: async (args, ctx) => {
const arg = args.trim();
if (arg === "--clear") {
if (args.trim() === "model current") {
if (await models.useCurrent(ctx)) modelRecovered(ctx);
return;
}
if (supervisorOnly) {
const bootstrap = supervisorBootstrap(ctx)!;
if (["worker", "supervisor", "zoom"].includes(args.trim())) await focusSupervisor(pi, bootstrap.binding, args.trim() as "worker" | "supervisor" | "zoom");
else ctx.ui.notify("This is the supervisor session. Use /goals worker to return to the plan's worker.", "info");
return;
}
const explicitPlan = args.trim() === "plan" || args.trim().startsWith("plan ");
const arg = explicitPlan ? args.trim().slice(4).trim() : args.trim();
if (!explicitPlan && ["supervisor", "worker", "zoom"].includes(arg)) {
try {
if (!state.supervisor) throw new Error("Select Ready with the steward enabled first");
await focusSupervisor(pi, state.supervisor, arg as "supervisor" | "worker" | "zoom");
} catch (error) { ctx.ui.notify(String(error), "warning"); }
return;
}
if (!explicitPlan && (arg === "clear" || arg === "--clear")) {
if (state.planVersion === null) {
ctx.ui.notify("No active plan to disconnect.", "info");
return;
}
const currentPlan = planRel(ctx);
await stopSupervisor(ctx);
clearAutoTimer();
state = { ...state, phase: null, planVersion: null, autoIntervalMs: null, autoPaused: false };
state = {
...state,
phase: null,
modelRecovery: null,
planVersion: null,
autoPaused: false,
supervisor: null,
};
persist();
updateWidget(ctx);
models.leave();
ctx.ui.notify(`Disconnected from ${currentPlan}; the file remains on disk.`, "info");
return;
}
if (arg === "--auto" || arg.startsWith("--auto ")) {
const value = arg.slice("--auto".length).trim();
if (!explicitPlan && (arg === "auto" || arg.startsWith("auto ") || arg === "--auto" || arg.startsWith("--auto "))) {
const command = arg.startsWith("--") ? "--auto" : "auto";
const value = arg.slice(command.length).trim();
if (value === "off") {
clearAutoTimer();
state = { ...state, autoIntervalMs: null, autoPaused: false };
@@ -279,7 +428,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
}
const minutes = value ? Number(value) : AUTO_DEFAULT_INTERVAL_MS / 60_000;
if (!Number.isInteger(minutes) || minutes < 1) {
ctx.ui.notify("Use /goals --auto [whole minutes], or /goals --auto off.", "warning");
ctx.ui.notify("Use /goals auto [whole minutes], or /goals auto off.", "warning");
return;
}
autoWakeInFlight = false;
@@ -292,16 +441,59 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
ctx.ui.notify(`Goal auto-continue enabled every ${minutes}m.`, "info");
return;
}
if (arg === "--judge" || arg.startsWith("--judge ")) {
const ref = arg.slice("--judge".length).trim();
if (!explicitPlan && (arg === "steward" || arg.startsWith("steward "))) {
const value = arg.slice("steward".length).trim() || "status";
if (value === "status") {
try {
const status = await supervisorRequest<{ connected: boolean }>(pi, "status");
ctx.ui.notify(`Plan supervisor: ${!state.stewardEnabled ? "disabled" : status.connected ? "connected" : "enabled, not connected; starts at Ready"}.`, "info");
} catch (error) { ctx.ui.notify(`Plan supervisor: ${state.stewardEnabled ? "enabled" : "disabled"}; ${String(error)}`, "warning"); }
return;
}
if (value !== "on" && value !== "off") {
ctx.ui.notify("Use /goals steward on, off, or status.", "warning");
return;
}
if (value === "on" && state.phase === "working" && !state.supervisor) {
ctx.ui.notify("Enable the persistent steward before selecting Ready so it can retain the planning context.", "warning");
return;
}
if (value === "off") await stopSupervisor(ctx);
state = {
...state,
stewardEnabled: value === "on",
...(value === "off" ? {
phase: state.phase === "starting" ? "planning" : state.phase,
supervisor: null,
} : {}),
};
persist();
updateWidget(ctx);
ctx.ui.notify(`Persistent plan steward ${value === "on" ? "enabled" : "disabled"}.`, "info");
return;
}
if (!explicitPlan && (arg === "judge" || arg.startsWith("judge ") || arg === "--judge" || arg.startsWith("--judge "))) {
const command = arg.startsWith("--") ? "--judge" : "judge";
const ref = arg.slice(command.length).trim();
state = { ...state, judgeModel: ref || null };
persist();
ctx.ui.notify(ref ? `Sign-off judge model set to ${ref}` : "Sign-off judge reset to the session model", "info");
return;
}
state = { ...state, phase: "planning", planVersion: nextVersion(ctx) };
await stopSupervisor(ctx);
if (!await models.enter("planning", ctx)) return;
state = {
...state,
phase: "planning",
modelRecovery: null,
reviewRequested: false,
questionsWaived: waivesAlignment(arg),
planVersion: nextVersion(ctx),
supervisor: null,
};
planningContextPending = true;
resyncReason = null;
pendingReminder = null;
writePlan(ctx, "");
persist();
updateWidget(ctx);
@@ -309,25 +501,19 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
// why plan mode read as never-ending: every reply re-armed it. They come back only on a
// resync (session start / compaction), when the model has genuinely lost them.
const seed = arg
? `We're in plan mode. Objective: ${arg}\n\n${planDrafting}\n\nWrite the plan to ${planPath(ctx)}.`
: `We're in plan mode. Tell me what you want to plan.\n\n${planDrafting}\n\nWrite the plan to ${planPath(ctx)}.`;
? `We're in plan mode. Objective: ${arg}\n\n${planDrafting}\n\n${alignmentPolicy(state.questionsWaived)}\n\nWrite the plan to ${planPath(ctx)}.`
: `We're in plan mode. Tell me what you want to plan.\n\n${planDrafting}\n\n${alignmentPolicy(state.questionsWaived)}\n\nWrite the plan to ${planPath(ctx)}.`;
pi.sendUserMessage(seed, { deliverAs: "followUp" });
},
});
// --- hooks --------------------------------------------------------------------------------------
/** What this LLM call should carry, if anything: a one-shot resync, or a staleness reminder. */
function dueInjection(ctx: ExtensionContext, plan: string): string | null {
const drainResync = (): string | null => {
const why = resyncReason;
resyncReason = null;
return why;
};
if (state.phase === "planning") return null;
/** What the next saved reminder should carry: a one-shot resync, or a staleness reminder. */
function dueReminder(ctx: ExtensionContext, plan: string): string | null {
if (state.phase === "planning" || state.phase === "starting") return null;
if (!plan.trim()) return null;
const why = drainResync();
if (why) return resync(plan, planRel(ctx), why);
if (resyncReason) return resync(plan, planRel(ctx), resyncReason);
if (turnsStale < STALE_TURNS) return null;
const goals = scanGoals(plan);
if (goals.length === 0) {
@@ -339,29 +525,47 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
return reminder(foldPlan(plan), planRel(ctx));
}
// The phase snapshot enters context only when planning starts or context was lost.
function confirmReminder(ctx: ExtensionContext): void {
if (!pendingReminder) return;
const id = pendingReminder.id;
const saved = ctx.sessionManager.getBranch().some(entry => entry.type === "custom_message" && (entry.details as { reminderId?: string } | undefined)?.reminderId === id);
if (!saved) return;
if (pendingReminder.planning) planningContextPending = false;
else {
if (resyncReason === pendingReminder.reason) resyncReason = null;
turnsStale = 0;
}
pendingReminder = null;
}
// Only call at before_agent_start: Pi persists the returned message before model context.
// If an earlier delivery failed, retain the due state and retry with fresh file text.
function reminderMessage(ctx: ExtensionContext) {
confirmReminder(ctx);
const planning = state.phase === "planning" || state.phase === "starting";
const content = planning ? planningContextPending ? planningState(planPath(ctx), state.questionsWaived) : null : dueReminder(ctx, readPlan(ctx));
if (!content) return;
const id = randomUUID();
pendingReminder = { id, planning, reason: resyncReason };
return { customType: planning ? PLANNING_CONTEXT : PLAN_REMINDER, content, display: false, details: { reminderId: id } };
}
pi.on("before_agent_start", async (_event, ctx) => {
if (state.phase !== "planning" || !planningContextPending) return;
planningContextPending = false;
return { message: { customType: PLANNING_CONTEXT, content: planningState(planPath(ctx)), display: false } };
const message = reminderMessage(ctx);
if (message) return { message };
});
// PI: Working turns never see an obsolete planning snapshot. Auto-compaction retries skip
// before_agent_start, so context restores the planning snapshot exactly once in that path.
// PI: Working turns never see an obsolete planning snapshot. Never add ephemeral messages:
// provider replay must see exactly the reminders saved in the session, including after compact.
pi.on("context", async (event, ctx) => {
const messages = state.phase === "planning" ? event.messages : event.messages.filter((message) => (message as { customType?: string }).customType !== PLANNING_CONTEXT);
if (state.phase === "planning" && planningContextPending) {
planningContextPending = false;
return { messages: [...messages, { role: "user" as const, content: [{ type: "text" as const, text: planningState(planPath(ctx)) }], timestamp: Date.now() }] };
}
const text = dueInjection(ctx, readPlan(ctx));
if (!text) return messages === event.messages ? undefined : { messages };
turnsStale = 0;
return { messages: [...messages, { role: "user" as const, content: [{ type: "text" as const, text }], timestamp: Date.now() }] };
confirmReminder(ctx);
if (state.phase === "planning" || state.phase === "starting") return;
return { messages: event.messages.filter((message) => (message as { customType?: string }).customType !== PLANNING_CONTEXT) };
});
// PI: Human plan-mode replies are durable evidence of the interview, not model summaries.
pi.on("input", async (event, ctx) => {
if (!models.ready) { ctx.ui.notify("Role model unavailable. Select a different model with /model, explicitly use the current one with /goals model current, or configure the saved model and reload.", "error"); return { action: "handled" as const }; }
if (event.source !== "extension") {
clearAutoTimer();
autoImmediateUsed = false;
@@ -371,17 +575,19 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
updateWidget(ctx);
}
}
if (state.phase === "planning" && event.source !== "extension") writePlan(ctx, appendInterview(readPlan(ctx), event.text));
if ((state.phase === "planning" || state.phase === "starting") && event.source !== "extension") writePlan(ctx, appendInterview(readPlan(ctx), event.text));
});
// The staleness clock sees only the working set. Log updates are durable evidence, not progress.
pi.on("turn_end", async (_event, ctx) => {
confirmReminder(ctx);
const workingSet = foldPlan(readPlan(ctx));
if (workingSet === lastSeenWorkingSet) {
turnsStale++;
return;
}
lastSeenWorkingSet = workingSet;
if (state.supervisor && state.phase === "working") void supervisorRequest(pi, "update", { bindingId: state.supervisor.id }).catch((error: Error) => ctx.ui.notify(error.message, "warning"));
turnsStale = 0;
updateWidget(ctx);
});
@@ -394,7 +600,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
if (state.phase === "working" && (event.toolName === "subagent" || (event.toolName === "process" && (event.input as { action?: string }).action === "start"))) {
runStartedBackgroundWork = true;
}
if (state.phase !== "planning") return;
if (!models.ready) return { block: true, reason: "Role model unavailable; select with /model before continuing." };
if (state.phase !== "planning" && state.phase !== "starting") return;
if (PLAN_MODE_BLOCKED_TOOLS.includes(event.toolName)) {
const target = (event.input as { path?: string }).path;
if (target && resolve(ctx.cwd, target) === resolve(planPath(ctx))) return;
@@ -406,18 +613,25 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
});
// A compaction loses context, so restore either the planning snapshot or the working plan once.
pi.on("session_compact", async () => {
if (state.phase === "planning") planningContextPending = true;
pi.on("session_compact", async (_event, ctx) => {
confirmReminder(ctx);
if (state.phase === "planning" || state.phase === "starting") planningContextPending = true;
else resyncReason = "The session was just compacted.";
});
// PI: Print after Pi settles. agent_end is still streaming, so its message queues behind the menu.
pi.on("agent_settled", async (_event, ctx) => {
let reviewOpen = false;
async function reviewPlan(ctx: ExtensionContext): Promise<void> {
if (reviewOpen || lifetime.signal.aborted) return;
reviewOpen = true;
try { await offerPlanReview(ctx); } finally { reviewOpen = false; }
}
async function offerPlanReview(ctx: ExtensionContext): Promise<void> {
if (state.phase === "working") {
settleAuto(ctx);
return;
}
if (state.phase !== "planning" || !ctx.hasUI) return;
if (state.phase !== "planning" || !state.reviewRequested || !ctx.hasUI) return;
let printed = "";
while (true) {
const plan = readPlan(ctx);
@@ -429,13 +643,13 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
printed = plan;
pi.sendMessage({ customType: "plan", content: plan, display: true });
}
const choice = await ctx.ui.select(`Plan drafted in ${planRel(ctx)}.`, ["Ready", "Refine", "Edit", "Cancel"]);
if (choice === "Refine") {
const notes = await ctx.ui.editor("What should change about the plan?", "");
if (!notes?.trim()) continue;
writePlan(ctx, appendInterview(plan, notes));
const choice = await ctx.ui.select(`Plan drafted in ${planRel(ctx)}.`, ["Ready", "Discuss", "Edit", "Cancel"]);
if (choice === "Discuss" || choice === undefined) {
if (state.modelRecovery && !await models.enter("planning", ctx)) return;
state = { ...state, modelRecovery: null };
state = { ...state, reviewRequested: false }; persist();
planningContextPending = true;
pi.sendUserMessage(`Revise the plan at ${planPath(ctx)} using these human notes:\n\n${notes}\n\nKeep the same goal structure.`, { deliverAs: "followUp" });
pi.sendUserMessage(discussPlan, { deliverAs: "followUp" });
return;
}
if (choice === "Edit") {
@@ -444,46 +658,104 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
continue;
}
if (choice === "Cancel") {
await stopSupervisor(ctx);
rmSync(planPath(ctx), { force: true });
state = { ...state, phase: null, planVersion: null };
state = {
...state,
phase: null,
modelRecovery: null,
planVersion: null,
supervisor: null,
};
persist();
updateWidget(ctx);
models.leave();
ctx.ui.notify("Plan discarded.", "info");
return;
}
if (choice !== "Ready") return;
state = { ...state, phase: "working" };
if (state.stewardEnabled) {
await startPlanSupervisor(ctx);
return;
}
const version = state.planVersion;
const approvedDraft = planHash(readPlan(ctx));
state = { ...state, modelRecovery: "worker" }; persist();
const workerReady = await models.enter("worker", ctx);
if (lifetime.signal.aborted) return;
if (state.phase !== "planning" || state.planVersion !== version || planHash(readPlan(ctx)) !== approvedDraft) {
await models.enter("planning", ctx);
if (!state.phase) models.leave();
ctx.ui.notify("Plan changed while restoring the worker model; review it again before Ready.", "warning");
return;
}
if (!workerReady) return;
state = { ...state, phase: "working", modelRecovery: null };
persist();
updateWidget(ctx);
pi.sendUserMessage(`Work the goals in ${planPath(ctx)}. Pick an open goal, mark it active ([/]), work its subtasks, and when its discriminator is satisfied fill its evidence: list, then call CompleteGoal with the goal's text. Keep the plan file current as you go.`, { deliverAs: "followUp" });
pi.sendUserMessage(workMessage(ctx), { deliverAs: "followUp" });
return;
}
});
}
pi.on("agent_settled", async (_event, ctx) => reviewPlan(ctx));
pi.on("session_start", async (_event, ctx) => {
const bootstrap = supervisorBootstrap(ctx);
if (bootstrap) {
supervisorOnly = true;
pi.setActiveTools(pi.getActiveTools().filter(tool => tool !== "CompleteGoal" && tool !== "RequestPlanReview"));
if (await models.enter("supervisor", ctx)) initializeSupervisor(pi, ctx, bootstrap, lifetime.signal);
return;
}
const last = ctx.sessionManager
.getEntries()
.filter((e: { type?: string; customType?: string }) => e.type === "custom" && e.customType === STATE)
.pop() as { data?: PlanState } | undefined;
// Upgrade cleared/unused legacy sessions, but never attach supervision mid-plan.
const saved = last?.data;
const useNewDefaults = saved?.defaultsVersion !== 1 && saved?.planVersion == null;
state = {
phase: last?.data?.phase ?? null,
defaultsVersion: 1,
phase: last?.data?.phase === "working" ? "working" : last?.data?.phase ? "planning" : null,
reviewRequested: last?.data?.reviewRequested ?? true,
questionsWaived: last?.data?.questionsWaived ?? false,
modelRecovery: last?.data?.modelRecovery ?? null,
judgeModel: last?.data?.judgeModel ?? null,
planVersion: last?.data?.planVersion ?? null,
autoIntervalMs: last?.data?.autoIntervalMs ?? null,
autoPaused: last?.data?.autoPaused ?? false,
autoIntervalMs: useNewDefaults || saved?.autoIntervalMs === undefined ? AUTO_DEFAULT_INTERVAL_MS : saved.autoIntervalMs,
autoPaused: useNewDefaults ? false : saved?.autoPaused ?? false,
stewardEnabled: useNewDefaults ? true : saved?.stewardEnabled ?? true,
supervisor: last?.data?.supervisor ?? null,
};
lastSeenWorkingSet = foldPlan(readPlan(ctx));
autoLastWorkingSet = lastSeenWorkingSet;
planningContextPending = state.phase === "planning";
planningContextPending = state.phase === "planning" || state.phase === "starting";
resyncReason = state.phase === "working" ? "New session." : null;
pendingReminder = null;
updateWidget(ctx);
if (state.phase && !await models.enter(state.phase === "working" || state.modelRecovery ? "worker" : "planning", ctx)) return;
scheduleAutoContinue(ctx);
});
pi.on("session_shutdown", async () => {
lifetime.abort();
operation?.abort();
clearAutoTimer();
});
pi.registerTool({
name: "RequestPlanReview",
label: "Review plan",
description: "Planning only: after task-specific alignment questions have been answered (or explicitly waived for this objective), and the final plan is ready, show the human Ready / Discuss / Edit / Cancel. Do not call while waiting for answers. Call again after discussion is finished, even for an unchanged draft. This does not approve or start work.",
parameters: Type.Object({}),
async execute(_id, _params, _signal, _update, ctx) {
if (supervisorOnly || state.phase !== "planning") return result("Only a planning session can request plan review.", true);
if (!scanGoals(readPlan(ctx)).length) return result("Draft concrete goals before requesting review.", true);
state = { ...state, reviewRequested: true }; persist();
return { ...result("Plan review requested. End this response and wait for the human's choice."), terminate: true };
},
});
// --- the one blessed tool: CompleteGoal ---------------------------------------------------------
pi.registerTool({
@@ -494,10 +766,26 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
goal: Type.String({ description: completeGoalParamDescription }),
}),
async execute(_id, params, signal, onUpdate, ctx) {
if (state.phase === "planning") return result("Planning is not approved. Choose Ready before signing off a goal.", true);
if (state.phase === "planning" || state.phase === "starting") return result("Planning is not approved. Wait for the steward or choose Ready before signing off a goal.", true);
const plan = readPlan(ctx);
if (!plan.trim()) return result(`No plan file at ${planRel(ctx)}. Run /goals to draft one.`, true);
if (supervisorOnly) throw new Error("Only the worker can complete its plan goals");
if (state.stewardEnabled) {
if (!state.supervisor) return result("No supervisor is paired. Retry Ready or use /goals steward off.", true);
const bindingId = state.supervisor.id;
const hash = planHash(plan);
try {
onUpdate?.({ content: [{ type: "text", text: "Supervisor checking trajectory and scope…" }], details: {} });
const decision = await supervisorRequest<SupervisorDecision>(pi, "review", { bindingId, goal: params.goal, planHash: hash }, AbortSignal.any([lifetime.signal, ...(signal ? [signal] : [])]));
if (state.supervisor?.id !== bindingId || planHash(readPlan(ctx)) !== hash || decision.bindingId !== bindingId || decision.goal !== params.goal || decision.planHash !== hash) return result("Plan or pairing changed during goal review; retry.", true);
if (decision.decision !== "approve") return result(`Supervisor: ${decision.decision}. ${decision.reason}`, true);
} catch (error) { return result(`Supervisor review failed: ${String(error)}`, true); }
}
const reviewedPlanHash = planHash(plan);
const reviewedVersion = state.planVersion;
const reviewedPairing = state.supervisor?.id;
const judgeModel = state.judgeModel ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null);
onUpdate?.({ content: [{ type: "text", text: `Read-only judge (${judgeModel ?? "pi default"}) inspecting: ${params.goal}` }], details: {} });
// decideSignOff runs the judge and derives the outcome + the one log line. judgeModel is never
@@ -518,6 +806,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
writeFileSync(join(ctx.cwd, rel), `goal: ${params.goal}\nmodel: ${judgeModel ?? "pi default"}\nerror: ${raw.error ?? "none"}\n\n${raw.output}\n`);
transcriptNote = ` (${rel})`;
}
if (state.planVersion !== reviewedVersion || state.supervisor?.id !== reviewedPairing || planHash(readPlan(ctx)) !== reviewedPlanHash) return result("The plan or supervisor changed during evidence review; no goal was signed off. Retry.", true);
if (outcome.logEntry) {
// Sign-off write: tick the goal [x] (exact-subject match; dogfood showed agent bookkeeping
// is the drift point) and append the audit log line, one write. On wording drift the tick
@@ -538,6 +827,20 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
return result(outcome.resultText, outcome.isError);
},
});
// Registered after role restoration, so rejoin cannot start a supervisor turn on the worker model.
supervise(pi, () => models.ready);
function modelRecovered(ctx: ExtensionContext): void {
if (supervisorOnly) {
const bootstrap = supervisorBootstrap(ctx);
if (bootstrap) initializeSupervisor(pi, ctx, bootstrap, lifetime.signal);
} else if (state.modelRecovery && models.ready) {
// Do not await a UI dialog inside Pi's model_select dispatch.
setImmediate(() => { void reviewPlan(ctx).catch(error => ctx.ui.notify(String(error), "error")); });
}
}
pi.on("model_select", (event, ctx) => {
if (models.ready && !models.restoring && event.source !== "restore") modelRecovered(ctx);
});
}
// --- helpers (module scope) --------------------------------------------------------------------
+31
View File
@@ -0,0 +1,31 @@
import { randomUUID } from "node:crypto";
export interface BackgroundState { quiet: boolean; description: string }
/** Public process-local protocols only. Missing owners remain explicitly unknown. */
export async function backgroundState(pi: any): Promise<BackgroundState> {
let processes: unknown;
pi.events.emit("processes:request:list", { reply: (value: unknown) => { processes = value; } });
const rows = Array.isArray(processes) ? processes : null;
const processKnown = rows?.every(p => p && ["running", "terminating", "terminate_timeout", "exited", "killed"].includes(p.status));
const activeProcesses = processKnown ? rows!.filter(p => !["exited", "killed"].includes(p.status)).length : null;
let activeSubagents: number | null = null;
if (pi.getAllTools?.().some((tool: any) => tool.name === "subagent")) {
const requestId = randomUUID();
activeSubagents = await new Promise<number | null>(resolve => {
let unsubscribe: unknown;
const finish = (value: number | null) => { clearTimeout(timer); if (typeof unsubscribe === "function") unsubscribe(); resolve(value); };
const timer = setTimeout(() => finish(null), 2_000);
unsubscribe = pi.events.on(`subagents:rpc:v1:reply:${requestId}`, (reply: any) => {
if (reply?.requestId !== requestId) return;
const count = reply?.success && reply?.data?.fleet?.version === 1 ? reply.data.fleet.totalActive : undefined;
finish(Number.isSafeInteger(count) && count >= 0 ? count : null);
});
pi.events.emit("subagents:rpc:v1:request", { version: 1, requestId, method: "status", params: {}, source: { extension: "pi-supervise" } });
});
}
return {
quiet: activeProcesses === 0 && activeSubagents === 0,
description: `processes: ${activeProcesses ?? "unknown (provider unavailable)"}; subagents: ${activeSubagents ?? "unknown (provider unavailable)"}; unregistered detached work is not tracked`,
};
}
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
import { createHash, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
/** Process-local integration; Intercom carries the corresponding peer messages. */
export const PLAN_API_EVENT = "pi-supervise:plan:v1";
export interface PlanBinding {
id: string;
planPath: string;
workerSession: string;
workerPane: string;
supervisorPane?: string;
supervisorSession?: string;
active?: boolean;
stopped?: boolean;
everyTurns: number;
intervalMs: number;
compactTokens: number;
}
export interface GoalReview {
requestId: string;
bindingId: string;
goal: string;
planHash: string;
}
export interface GoalDecision extends GoalReview {
decision: "approve" | "needs_work" | "needs_user";
reason: string;
}
export type PlanWire =
| { t: "plan_hello" | "plan_hello_ack"; to: string; bindingId: string; role: "worker" | "supervisor"; sessionFile: string }
| ({ t: "goal_review"; to: string } & GoalReview)
| ({ t: "goal_decision"; to: string } & GoalDecision)
| { t: "goal_cancel"; to: string; requestId: string; bindingId: string }
| { t: "plan_update"; to: string; bindingId: string }
| { t: "plan_activate" | "plan_stop"; to: string; bindingId: string };
export interface PlanApiRequest {
version: 1;
method: "prepare" | "bootstrap" | "attached" | "activate" | "status" | "review" | "update" | "stop";
binding?: PlanBinding;
bindingId?: string;
workerId?: string;
goal?: string;
planHash?: string;
signal?: AbortSignal;
handled?: boolean;
resolve(value: unknown): void;
reject(error: Error): void;
}
export function validBinding(value: unknown): value is PlanBinding {
if (!value || typeof value !== "object") return false;
const b = value as PlanBinding;
return [b.id, b.planPath, b.workerSession, b.workerPane].every(v => typeof v === "string" && v.length > 0)
&& [b.everyTurns, b.intervalMs, b.compactTokens].every(v => Number.isSafeInteger(v) && v > 0);
}
export function planText(binding: PlanBinding): string {
return readFileSync(binding.planPath, "utf8");
}
export function planHash(text: string): string {
return createHash("sha256").update(text).digest("hex");
}
export function validPlanWire(value: any): value is PlanWire {
if (!value || typeof value.to !== "string" || typeof value.bindingId !== "string") return false;
if (value.t === "plan_hello" || value.t === "plan_hello_ack") return ["worker", "supervisor"].includes(value.role) && typeof value.sessionFile === "string";
if (value.t === "plan_update" || value.t === "plan_activate" || value.t === "plan_stop") return true;
if (typeof value.requestId !== "string") return false;
if (value.t === "goal_cancel") return true;
if (typeof value.goal !== "string" || typeof value.planHash !== "string") return false;
if (value.t === "goal_review") return true;
return value.t === "goal_decision" && ["approve", "needs_work", "needs_user"].includes(value.decision) && typeof value.reason === "string";
}
/** A wait owns its cancellation and deadline; a timeout never means approval. */
export function pendingReply<T>(signal: AbortSignal | undefined, cancel: () => void, timeoutMs = 600_000) {
let finish!: (value?: T, error?: Error) => void;
const promise = new Promise<T>((resolve, reject) => {
let settled = false;
const abort = () => finish(undefined, new Error("Supervisor request cancelled"));
const timer = setTimeout(() => finish(undefined, new Error("Supervisor request timed out; retry or turn the steward off")), timeoutMs);
finish = (value, error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal?.removeEventListener("abort", abort);
if (error) { cancel(); reject(error); } else resolve(value as T);
};
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) queueMicrotask(abort);
});
return { requestId: randomUUID(), promise, finish };
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Every word the supervisor session reads, in the order it reads them, so this file is the run:
*
* 1. loadSupervisorPrompt, the policy, read once at /supervise
* 2. BRIEF, sent once at pairing, carrying that policy
* 3. TOOL_*, the three verdicts, in context at every model call because tools always are
* 4. REVIEW_NUDGE, sent with every view, and short because 1 to 3 already said the rest
* 5. NO_GOAL and DONE_BLOCKED, refusals, read only when a tool is refused
* 6. DEFAULT_SUPERVISOR_PROMPT, the policy used when no SUPERVISOR.md exists
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
/**
* Same precedence as @monotykamary/pi-supervisor, so an existing SUPERVISOR.md keeps working:
* <cwd>/.pi/SUPERVISOR.md, then <agent dir>/SUPERVISOR.md, then the default below.
*
* getAgentDir is pi's own, so a profile that moves the agent dir moves this with it. That matters:
* the only SUPERVISOR.md on this machine lives in a profile, not in ~/.pi/agent.
*/
export function loadSupervisorPrompt(cwd: string): { prompt: string; source: string } {
for (const path of [join(cwd, ".pi", "SUPERVISOR.md"), join(getAgentDir(), "SUPERVISOR.md")]) {
if (existsSync(path)) return { prompt: readFileSync(path, "utf-8").trim(), source: path };
}
return { prompt: DEFAULT_SUPERVISOR_PROMPT, source: "built-in" };
}
/**
* Put in the supervisor's context when pairing, so it knows its job before the first view arrives.
*
* The last line has been wrong three times. "Reply with exactly: watching" taught a text answer,
* and deepseek-v4-flash then answered two real views with the plain word "wait" and no tool call
* (session 019ff4eb-c66c, 2026-08-12). Ordering a let_it_run call taught the opposite: with no view
* to read, one supervisor answered its own brief 98 times in a row (019ffa5f, 2026-08-13). Asking
* for no answer at all still got two calls, because the brief arrived as a user message and a user
* message is a turn. It now arrives without one, so the view is the first thing there is to answer.
*/
export const BRIEF = (policy: string, goal: string, worker: string) =>
`${policy}
You are now supervising the pi session "${worker}".
The goal is between the tags below, exactly as the human typed it. Nothing outside the tags is
part of the goal. A multi-line goal appears as a one-line locator in ordinary views. Its full text
returns after a goal change, reload, compaction, and before every fifth review.
<goal>
${goal || "not given, so infer it from the first view you receive and call set_goal"}
</goal>
Your verdict is a tool call, not text: let_it_run, steer or done. If the policy above tells you to reply
with JSON, ignore that part: it belongs to a different supervisor and nothing parses it here.
You see the worker twice: when it stops, and on a check in while it is still working. Each view
carries only what is new since your last look, so read it against what you already know rather
than expecting the whole session again.
The view names the worker's model and how full its context is. A small or fast model needs one
small step per instruction. A worker near the top of its context is about to compact, so tell it
to write down what matters before it loses the detail.
There is no round limit and no budget. Supervision runs until the human stops it. Ending early is
the failure this exists to prevent, so never stop because it feels like enough.
The human typing in the worker session is not a handover, and it is not a reason to stand back.
They say a word and go to bed; the worker is then stopped with nobody driving it, which is the
state you exist for. They will stop you themselves when they want you stopped. Judge the worker
against the goal and nothing else.
Every view says how long the worker has gone with no new turn. A worker that has produced nothing
for a long time is stuck, or waiting for you, or in one command that will not return. Say which
one you think it is, and use the number rather than guessing from the turns.
The worker sends its first view as it pairs. It is below this message, and it is what you
answer.`;
/**
* Sent on a resume or a /reload that finds the pairing still alive.
*
* Short on purpose: the policy is already in the transcript above it. Only the answer shape and the
* goal repeat, because those are what a supervisor drops first, and because a /reload is how a
* changed prompt reaches a running session. Without this, fixing the wording of the brief needs
* /supervise stop and a fresh pairing, which throws away the supervisor's memory of its own steers.
*/
export const REANCHOR = (goal: string, rounds: number) =>
`Supervising again, after a reload or a restart.
<goal>
${goal || "not set"}
</goal>
${rounds} instructions so far.
A view of the worker follows. Answer it with one tool call: steer, done or let_it_run. The word on its
own does nothing; only the call reaches the worker.`;
/** Sent when the human runs /supervise goal, so the supervisor does not judge against the old one. */
export const GOAL_CHANGED = (goal: string) =>
`The human changed the goal. From now on judge the worker against what is between the tags,
and against nothing else:
<goal>
${goal}
</goal>
A fresh view follows. Answer it with one tool call: steer, done or let_it_run.`;
/**
* The three verdicts. These live in the tool descriptions, which the API sends at every model call,
* so they are the only instructions here that a supervisor compaction cannot lose.
*/
export const TOOL_LET_IT_RUN =
"Use when the current worker view gives quoted evidence that no instruction is needed."
+ " The call sends no message to the worker. Call it once, then end the current supervisor response."
// Repeated here because a tool description survives a compaction and the brief does not. The
// live failure was a let_it_run reasoned "human is actively directing", two hours before dawn.
+ " A human message does not end supervision; only an explicit stop command ends supervision.";
/**
* How a look ends, and it must appear in every verdict's result.
*
* A tool result reads as a prompt to act again. A turn ends only when the assistant writes text and
* calls no tool, so a result that does not name that exit leaves another tool call as the only move.
* The let_it_run result used to say "Say nothing more until the next view arrives", which forbids the
* exit outright: session 019ffa73 answered with a second let_it_run on all sixteen looks before 11:05Z
* and aborted every one. The steer result said nothing about ending, and cost a spare let_it_run on
* 22 of 22 steers in the fifteen hours after.
*/
export const END_TURN =
`End the current supervisor response now: write one short line or no text, then make no further tool call.`;
export const LET_IT_RUN_ACK = (reason: string, workerStopped = false) =>
`No supervisor instruction was sent for the current worker view. Supervisor-provided reason, not independently verified: ${reason}\n\nThe supervisor has completed its verdict for the current worker view. ${END_TURN}
${workerStopped ? STOPPED_WARNING : "A later worker view starts the next supervisor review."}`;
/**
* Added to the let_it_run result when the view said the worker had stopped.
*
* Session 019ffa73, 2026-08-14: the worker stopped, the supervisor answered let_it_run "waiting for
* the worker to re-queue", and both sat still for two and a half hours. A stopped worker does not
* resume on its own, so letting it run leaves it stopped. The timer now looks again either way, and
* this says why that look will show the same thing.
*/
export const STOPPED_WARNING =
`The current worker view reports that worker execution stopped. A stopped worker does not resume
without a new user or supervisor message. If the goal remains unmet, send a concrete continuation
instruction. A human message does not end supervision. A later worker view will report the worker state.`;
/** The answer to a second let_it_run in one look. Costs a round trip and no error line. */
export const LET_IT_RUN_AGAIN =
`The supervisor already recorded a verdict for the current worker view. This second let_it_run call
sent no instruction. ${END_TURN}`;
/** The answer after a supervisor directive is sent. A repeat warning is appended after it. */
export const STEER_ACK = (round: number, workerId: string) =>
`Supervisor instruction ${round} was sent to worker session ${workerId}. Worker receipt and execution
are not confirmed. The supervisor has completed its verdict for the current worker view. ${END_TURN}`;
export const TOOL_STEER =
"Send one concrete next action to the worker. The extension sends the message to the paired worker session; worker receipt and execution require a later worker view.";
export const TOOL_DONE =
"Declare the goal met and stop supervising. Only call this with quoted evidence from the view.";
/**
* Sent with every view, so it is deliberately short.
*
* What used to be here and is now sent once: the verdict rules (BRIEF, and the tool descriptions,
* which survive a compaction) and the instructions already sent (the supervisor's own steer calls
* are in its context; the steer tool warns about a repeat when it happens). A multi-line goal
* stays as a one-line locator inside the view.
*
* A check in is not a decision point. Interrupting a working agent is expensive and usually wrong,
* so the two triggers ask for different things.
*/
/**
* The two openers, and the test the context pruner uses to find a view it can drop.
*
* They are constants because two things read them: the nudge that writes a view, and the pruner
* that later collapses it. Matching the prose in two places would let them drift silently, and a
* pruner that stops recognising views just quietly stops working.
*/
export const VIEW_STOPPED = "The worker stopped.";
export const VIEW_CHECKIN = "Checking in on the worker, which is still going.";
export const isViewText = (text: string) => text.startsWith(VIEW_STOPPED) || text.startsWith(VIEW_CHECKIN);
/** What an old view is replaced with. Short, and it says where the content went. */
export const VIEW_PRUNED =
"[an earlier view of the worker, dropped once you had judged it. Your verdict on it follows.]";
export const REVIEW_NUDGE = (view: string, rounds: number, stopped: boolean) =>
stopped
? `${VIEW_STOPPED}
${view}
${rounds} instructions so far. The status line says how long it has had no new turn. It will not start
again by itself, and the human being present does not count as somebody driving it. Answer with one
tool call: steer, done or let_it_run. The word on its own does nothing; only the call reaches the
worker.`
: `${VIEW_CHECKIN}
${view}
Call let_it_run unless the view gives concrete evidence that the worker needs an instruction.`;
/** Refusal shown when done is called while the worker still has work running. */
export const DONE_BLOCKED = (what: string) =>
`Cannot finish: the worker still has work running (${what}). Wait for the next view.`;
/** Refusal shown when the supervisor tries to steer with no goal set. */
export const NO_GOAL = `No goal is set, so you must not steer or finish. Inventing a task is worse
than doing nothing. Either call set_goal with the goal you infer from the worker's view, which
tells the human what you chose, or reply in plain text asking them for it. Your reply reaches
their phone.`;
/**
* Default supervisor prompt. A project SUPERVISOR.md overrides it, same as @monotykamary/pi-supervisor.
* Unlike that extension there is no JSON verdict to parse, because the verdict is a tool call.
*/
export const DEFAULT_SUPERVISOR_PROMPT = `You supervise a coding agent from outside its session.
Your job is to make it reach the goal without the human stepping in.
Judge from the view only. You cannot see the worker's files unless you read them yourself.
Call steer when the work is incomplete, when the worker asked a question you can answer with a
sensible default, or when it claims success without evidence. One concrete next action per steer.
Never repeat a steer that had no effect; change the approach instead.
The view line "child pi processes still running" means the worker delegated to a subagent that is
still working. It stopped, the subagent did not. Do not call done, it will be refused. Steer the
worker to wait for that subagent and report what it produced.
The view line "no new file or commit for N reviews in a row" means your last N instructions
moved nothing the worker's session can show. Two or more is your signal to change approach, ask the
human, or check whether the goal is already met. Sometimes it is honest work on one file, so read
the recent turns before you decide.
Call done only when all of these hold:
1. the worker named the artifact file it produced, with a path
2. the worker quoted text from that file, rather than summarising it
3. nothing in the view contradicts the claim
A confident summary is not evidence. When in doubt, steer.
When the worker does machine learning or data research, a wrong result looks exactly like a right
one. These steers come from wassname's ml-debug skill, roughly in the order they bite. Each one is
something you can see in the view.
- It concluded without reading its data. Steer it to paste the lines it read into the chat, a raw
sample and the metric line, not a summary of them. Quoting is the point, twice over: you and the
human can then check the same text, and an agent that has to quote has to look (Karpathy inspects
the data before touching the model; Nanda: read your data, often it is quite bad). A conclusion
with no quoted output, a ranking with no per-item evidence, or "the method failed" with no sample
of what the output looked like, all mean it has not looked.
- It reports a surprising win. Most true results are boring, so an exciting one is more likely to
be false (Neel Nanda). Steer it to rule out a bug, leakage or a broken evaluation first.
- It reports a failure and moves on, or calls the failure a property of the method. Assume a bug:
bugs are far more common, and far cheaper to find, than a real negative result (Andy Jones).
Steer it to write two or three diagnoses, one of them a bug in its own code, put a rough
probability on each, and run the cheapest test that tells them apart. Broken research code fails
silently and still runs, so "it ran" is not evidence that it worked.
- It is about to start another long run without saying what each outcome would mean. Steer it to
write that prediction first (Rahtz: think more, experiment less). On a shared GPU that is the
cheapest hour you can buy.
- It compares two methods from one run each. Seed variance alone splits identical configurations
into different distributions (Henderson), so steer it to say what varies before it ranks
anything.
- It changed two things in one run and credits one of them. Changing anything changes everything
(Sculley et al., CACE). Steer it to say what it can actually attribute, or to rerun with one
change.
- It saw a number it cannot explain and carried on. An anomaly it did not go looking for is the
cheapest bug it will ever find, so steer it to chase that before anything else.
Three more ways work gets faked, from @monotykamary/pi-supervisor's cheating list. Steer, and ask
for the output that would settle it.
- the worker edits a test to weaken an assertion, or skips a failing one, and calls that progress
- it reports a number without the command output it came from, or edits the measurement instead of
the thing being measured
- it runs a smaller dataset or part of the suite, then reports as if it ran the whole thing
Do not answer questions that need real human knowledge: passwords, credentials, spending money,
or a choice between two designs the human cares about. For those, reply in plain text saying what
you need. Your reply reaches the human's phone.`;
+118
View File
@@ -0,0 +1,118 @@
/**
* What the two sessions say to each other over the pi-intercom extension channel.
*
* The channel never enters a transcript and never starts a turn, so each side triggers its own
* turn locally with pi.sendUserMessage after it receives one of these.
*/
import { type PlanBinding, type PlanWire, validBinding, validPlanWire } from "./plan-api.js";
export const NAMESPACE = "wassname/pi-intercom-supervisor/v1";
// No round cap, no budget, on purpose. Supervision runs until the human stops it with
// /supervise stop, because premature stopping is the failure this whole thing exists to prevent
// (wassname's SUPERVISOR.md, citing arXiv:2410.07095: 8.7% vs 0.8% on MLE-bench).
export type Wire = PlanWire
/** Roll call, broadcast, so "to" is the wildcard rather than a session. Only /supervise sends it. */
| { t: "who"; to: "*" }
/** The answer to a roll call: I load this extension, I am free, and I am not a child run. */
| { t: "here"; to: string }
| { t: "pair"; to: string; goal: string; plan?: PlanBinding }
| { t: "paired"; to: string; plan?: PlanBinding }
| { t: "goal"; to: string; goal: string }
/** stopped: the worker settled, so this is a decision point. false: a check in mid-turn. */
| { t: "view"; to: string; view: string; stopped: boolean }
/** Supervisor asks for a view now. Its own turn cannot make one: the worker publishes them. */
| { t: "look"; to: string }
| { t: "directive"; to: string; text: string }
| { t: "done"; to: string; reason: string }
| { t: "unpair"; to: string };
/** Validates the field each kind carries, so a malformed peer cannot inject "[supervisor] undefined". */
export function isWire(payload: unknown): payload is Wire {
if (typeof payload !== "object" || payload === null) return false;
if (validPlanWire(payload)) return true;
const { t, to, goal, view, stopped, text, reason, plan } = payload as Record<string, unknown>;
if ((t === "pair" || t === "paired") && plan !== undefined && !validBinding(plan)) return false;
if (typeof to !== "string") return false;
if (t === "pair" || t === "goal") return typeof goal === "string";
if (t === "view") return typeof view === "string" && typeof stopped === "boolean";
if (t === "directive") return typeof text === "string" && text.trim().length > 0;
if (t === "done") return typeof reason === "string";
return t === "unpair" || t === "paired" || t === "look" || t === "who" || t === "here";
}
/**
* Words two instructions share, over the words either uses. Stopwords and short words dropped.
*
* Six remembered instructions do not stop repetition, because the same order rephrased reads as
* new. This catches the rephrasing that shares vocabulary; it cannot catch a true paraphrase.
*/
export function overlap(a: string, b: string): number {
const words = (s: string) =>
new Set(
s
.toLowerCase()
.split(/[^a-z0-9_./-]+/)
.filter((w) => w.length > 3 && !STOPWORDS.has(w)),
);
const [x, y] = [words(a), words(b)];
if (!x.size || !y.size) return 0;
const shared = [...x].filter((w) => y.has(w)).length;
return shared / (x.size + y.size - shared);
}
const STOPWORDS = new Set([
"then", "with", "that", "this", "from", "into", "your", "each", "have", "then", "should", "please",
"make", "sure", "also", "them", "they", "what", "when", "here", "there", "which", "will", "would",
]);
/**
* Two instructions sharing this much vocabulary get flagged back to the supervisor.
*
* Measured on rewordings of one instruction: about 0.44. On two different instructions: under 0.2.
* A true paraphrase that shares no words scores 0 and slips through, so this is a floor on
* repetition, not a bound.
*/
export const OVERLAP_WARN = 0.4;
export interface SuperviseState {
role: "none" | "worker" | "supervisor";
/** Intercom session ID of the other side. The broker stamps this, so it cannot be forged. */
pairedId: string;
goal: string;
steerRounds: number;
/** Recent steer texts, so the supervisor can see repetition after its own context is compacted. */
recentSteers: string[];
/** Optional pi-goals integration. Standalone supervision keeps its original policy. */
plan?: PlanBinding;
/** Supervisor bootstrap completed and the worker acknowledged this plan pairing. */
planInitialized?: boolean;
}
export const EMPTY_STATE: SuperviseState = {
role: "none",
pairedId: "",
goal: "",
steerRounds: 0,
recentSteers: [],
};
/** How many past steers to keep and show back. Enough to spot a loop, small enough to stay cheap. */
export const STEER_MEMORY = 6;
/** Session entry type used to persist state, so a compaction or reload cannot reset the count. */
export const STATE_ENTRY = "supervise-state";
/** Rebuild state from session entries. The last one written wins. */
export function restoreState(entries: Array<{ type: string; customType?: string; data?: unknown }>): SuperviseState {
let state = EMPTY_STATE;
for (const entry of entries) {
if (entry.type === "custom" && entry.customType === STATE_ENTRY && entry.data) {
// Merge over the defaults so a record written before a field existed still loads.
state = { ...EMPTY_STATE, ...(entry.data as Partial<SuperviseState>) };
}
}
return state;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Child pi processes, so a settled worker with a subagent still running is not called finished.
*
* The in-session check only sees tool calls that never got a result. A subagent spawned as its own
* process leaves no such trace, so the worker settles and the view looks quiet.
*
* This is a snapshot, not a wait. The original polls here for up to two minutes, and pi awaits the
* settle handler (agent-session.js:330), so that holds the worker's own settle for the whole poll.
* The loop already does the waiting: the supervisor sees the process listed, done is refused, and
* it steers instead. That leaves the waiting in the transcript where you can read it.
*
* Ported from @monotykamary/pi-supervisor (MIT), src/subagent-detector.ts. Extension agnostic: it
* does not matter who spawned them. Nothing is caught here, so a broken ps is loud.
*/
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
interface PiProcess {
pid: number;
ppid: number;
}
async function piProcesses(): Promise<PiProcess[]> {
if (process.platform !== "darwin" && process.platform !== "linux") return [];
const { stdout } = await execAsync(`ps -eo ppid,pid,comm | grep -E "\\bpi\\b" || true`);
return stdout
.trim()
.split("\n")
.map((line) => line.trim().split(/\s+/))
.filter((parts) => parts.length >= 3 && parts[2] === "pi")
.map((parts) => ({ ppid: Number(parts[0]), pid: Number(parts[1]) }));
}
export async function childPiProcesses(): Promise<number[]> {
return (await piProcesses()).filter((p) => p.ppid === process.pid).map((p) => p.pid);
}
+15
View File
@@ -0,0 +1,15 @@
/** Typed boundary for pi-vcc 0.5.0's source-only API. Its own source uses older Pi message
* unions and Intl.Segmenter types; do not typecheck that dependency as pi-goals source. */
declare module "@sting8k/pi-vcc/src/core/summarize.ts" {
export function compile(input: { messages: unknown[] }): string;
}
declare module "@sting8k/pi-vcc/src/core/normalize.ts" {
export function normalize(messages: unknown[]): VccBlock[];
interface VccBlock { type: string; [key: string]: unknown }
}
declare module "@sting8k/pi-vcc/src/extract/files.ts" {
export function extractFiles(blocks: unknown[]): { modified: Set<string>; created: Set<string> };
}
declare module "@sting8k/pi-vcc/src/extract/commits.ts" {
export function extractCommits(blocks: unknown[]): Array<{ hash?: string; message: string }>;
}
+303
View File
@@ -0,0 +1,303 @@
/**
* The worker view: what the supervisor judges from.
*
* Built from ctx.sessionManager.getBranch(), which already follows the live leaf path, so a fork
* or a rewind cannot leave dead entries in here. There is no disk read and no cross-branch merge.
*
* The body is pi-vcc's compiler, the same algorithmic compactor the worker can run, called here on
* the live messages with the worker's last compaction summary as previousSummary. So the view is
* "compaction summary, merged with everything since". We add what a compactor has no reason to
* track: unanswered tool calls and whether anything changed since the last review.
*/
import { normalize } from "@sting8k/pi-vcc/src/core/normalize.ts";
import { compile } from "@sting8k/pi-vcc/src/core/summarize.ts";
import { extractCommits } from "@sting8k/pi-vcc/src/extract/commits.ts";
import { extractFiles } from "@sting8k/pi-vcc/src/extract/files.ts";
const SUPERVISOR_PREFIX = "[supervisor] ";
/** Entry shapes we read. Only the fields this file touches, taken from real session jsonl. */
export interface Block {
type: string;
id?: string;
text?: string;
/** Set on `type: "thinking"` blocks. Empty when the provider redacted the reasoning. */
thinking?: string;
name?: string;
arguments?: Record<string, unknown>;
}
export interface AgentMsg {
role: "user" | "assistant" | "toolResult" | string;
content?: string | Block[];
toolName?: string;
toolCallId?: string;
isError?: boolean;
}
export interface Entry {
type: string;
message?: AgentMsg;
/** ISO, written on every entry by the session manager (core/session-manager.d.ts:21). */
timestamp?: string;
/** Written by whichever compactor the worker runs. VCC's summary lands here too. */
summary?: string;
tokensBefore?: number;
}
/**
* Milliseconds since the worker last put a message in its session.
*
* The clock a stuck worker shows on, and the only one that reads the same for both ways of being
* stuck: sitting at the prompt, and inside one command that never returns. A supervisor directive
* does not reset it. Time since the last look measures the supervisor instead, and understates a
* worker that stopped hours before.
*/
export function sinceLastTurn(entries: Entry[], now = Date.now()): number {
const last = [...entries].reverse().find((e) =>
e.type === "message" && e.timestamp && !(e.message?.role === "user" && textOf(e.message).startsWith(SUPERVISOR_PREFIX))
);
return last ? now - Date.parse(last.timestamp!) : 0;
}
/** A duration a supervisor can read at a glance: 2h27m, 45m, 30s. */
export function age(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s}s`;
const m = Math.round(s / 60);
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
}
/** Extension channel payloads cap at 16 KiB, so the view must stay under it. */
export const MAX_VIEW_BYTES = 15000;
const GOAL_PREVIEW_CHARS = 160;
/** A long goal remains identifiable in every view without replaying its whole rubric. */
export function goalPreview(goal: string): string {
if (!goal.includes("\n")) return goal || "not set";
const firstLine = goal.split("\n").find((line) => line.trim())?.trim() || "not set";
return `${firstLine.slice(0, GOAL_PREVIEW_CHARS)} [...]`;
}
function blocks(msg: AgentMsg): Block[] {
return Array.isArray(msg.content) ? msg.content : [];
}
function textOf(msg: AgentMsg): string {
if (typeof msg.content === "string") return msg.content;
return blocks(msg)
.filter((b) => b.type === "text")
.map((b) => b.text ?? "")
.join("\n");
}
/**
* Tool calls with no matching result on this branch. A settled worker with an unanswered
* subagent call still has delegated work running, and "done" then means nothing.
*/
export function outstandingWork(entries: Entry[]): string[] {
const called = new Map<string, string>();
const answered = new Set<string>();
for (const entry of entries) {
const msg = entry.message;
if (!msg) continue;
for (const b of blocks(msg)) {
if (b.type === "toolCall" && b.id) called.set(b.id, b.name ?? "?");
}
if (msg.role === "toolResult" && msg.toolCallId) answered.add(msg.toolCallId);
}
return [...called].filter(([id]) => !answered.has(id)).map(([, name]) => name);
}
/** The summary written by whichever compactor the worker runs. Empty when it has not compacted. */
export function compactionSummary(entries: Entry[]): string {
let summary = "";
for (const entry of entries) {
if (entry.type === "compaction" && entry.summary) summary = entry.summary;
}
return summary;
}
/**
* What the worker has changed: the files it wrote and the commits it made.
*
* Two reviews with the same key mean the last instruction produced neither. That is evidence for
* the supervisor, not a rule: re-editing one file while a test still fails looks the same, and is
* sometimes the right thing to be doing.
*
* Read from pi-vcc's extractor rather than from its rendered section, which caps the list at ten
* paths and would freeze this key on any run long enough to matter.
*/
export function progressKey(entries: Entry[]): string {
const blocks = normalize(messagesSince(entries) as any);
const files = extractFiles(blocks);
const commits = extractCommits(blocks).map((c) => c.hash ?? c.message);
return [[...files.modified].sort(), [...files.created].sort(), commits].map((p) => p.join(",")).join("||");
}
/**
* Messages after the worker's last compaction.
*
* getBranch keeps the entries a compaction replaced, so handing every message to compile alongside
* the summary would send the supervisor both copies and spend the byte budget twice. Supervisor
* directives already live in the supervisor transcript, so exclude their worker-session echo.
*/
function messagesSince(entries: Entry[]): AgentMsg[] {
const lastCompaction = entries.map((e) => e.type).lastIndexOf("compaction");
return entries
.slice(lastCompaction + 1)
.filter((e) => e.type === "message" && e.message)
.map((e) => e.message!)
.filter((message) => message.role !== "user" || !textOf(message).startsWith(SUPERVISOR_PREFIX));
}
/** What the caller records after a view goes out, and hands back as `since` on the next one. */
export function turnsSince(entries: Entry[]): number {
return messagesSince(entries).length;
}
/** Reasoning blocks kept, newest first, and the tail kept from each. A block ends on a decision. */
const THINKING_BLOCKS = 2;
const THINKING_CHARS = 400;
/**
* Keep the last few reasoning blocks by rewriting them as text, and let pi-vcc drop the rest.
*
* normalize() keeps only text and toolCall blocks from an assistant message, so reasoning never
* reaches the supervisor although you see it on screen. Rewriting in place leaves each thought
* next to the tool call it produced, which is the order you read a session in. A separate section
* at the top of the view would divorce the thought from what it did.
*
* Only the last two, because one worker session here held 161 reasoning blocks and all of them
* would make the view a second transcript. Everything older needs no work: pi-vcc drops it.
*/
function keepRecentThinking(msgs: AgentMsg[]): AgentMsg[] {
const keep = new Set<string>();
outer: for (let i = msgs.length - 1; i >= 0; i--) {
const content = msgs[i].content;
if (!Array.isArray(content)) continue;
for (let j = content.length - 1; j >= 0; j--) {
if (content[j].type !== "thinking" || !content[j].thinking) continue;
keep.add(`${i}:${j}`);
if (keep.size === THINKING_BLOCKS) break outer;
}
}
if (!keep.size) return msgs;
return msgs.map((msg, i) =>
Array.isArray(msg.content)
? {
...msg,
content: msg.content.map((b, j) =>
keep.has(`${i}:${j}`) ? { type: "text", text: `(thinking) ${b.thinking!.slice(-THINKING_CHARS)}` } : b
),
}
: msg
);
}
const VCC_SEPARATOR = "\n\n---\n\n";
/** pi-vcc's section names, in the order formatSummary writes them (its format.ts). */
const VCC_HEADERS = ["Session Goal", "Files And Changes", "Commits", "Outstanding Context", "User Preferences"];
/**
* pi-vcc's compiled summary, split into its header sections and its brief transcript.
*
* compile() writes `sections + "\n\n---\n\n" + brief`, and drops either part when it is empty, so
* all four combinations are possible. Get this wrong and the header block lands in the transcript,
* where the byte cut eats the newest turns instead of the oldest.
*/
function vccSections(fresh: AgentMsg[]): { headers: string; brief: string } {
// No previousSummary: compile's merge reads the fresh brief with briefOf, which finds nothing
// when the fresh messages produced no header sections, and the newest turns vanish. The
// compaction summary goes into the view above this instead, which loses nothing.
//
// compile appends a note telling the reader to call vcc_recall, which the supervisor does not
// have. Matched on the tool name because wrapLongLines rewraps the note before we see it.
const compiled = compile({ messages: keepRecentThinking(fresh) as any })
.replace(/\n*-*\n*Use `vcc_recall`[\s\S]*$/, "")
.trim();
if (!VCC_HEADERS.some((h) => compiled.startsWith(`[${h}]`))) return { headers: "", brief: compiled };
const at = compiled.indexOf(VCC_SEPARATOR);
if (at < 0) return { headers: compiled, brief: "" };
return { headers: compiled.slice(0, at), brief: compiled.slice(at + VCC_SEPARATOR.length) };
}
export interface ViewInput {
goal: string;
status: string;
entries: Entry[];
/**
* Turns the supervisor has already been sent, from turnsSince() after the last view.
*
* The supervisor is a real session and keeps every view it has read, so re-sending the whole
* transcript every time is a second copy of what it already has. This is a person glancing at a
* screen: they read the new lines, not the scrollback. Past the compaction or a rewind this no
* longer lines up, and the view says so and sends everything after the compaction.
*/
since?: number;
/** Reviews in a row where progressKey did not change. 0 means something changed this time. */
stale?: number;
/** Child pi processes still running. A settled worker with one of these is still spending. */
subagents?: number[];
/**
* The worker's model and how full its context is, from the intercom presence record.
*
* A supervisor steering a small fast model should give smaller steps than one steering a frontier
* model, and a worker near the top of its context is about to compact and lose detail.
*/
model?: string;
}
/** Render the view, and cut it to MAX_VIEW_BYTES so the broker cannot reject it. */
export function buildView({ goal, status, entries, since = 0, stale = 0, subagents = [], model = "" }: ViewInput): string {
const messages = entries.filter((e) => e.type === "message" && e.message);
const pending = outstandingWork(messages);
const workerMessages = messagesSince(entries);
const total = workerMessages.length;
// A compaction or a rewind leaves the mark past the end. Restart from the compaction and say so,
// otherwise the supervisor silently reads a slice of the wrong history.
const restarted = since > total;
const from = restarted ? 0 : since;
const fresh = workerMessages.slice(from);
const { headers, brief } = vccSections(fresh);
const earlier = compactionSummary(entries);
const head = [
// Short goals are the criterion on every review. A multi-line research rubric is reinserted
// into the supervisor context at its own cadence, so this view carries only its locator.
`<goal>`,
goalPreview(goal),
`</goal>`,
``,
`# Worker`,
...(model ? [`model: ${model}`] : []),
`status: ${status}`,
`turns: ${workerMessages.length}`,
`tool calls with no result: ${pending.length ? pending.join(", ") : "none"}`,
`child pi processes still running: ${subagents.length ? subagents.join(", ") : "none"}`,
...(stale > 0 ? [`no new file or commit for ${stale} reviews in a row`] : []),
``,
// Sent when this view starts at the compaction boundary, which is the first view and every
// view after the worker compacts. In between the supervisor already has it.
...(from === 0 && earlier
? [
restarted ? `# The worker compacted, so this view restarts. Everything before it:` : `# Earlier work, from the worker's own compaction summary`,
earlier.slice(0, 6000),
``,
]
: []),
...(headers ? [`# Files, commits and context, from the new turns only`, headers, ``] : []),
from > 0 ? `# New turns since your last look (${total - from} of ${total})` : `# Turns so far`,
].join("\n");
// Oldest brief lines go first, because the newest turns are what the next instruction rests on.
let lines = brief.split("\n");
let view = `${head}\n${lines.join("\n")}\n`;
while (Buffer.byteLength(view, "utf-8") > MAX_VIEW_BYTES && lines.length > 1) {
lines = lines.slice(1);
view = `${head}\n[earlier turns cut to fit the channel]\n${lines.join("\n")}\n`;
}
if (Buffer.byteLength(view, "utf-8") <= MAX_VIEW_BYTES) return view;
// The head alone can overflow, on a long goal. The broker drops anything over
// 16 KiB and never tells the extension, so the supervisor would go blind. Cut, and say so.
return `${Buffer.from(view, "utf-8").subarray(0, MAX_VIEW_BYTES - 40).toString("utf-8")}\n[view cut here to fit the channel]\n`;
}
+36 -16
View File
@@ -4,7 +4,7 @@
* Design: the plan file is for LLMs and the human, not for TypeScript. No parser and no schema;
* the skeleton below is a convention the drafting prompt teaches, the working agent maintains with
* its normal Edit tool, and the judge reads natively. The harness does three things for a
* cooperative-but-confused model: memory (a transient re-send of the plan when it goes stale),
* cooperative-but-confused model: memory (a saved reminder of the plan when it goes stale),
* format guidance (the skeleton), and fresh eyes (the read-only judge in CompleteGoal).
*
* THE FOLD: everything above "## Log" is the working set (title, user voice, goals,
@@ -37,9 +37,12 @@ human would need to approve later. If any is uncertain, reduce uncertainty now:
search the web when they can answer, then ask the human to confirm your interpretation, pin down the
outcome or task, or approve an editorial or other preference choice. Do not present the review menu
with a placeholder goal such as "work out the thing", "improve it", or "investigate".
3. For independent high-impact questions, build a decision tree and ask the whole frontier in one
round. Ask only questions worth the human's time, where the answer materially reduces uncertainty
while discovering the right plan. Each question must be short and self-contained: state the relevant
3. By default, before proposing a final plan, ask at least THREE distinct task-specific alignment
questions in ONE chat round: test agreement about the expected result, scope and constraints, and
success/failure criteria. Even if you think you understand, check how far apart your interpretations
are. Wait for the human's answers and use them before declaring the plan final. Do not ask technical
facts that read-only inspection can resolve, or use a generic ritual questionnaire. Additional
questions should materially reduce uncertainty while discovering the right plan. Each question must be short and self-contained: state the relevant
context, use the human's language and ASD-STE100
Simple Technical English, and give a recommended answer. Record each answer in ## Interview. Do not
make the plan final while material user decisions remain open.
@@ -50,9 +53,12 @@ not replace, defer, or contradict it; ask the human if an inference would change
5. When every goal has an object, observable result, settled scope, and required approval, draft the
plan file and present it. It should be safe to work overnight and present the requested outcome.
How this mode ends: after each settled draft the human gets a menu (Ready / Refine / Edit / Cancel).
Plan mode ends only when they pick Ready. Refine collects short revision notes. Edit opens the full
plan. When a new requirement arrives, fold it in, say what changed, and present the plan again.
How this mode ends: when alignment is complete and the plan is ready, call RequestPlanReview to
show Ready / Discuss / Edit / Cancel. Only Ready ends planning. Discuss returns to normal chat:
ask useful alignment questions, wait for answers, and continue discussing for as many turns as
needed. Do not request review while awaiting answers. When discussion is finished, call
RequestPlanReview again, even if the draft did not change. Edit opens the full plan directly.
When a new requirement arrives, fold it in, say what changed, and request review when ready.
Detail that doesn't change a goal or a discriminator belongs in the appendix, not in the goals.
Right-size it:
@@ -140,24 +146,40 @@ Conventions:
- Appendix: unlimited and unverified. Alternatives, links, dead ends, and the settled detail that
is not part of the approved goals. Nothing here is approved and nothing here is checked.
When the goals are drafted, present them and say the plan is final. Do not begin execution.`;
After the alignment answers are incorporated, present the final plan and call RequestPlanReview. Do not begin execution.`;
/* ─────────────────────────────────────────────────────────────────────────
* 3. reminder — EXEC. Transient, never persisted, and only when the plan went stale for a couple of
* 3. reminder — EXEC. Saved at the next natural prompt after the plan goes stale for several
* turns. pi-tasks tried a per-turn injection and deleted it: "wallpaper noise that trains the
* model to ignore the task block" (tintinweb/pi-tasks CHANGELOG.md:149). Carries the folded plan
* (above ## Log), because a nudge with no plan in it makes the model go read the file anyway.
* ──────────────────────────────────────────────────────────────────────── */
export function planningState(planPath: string): string {
/** A waiver belongs only to the current objective, never to an earlier plan's transcript. */
export function waivesAlignment(objective: string): boolean {
// Only standalone affirmative clauses, not negations or quoted feature names.
const unquoted = objective.replace(/"[^"]*"|“[^”]*”|[^]*|`[^`]*`/g, " ").replace(/(^|\W)'[^'\n]*'(?=$|\W)/g, "$1 ");
return unquoted.split(/[;,.!?\n]+/).some(clause => /^(?:please\s+)?(?:no[- ](?:questions|q[']s)|skip(?:[- ](?:the|all))?[- ](?:questions|q[']s)|(?:don[']t|do not) ask(?: me)?(?: any)? questions)(?:\s+please)?(?:\s+(?:and|then)\b.+)?$/i.test(clause.trim()));
}
export function alignmentPolicy(waived: boolean): string {
return waived
? "Current-plan alignment: the human explicitly waived questions in this objective. Skip the default three-question round for THIS plan only."
: "Current-plan alignment: ask at least THREE task-specific questions in ONE chat round before the final plan; wait for answers and use them. Check the expected result, scope/constraints, and success/failure criteria. A waiver in any previous plan does NOT apply. Do not repeat questions already answered for this plan.";
}
export const discussPlan = "Continue discussing this draft in normal chat. Ask useful, task-specific alignment questions to check where your understanding differs from the human's: expected result, scope/constraints, and success/failure criteria. Wait for answers; do not open an editor or request review yet. Keep the draft and incorporate answers. When discussion is finished and the plan is ready, call RequestPlanReview, even if the draft is unchanged.";
export function planningState(planPath: string, questionsWaived = false): string {
return `\
[PLANNING MODE]
${alignmentPolicy(questionsWaived)}
The plan at ${planPath} is the only file you may change. Use read-only repository tools or web search
when either can resolve a fact. Ask the human to confirm unresolved interpretation, outcome, task,
scope, or a choice that needs their approval. Batch independent high-impact questions in one short,
self-contained round with relevant context and a recommendation. Do not draft a placeholder goal
without a concrete object, observable result, settled scope, and required approval. Do not execute
work, mark a goal [/] or [x], or sign off a goal. The plan is not approved until the human selects
Ready.`;
Ready. Call RequestPlanReview only when alignment is complete and the plan is ready, not while waiting for chat answers.`;
}
export function reminder(foldedPlan: string, planRel: string): string {
@@ -197,11 +219,9 @@ ${plan}
</system-reminder>`;
}
/* ─────────────────────────────────────────────────────────────────────────
* 4. completeGoal — SIGN-OFF, agent-side: the one blessed tool
* ──────────────────────────────────────────────────────────────────────── */
export const completeGoalDescription =
"Sign off a goal once its discriminator is satisfied. First fill the goal's evidence: list in the " +
"Sign off a goal once its discriminator is satisfied. When the optional persistent steward is enabled, " +
"this call asks the retained supervisor about direction and scope before running the fresh judge. First fill the goal's evidence: list in the " +
"plan file: each item pairs a durable artifact with a short read of it (a quoted+linked log, a " +
"table plus how to read it, a metric plus what it shows -- not a bare claim). Quote verbatim from " +
"output you actually observed; never reconstruct numbers from memory. If you couldn't see an " +
@@ -221,7 +241,7 @@ export const completeGoalDescription =
export const completeGoalParamDescription = "The goal's text: the line after 'goal:' in the plan file.";
/* ─────────────────────────────────────────────────────────────────────────
* 5. judge — SIGN-OFF, judge-side: the one rigorous check. Runs on a fresh
* 6. judge — SIGN-OFF, judge-side: the one rigorous check. Runs on a fresh
* read-only pi subprocess (--no-session) so it never sees the working
* agent's transcript. It gets the WHOLE plan file: it finds the goal,
* reads discriminator/failure modes/evidence itself (no parser between).
+88
View File
@@ -0,0 +1,88 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
export type ModelRole = "planning" | "worker" | "supervisor";
export interface ModelChoice { provider: string; id: string }
/** One atomic file per role: a supervisor process cannot clobber the worker's choice. */
export class RoleModels {
private role: ModelRole | null = null;
private automatic = 0;
private available = true;
constructor(private pi: ExtensionAPI, private directory = join(getAgentDir(), "pi-goals")) {
pi.on("model_select", (event, ctx) => {
// setModel can emit "set" itself. Session restore is not a human preference either.
if (this.automatic || event.source === "restore" || !this.role) return;
try { this.save(this.role, event.model); this.available = true; }
catch (error) { ctx.ui.notify(`Could not remember ${this.role} model: ${String(error)}`, "error"); }
});
}
get ready(): boolean { return this.available; }
get activeRole(): ModelRole | null { return this.role; }
get restoring(): boolean { return this.automatic > 0; }
leave(): void { this.role = null; this.available = true; }
/** Explicit acknowledgement works even when Pi suppresses same-model model_select. */
async useCurrent(ctx: ExtensionContext): Promise<boolean> {
const role = this.role;
if (!role || this.available) { ctx.ui.notify("No role model is paused.", "info"); return false; }
this.automatic++;
try {
const model = ctx.model;
if (!model || !await this.pi.setModel(model)) throw new Error("The current model is missing or unauthenticated");
this.save(role, model);
this.available = true;
ctx.ui.notify(`Explicitly saved current model ${model.provider}/${model.id} for ${role}.`, "info");
return true;
} catch (error) {
ctx.ui.notify(`Could not recover ${role}: ${String(error)}. Saved choice unchanged.`, "error");
return false;
} finally { this.automatic--; }
}
private path(role: ModelRole): string { return join(this.directory, `${role}-model.json`); }
private read(role: ModelRole): ModelChoice | undefined {
let raw: string;
try { raw = readFileSync(this.path(role), "utf8"); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; }
const value = JSON.parse(raw);
if (!value || typeof value.provider !== "string" || !value.provider || typeof value.id !== "string" || !value.id) throw new Error(`Invalid model preference: ${this.path(role)}`);
return { provider: value.provider, id: value.id };
}
private save(role: ModelRole, model: ModelChoice): void {
mkdirSync(this.directory, { recursive: true });
const temp = `${this.path(role)}.${randomUUID()}.tmp`;
try {
writeFileSync(temp, `${JSON.stringify({ provider: model.provider, id: model.id })}\n`, { mode: 0o600 });
renameSync(temp, this.path(role));
} finally { rmSync(temp, { force: true }); }
}
/** Failure keeps the saved choice and pauses the role; only an explicit selection replaces it. */
async enter(role: ModelRole, ctx: ExtensionContext): Promise<boolean> {
this.role = role;
this.automatic++;
try {
const choice = this.read(role);
if (!choice) {
if (!ctx.model) throw new Error("No current model. Select one with /model first.");
this.save(role, ctx.model);
if (!await this.pi.setModel(ctx.model)) throw new Error(`Current model ${ctx.model.provider}/${ctx.model.id} has no authentication.`);
} else {
const model = ctx.modelRegistry.find(choice.provider, choice.id);
if (!model) throw new Error(`Remembered model ${choice.provider}/${choice.id} is unavailable.`);
if (!await this.pi.setModel(model)) throw new Error(`Remembered model ${choice.provider}/${choice.id} has no authentication.`);
}
this.available = true;
return true;
} catch (error) {
this.available = false;
ctx.ui.notify(`${role} model paused: ${String(error)} Saved choice unchanged; configure that model and retry, select a different model with /model, or explicitly use the current model for this paused role with /goals model current. Then retry Ready if work has not started.`, "error");
return false;
} finally { this.automatic--; }
}
}
+121
View File
@@ -0,0 +1,121 @@
import { createHash, randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import { type ExtensionAPI, type ExtensionContext, SessionManager } from "@earendil-works/pi-coding-agent";
export const SUPERVISOR_ROLE = "pi-goals-supervisor";
const PLAN_API = "pi-supervise:plan:v1";
export interface SupervisorBinding {
id: string;
planPath: string;
workerSession: string;
workerPane: string;
supervisorPane?: string;
supervisorSession?: string;
active?: boolean;
everyTurns: number;
intervalMs: number;
compactTokens: number;
}
interface Bootstrap { binding: SupervisorBinding; workerId: string }
interface SupervisorStatus { connected: boolean; binding?: SupervisorBinding; workerId: string; role?: string }
export interface SupervisorDecision { bindingId: string; goal: string; planHash: string; decision: "approve" | "needs_work" | "needs_user"; reason: string }
export function planHash(text: string): string {
return createHash("sha256").update(text).digest("hex");
}
/** The owner claims synchronously; its promise includes peer acknowledgement or review. */
export function supervisorRequest<T>(pi: ExtensionAPI, method: string, params: Record<string, unknown> = {}, signal?: AbortSignal): Promise<T> {
return new Promise((resolve, reject) => {
const request = { version: 1, method, ...params, signal, handled: false, resolve, reject };
pi.events.emit(PLAN_API, request);
if (!request.handled) reject(new Error("The internal supervisor is not registered. Load the pi-goals package directory (not only src/index.ts), then reload Pi."));
});
}
async function herdr(pi: ExtensionAPI, args: string[], signal?: AbortSignal): Promise<Record<string, any>> {
if (process.env.HERDR_ENV !== "1") throw new Error("Start Pi inside Herdr to launch or focus the supervisor. No pane was created.");
const result = await pi.exec("herdr", args, { timeout: 45_000, signal });
if (result.code !== 0) throw new Error(`Herdr: ${result.stderr || result.stdout}`);
const parsed = JSON.parse(result.stdout);
if (parsed.error) throw new Error(`Herdr: ${parsed.error.message}`);
return parsed.result ?? parsed;
}
export async function focusSupervisor(pi: ExtensionAPI, binding: SupervisorBinding, target: "supervisor" | "worker" | "zoom"): Promise<void> {
const pane = target === "worker" ? binding.workerPane : binding.supervisorPane;
if (!pane) throw new Error("No supervisor pane is recorded. Select Ready to start it.");
try { await herdr(pi, target === "zoom" ? ["pane", "zoom", "--pane", pane, "--toggle"] : ["agent", "focus", pane]); }
catch (error) { throw new Error(`${String(error)}. Session location/liveness is unknown. Locate the existing supervisor first; only after confirming it is no longer running, reopen pi --session ${JSON.stringify(binding.supervisorSession)}.`); }
}
export function supervisorBootstrap(ctx: ExtensionContext): Bootstrap | undefined {
const entry = ctx.sessionManager.getBranch().filter(e => e.type === "custom" && e.customType === SUPERVISOR_ROLE).at(-1);
return entry?.type === "custom" ? entry.data as Bootstrap : undefined;
}
export function initializeSupervisor(pi: ExtensionAPI, ctx: ExtensionContext, bootstrap: Bootstrap, signal?: AbortSignal): void {
// session_start handlers are ordered. Let all packages initialize before requesting their API.
setImmediate(() => {
if (signal?.aborted) return;
void supervisorRequest(pi, "bootstrap", bootstrap as unknown as Record<string, unknown>, signal).catch((error: Error) => {
if (!signal?.aborted) ctx.ui.notify(`Supervisor initialization failed: ${error.message}`, "error");
});
});
}
export async function startSupervisor(
pi: ExtensionAPI, ctx: ExtensionContext, planPath: string, existing: SupervisorBinding | null,
save: (binding: SupervisorBinding) => void, signal: AbortSignal,
): Promise<SupervisorBinding> {
if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_PANE_ID) throw new Error("Start Pi inside Herdr before enabling the steward at Ready.");
const status = await supervisorRequest<SupervisorStatus>(pi, "status", {}, signal);
signal.throwIfAborted();
if (existing && status.connected && status.binding?.id === existing.id) return status.binding;
if ((!existing && status.role && status.role !== "none") || (status.binding && status.binding.id !== existing?.id)) throw new Error("This session already has another supervision relationship. Stop it explicitly before Ready.");
const parent = ctx.sessionManager.getSessionFile();
const leaf = ctx.sessionManager.getLeafId();
if (!parent || !leaf) throw new Error("The planning session must be persisted before creating its supervisor fork.");
const packageRoot = fileURLToPath(new URL("../", import.meta.url));
let binding = existing ?? {
id: randomUUID(), planPath, workerSession: parent, workerPane: process.env.HERDR_PANE_ID,
everyTurns: 50, intervalMs: 60 * 60_000, compactTokens: 100_000,
};
if (!existing) {
save(binding);
await supervisorRequest(pi, "prepare", { binding }, signal);
signal.throwIfAborted();
}
if (!binding.supervisorSession) {
const fork = SessionManager.open(parent);
const sessionFile = fork.createBranchedSession(leaf);
if (!sessionFile) throw new Error("Could not persist the supervisor fork");
binding = { ...binding, supervisorSession: sessionFile };
fork.appendCustomEntry(SUPERVISOR_ROLE, { binding, workerId: status.workerId });
fork.appendSessionInfo(`Supervisor ${binding.id.slice(0, 8)}`);
save(binding);
}
if (binding.supervisorPane) {
// An existing occupant is not permission to start another process on the same session file.
await focusSupervisor(pi, binding, "supervisor");
signal.throwIfAborted();
return await supervisorRequest<SupervisorBinding>(pi, "attached", { bindingId: binding.id }, signal);
}
const split = await herdr(pi, ["pane", "split", "--current", "--direction", "right", "--cwd", ctx.cwd, "--no-focus"], signal);
const pane = split.pane?.pane_id;
if (typeof pane !== "string") throw new Error("Herdr split did not return a pane ID");
binding = { ...binding, supervisorPane: pane };
save(binding);
signal.throwIfAborted();
// Keep bootstrap and worker binding identical, including the returned pane identity.
SessionManager.open(binding.supervisorSession!).appendCustomEntry(SUPERVISOR_ROLE, { binding, workerId: status.workerId });
const waiting = supervisorRequest<SupervisorBinding>(pi, "attached", { bindingId: binding.id }, signal);
void waiting.catch(() => {});
try {
await herdr(pi, ["agent", "start", `supervisor-${binding.id.slice(0, 8)}`, "--kind", "pi", "--pane", pane, "--", "--session", binding.supervisorSession!,
"-e", packageRoot], signal);
return await waiting;
} catch (error) {
throw new Error(`Supervisor startup incomplete: ${String(error)}. Inspect the recorded pane, resolve startup, reload it, then retry Ready.`);
}
}
+411
View File
@@ -0,0 +1,411 @@
{"type":"session","version":3,"id":"fixture","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/tmp/fixture"}
{"type":"message","id":"e0000","parentId":null,"timestamp":"2026-01-01T00:00:00.000Z","message":{"role":"user","content":[{"type":"text","text":"shared turn 0, this text is on every branch"}]}}
{"type":"message","id":"e0001","parentId":"e0000","timestamp":"2026-01-01T00:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"shared reply 0, this text is on every branch"}]}}
{"type":"message","id":"e0002","parentId":"e0001","timestamp":"2026-01-01T00:00:02.000Z","message":{"role":"user","content":[{"type":"text","text":"shared turn 1, this text is on every branch"}]}}
{"type":"message","id":"e0003","parentId":"e0002","timestamp":"2026-01-01T00:00:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"shared reply 1, this text is on every branch"}]}}
{"type":"message","id":"e0004","parentId":"e0003","timestamp":"2026-01-01T00:00:04.000Z","message":{"role":"user","content":[{"type":"text","text":"shared turn 2, this text is on every branch"}]}}
{"type":"message","id":"e0005","parentId":"e0004","timestamp":"2026-01-01T00:00:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"shared reply 2, this text is on every branch"}]}}
{"type":"message","id":"e0006","parentId":"e0005","timestamp":"2026-01-01T00:00:06.000Z","message":{"role":"user","content":[{"type":"text","text":"shared turn 3, this text is on every branch"}]}}
{"type":"message","id":"e0007","parentId":"e0006","timestamp":"2026-01-01T00:00:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"shared reply 3, this text is on every branch"}]}}
{"type":"message","id":"e0008","parentId":"e0007","timestamp":"2026-01-01T00:00:08.000Z","message":{"role":"user","content":[{"type":"text","text":"shared turn 4, this text is on every branch"}]}}
{"type":"message","id":"e0009","parentId":"e0008","timestamp":"2026-01-01T00:00:09.000Z","message":{"role":"assistant","content":[{"type":"text","text":"shared reply 4, this text is on every branch"}]}}
{"type":"message","id":"e0010","parentId":"e0009","timestamp":"2026-01-01T00:00:10.000Z","message":{"role":"user","content":[{"type":"text","text":"shared turn 5, this text is on every branch"}]}}
{"type":"message","id":"e0011","parentId":"e0010","timestamp":"2026-01-01T00:00:11.000Z","message":{"role":"assistant","content":[{"type":"text","text":"shared reply 5, this text is on every branch"}]}}
{"type":"message","id":"e0012","parentId":"e0011","timestamp":"2026-01-01T00:01:40.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 0, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0013","parentId":"e0012","timestamp":"2026-01-01T00:01:41.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 0, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0014","parentId":"e0013","timestamp":"2026-01-01T00:01:42.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 1, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0015","parentId":"e0014","timestamp":"2026-01-01T00:01:43.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 1, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0016","parentId":"e0015","timestamp":"2026-01-01T00:01:44.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 2, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0017","parentId":"e0016","timestamp":"2026-01-01T00:01:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 2, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0018","parentId":"e0017","timestamp":"2026-01-01T00:01:46.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 3, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0019","parentId":"e0018","timestamp":"2026-01-01T00:01:47.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 3, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0020","parentId":"e0019","timestamp":"2026-01-01T00:01:48.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 4, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0021","parentId":"e0020","timestamp":"2026-01-01T00:01:49.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 4, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0022","parentId":"e0021","timestamp":"2026-01-01T00:01:50.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 5, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0023","parentId":"e0022","timestamp":"2026-01-01T00:01:51.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 5, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0024","parentId":"e0023","timestamp":"2026-01-01T00:01:52.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 6, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0025","parentId":"e0024","timestamp":"2026-01-01T00:01:53.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 6, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0026","parentId":"e0025","timestamp":"2026-01-01T00:01:54.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 7, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0027","parentId":"e0026","timestamp":"2026-01-01T00:01:55.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 7, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0028","parentId":"e0027","timestamp":"2026-01-01T00:01:56.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 8, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0029","parentId":"e0028","timestamp":"2026-01-01T00:01:57.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 8, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0030","parentId":"e0029","timestamp":"2026-01-01T00:01:58.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 9, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0031","parentId":"e0030","timestamp":"2026-01-01T00:01:59.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 9, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0032","parentId":"e0031","timestamp":"2026-01-01T00:02:00.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 10, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0033","parentId":"e0032","timestamp":"2026-01-01T00:02:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 10, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0034","parentId":"e0011","timestamp":"2026-01-01T00:05:00.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 0, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0035","parentId":"e0034","timestamp":"2026-01-01T00:05:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 0, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0036","parentId":"e0035","timestamp":"2026-01-01T00:05:02.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 1, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0037","parentId":"e0036","timestamp":"2026-01-01T00:05:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 1, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0038","parentId":"e0037","timestamp":"2026-01-01T00:05:04.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 2, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0039","parentId":"e0038","timestamp":"2026-01-01T00:05:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 2, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0040","parentId":"e0039","timestamp":"2026-01-01T00:05:06.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 3, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0041","parentId":"e0040","timestamp":"2026-01-01T00:05:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 3, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0042","parentId":"e0041","timestamp":"2026-01-01T00:05:08.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 4, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0043","parentId":"e0042","timestamp":"2026-01-01T00:05:09.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 4, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0044","parentId":"e0043","timestamp":"2026-01-01T00:05:10.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 5, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0045","parentId":"e0044","timestamp":"2026-01-01T00:05:11.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 5, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0046","parentId":"e0045","timestamp":"2026-01-01T00:05:12.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 6, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0047","parentId":"e0046","timestamp":"2026-01-01T00:05:13.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 6, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0048","parentId":"e0047","timestamp":"2026-01-01T00:05:14.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 7, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0049","parentId":"e0048","timestamp":"2026-01-01T00:05:15.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 7, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0050","parentId":"e0049","timestamp":"2026-01-01T00:05:16.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 8, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0051","parentId":"e0050","timestamp":"2026-01-01T00:05:17.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 8, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0052","parentId":"e0051","timestamp":"2026-01-01T00:05:18.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 9, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0053","parentId":"e0052","timestamp":"2026-01-01T00:05:19.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 9, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0054","parentId":"e0053","timestamp":"2026-01-01T00:05:20.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 10, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0055","parentId":"e0054","timestamp":"2026-01-01T00:05:21.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 10, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0056","parentId":"e0055","timestamp":"2026-01-01T00:05:22.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 11, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0057","parentId":"e0056","timestamp":"2026-01-01T00:05:23.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 11, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0058","parentId":"e0057","timestamp":"2026-01-01T00:05:24.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 12, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0059","parentId":"e0058","timestamp":"2026-01-01T00:05:25.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 12, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0060","parentId":"e0059","timestamp":"2026-01-01T00:05:26.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 13, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0061","parentId":"e0060","timestamp":"2026-01-01T00:05:27.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 13, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0062","parentId":"e0061","timestamp":"2026-01-01T00:05:28.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 14, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0063","parentId":"e0062","timestamp":"2026-01-01T00:05:29.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 14, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0064","parentId":"e0063","timestamp":"2026-01-01T00:05:30.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 15, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0065","parentId":"e0064","timestamp":"2026-01-01T00:05:31.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 15, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0066","parentId":"e0065","timestamp":"2026-01-01T00:05:32.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 16, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0067","parentId":"e0066","timestamp":"2026-01-01T00:05:33.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 16, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0068","parentId":"e0067","timestamp":"2026-01-01T00:05:34.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 17, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0069","parentId":"e0068","timestamp":"2026-01-01T00:05:35.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 17, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0070","parentId":"e0069","timestamp":"2026-01-01T00:05:36.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 18, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0071","parentId":"e0070","timestamp":"2026-01-01T00:05:37.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 18, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0072","parentId":"e0071","timestamp":"2026-01-01T00:05:38.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 19, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0073","parentId":"e0072","timestamp":"2026-01-01T00:05:39.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 19, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0074","parentId":"e0073","timestamp":"2026-01-01T00:05:40.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 20, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0075","parentId":"e0074","timestamp":"2026-01-01T00:05:41.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 20, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0076","parentId":"e0075","timestamp":"2026-01-01T00:05:42.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 21, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0077","parentId":"e0076","timestamp":"2026-01-01T00:05:43.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 21, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0078","parentId":"e0077","timestamp":"2026-01-01T00:05:44.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 22, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0079","parentId":"e0078","timestamp":"2026-01-01T00:05:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 22, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0080","parentId":"e0079","timestamp":"2026-01-01T00:05:46.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 23, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0081","parentId":"e0080","timestamp":"2026-01-01T00:05:47.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 23, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0082","parentId":"e0081","timestamp":"2026-01-01T00:05:48.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 24, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0083","parentId":"e0082","timestamp":"2026-01-01T00:05:49.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 24, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0084","parentId":"e0083","timestamp":"2026-01-01T00:05:50.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 25, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0085","parentId":"e0084","timestamp":"2026-01-01T00:05:51.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 25, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0086","parentId":"e0085","timestamp":"2026-01-01T00:05:52.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 26, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0087","parentId":"e0086","timestamp":"2026-01-01T00:05:53.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 26, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0088","parentId":"e0087","timestamp":"2026-01-01T00:05:54.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 27, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0089","parentId":"e0088","timestamp":"2026-01-01T00:05:55.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 27, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0090","parentId":"e0089","timestamp":"2026-01-01T00:05:56.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 28, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0091","parentId":"e0090","timestamp":"2026-01-01T00:05:57.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 28, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0092","parentId":"e0091","timestamp":"2026-01-01T00:05:58.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 29, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0093","parentId":"e0092","timestamp":"2026-01-01T00:05:59.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 29, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0094","parentId":"e0093","timestamp":"2026-01-01T00:06:00.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 30, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0095","parentId":"e0094","timestamp":"2026-01-01T00:06:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 30, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0096","parentId":"e0095","timestamp":"2026-01-01T00:06:02.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 31, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0097","parentId":"e0096","timestamp":"2026-01-01T00:06:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 31, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0098","parentId":"e0097","timestamp":"2026-01-01T00:06:04.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 32, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0099","parentId":"e0098","timestamp":"2026-01-01T00:06:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 32, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0100","parentId":"e0099","timestamp":"2026-01-01T00:06:06.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 33, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0101","parentId":"e0100","timestamp":"2026-01-01T00:06:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 33, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0102","parentId":"e0101","timestamp":"2026-01-01T00:06:08.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 34, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0103","parentId":"e0102","timestamp":"2026-01-01T00:06:09.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 34, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0104","parentId":"e0103","timestamp":"2026-01-01T00:06:10.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 35, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0105","parentId":"e0104","timestamp":"2026-01-01T00:06:11.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 35, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0106","parentId":"e0105","timestamp":"2026-01-01T00:06:12.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 36, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0107","parentId":"e0106","timestamp":"2026-01-01T00:06:13.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 36, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0108","parentId":"e0107","timestamp":"2026-01-01T00:06:14.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 37, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0109","parentId":"e0108","timestamp":"2026-01-01T00:06:15.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 37, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0110","parentId":"e0109","timestamp":"2026-01-01T00:06:16.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 38, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0111","parentId":"e0110","timestamp":"2026-01-01T00:06:17.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 38, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0112","parentId":"e0111","timestamp":"2026-01-01T00:06:18.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 39, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0113","parentId":"e0112","timestamp":"2026-01-01T00:06:19.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 39, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0114","parentId":"e0113","timestamp":"2026-01-01T00:06:20.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 40, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0115","parentId":"e0114","timestamp":"2026-01-01T00:06:21.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 40, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0116","parentId":"e0115","timestamp":"2026-01-01T00:06:22.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 41, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0117","parentId":"e0116","timestamp":"2026-01-01T00:06:23.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 41, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0118","parentId":"e0117","timestamp":"2026-01-01T00:06:24.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 42, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0119","parentId":"e0118","timestamp":"2026-01-01T00:06:25.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 42, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0120","parentId":"e0119","timestamp":"2026-01-01T00:06:26.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 43, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0121","parentId":"e0120","timestamp":"2026-01-01T00:06:27.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 43, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0122","parentId":"e0121","timestamp":"2026-01-01T00:06:28.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 44, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0123","parentId":"e0122","timestamp":"2026-01-01T00:06:29.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 44, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0124","parentId":"e0123","timestamp":"2026-01-01T00:06:30.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 45, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0125","parentId":"e0124","timestamp":"2026-01-01T00:06:31.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 45, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0126","parentId":"e0125","timestamp":"2026-01-01T00:06:32.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 46, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0127","parentId":"e0126","timestamp":"2026-01-01T00:06:33.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 46, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0128","parentId":"e0127","timestamp":"2026-01-01T00:06:34.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 47, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0129","parentId":"e0128","timestamp":"2026-01-01T00:06:35.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 47, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0130","parentId":"e0129","timestamp":"2026-01-01T00:06:36.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 48, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0131","parentId":"e0130","timestamp":"2026-01-01T00:06:37.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 48, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0132","parentId":"e0131","timestamp":"2026-01-01T00:06:38.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 49, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0133","parentId":"e0132","timestamp":"2026-01-01T00:06:39.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 49, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0134","parentId":"e0133","timestamp":"2026-01-01T00:06:40.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 50, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0135","parentId":"e0134","timestamp":"2026-01-01T00:06:41.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 50, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0136","parentId":"e0135","timestamp":"2026-01-01T00:06:42.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 51, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0137","parentId":"e0136","timestamp":"2026-01-01T00:06:43.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 51, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0138","parentId":"e0137","timestamp":"2026-01-01T00:06:44.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 52, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0139","parentId":"e0138","timestamp":"2026-01-01T00:06:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 52, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0140","parentId":"e0139","timestamp":"2026-01-01T00:06:46.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 53, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0141","parentId":"e0140","timestamp":"2026-01-01T00:06:47.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 53, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0142","parentId":"e0141","timestamp":"2026-01-01T00:06:48.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 54, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0143","parentId":"e0142","timestamp":"2026-01-01T00:06:49.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 54, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0144","parentId":"e0143","timestamp":"2026-01-01T00:06:50.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 55, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0145","parentId":"e0144","timestamp":"2026-01-01T00:06:51.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 55, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0146","parentId":"e0145","timestamp":"2026-01-01T00:06:52.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 56, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0147","parentId":"e0146","timestamp":"2026-01-01T00:06:53.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 56, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0148","parentId":"e0147","timestamp":"2026-01-01T00:06:54.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 57, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0149","parentId":"e0148","timestamp":"2026-01-01T00:06:55.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 57, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0150","parentId":"e0149","timestamp":"2026-01-01T00:06:56.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 58, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0151","parentId":"e0150","timestamp":"2026-01-01T00:06:57.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 58, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0152","parentId":"e0151","timestamp":"2026-01-01T00:06:58.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 59, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0153","parentId":"e0152","timestamp":"2026-01-01T00:06:59.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 59, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0154","parentId":"e0153","timestamp":"2026-01-01T00:07:00.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 60, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0155","parentId":"e0154","timestamp":"2026-01-01T00:07:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 60, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0156","parentId":"e0155","timestamp":"2026-01-01T00:07:02.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 61, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0157","parentId":"e0156","timestamp":"2026-01-01T00:07:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 61, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0158","parentId":"e0157","timestamp":"2026-01-01T00:07:04.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 62, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0159","parentId":"e0158","timestamp":"2026-01-01T00:07:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 62, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0160","parentId":"e0159","timestamp":"2026-01-01T00:07:06.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 63, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0161","parentId":"e0160","timestamp":"2026-01-01T00:07:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 63, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0162","parentId":"e0161","timestamp":"2026-01-01T00:07:08.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 64, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0163","parentId":"e0162","timestamp":"2026-01-01T00:07:09.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 64, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0164","parentId":"e0163","timestamp":"2026-01-01T00:07:10.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 65, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0165","parentId":"e0164","timestamp":"2026-01-01T00:07:11.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 65, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0166","parentId":"e0165","timestamp":"2026-01-01T00:07:12.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 66, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0167","parentId":"e0166","timestamp":"2026-01-01T00:07:13.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 66, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0168","parentId":"e0167","timestamp":"2026-01-01T00:07:14.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 67, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0169","parentId":"e0168","timestamp":"2026-01-01T00:07:15.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 67, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0170","parentId":"e0169","timestamp":"2026-01-01T00:07:16.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 68, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0171","parentId":"e0170","timestamp":"2026-01-01T00:07:17.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 68, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0172","parentId":"e0171","timestamp":"2026-01-01T00:07:18.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 69, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0173","parentId":"e0172","timestamp":"2026-01-01T00:07:19.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 69, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0174","parentId":"e0173","timestamp":"2026-01-01T00:07:20.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 70, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0175","parentId":"e0174","timestamp":"2026-01-01T00:07:21.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 70, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0176","parentId":"e0175","timestamp":"2026-01-01T00:07:22.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 71, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0177","parentId":"e0176","timestamp":"2026-01-01T00:07:23.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 71, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0178","parentId":"e0177","timestamp":"2026-01-01T00:07:24.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 72, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0179","parentId":"e0178","timestamp":"2026-01-01T00:07:25.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 72, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0180","parentId":"e0179","timestamp":"2026-01-01T00:07:26.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 73, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0181","parentId":"e0180","timestamp":"2026-01-01T00:07:27.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 73, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0182","parentId":"e0181","timestamp":"2026-01-01T00:07:28.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 74, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0183","parentId":"e0182","timestamp":"2026-01-01T00:07:29.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 74, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0184","parentId":"e0183","timestamp":"2026-01-01T00:07:30.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 75, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0185","parentId":"e0184","timestamp":"2026-01-01T00:07:31.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 75, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0186","parentId":"e0185","timestamp":"2026-01-01T00:07:32.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 76, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0187","parentId":"e0186","timestamp":"2026-01-01T00:07:33.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 76, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0188","parentId":"e0187","timestamp":"2026-01-01T00:07:34.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 77, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0189","parentId":"e0188","timestamp":"2026-01-01T00:07:35.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 77, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0190","parentId":"e0189","timestamp":"2026-01-01T00:07:36.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 78, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0191","parentId":"e0190","timestamp":"2026-01-01T00:07:37.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 78, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0192","parentId":"e0191","timestamp":"2026-01-01T00:07:38.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 79, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0193","parentId":"e0192","timestamp":"2026-01-01T00:07:39.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 79, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0194","parentId":"e0193","timestamp":"2026-01-01T00:07:40.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 80, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0195","parentId":"e0194","timestamp":"2026-01-01T00:07:41.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 80, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0196","parentId":"e0195","timestamp":"2026-01-01T00:07:42.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 81, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0197","parentId":"e0196","timestamp":"2026-01-01T00:07:43.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 81, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0198","parentId":"e0197","timestamp":"2026-01-01T00:07:44.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 82, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0199","parentId":"e0198","timestamp":"2026-01-01T00:07:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 82, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0200","parentId":"e0199","timestamp":"2026-01-01T00:07:46.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 83, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0201","parentId":"e0200","timestamp":"2026-01-01T00:07:47.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 83, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0202","parentId":"e0201","timestamp":"2026-01-01T00:07:48.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 84, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0203","parentId":"e0202","timestamp":"2026-01-01T00:07:49.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 84, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0204","parentId":"e0203","timestamp":"2026-01-01T00:07:50.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 85, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0205","parentId":"e0204","timestamp":"2026-01-01T00:07:51.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 85, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0206","parentId":"e0205","timestamp":"2026-01-01T00:07:52.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 86, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0207","parentId":"e0206","timestamp":"2026-01-01T00:07:53.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 86, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0208","parentId":"e0207","timestamp":"2026-01-01T00:07:54.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 87, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0209","parentId":"e0208","timestamp":"2026-01-01T00:07:55.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 87, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0210","parentId":"e0209","timestamp":"2026-01-01T00:07:56.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 88, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0211","parentId":"e0210","timestamp":"2026-01-01T00:07:57.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 88, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0212","parentId":"e0211","timestamp":"2026-01-01T00:07:58.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 89, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0213","parentId":"e0212","timestamp":"2026-01-01T00:07:59.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 89, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0214","parentId":"e0213","timestamp":"2026-01-01T00:08:00.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 90, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0215","parentId":"e0214","timestamp":"2026-01-01T00:08:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 90, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0216","parentId":"e0215","timestamp":"2026-01-01T00:08:02.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 91, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0217","parentId":"e0216","timestamp":"2026-01-01T00:08:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 91, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0218","parentId":"e0217","timestamp":"2026-01-01T00:08:04.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 92, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0219","parentId":"e0218","timestamp":"2026-01-01T00:08:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 92, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0220","parentId":"e0219","timestamp":"2026-01-01T00:08:06.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 93, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0221","parentId":"e0220","timestamp":"2026-01-01T00:08:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 93, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0222","parentId":"e0221","timestamp":"2026-01-01T00:08:08.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 94, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0223","parentId":"e0222","timestamp":"2026-01-01T00:08:09.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 94, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0224","parentId":"e0223","timestamp":"2026-01-01T00:08:10.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 95, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0225","parentId":"e0224","timestamp":"2026-01-01T00:08:11.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 95, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0226","parentId":"e0225","timestamp":"2026-01-01T00:08:12.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 96, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0227","parentId":"e0226","timestamp":"2026-01-01T00:08:13.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 96, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0228","parentId":"e0227","timestamp":"2026-01-01T00:08:14.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 97, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0229","parentId":"e0228","timestamp":"2026-01-01T00:08:15.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 97, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0230","parentId":"e0229","timestamp":"2026-01-01T00:08:16.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 98, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0231","parentId":"e0230","timestamp":"2026-01-01T00:08:17.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 98, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0232","parentId":"e0231","timestamp":"2026-01-01T00:08:18.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 99, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0233","parentId":"e0232","timestamp":"2026-01-01T00:08:19.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 99, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0234","parentId":"e0233","timestamp":"2026-01-01T00:08:20.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 100, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0235","parentId":"e0234","timestamp":"2026-01-01T00:08:21.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 100, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0236","parentId":"e0235","timestamp":"2026-01-01T00:08:22.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 101, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0237","parentId":"e0236","timestamp":"2026-01-01T00:08:23.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 101, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0238","parentId":"e0237","timestamp":"2026-01-01T00:08:24.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 102, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0239","parentId":"e0238","timestamp":"2026-01-01T00:08:25.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 102, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0240","parentId":"e0239","timestamp":"2026-01-01T00:08:26.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 103, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0241","parentId":"e0240","timestamp":"2026-01-01T00:08:27.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 103, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0242","parentId":"e0241","timestamp":"2026-01-01T00:08:28.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 104, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0243","parentId":"e0242","timestamp":"2026-01-01T00:08:29.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 104, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0244","parentId":"e0243","timestamp":"2026-01-01T00:08:30.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 105, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0245","parentId":"e0244","timestamp":"2026-01-01T00:08:31.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 105, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0246","parentId":"e0245","timestamp":"2026-01-01T00:08:32.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 106, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0247","parentId":"e0246","timestamp":"2026-01-01T00:08:33.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 106, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0248","parentId":"e0247","timestamp":"2026-01-01T00:08:34.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 107, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0249","parentId":"e0248","timestamp":"2026-01-01T00:08:35.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 107, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0250","parentId":"e0249","timestamp":"2026-01-01T00:08:36.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 108, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0251","parentId":"e0250","timestamp":"2026-01-01T00:08:37.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 108, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0252","parentId":"e0251","timestamp":"2026-01-01T00:08:38.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 109, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0253","parentId":"e0252","timestamp":"2026-01-01T00:08:39.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 109, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0254","parentId":"e0253","timestamp":"2026-01-01T00:08:40.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 110, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0255","parentId":"e0254","timestamp":"2026-01-01T00:08:41.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 110, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0256","parentId":"e0255","timestamp":"2026-01-01T00:08:42.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 111, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0257","parentId":"e0256","timestamp":"2026-01-01T00:08:43.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 111, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0258","parentId":"e0257","timestamp":"2026-01-01T00:08:44.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 112, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0259","parentId":"e0258","timestamp":"2026-01-01T00:08:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 112, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0260","parentId":"e0259","timestamp":"2026-01-01T00:08:46.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 113, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0261","parentId":"e0260","timestamp":"2026-01-01T00:08:47.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 113, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0262","parentId":"e0261","timestamp":"2026-01-01T00:08:48.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 114, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0263","parentId":"e0262","timestamp":"2026-01-01T00:08:49.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 114, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0264","parentId":"e0263","timestamp":"2026-01-01T00:08:50.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 115, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0265","parentId":"e0264","timestamp":"2026-01-01T00:08:51.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 115, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0266","parentId":"e0265","timestamp":"2026-01-01T00:08:52.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 116, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0267","parentId":"e0266","timestamp":"2026-01-01T00:08:53.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 116, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0268","parentId":"e0267","timestamp":"2026-01-01T00:08:54.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 117, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0269","parentId":"e0268","timestamp":"2026-01-01T00:08:55.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 117, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0270","parentId":"e0269","timestamp":"2026-01-01T00:08:56.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 118, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0271","parentId":"e0270","timestamp":"2026-01-01T00:08:57.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 118, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0272","parentId":"e0271","timestamp":"2026-01-01T00:08:58.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 119, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0273","parentId":"e0272","timestamp":"2026-01-01T00:08:59.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 119, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0274","parentId":"e0273","timestamp":"2026-01-01T00:09:00.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 120, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0275","parentId":"e0274","timestamp":"2026-01-01T00:09:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 120, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0276","parentId":"e0275","timestamp":"2026-01-01T00:09:02.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 121, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0277","parentId":"e0276","timestamp":"2026-01-01T00:09:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 121, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0278","parentId":"e0277","timestamp":"2026-01-01T00:09:04.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 122, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0279","parentId":"e0278","timestamp":"2026-01-01T00:09:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 122, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0280","parentId":"e0279","timestamp":"2026-01-01T00:09:06.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 123, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0281","parentId":"e0280","timestamp":"2026-01-01T00:09:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 123, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0282","parentId":"e0281","timestamp":"2026-01-01T00:09:08.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 124, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0283","parentId":"e0282","timestamp":"2026-01-01T00:09:09.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 124, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0284","parentId":"e0283","timestamp":"2026-01-01T00:09:10.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 125, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0285","parentId":"e0284","timestamp":"2026-01-01T00:09:11.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 125, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0286","parentId":"e0285","timestamp":"2026-01-01T00:09:12.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 126, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0287","parentId":"e0286","timestamp":"2026-01-01T00:09:13.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 126, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0288","parentId":"e0287","timestamp":"2026-01-01T00:09:14.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 127, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0289","parentId":"e0288","timestamp":"2026-01-01T00:09:15.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 127, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0290","parentId":"e0289","timestamp":"2026-01-01T00:09:16.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 128, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0291","parentId":"e0290","timestamp":"2026-01-01T00:09:17.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 128, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0292","parentId":"e0291","timestamp":"2026-01-01T00:09:18.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 129, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0293","parentId":"e0292","timestamp":"2026-01-01T00:09:19.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 129, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0294","parentId":"e0293","timestamp":"2026-01-01T00:09:20.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 130, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0295","parentId":"e0294","timestamp":"2026-01-01T00:09:21.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 130, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0296","parentId":"e0295","timestamp":"2026-01-01T00:09:22.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 131, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0297","parentId":"e0296","timestamp":"2026-01-01T00:09:23.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 131, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0298","parentId":"e0297","timestamp":"2026-01-01T00:09:24.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 132, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0299","parentId":"e0298","timestamp":"2026-01-01T00:09:25.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 132, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0300","parentId":"e0299","timestamp":"2026-01-01T00:09:26.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 133, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0301","parentId":"e0300","timestamp":"2026-01-01T00:09:27.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 133, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0302","parentId":"e0301","timestamp":"2026-01-01T00:09:28.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 134, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0303","parentId":"e0302","timestamp":"2026-01-01T00:09:29.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 134, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0304","parentId":"e0303","timestamp":"2026-01-01T00:09:30.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 135, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0305","parentId":"e0304","timestamp":"2026-01-01T00:09:31.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 135, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0306","parentId":"e0305","timestamp":"2026-01-01T00:09:32.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 136, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0307","parentId":"e0306","timestamp":"2026-01-01T00:09:33.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 136, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0308","parentId":"e0307","timestamp":"2026-01-01T00:09:34.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 137, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0309","parentId":"e0308","timestamp":"2026-01-01T00:09:35.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 137, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0310","parentId":"e0309","timestamp":"2026-01-01T00:09:36.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 138, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0311","parentId":"e0310","timestamp":"2026-01-01T00:09:37.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 138, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0312","parentId":"e0311","timestamp":"2026-01-01T00:09:38.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 139, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0313","parentId":"e0312","timestamp":"2026-01-01T00:09:39.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 139, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0314","parentId":"e0313","timestamp":"2026-01-01T00:09:40.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 140, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0315","parentId":"e0314","timestamp":"2026-01-01T00:09:41.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 140, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0316","parentId":"e0315","timestamp":"2026-01-01T00:09:42.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 141, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0317","parentId":"e0316","timestamp":"2026-01-01T00:09:43.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 141, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0318","parentId":"e0317","timestamp":"2026-01-01T00:09:44.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 142, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0319","parentId":"e0318","timestamp":"2026-01-01T00:09:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 142, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0320","parentId":"e0319","timestamp":"2026-01-01T00:09:46.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 143, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0321","parentId":"e0320","timestamp":"2026-01-01T00:09:47.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 143, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0322","parentId":"e0321","timestamp":"2026-01-01T00:09:48.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 144, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0323","parentId":"e0322","timestamp":"2026-01-01T00:09:49.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 144, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0324","parentId":"e0323","timestamp":"2026-01-01T00:09:50.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 145, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0325","parentId":"e0324","timestamp":"2026-01-01T00:09:51.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 145, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0326","parentId":"e0325","timestamp":"2026-01-01T00:09:52.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 146, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0327","parentId":"e0326","timestamp":"2026-01-01T00:09:53.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 146, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0328","parentId":"e0327","timestamp":"2026-01-01T00:09:54.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 147, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0329","parentId":"e0328","timestamp":"2026-01-01T00:09:55.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 147, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0330","parentId":"e0329","timestamp":"2026-01-01T00:09:56.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 148, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0331","parentId":"e0330","timestamp":"2026-01-01T00:09:57.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 148, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0332","parentId":"e0331","timestamp":"2026-01-01T00:09:58.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 149, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0333","parentId":"e0332","timestamp":"2026-01-01T00:09:59.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 149, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0334","parentId":"e0333","timestamp":"2026-01-01T00:10:00.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 150, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0335","parentId":"e0334","timestamp":"2026-01-01T00:10:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 150, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0336","parentId":"e0335","timestamp":"2026-01-01T00:10:02.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 151, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0337","parentId":"e0336","timestamp":"2026-01-01T00:10:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 151, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0338","parentId":"e0337","timestamp":"2026-01-01T00:10:04.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 152, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0339","parentId":"e0338","timestamp":"2026-01-01T00:10:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 152, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0340","parentId":"e0339","timestamp":"2026-01-01T00:10:06.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 153, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0341","parentId":"e0340","timestamp":"2026-01-01T00:10:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 153, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0342","parentId":"e0341","timestamp":"2026-01-01T00:10:08.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 154, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0343","parentId":"e0342","timestamp":"2026-01-01T00:10:09.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 154, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0344","parentId":"e0343","timestamp":"2026-01-01T00:10:10.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 155, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0345","parentId":"e0344","timestamp":"2026-01-01T00:10:11.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 155, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0346","parentId":"e0345","timestamp":"2026-01-01T00:10:12.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 156, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0347","parentId":"e0346","timestamp":"2026-01-01T00:10:13.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 156, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0348","parentId":"e0347","timestamp":"2026-01-01T00:10:14.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 157, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0349","parentId":"e0348","timestamp":"2026-01-01T00:10:15.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 157, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0350","parentId":"e0349","timestamp":"2026-01-01T00:10:16.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 158, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0351","parentId":"e0350","timestamp":"2026-01-01T00:10:17.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 158, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0352","parentId":"e0351","timestamp":"2026-01-01T00:10:18.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 159, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0353","parentId":"e0352","timestamp":"2026-01-01T00:10:19.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 159, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0354","parentId":"e0353","timestamp":"2026-01-01T00:10:20.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 160, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0355","parentId":"e0354","timestamp":"2026-01-01T00:10:21.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 160, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0356","parentId":"e0355","timestamp":"2026-01-01T00:10:22.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 161, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0357","parentId":"e0356","timestamp":"2026-01-01T00:10:23.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 161, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0358","parentId":"e0357","timestamp":"2026-01-01T00:10:24.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 162, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0359","parentId":"e0358","timestamp":"2026-01-01T00:10:25.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 162, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0360","parentId":"e0359","timestamp":"2026-01-01T00:10:26.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 163, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0361","parentId":"e0360","timestamp":"2026-01-01T00:10:27.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 163, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0362","parentId":"e0361","timestamp":"2026-01-01T00:10:28.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 164, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0363","parentId":"e0362","timestamp":"2026-01-01T00:10:29.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 164, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0364","parentId":"e0363","timestamp":"2026-01-01T00:10:30.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 165, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0365","parentId":"e0364","timestamp":"2026-01-01T00:10:31.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 165, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0366","parentId":"e0365","timestamp":"2026-01-01T00:10:32.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 166, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0367","parentId":"e0366","timestamp":"2026-01-01T00:10:33.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 166, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0368","parentId":"e0367","timestamp":"2026-01-01T00:10:34.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 167, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0369","parentId":"e0368","timestamp":"2026-01-01T00:10:35.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 167, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0370","parentId":"e0369","timestamp":"2026-01-01T00:10:36.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 168, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0371","parentId":"e0370","timestamp":"2026-01-01T00:10:37.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 168, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0372","parentId":"e0371","timestamp":"2026-01-01T00:10:38.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 169, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0373","parentId":"e0372","timestamp":"2026-01-01T00:10:39.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 169, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0374","parentId":"e0373","timestamp":"2026-01-01T00:10:40.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 170, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0375","parentId":"e0374","timestamp":"2026-01-01T00:10:41.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 170, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0376","parentId":"e0375","timestamp":"2026-01-01T00:10:42.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 171, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0377","parentId":"e0376","timestamp":"2026-01-01T00:10:43.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 171, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0378","parentId":"e0377","timestamp":"2026-01-01T00:10:44.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 172, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0379","parentId":"e0378","timestamp":"2026-01-01T00:10:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 172, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0380","parentId":"e0379","timestamp":"2026-01-01T00:10:46.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 173, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0381","parentId":"e0380","timestamp":"2026-01-01T00:10:47.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 173, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0382","parentId":"e0381","timestamp":"2026-01-01T00:10:48.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 174, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0383","parentId":"e0382","timestamp":"2026-01-01T00:10:49.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 174, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0384","parentId":"e0383","timestamp":"2026-01-01T00:10:50.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 175, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0385","parentId":"e0384","timestamp":"2026-01-01T00:10:51.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 175, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0386","parentId":"e0385","timestamp":"2026-01-01T00:10:52.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 176, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0387","parentId":"e0386","timestamp":"2026-01-01T00:10:53.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 176, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0388","parentId":"e0387","timestamp":"2026-01-01T00:10:54.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 177, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0389","parentId":"e0388","timestamp":"2026-01-01T00:10:55.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 177, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0390","parentId":"e0389","timestamp":"2026-01-01T00:10:56.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 178, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0391","parentId":"e0390","timestamp":"2026-01-01T00:10:57.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 178, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0392","parentId":"e0391","timestamp":"2026-01-01T00:10:58.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 179, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0393","parentId":"e0392","timestamp":"2026-01-01T00:10:59.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 179, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0394","parentId":"e0393","timestamp":"2026-01-01T00:11:00.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 180, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0395","parentId":"e0394","timestamp":"2026-01-01T00:11:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 180, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0396","parentId":"e0395","timestamp":"2026-01-01T00:11:02.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 181, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0397","parentId":"e0396","timestamp":"2026-01-01T00:11:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 181, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0398","parentId":"e0397","timestamp":"2026-01-01T00:11:04.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 182, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0399","parentId":"e0398","timestamp":"2026-01-01T00:11:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 182, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0400","parentId":"e0399","timestamp":"2026-01-01T00:11:06.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 183, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0401","parentId":"e0400","timestamp":"2026-01-01T00:11:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 183, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0402","parentId":"e0401","timestamp":"2026-01-01T00:11:08.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 184, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0403","parentId":"e0402","timestamp":"2026-01-01T00:11:09.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 184, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0404","parentId":"e0403","timestamp":"2026-01-01T00:11:10.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 185, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0405","parentId":"e0404","timestamp":"2026-01-01T00:11:11.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 185, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0406","parentId":"e0405","timestamp":"2026-01-01T00:11:12.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 186, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0407","parentId":"e0406","timestamp":"2026-01-01T00:11:13.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 186, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0408","parentId":"e0407","timestamp":"2026-01-01T00:11:14.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 187, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0409","parentId":"e0408","timestamp":"2026-01-01T00:11:15.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 187, this is the branch the supervisor should read"}]}}
+26
View File
@@ -0,0 +1,26 @@
/** Test-only Herdr adapter. Starts a real supervisor Pi in RPC mode, never a live pane. */
import { spawn } from "node:child_process";
import { appendFileSync } from "node:fs";
import { join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import goals from "../../src/index.js";
export default function testHost(pi: ExtensionAPI): void {
const children: ReturnType<typeof spawn>[] = [];
goals({ ...pi, exec: async (command, args, options) => {
if (command !== "herdr") return pi.exec(command, args, options);
if (args[0] === "pane") return { code: 0, stdout: JSON.stringify({ result: { pane: { pane_id: "test-supervisor-pane" } } }), stderr: "", killed: false };
if (args[1] === "start") {
const native = args.slice(args.indexOf("--") + 1);
const child = spawn(process.execPath, [process.argv[1], "--mode", "rpc", "--no-extensions", ...native, "--model", "offline/test"], {
cwd: process.cwd(), env: process.env, stdio: ["pipe", "pipe", "pipe"],
});
children.push(child);
const log = join(process.cwd(), "supervisor-rpc.jsonl");
child.stdout?.on("data", chunk => appendFileSync(log, chunk));
child.stderr?.on("data", chunk => appendFileSync(join(process.cwd(), "supervisor-stderr.log"), chunk));
}
return { code: 0, stdout: "{}", stderr: "", killed: false };
} });
pi.on("session_shutdown", () => { for (const child of children) child.kill(); });
}
+10
View File
@@ -0,0 +1,10 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { type ModelRole, RoleModels } from "../../src/role-models.js";
const [directory, role, provider, id] = process.argv.slice(2);
let selected: (event: any, ctx: any) => void;
const pi = { on(_name: string, handler: any) { selected = handler; }, setModel: async () => true };
const ctx = { model: { provider, id }, modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) }, ui: { notify(message: string) { throw new Error(message); } } };
const models = new RoleModels(pi as unknown as ExtensionAPI, directory);
await models.enter(role as ModelRole, ctx as unknown as ExtensionContext);
selected!({ source: "cycle", model: { provider, id } }, ctx);
+311 -52
View File
@@ -5,6 +5,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import piGoalsExtension from "../src/index.js";
vi.mock("../src/internal/supervisor/index.js", () => ({ default: () => {} }));
function setup(
selectChoices: Array<string | undefined>,
editorChoices: Array<string | undefined> = [],
@@ -14,19 +16,33 @@ function setup(
const commands = new Map<string, any>();
const hooks = new Map<string, any>();
const tools = new Map<string, any>();
const entries: Array<{ type: string; customType: string; data: unknown }> = [];
const entries: Array<{ type: string; customType: string; data?: unknown; details?: unknown; content?: string }> = [];
const events: string[] = [];
const messages: Array<{ content: string; display?: boolean }> = [];
const messages: Array<{ content: string; display?: boolean; customType?: string }> = [];
const busHandlers = new Map<string, Set<(value: unknown) => unknown>>();
const bus = {
on(name: string, handler: (value: unknown) => unknown) {
const handlers = busHandlers.get(name) ?? new Set();
handlers.add(handler);
busHandlers.set(name, handlers);
return () => handlers.delete(handler);
},
emit(name: string, value: unknown) {
for (const handler of busHandlers.get(name) ?? []) void handler(value);
},
};
const ctx = {
cwd,
model: { provider: "offline", id: "test" },
modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) },
hasUI: true,
isIdle: () => true,
sessionManager: { getSessionId: () => "session-a", getEntries: () => entries },
sessionManager: { getSessionId: () => "session-a", getSessionFile: () => join(cwd, "session-a.jsonl"), getEntries: () => entries, getBranch: () => entries },
ui: {
theme: { fg: (_kind: string, text: string) => text },
setStatus: () => {},
setWidget: () => {},
notify: () => {},
notify: vi.fn(),
select: async () => {
events.push("select");
return selectChoices.shift();
@@ -37,24 +53,101 @@ function setup(
},
},
};
let active = ["read", "write", "edit", "bash", "CompleteGoal", "RequestPlanReview"];
const pi = {
getActiveTools: () => active,
setActiveTools: (names: string[]) => { active = names; },
setModel: vi.fn(async (model: { provider: string; id: string }) => { const previousModel = ctx.model; ctx.model = model; await hooks.get("model_select")?.({ model, previousModel, source: "set" }, ctx); return true; }),
registerCommand: (name: string, command: any) => commands.set(name, command),
on: (name: string, handler: any) => hooks.set(name, handler),
on: (name: string, handler: any) => { const previous = hooks.get(name); hooks.set(name, async (...args: any[]) => { const prior = await previous?.(...args); return await handler(...args) ?? prior; }); },
appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }),
registerTool: (tool: any) => tools.set(tool.name, tool),
sendMessage: (message: { content: string; display?: boolean }) => {
events: bus,
sendMessage: (message: { content: string; display?: boolean; customType?: string }) => {
events.push("display");
messages.push(message);
},
sendUserMessage: (message: string) => messages.push({ content: message }),
};
piGoalsExtension(pi as unknown as ExtensionAPI);
return { commands, ctx, cwd, entries, events, hooks, messages, tools };
return { pi, bus, commands, ctx, cwd, entries, events, hooks, messages, tools };
}
async function promptReminder(flow: ReturnType<typeof setup>) {
const result = await flow.hooks.get("before_agent_start")({}, flow.ctx);
if (result?.message) flow.entries.push({ type: "custom_message", ...result.message });
await flow.hooks.get("context")({ messages: [] }, flow.ctx);
return result?.message;
}
async function settleDraft(flow: ReturnType<typeof setup>) {
await flow.tools.get("RequestPlanReview").execute("", {}, undefined, undefined, flow.ctx);
await flow.hooks.get("agent_settled")({}, flow.ctx);
}
describe("/goals draft flow", () => {
it("preserves prior drafts, displays the plan before Refine, and records editor notes", async () => {
const flow = setup(["Refine"], ["Keep two columns.\nDo not add a filter."]);
it("enables steward and hourly auto by default in new and cleared legacy sessions", async () => {
for (const legacy of [false, true]) {
const flow = setup([]);
try {
if (legacy) flow.entries.push({ type: "custom", customType: "pi-goals-state", data: { phase: null, planVersion: null, stewardEnabled: false, autoIntervalMs: null } });
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ defaultsVersion: 1, stewardEnabled: true, autoIntervalMs: 3_600_000 });
await flow.commands.get("goals").handler("clear", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null, stewardEnabled: true, autoIntervalMs: 3_600_000 });
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
}
});
it("preserves explicit off preferences across reload and a new plan", async () => {
const flow = setup([]);
try {
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("auto off", flow.ctx);
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ defaultsVersion: 1, stewardEnabled: false, autoIntervalMs: null });
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("does not enable automation midway through a legacy working plan", async () => {
const flow = setup([]);
try {
flow.entries.push({ type: "custom", customType: "pi-goals-state", data: { phase: "working", planVersion: 1, stewardEnabled: false, autoIntervalMs: null } });
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.commands.get("goals").handler("judge", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", stewardEnabled: false, autoIntervalMs: null });
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("uses the plan escape for objectives beginning with reserved command words", async () => {
const flow = setup([]);
try {
for (const objective of ["judge the vendor options", "auto generate captions", "steward the migration", "clear"]) {
await flow.commands.get("goals").handler(`plan ${objective}`, flow.ctx);
expect(flow.messages.at(-1)?.content).toContain(`Objective: ${objective}`);
}
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", planVersion: 4, judgeModel: null });
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("keeps an enabled plan in planning when a real supervisor cannot be launched", async () => {
const flow = setup(["Ready"]);
vi.stubEnv("HERDR_ENV", "");
try {
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
writeFileSync(join(flow.cwd, ".pi/plan/session-a-v1.md"), "# Plan\n\n1. [ ] goal: make the file\n");
await settleDraft(flow);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", stewardEnabled: true });
expect(flow.messages.some(message => message.content.startsWith("Work the goals"))).toBe(false);
} finally { vi.unstubAllEnvs(); rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("preserves prior drafts, displays the plan before Discuss, and records chat answers", async () => {
const flow = setup(["Discuss"]);
try {
const legacy = join(flow.cwd, ".pi/plan/session-a.md");
mkdirSync(join(flow.cwd, ".pi/plan"), { recursive: true });
@@ -68,9 +161,10 @@ describe("/goals draft flow", () => {
writeFileSync(v1, plan);
await flow.hooks.get("input")({ text: "The result must preserve column order.", source: "interactive" }, flow.ctx);
await flow.hooks.get("agent_settled")({}, flow.ctx);
expect(flow.events).toEqual(["display", "select", "editor"]);
expect(flow.messages.at(-1)?.content).toContain("Revise the plan at");
await settleDraft(flow);
expect(flow.events).toEqual(["display", "select"]);
expect(flow.messages.at(-1)?.content).toContain("normal chat");
await flow.hooks.get("input")({ text: "Keep two columns.\nDo not add a filter.", source: "interactive" }, flow.ctx);
expect(flow.messages.find((message) => message.display)?.content).toContain("goal: preserve this");
const interviewedPlan = readFileSync(v1, "utf-8");
expect(interviewedPlan).toContain("> The result must preserve column order.");
@@ -85,9 +179,12 @@ describe("/goals draft flow", () => {
expect(readFileSync(join(flow.cwd, ".pi/plan/session-a-v2.md"), "utf-8")).toBe("");
expect(flow.messages.at(-1)?.content).toContain("session-a-v2.md");
await flow.commands.get("goals").handler("judge the vendor options", flow.ctx);
await flow.commands.get("goals").handler("compare the vendor options", flow.ctx);
expect(readFileSync(join(flow.cwd, ".pi/plan/session-a-v3.md"), "utf-8")).toBe("");
expect(flow.messages.at(-1)?.content).toContain("Objective: judge the vendor options");
expect(flow.messages.at(-1)?.content).toContain("Objective: compare the vendor options");
await flow.commands.get("goals").handler("judge provider/model", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ judgeModel: "provider/model", planVersion: 3 });
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
@@ -100,7 +197,7 @@ describe("/goals draft flow", () => {
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: preserve this\n");
await flow.commands.get("goals").handler("--clear", flow.ctx);
await flow.commands.get("goals").handler("clear", flow.ctx);
expect(readFileSync(planPath, "utf-8")).toContain("goal: preserve this");
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null, planVersion: null });
@@ -112,43 +209,63 @@ describe("/goals draft flow", () => {
}
});
it("waits for Refine notes before starting a revision turn", async () => {
let submitNotes: (notes: string) => void;
const flow = setup(["Refine"], [], () => new Promise((resolve) => {
submitNotes = resolve;
}));
it("Discuss allows multiple chat turns, survives reload, and reopens review for an unchanged draft", async () => {
const flow = setup(["Discuss", "Ready"]);
try {
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: make this specific\n");
const review = flow.hooks.get("agent_settled")({}, flow.ctx);
await new Promise((resolve) => setImmediate(resolve));
expect(flow.events).toEqual(["display", "select", "editor"]);
expect(flow.messages.filter((message) => !message.display)).toHaveLength(1);
submitNotes!("Name the output artifact.");
await review;
expect(flow.messages.at(-1)?.content).toContain("Revise the plan at");
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
const path = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(path, "# Plan\n\n1. [ ] goal: make this specific\n");
await settleDraft(flow);
expect(flow.events).toEqual(["display", "select"]);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", reviewRequested: false });
await flow.hooks.get("agent_settled")({}, flow.ctx); // question turn, not another menu
await flow.hooks.get("input")({ text: "Only the existing output; no new UI.", source: "interactive" }, flow.ctx);
await flow.hooks.get("agent_settled")({}, flow.ctx);
await flow.hooks.get("session_start")({ reason: "reload" }, flow.ctx);
await flow.hooks.get("agent_settled")({}, flow.ctx);
expect(flow.events).toEqual(["display", "select"]);
const unchanged = readFileSync(path, "utf8");
await settleDraft(flow);
expect(readFileSync(path, "utf8")).toBe(unchanged);
await flow.hooks.get("agent_settled")({}, flow.ctx);
expect(flow.events).toEqual(["display", "select", "display", "select"]);
expect(flow.messages.filter(m => m.content.startsWith("Work the goals"))).toHaveLength(1);
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("starts work only when the human chooses Ready", async () => {
const flow = setup(["Ready"]);
try {
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: work on this\n");
await flow.hooks.get("agent_settled")({}, flow.ctx);
await settleDraft(flow);
expect(flow.events).toEqual(["display", "select"]);
expect(flow.messages.filter((message) => !message.display)).toHaveLength(2);
expect(flow.messages.at(-1)?.content).toContain("Work the goals");
await flow.hooks.get("session_start")({}, flow.ctx);
expect(await flow.hooks.get("before_agent_start")({}, flow.ctx)).toBeUndefined();
expect((await promptReminder(flow)).content).toContain("New session.");
expect(await promptReminder(flow)).toBeUndefined();
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
});
it("refuses to enable a steward after an unreviewed plan is already working", async () => {
const flow = setup(["Ready"]);
try {
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: make the file\n");
await settleDraft(flow);
await flow.commands.get("goals").handler("steward on", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", stewardEnabled: false, supervisor: null });
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
@@ -163,7 +280,7 @@ describe("/goals draft flow", () => {
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, original);
await flow.hooks.get("agent_settled")({}, flow.ctx);
await settleDraft(flow);
expect(flow.events).toEqual(["display", "select", "editor", "display", "select"]);
expect(() => readFileSync(planPath, "utf-8")).toThrow();
@@ -176,49 +293,76 @@ describe("/goals draft flow", () => {
it("reminds every eight unchanged working-set turns, ignoring log-only edits", async () => {
const flow = setup(["Ready"]);
try {
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n\n## Log\n");
await flow.hooks.get("agent_settled")({}, flow.ctx);
await settleDraft(flow);
await flow.hooks.get("turn_end")({}, flow.ctx);
for (let turn = 0; turn < 3; turn++) await flow.hooks.get("turn_end")({}, flow.ctx);
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n\n## Log\n- checked input\n");
for (let turn = 0; turn < 5; turn++) await flow.hooks.get("turn_end")({}, flow.ctx);
const reminder = await flow.hooks.get("context")({ messages: [] }, flow.ctx);
expect(reminder.messages.at(-1).content[0].text).toContain(".pi/plan/session-a-v1.md");
expect((await flow.hooks.get("context")({ messages: [] }, flow.ctx)).messages).toHaveLength(0);
const reminder = await promptReminder(flow);
expect(reminder.content).toContain(".pi/plan/session-a-v1.md");
expect(reminder.content).not.toContain("checked input");
expect(await promptReminder(flow)).toBeUndefined();
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n - [x] inspect input\n\n## Log\n- checked input\n");
await flow.hooks.get("turn_end")({}, flow.ctx);
for (let turn = 0; turn < 7; turn++) await flow.hooks.get("turn_end")({}, flow.ctx);
expect((await flow.hooks.get("context")({ messages: [] }, flow.ctx)).messages).toHaveLength(0);
expect(await promptReminder(flow)).toBeUndefined();
await flow.hooks.get("turn_end")({}, flow.ctx);
expect((await flow.hooks.get("context")({ messages: [] }, flow.ctx)).messages.at(-1).content[0].text).toContain("make the output");
expect((await promptReminder(flow)).content).toContain("make the output");
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
});
it("does not acknowledge an unpersisted reminder; retries fresh on the next natural prompt", async () => {
const flow = setup([]);
try {
flow.entries.push({ type: "custom", customType: "pi-goals-state", data: { phase: "working", planVersion: 1, stewardEnabled: false, autoIntervalMs: null } });
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
mkdirSync(join(flow.cwd, ".pi/plan"), { recursive: true });
writeFileSync(planPath, "1. [ ] goal: old plan\n");
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.hooks.get("session_compact")({}, flow.ctx);
const unsaved = await flow.hooks.get("before_agent_start")({}, flow.ctx);
expect(unsaved.message.content).toContain("old plan");
expect((await flow.hooks.get("context")({ messages: [] }, flow.ctx)).messages).toEqual([]);
writeFileSync(planPath, "1. [ ] goal: fresh plan\n");
const saved = await promptReminder(flow);
expect(saved.content).toContain("The session was just compacted.");
expect(saved.content).toContain("fresh plan");
expect(saved.details.reminderId).not.toBe(unsaved.message.details.reminderId);
expect(await promptReminder(flow)).toBeUndefined();
expect(flow.messages).toEqual([]); // no queued sendMessage/sendUserMessage delivery
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("auto-continues once on stop, then pauses after two no-progress wakes", async () => {
vi.useFakeTimers();
const flow = setup(["Ready"]);
try {
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n");
await flow.hooks.get("agent_settled")({}, flow.ctx);
await flow.commands.get("goals").handler("--auto 1", flow.ctx);
await settleDraft(flow);
await flow.commands.get("goals").handler("auto 1", flow.ctx);
await flow.hooks.get("agent_settled")({}, flow.ctx);
await settleDraft(flow);
await vi.advanceTimersByTimeAsync(0);
const autoMessages = () => flow.messages.filter((message) => message.content.includes("Auto-continue is enabled"));
expect(autoMessages()).toHaveLength(1);
await flow.hooks.get("agent_settled")({}, flow.ctx);
await settleDraft(flow);
await vi.advanceTimersByTimeAsync(60_000);
expect(autoMessages()).toHaveLength(2);
await flow.hooks.get("agent_settled")({}, flow.ctx);
await settleDraft(flow);
await vi.advanceTimersByTimeAsync(60_000);
expect(autoMessages()).toHaveLength(2);
} finally {
@@ -231,14 +375,15 @@ describe("/goals draft flow", () => {
vi.useFakeTimers();
const flow = setup(["Ready"]);
try {
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n");
await flow.hooks.get("agent_settled")({}, flow.ctx);
await flow.commands.get("goals").handler("--auto 1", flow.ctx);
await settleDraft(flow);
await flow.commands.get("goals").handler("auto 1", flow.ctx);
await flow.hooks.get("agent_start")({}, flow.ctx);
await flow.hooks.get("tool_call")({ toolName: "process", input: { action: "start" } }, flow.ctx);
await flow.hooks.get("agent_settled")({}, flow.ctx);
await settleDraft(flow);
await vi.advanceTimersByTimeAsync(0);
const autoMessages = () => flow.messages.filter((message) => message.content.includes("Auto-continue is enabled"));
expect(autoMessages()).toHaveLength(0);
@@ -269,7 +414,8 @@ describe("/goals draft flow", () => {
const pythonWrite = await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "python -c \"open('README.md', 'w')\"" } }, flow.ctx);
const signoff = await flow.tools.get("CompleteGoal").execute("", { goal: "work" }, undefined, undefined, flow.ctx);
await flow.hooks.get("session_compact")({}, flow.ctx);
const compacted = await flow.hooks.get("context")({ messages: [] }, flow.ctx);
expect(await flow.hooks.get("context")({ messages: [] }, flow.ctx)).toBeUndefined();
const compacted = await promptReminder(flow);
expect(writePlan).toBeUndefined();
expect(writeCode?.block).toBe(true);
@@ -278,9 +424,122 @@ describe("/goals draft flow", () => {
expect(pipeShell?.block).toBe(true);
expect(pythonWrite?.block).toBe(true);
expect(signoff.isError).toBe(true);
expect(compacted.messages.at(-1).content[0].text).toContain("[PLANNING MODE]");
expect(compacted.content).toContain("[PLANNING MODE]");
expect(await promptReminder(flow)).toBeUndefined();
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
});
});
describe("role models across goal lifecycle", () => {
const choice = (role: string) => ({ provider: `${role}-provider`, id: `${role}-model` });
const select = async (flow: ReturnType<typeof setup>, role: string) => {
flow.ctx.model = choice(role);
await flow.hooks.get("model_select")({ source: "cycle", model: flow.ctx.model }, flow.ctx);
};
const draft = (flow: ReturnType<typeof setup>, version = 1) => writeFileSync(join(flow.cwd, `.pi/plan/session-a-v${version}.md`), "# Plan\n\n1. [ ] goal: produce the artifact\n");
it("restores each role at Ready, fresh-instance reload/resume and the next plan without changing judge override", async () => {
const flow = setup(["Ready"]); const fresh = setup([]);
try {
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("first", flow.ctx); await select(flow, "planning"); draft(flow);
await settleDraft(flow); await select(flow, "worker");
await flow.commands.get("goals").handler("judge isolated/judge", flow.ctx);
fresh.entries.push(...structuredClone(flow.entries));
await fresh.hooks.get("session_start")({ reason: "resume" }, fresh.ctx);
expect(fresh.ctx.model).toEqual(choice("worker"));
await fresh.commands.get("goals").handler("second", fresh.ctx);
expect(fresh.ctx.model).toEqual(choice("planning"));
expect(fresh.entries.at(-1)?.data).toMatchObject({ phase: "planning", judgeModel: "isolated/judge" });
await fresh.hooks.get("session_start")({ reason: "reload" }, fresh.ctx);
expect(fresh.ctx.model).toEqual(choice("planning"));
} finally { for (const f of [flow, fresh]) { await f.hooks.get("session_shutdown")({}, f.ctx); rmSync(f.cwd, { recursive: true, force: true }); } }
});
it("failed Ready and explicit cancellation never save a worker choice over planning", async () => {
const flow = setup(["Ready", "Cancel"]); vi.stubEnv("HERDR_ENV", "");
try {
await flow.commands.get("goals").handler("objective", flow.ctx); await select(flow, "planning"); draft(flow);
await settleDraft(flow); expect(flow.ctx.model).toEqual(choice("planning"));
expect(flow.messages.some(m => m.content.startsWith("Work the goals"))).toBe(false);
await settleDraft(flow);
await select(flow, "outside-plan");
await flow.commands.get("goals").handler("next plan", flow.ctx);
expect(flow.ctx.model).toEqual(choice("planning"));
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("unavailable worker model blocks Ready until an explicit selection, without changing its remembered provider", async () => {
const flow = setup(["Ready", "Ready"]);
const dir = join(process.env.PI_CODING_AGENT_DIR!, "pi-goals"); mkdirSync(dir);
writeFileSync(join(dir, "worker-model.json"), JSON.stringify(choice("worker")));
try {
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx); draft(flow);
flow.pi.setModel.mockResolvedValueOnce(false);
await settleDraft(flow);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning" });
expect(flow.messages.some(m => m.content.startsWith("Work the goals"))).toBe(false);
expect(flow.ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("has no authentication"), "error");
expect(JSON.parse(readFileSync(join(dir, "worker-model.json"), "utf8"))).toEqual(choice("worker"));
await select(flow, "replacement-worker"); await settleDraft(flow);
expect(flow.ctx.model).toEqual(choice("replacement-worker"));
expect(flow.messages.filter(m => m.content.startsWith("Work the goals"))).toHaveLength(1);
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("supervisor resume uses its own preference and never restores inherited worker automation/tools", async () => {
const flow = setup([]);
const dir = join(process.env.PI_CODING_AGENT_DIR!, "pi-goals"); mkdirSync(dir);
writeFileSync(join(dir, "supervisor-model.json"), JSON.stringify(choice("supervisor")));
try {
flow.entries.push({ type: "custom", customType: "pi-goals-state", data: { phase: "working", planVersion: 1, autoIntervalMs: 1, stewardEnabled: false } });
flow.entries.push({ type: "custom", customType: "pi-goals-supervisor", data: { binding: { id: "binding" }, workerId: "worker" } });
await flow.hooks.get("session_start")({ reason: "resume" }, flow.ctx);
expect(flow.ctx.model).toEqual(choice("supervisor"));
expect(flow.pi.getActiveTools()).not.toContain("CompleteGoal");
expect(flow.pi.getActiveTools()).not.toContain("RequestPlanReview");
await flow.hooks.get("agent_settled")({}, flow.ctx);
expect(flow.messages).toEqual([]);
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("waives alignment only for the current plan and Escape returns to chat without deleting the draft", async () => {
const flow = setup([undefined]);
try {
await flow.commands.get("goals").handler("first; no questions", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ questionsWaived: true });
expect(flow.messages.at(-1)?.content).toContain("THIS plan only");
await flow.commands.get("goals").handler("second", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ questionsWaived: false, reviewRequested: false });
expect(flow.messages.at(-1)?.content).toContain("previous plan does NOT apply");
draft(flow, 2);
await flow.hooks.get("agent_settled")({}, flow.ctx); expect(flow.events).toEqual([]);
await settleDraft(flow);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", reviewRequested: false });
expect(readFileSync(join(flow.cwd, ".pi/plan/session-a-v2.md"), "utf8")).toContain("produce the artifact");
expect(flow.events).toEqual(["display", "select"]);
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
});
it("cancels an ordinary Ready handoff suspended in setModel without losing planning preference", async () => {
const flow = setup(["Ready"]);
try {
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
writeFileSync(join(flow.cwd, ".pi/plan/session-a-v1.md"), "# Plan\n\n1. [ ] goal: produce output\n");
const dir = join(process.env.PI_CODING_AGENT_DIR!, "pi-goals");
writeFileSync(join(dir, "worker-model.json"), JSON.stringify({ provider: "worker", id: "work" }));
let finish!: () => void;
flow.pi.setModel.mockImplementationOnce(async model => { await new Promise<void>(resolve => { finish = resolve; }); flow.ctx.model = model; return true; });
const starting = settleDraft(flow);
await vi.waitFor(() => expect(finish).toBeTypeOf("function"));
await flow.commands.get("goals").handler("clear", flow.ctx);
finish(); await starting;
expect(flow.ctx.model).toEqual({ provider: "offline", id: "test" });
expect(flow.messages.some(m => m.content.startsWith("Work the goals"))).toBe(false);
expect(JSON.parse(readFileSync(join(dir, "worker-model.json"), "utf8"))).toEqual({ provider: "worker", id: "work" });
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
File diff suppressed because it is too large Load Diff
+328
View File
@@ -0,0 +1,328 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { backgroundState } from "../../src/internal/supervisor/background.js";
import extension from "../../src/internal/supervisor/index.js";
import { PLAN_API_EVENT, type PlanBinding, planHash } from "../../src/internal/supervisor/plan-api.js";
const tick = () => new Promise(resolve => setImmediate(resolve));
function pairHarness() {
const cwd = mkdtempSync(join(tmpdir(), "supervise-plan-"));
const peers: any[] = [];
const wire: any[] = [];
const planPath = join(cwd, "plan.md");
writeFileSync(planPath, '# Plan\n\nUser voice: render the literal "[x]"\n\n1. [ ] goal: first\n2. [ ] goal: second\n');
const binding: PlanBinding = { id: "binding", planPath, workerSession: join(cwd, "worker.jsonl"), workerPane: "w1:p1", supervisorSession: join(cwd, "supervisor.jsonl"), supervisorPane: "w1:p2", everyTurns: 50, intervalMs: 3_600_000, compactTokens: 100_000 };
function make(id: string, entries: any[] = [], sessionFile = join(cwd, `${id}.jsonl`)) {
const hooks = new Map<string, any[]>(); const listeners = new Map<string, Set<any>>(); const tools = new Map<string, any>();
const messages: any[] = []; const contexts: any[] = []; const commands = new Map<string, any>();
let active: string[] = ["read", "grep", "bash", "edit", "write"];
const peer: any = { id, messages, contexts, entries, tools, commands, modelReady: true, activeProcesses: 0, activeSubagents: 0, idle: true, compactions: 0, tokens: 50_000, aborts: 0 };
const bus = {
on(name: string, handler: any) { const set = listeners.get(name) ?? new Set(); set.add(handler); listeners.set(name, set); return () => set.delete(handler); },
emit(name: string, value: any) {
if (name === "intercom:extension-register") {
peer.receive = value.onEvent;
value.onReady({ snapshot: () => ({ connected: true, supported: true }), listSessions: async () => peers.map(p => ({ id: p.id, pid: p === peer ? process.pid : process.pid + 1, cwd, model: "test" })), publish(payload: any) {
wire.push({ from: peer.id, ...payload });
for (const p of peers) queueMicrotask(() => p.receive?.({ type: "message", fromSessionId: peer.id, payload }));
} }); return true;
}
if (name === "processes:request:list") value.reply(Array.from({ length: peer.activeProcesses }, () => ({ status: "running" })));
if (name === "subagents:rpc:v1:request") bus.emit(`subagents:rpc:v1:reply:${value.requestId}`, { requestId: value.requestId, success: true, data: { fleet: { version: 1, totalActive: peer.activeSubagents } } });
for (const handler of listeners.get(name) ?? []) handler(value);
},
};
const pi: any = { events: bus, on(name: string, fn: any) { hooks.set(name, [...(hooks.get(name) ?? []), fn]); }, registerTool(tool: any) { tools.set(tool.name, tool); active.push(tool.name); }, registerCommand(name: string, command: any) { commands.set(name, command); }, appendEntry(customType: string, data: any) { entries.push({ type: "custom", customType, data }); }, getActiveTools: () => active, setActiveTools: (names: string[]) => { active = names; }, getAllTools: () => [{ name: "subagent" }], sendMessage: (message: any) => contexts.push(message), sendUserMessage: (text: string, options: any) => messages.push({ text, options }) };
const ctx: any = { cwd, hasUI: true, model: { contextWindow: 200_000 }, isIdle: () => peer.idle, abort: () => { peer.aborts++; }, getContextUsage: () => ({ tokens: peer.tokens }), compact({ onComplete }: any) { peer.compactions++; peer.tokens = 10_000; onComplete({}); }, ui: { notify() {}, setStatus() {}, theme: { fg: (_: any, text: string) => text } }, sessionManager: { getEntries: () => entries, getBranch: () => entries, getSessionFile: () => sessionFile } };
peer.pi = pi; peer.ctx = ctx;
peer.hook = async (name: string, event = {}) => { for (const fn of hooks.get(name) ?? []) await fn(event, ctx); };
peer.request = (method: string, params: any = {}) => new Promise<any>((resolve, reject) => bus.emit(PLAN_API_EVENT, { version: 1, method, ...params, resolve, reject }));
peers.push(peer); extension(pi, () => peer.modelReady); return peer;
}
const worker = make("worker"); const supervisor = make("supervisor");
return { worker, supervisor, binding, wire, planPath, async restart(peer: any, id: string) { await peer.hook("session_shutdown"); peers.splice(peers.indexOf(peer), 1); const replacement = make(id, structuredClone(peer.entries), peer.ctx.sessionManager.getSessionFile()); await replacement.hook("session_start"); await tick(); return replacement; }, async start() { await worker.hook("session_start"); await supervisor.hook("session_start"); await worker.request("prepare", { binding }); const attached = worker.request("attached", { bindingId: binding.id }); void attached.catch(() => {}); await supervisor.request("bootstrap", { binding, workerId: "worker" }); await attached; }, async close() { for (const peer of peers) await peer.hook("session_shutdown"); rmSync(cwd, { recursive: true, force: true }); } };
}
test("plan bootstrap compacts only the supervisor and pairing alone never starts a worker or a review", async () => {
const h = pairHarness(); try {
await h.start(); assert.equal(h.worker.compactions, 0); assert.equal(h.supervisor.compactions, 1);
assert.equal(h.worker.messages.length, 0); assert.equal(h.supervisor.messages.length, 0);
assert.ok(h.supervisor.contexts.some((m: any) => m.content.includes('literal "[x]"')));
await h.worker.request("activate", { bindingId: h.binding.id }); await tick();
assert.equal(h.supervisor.messages.length, 1); assert.match(h.supervisor.messages[0].text, /still going/);
assert.equal(h.worker.messages.length, 0);
} finally { await h.close(); }
});
test("goal decisions are correlated, preserve the pair across two goals, and cannot call overall done", async () => {
const h = pairHarness(); try {
await h.start(); const hash = planHash(readFileSync(h.planPath, "utf8"));
for (const goal of ["first", "second"]) {
const result = h.worker.request("review", { bindingId: h.binding.id, goal, planHash: hash }); await tick();
const req = h.wire.findLast((w: any) => w.t === "goal_review");
await assert.rejects(h.supervisor.tools.get("done").execute("", { reason: "done" }, undefined, undefined, h.supervisor.ctx), /Open plan goals/);
await assert.rejects(h.supervisor.tools.get("review_goal").execute("", { requestId: "stale", decision: "approve", reason: "ok" }), /No matching/);
await h.supervisor.tools.get("review_goal").execute("", { requestId: req.requestId, decision: "approve", reason: "Faithful to the plan" });
assert.equal((await result).goal, goal); assert.equal((await h.worker.request("status")).connected, true);
}
assert.equal(h.wire.filter((w: any) => w.t === "done").length, 0);
} finally { await h.close(); }
});
test("abort and stop cancel pending requests; late decisions cannot approve a replacement", async () => {
const h = pairHarness(); try {
await h.start(); const abort = new AbortController();
const pending = h.worker.request("review", { bindingId: h.binding.id, goal: "first", planHash: planHash(readFileSync(h.planPath, "utf8")), signal: abort.signal });
const rejected = assert.rejects(pending, /cancelled/); await tick(); abort.abort(); await rejected; await tick();
assert.equal(h.supervisor.aborts, 1);
await h.worker.request("stop", { bindingId: h.binding.id }); await tick(); assert.equal((await h.worker.request("status")).connected, false);
} finally { await h.close(); }
});
test("50 actual model turns trigger one view, independent of the number of messages", async () => {
const h = pairHarness(); try {
await h.start(); await h.worker.request("activate", { bindingId: h.binding.id }); await tick(); h.worker.idle = false;
const count = h.wire.filter((w: any) => w.t === "view").length;
for (let n = 0; n < 49; n++) await h.worker.hook("turn_end");
assert.equal(h.wire.filter((w: any) => w.t === "view").length, count);
await h.worker.hook("turn_end"); await tick();
assert.equal(h.wire.filter((w: any) => w.t === "view").length, count + 1);
} finally { await h.close(); }
});
test("unknown background providers are not proof of quiescence", async () => {
const result = await backgroundState({ events: { emit() {} }, getAllTools: () => [] });
assert.equal(result.quiet, false); assert.match(result.description, /unknown/);
});
test("stale plan content invalidates a pending goal review", async () => {
const h = pairHarness(); try {
await h.start(); const abort = new AbortController();
const pending = h.worker.request("review", { bindingId: h.binding.id, goal: "first", planHash: planHash(readFileSync(h.planPath, "utf8")), signal: abort.signal });
const rejected = assert.rejects(pending, /cancelled/); await tick();
const req = h.wire.findLast((w: any) => w.t === "goal_review"); writeFileSync(h.planPath, "Human changed the requirement");
await assert.rejects(h.supervisor.tools.get("review_goal").execute("", { requestId: req.requestId, decision: "approve", reason: "ok" }), /Plan changed/);
abort.abort(); await rejected;
} finally { await h.close(); }
});
test("small forks skip compaction, but real compaction failure prevents pairing", async () => {
const small = pairHarness(); try {
small.supervisor.tokens = 10_000; await small.start(); assert.equal(small.supervisor.compactions, 0);
assert.ok(small.supervisor.contexts.some((m: any) => m.content.includes('literal "[x]"')));
} finally { await small.close(); }
const failed = pairHarness(); try {
failed.supervisor.ctx.compact = ({ onError }: any) => onError(new Error("Compaction provider failed"));
await assert.rejects(failed.start(), /Compaction provider failed/);
assert.equal(failed.wire.filter((w: any) => w.t === "pair").length, 0);
} finally { await failed.close(); }
});
test("the hour timer and a coincident turn checkpoint produce a single view", async (t) => {
t.mock.timers.enable({ apis: ["Date", "setInterval", "setTimeout"] });
const h = pairHarness(); try {
await h.start(); await h.worker.request("activate", { bindingId: h.binding.id }); await tick(); h.worker.idle = false;
const count = h.wire.filter((w: any) => w.t === "view").length;
for (let n = 0; n < 49; n++) await h.worker.hook("turn_end");
t.mock.timers.tick(3_600_000); await h.worker.hook("turn_end"); await tick();
assert.equal(h.wire.filter((w: any) => w.t === "view").length, count + 1);
} finally { await h.close(); t.mock.timers.reset(); }
});
test("settled checks wait for tracked processes and subagents to finish", async (t) => {
t.mock.timers.enable({ apis: ["Date", "setInterval", "setTimeout"] });
const h = pairHarness(); try {
await h.start(); await h.worker.request("activate", { bindingId: h.binding.id }); await tick();
t.mock.timers.tick(2_000); h.worker.activeProcesses = 1; h.worker.activeSubagents = 1;
const count = h.wire.filter((w: any) => w.t === "view").length;
await h.worker.hook("agent_settled"); await tick();
assert.equal(h.wire.filter((w: any) => w.t === "view").length, count);
h.worker.activeProcesses = 0; h.worker.pi.events.emit("processes:ended", {}); await tick();
assert.equal(h.wire.filter((w: any) => w.t === "view").length, count);
h.worker.activeSubagents = 0; h.worker.pi.events.emit("subagent:async-complete", {}); await tick();
assert.equal(h.wire.filter((w: any) => w.t === "view").length, count + 1);
} finally { await h.close(); t.mock.timers.reset(); }
});
test("bootstrap stop cannot resurrect a supervisor after compaction completes", async () => {
const h = pairHarness(); let finish!: () => void;
h.supervisor.ctx.compact = ({ onComplete }: any) => { finish = onComplete; };
try {
const starting = h.start(); const rejected = assert.rejects(starting, /cancelled/); await tick();
await h.worker.request("stop", { bindingId: h.binding.id }); await tick(); finish(); await rejected;
assert.equal(h.wire.filter((w: any) => w.t === "pair").length, 0);
const restarted = await h.restart(h.supervisor, "stopped-bootstrap-reloaded");
await assert.rejects(restarted.request("bootstrap", { binding: h.binding, workerId: "worker" }), /was stopped/);
assert.equal(restarted.compactions, 0);
assert.equal(h.wire.filter((w: any) => w.t === "pair").length, 0);
assert.equal((await restarted.request("status")).binding.stopped, true);
} finally { await h.close(); }
});
test("a restarted worker reconnects by exact saved session identity without a new supervisor", async () => {
const h = pairHarness(); try {
await h.start(); await h.worker.request("activate", { bindingId: h.binding.id }); await tick();
const reloaded = await h.restart(h.worker, "worker-after-restart");
assert.equal((await reloaded.request("status")).connected, true);
assert.equal(h.wire.filter((w: any) => w.t === "pair").length, 1);
assert.equal(h.supervisor.compactions, 1);
const pending = reloaded.request("review", { bindingId: h.binding.id, goal: "first", planHash: planHash(readFileSync(h.planPath, "utf8")) }); await tick();
const req = h.wire.findLast((w: any) => w.t === "goal_review");
await h.supervisor.tools.get("review_goal").execute("", { requestId: req.requestId, decision: "approve", reason: "Same plan" });
assert.equal((await pending).decision, "approve");
} finally { await h.close(); }
});
test("unknown initial context must compact instead of taking the known-small shortcut", async () => {
const h = pairHarness(); try {
h.supervisor.tokens = null;
await h.start();
assert.equal(h.supervisor.compactions, 1);
} finally { await h.close(); }
});
test("null post-compaction usage cannot raise the next configured 100k checkpoint", async () => {
const h = pairHarness(); try {
h.supervisor.tokens = 150_000;
h.supervisor.ctx.compact = ({ onComplete }: any) => {
h.supervisor.compactions++;
h.supervisor.tokens = null; // Native Pi has no assistant usage yet after compaction.
onComplete({ estimatedTokensAfter: 10_000 });
};
await h.start();
await h.worker.request("activate", { bindingId: h.binding.id }); await tick();
assert.equal(h.supervisor.compactions, 1);
h.supervisor.tokens = 100_001;
await h.supervisor.receive({ type: "message", fromSessionId: "worker", payload: { t: "view", to: "supervisor", view: "New work since compaction", stopped: false } });
await tick();
assert.equal(h.supervisor.compactions, 2);
} finally { await h.close(); }
});
test("stopping a routine view during compaction invalidates its suspended continuation", async () => {
const h = pairHarness(); let finish!: () => void;
try {
await h.start(); await h.worker.request("activate", { bindingId: h.binding.id }); await tick();
h.supervisor.tokens = 150_000;
h.supervisor.ctx.compact = ({ onComplete }: any) => { finish = onComplete; };
h.supervisor.receive({ type: "message", fromSessionId: "worker", payload: { t: "view", to: "supervisor", view: "Must not restart the stopped supervisor", stopped: false } });
await tick(); assert.equal(typeof finish, "function");
await h.worker.request("stop", { bindingId: h.binding.id }); await tick();
const messages = h.supervisor.messages.length;
const contexts = h.supervisor.contexts.length;
const entries = h.supervisor.entries.length;
finish(); await tick();
assert.equal(h.supervisor.messages.length, messages);
assert.equal(h.supervisor.contexts.length, contexts);
assert.equal(h.supervisor.entries.length, entries);
assert.equal((await h.supervisor.request("status")).binding.stopped, true);
} finally { await h.close(); }
});
test("restart of a provisional bootstrap resumes compaction and pairing in the same saved session", async () => {
const h = pairHarness(); let finish!: () => void;
try {
await h.worker.hook("session_start"); await h.supervisor.hook("session_start");
await h.worker.request("prepare", { binding: h.binding });
const attached = h.worker.request("attached", { bindingId: h.binding.id });
void attached.catch(() => {});
h.supervisor.ctx.compact = ({ onComplete }: any) => { finish = onComplete; };
const interrupted = h.supervisor.request("bootstrap", { binding: h.binding, workerId: "worker" });
const rejected = assert.rejects(interrupted, /cancelled/); await tick();
assert.equal(h.supervisor.entries.at(-1).data.role, "supervisor");
assert.notEqual(h.supervisor.entries.at(-1).data.planInitialized, true);
const restarted = await h.restart(h.supervisor, "supervisor-restarted");
finish(); await rejected;
assert.equal(h.wire.filter((w: any) => w.t === "look" || w.t === "plan_hello").length, 0);
await restarted.request("bootstrap", { binding: h.binding, workerId: "worker" });
const ready = await attached;
assert.equal(ready.supervisorSession, h.binding.supervisorSession);
assert.equal(restarted.ctx.sessionManager.getSessionFile(), h.binding.supervisorSession);
assert.equal(restarted.compactions, 1);
assert.equal(restarted.entries.at(-1).data.planInitialized, true);
assert.equal((await h.worker.request("status")).connected, true);
assert.equal(h.wire.filter((w: any) => w.t === "pair").length, 1);
// An acknowledged reload reuses the pairing rather than bootstrapping once again.
const acknowledged = await h.restart(restarted, "supervisor-acknowledged-reloaded");
await acknowledged.request("bootstrap", { binding: h.binding, workerId: "worker" });
assert.equal(acknowledged.compactions, 0);
assert.equal(h.wire.filter((w: any) => w.t === "pair").length, 1);
} finally { await h.close(); }
});
for (const stop of ["command", "done"]) test(`${stop} preserves a stopped supervisor across reload`, async () => {
const h = pairHarness(); try {
await h.start();
if (stop === "command") await h.supervisor.commands.get("supervise").handler("stop", h.supervisor.ctx);
else {
writeFileSync(h.planPath, "# Plan\n\n1. [x] goal: first\n2. [x] goal: second\n");
await h.worker.request("activate", { bindingId: h.binding.id }); await tick();
await h.supervisor.tools.get("done").execute("", { reason: "All goals accepted" }, undefined, undefined, h.supervisor.ctx);
}
await tick(); const pairCount = h.wire.filter((w: any) => w.t === "pair").length;
const restarted = await h.restart(h.supervisor, `${stop}-reloaded`);
await assert.rejects(restarted.request("bootstrap", { binding: h.binding, workerId: "worker" }), /was stopped/);
assert.equal(restarted.compactions, 0);
assert.equal(h.wire.filter((w: any) => w.t === "pair").length, pairCount);
assert.equal((await restarted.request("status")).binding.stopped, true);
} finally { await h.close(); }
});
test("same-binding replay retains activation when the supervisor lost its acknowledgement", async () => {
const h = pairHarness(); try {
await h.worker.hook("session_start"); await h.supervisor.hook("session_start");
await h.worker.request("prepare", { binding: h.binding });
const attached = h.worker.request("attached", { bindingId: h.binding.id });
// Simulate loss of the supervisor before it receives/persists the acknowledgement.
// The worker still receives pair and can finish Ready independently of that process.
h.supervisor.receive = () => {};
void h.supervisor.request("bootstrap", { binding: h.binding, workerId: "worker" }).catch(() => {});
await attached;
await h.worker.request("activate", { bindingId: h.binding.id }); await tick();
assert.equal((await h.worker.request("status")).binding.active, true);
assert.notEqual(h.supervisor.entries.at(-1).data.planInitialized, true);
assert.notEqual(h.supervisor.entries.at(-1).data.plan.active, true);
const restarted = await h.restart(h.supervisor, "supervisor-after-lost-ack");
await restarted.request("bootstrap", { binding: h.binding, workerId: "worker" });
assert.equal(restarted.ctx.sessionManager.getSessionFile(), h.binding.supervisorSession);
assert.equal((await h.worker.request("status")).binding.active, true);
assert.equal(h.wire.findLast((w: any) => w.t === "paired").plan.active, true);
assert.equal((await restarted.request("status")).binding.active, true);
assert.equal(restarted.entries.at(-1).data.planInitialized, true);
const views = h.wire.filter((w: any) => w.t === "view").length;
const messages = restarted.messages.length;
h.worker.idle = false;
for (let n = 0; n < 50; n++) await h.worker.hook("turn_end");
await tick();
assert.equal(h.wire.filter((w: any) => w.t === "view").length, views + 1);
assert.equal(restarted.messages.length, messages + 1);
assert.equal(h.wire.filter((w: any) => w.t === "plan_activate").length, 1);
assert.equal(h.worker.messages.length, 0); // No new Ready/work handoff was necessary.
} finally { await h.close(); }
});
test("model-unavailable stop validates binding, cancels pending reviews and ignores old directives", async () => {
const h = pairHarness(); try {
await h.start(); await h.worker.request("activate", { bindingId: h.binding.id }); await tick();
const abort = new AbortController();
const pending = h.worker.request("review", { bindingId: h.binding.id, goal: "first", planHash: planHash(readFileSync(h.planPath, "utf8")), signal: abort.signal });
const cancelled = assert.rejects(pending, /cancelled/); await tick();
h.worker.modelReady = false; abort.abort(); await cancelled; await tick();
await assert.rejects(h.worker.request("stop", { bindingId: "wrong-binding" }), /No matching plan pairing/);
await h.worker.request("stop", { bindingId: h.binding.id }); await tick();
assert.equal(h.worker.entries.at(-1).data.role, "none");
assert.equal(h.supervisor.entries.at(-1).data.role, "none");
const before = h.worker.messages.length;
h.worker.modelReady = true;
h.worker.receive({ type: "message", fromSessionId: "supervisor", payload: { t: "directive", to: "worker", text: "stale instruction" } });
await tick(); assert.equal(h.worker.messages.length, before);
} finally { await h.close(); }
});
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { copyFileSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { childPiProcesses } from "../../src/internal/supervisor/subagents.js";
/**
* Runs the real ps command against a real child process, because the parsing is the part most
* likely to be quietly wrong: a column order or a comm name that differs by platform reads as
* "no subagents", which is the answer that lets the supervisor declare a busy worker finished.
*/
function spawnChildNamedPi() {
const dir = mkdtempSync(join(tmpdir(), "supervisor-test-"));
const fake = join(dir, "pi");
copyFileSync("/usr/bin/sleep", fake); // ps reports comm from the executable name
return spawn(fake, ["30"], { stdio: "ignore" });
}
test("a child process named pi is found by ps, and stops being found when it exits", async (t) => {
if (process.platform !== "linux" && process.platform !== "darwin") return t.skip("ps only");
assert.deepEqual(await childPiProcesses(), [], "no subagents before one is started");
const child = spawnChildNamedPi();
await new Promise((r) => setTimeout(r, 300));
assert.deepEqual(await childPiProcesses(), [child.pid], "the running child must be reported");
child.kill();
await new Promise((r) => child.once("exit", r));
assert.deepEqual(await childPiProcesses(), [], "and not reported once it exits");
});
test("the check is a snapshot, so it cannot hold up the worker's settle", async (t) => {
if (process.platform !== "linux" && process.platform !== "darwin") return t.skip("ps only");
const child = spawnChildNamedPi();
await new Promise((r) => setTimeout(r, 300));
const started = Date.now();
assert.deepEqual(await childPiProcesses(), [child.pid]);
assert.ok(Date.now() - started < 1000, "it returns while the child is still running, it does not wait");
child.kill();
});
+314
View File
@@ -0,0 +1,314 @@
import assert from "node:assert/strict";
import test from "node:test";
import { age, buildView, type Entry, goalPreview, MAX_VIEW_BYTES, outstandingWork, progressKey, sinceLastTurn, turnsSince } from "../../src/internal/supervisor/view.js";
function assistant(text: string, calls: Array<{ name: string; args: Record<string, unknown> }> = []): Entry {
return {
type: "message",
message: {
role: "assistant",
content: [
{ type: "text", text },
...calls.map((c) => ({ type: "toolCall", name: c.name, arguments: c.args })),
],
},
};
}
function toolResult(toolName: string, text: string, isError = false): Entry {
return { type: "message", message: { role: "toolResult", toolName, isError, content: [{ type: "text", text }] } };
}
test("a one-line goal stays whole while a multi-line goal has a locator", () => {
assert.equal(goalPreview("fix the parser"), "fix the parser");
assert.equal(goalPreview("Build the causal evaluation.\nThe full rubric follows."), "Build the causal evaluation. [...]");
const view = buildView({
goal: "Build the causal evaluation.\nThe full rubric follows.",
status: "idle",
entries: [assistant("working")],
});
assert.match(view, /<goal>\nBuild the causal evaluation\. \[\.\.\.\]\n<\/goal>/);
assert.doesNotMatch(view, /The full rubric follows/);
});
test("a view carries only the turns the supervisor has not been sent", () => {
// The supervisor is a real session and keeps every view it read, so re-sending the whole
// transcript each time is a second copy of what it already has. It grows with every review.
const entries = [assistant("the first thing I did"), assistant("the second thing I did")];
const first = buildView({ goal: "g", status: "idle", entries });
assert.match(first, /the first thing I did/);
assert.equal(turnsSince(entries), 2);
entries.push(assistant("the third thing I did"));
const next = buildView({ goal: "g", status: "idle", entries, since: 2 });
assert.match(next, /the third thing I did/);
assert.doesNotMatch(next, /the first thing I did/, "already sent, so it must not go again");
assert.match(next, /# New turns since your last look \(1 of 3\)/);
});
test("the last two reasoning blocks stay in the narrative, and older ones drop out", () => {
// Block shape read out of a real session jsonl. pi-vcc's normalize keeps only text and toolCall,
// so reasoning reaches nobody, although you see it on screen. It belongs beside the tool call it
// produced, not in a section of its own, because that is the order you read a session in.
const thinker = (thinking: string, said: string): Entry => ({
type: "message",
message: { role: "assistant", content: [{ type: "thinking", thinking }, { type: "text", text: said }] },
});
const entries = [
thinker("TOO OLD TO SEND", "first try"),
thinker("the bash tool is dead, LET ME TEST READ", "second try"),
thinker("read works, SO I WILL DELEGATE INSTEAD", "third try"),
];
const view = buildView({ goal: "g", status: "working", entries });
assert.match(view, /LET ME TEST READ[\s\S]*SO I WILL DELEGATE INSTEAD/, "in order, oldest first");
assert.match(view, /LET ME TEST READ[\s\S]*second try/, "each thought sits with the turn it produced");
assert.doesNotMatch(view, /TOO OLD TO SEND/, "two blocks, or the view becomes a second transcript");
assert.match(view, /\(thinking\)/, "marked, so the supervisor knows it is reasoning and not speech");
});
test("a compaction restarts the view, so no turn falls into the gap", () => {
// getBranch keeps the entries a compaction replaced, so the mark now points past the end. Read
// the wrong slice here and the supervisor silently reads a piece of the old history.
const entries: Entry[] = [
assistant("old work"),
{ type: "compaction", summary: "SUMMARY OF THE OLD WORK" },
assistant("work after the compaction"),
];
const view = buildView({ goal: "g", status: "idle", entries, since: 9 });
assert.match(view, /The worker compacted, so this view restarts/);
assert.match(view, /SUMMARY OF THE OLD WORK/);
assert.match(view, /work after the compaction/);
assert.equal(turnsSince(entries), 1, "the mark restarts from the compaction, not from the session");
});
test("pi-vcc reports the files the worker wrote, and separates them from the ones it read", () => {
const view = buildView({
goal: "g",
status: "idle",
entries: [
assistant("editing", [{ name: "edit", args: { path: "src/a.ts" } }]),
assistant("reading", [{ name: "read", args: { path: "src/never.ts" } }]),
assistant("writing", [{ name: "write", args: { file_path: "docs/b.md" } }]),
],
});
assert.match(view, /Modified:.*src\/a\.ts/);
assert.match(view, /Read:.*src\/never\.ts/);
});
test("progressKey is unchanged when a review produced no new file or commit", () => {
const worked = [assistant("editing", [{ name: "edit", args: { path: "src/a.ts" } }])];
const talked = [...worked, assistant("I will look into that shortly.")];
const wroteMore = [...worked, assistant("editing", [{ name: "write", args: { path: "src/b.ts" } }])];
assert.equal(progressKey(talked), progressKey(worked), "talking is not progress");
assert.notEqual(progressKey(wroteMore), progressKey(worked), "a new file is progress");
});
test("progressKey still sees a new file past pi-vcc's ten path display cap", () => {
// The rendered section stops at ten paths and says "(+N more)", so reading it there froze this
// key on exactly the long runs it exists for.
const many = (n: number) =>
Array.from({ length: n }, (_, i) => assistant("editing", [{ name: "edit", args: { path: `src/f${i}.ts` } }]));
assert.notEqual(progressKey(many(13)), progressKey(many(12)), "the 13th file must count as progress");
});
test("a commit counts as progress, even when no file was written since", () => {
// A worker whose whole turn was committing looked stagnant while the view line claims to count
// commits.
const ran = (cmd: string, out: string): Entry[] => [
assistant("committing", [{ name: "bash", args: { command: cmd } }]),
toolResult("bash", out),
];
const before = ran("git status", "nothing to commit");
const after = [...before, ...ran(`git commit -m "fix the parser"`, "[main abc1234] fix the parser")];
assert.notEqual(progressKey(after), progressKey(before));
});
test("outstandingWork finds tool calls that never got a result", () => {
const answered: Entry = {
type: "message",
message: { role: "toolResult", toolName: "read", toolCallId: "call-1", content: [{ type: "text", text: "ok" }] },
};
const entries: Entry[] = [
{ type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "call-1", name: "read" }] } },
answered,
{ type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "call-2", name: "subagent" }] } },
];
assert.deepEqual(outstandingWork(entries), ["subagent"]);
assert.deepEqual(outstandingWork([entries[0], answered]), []);
});
test("buildView reports a tool call with no result, so done can be refused", () => {
const busy = buildView({
goal: "g",
status: "idle",
entries: [{ type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "x", name: "subagent", arguments: {} }] } }],
});
assert.match(busy, /^tool calls with no result: subagent$/m);
const quiet = buildView({ goal: "g", status: "idle", entries: [assistant("all done")] });
assert.match(quiet, /^tool calls with no result: none$/m);
});
test("the view says how many reviews in a row changed nothing, and says nothing at zero", () => {
const entries = [assistant("hi")];
assert.match(buildView({ goal: "g", status: "idle", entries, stale: 3 }), /no new file or commit for 3 reviews/);
assert.doesNotMatch(buildView({ goal: "g", status: "idle", entries }), /reviews in a row/);
});
test("the view merges the worker's compaction summary with the turns after it", () => {
// pi-vcc's compile() takes the old summary as previousSummary, so nothing between the summary
// and the newest turn falls in the gap between them.
const view = buildView({
goal: "g",
status: "idle",
entries: [
{ type: "compaction", summary: "[Session Goal]\n- build the dataset" },
assistant("carrying on", [{ name: "write", args: { path: "after.md" } }]),
],
});
assert.match(view, /build the dataset/, "the summary from before the compaction survives");
assert.match(view, /after\.md/, "so does the work done after it");
});
test("a turn the compaction summary already covers is not sent twice", () => {
// getBranch keeps the entries a compaction replaced, so passing all of them alongside the
// summary spends the byte budget on two copies of the same work.
const view = buildView({
goal: "g",
status: "idle",
entries: [
assistant("COVERED BY THE SUMMARY", [{ name: "edit", args: { path: "old.ts" } }]),
{ type: "compaction", summary: "[Session Goal]\n- build the dataset" },
assistant("after the compaction"),
],
});
assert.match(view, /build the dataset/);
assert.match(view, /after the compaction/);
assert.doesNotMatch(view, /COVERED BY THE SUMMARY/);
});
test("pi-vcc's sections and its transcript land on the right sides of the split", () => {
// compile() emits `sections + "\n\n---\n\n" + transcript`, and drops either part when it is
// empty. Splitting that wrong put the sections under "Recent turns", where the byte cut eats
// them from the top.
const view = buildView({
goal: "g",
status: "idle",
entries: [{ type: "message", message: { role: "user", content: "make the results table" } }],
});
const [above, below] = view.split("# Turns so far");
assert.match(above, /\[Session Goal\]/, "the sections belong above");
assert.match(below, /make the results table/, "the transcript belongs below");
assert.doesNotMatch(below, /\[Session Goal\]/);
});
test("the view does not tell the supervisor to use vcc_recall, a tool it does not have", () => {
const view = buildView({ goal: "g", status: "idle", entries: [assistant("hi")] });
assert.doesNotMatch(view, /vcc_recall/);
});
test("supervisor directives are not sent back as worker evidence", () => {
const directive: Entry = {
type: "message",
message: { role: "user", content: "[supervisor] Read exactly src/a.ts and quote it." },
};
const first = buildView({
goal: "g",
status: "working",
entries: [directive, assistant("I read src/a.ts", [{ name: "read", args: { path: "src/a.ts" } }])],
});
assert.doesNotMatch(first, /Read exactly src\/a\.ts/);
assert.match(first, /I read src\/a\.ts/);
const next = buildView({
goal: "g",
status: "working",
entries: [directive, assistant("I read src/a.ts", [{ name: "read", args: { path: "src/a.ts" } }]), directive],
since: 1,
});
assert.match(next, /# New turns since your last look \(0 of 1\)/);
assert.doesNotMatch(next, /Read exactly src\/a\.ts/);
});
test("bookkeeping tool calls are kept out of the transcript", () => {
const view = buildView({
goal: "g",
status: "idle",
entries: [
assistant("planning", [{ name: "TodoWrite", args: {} }]),
toolResult("TodoWrite", "todo list updated with 5 items"),
assistant("real work", [{ name: "edit", args: { path: "src/a.ts" } }]),
],
});
assert.doesNotMatch(view, /todo list updated/);
assert.match(view, /src\/a\.ts/);
});
test("buildView reports the goal, status, and files without historical failures", () => {
const view = buildView({
goal: "make the table",
status: "idle",
entries: [assistant("done", [{ name: "write", args: { path: "results.md" } }]), toolResult("bash", "exit code 2")],
});
assert.match(view, /<goal>\nmake the table\n<\/goal>/);
assert.match(view, /status: idle/);
assert.match(view, /turns: 2/);
assert.match(view, /results\.md/);
assert.doesNotMatch(view, /# Problems|bash exit 2/);
});
test("how long the worker has been quiet, measured from its own last entry", () => {
// The number wassname asked for after the 2h27m silence: "there have been no turns for this
// long, is it stuck". Measured from the worker's last message, not from the supervisor's last
// look, so it reads the same whether the worker sits at the prompt or hangs inside one command.
const now = Date.parse("2026-08-14T04:29:00Z");
const at = (iso: string): Entry => ({ ...assistant("working"), timestamp: iso });
const stopped = [at("2026-08-14T01:00:00Z"), at("2026-08-14T02:02:18Z")];
assert.equal(age(sinceLastTurn(stopped, now)), "2h27m", "the real overnight gap, from session 019ffa73");
const supervisorMessage: Entry = {
type: "message",
timestamp: "2026-08-14T04:28:59Z",
message: { role: "user", content: "[supervisor] Do not reset the worker clock." },
};
assert.equal(age(sinceLastTurn([...stopped, supervisorMessage], now)), "2h27m", "a supervisor directive is not worker progress");
// The last non-supervisor entry wins even when an older one follows it in some other order, and a branch with
// no timestamps at all reports 0 rather than throwing.
assert.equal(age(sinceLastTurn([at("2026-08-14T04:28:30Z")], now)), "30s");
assert.equal(age(sinceLastTurn([at("2026-08-14T03:44:00Z")], now)), "45m");
assert.equal(sinceLastTurn([assistant("no stamp")], now), 0);
});
test("buildView keeps the newest turns when it has to cut for the channel limit", () => {
const long = Array.from({ length: 400 }, (_, i) => assistant(`turn ${i} ${"x".repeat(400)}`));
const view = buildView({ goal: "g", status: "idle", entries: long });
assert.ok(Buffer.byteLength(view, "utf-8") <= MAX_VIEW_BYTES, `view was ${Buffer.byteLength(view)} bytes`);
assert.match(view, /turn 399/, "the newest turn must survive the cut");
assert.doesNotMatch(view, /turn 0 /, "the oldest must be the one dropped");
});
test("pi's own branch logic drops the abandoned fork, on a session file", async () => {
// Synthetic fixture copied from the upstream make-fixture.ts output; no real transcript.
// An earlier version of this test asserted on the rendered view, and passed even when fed every
// abandoned entry, because byte trimming removed them anyway.
const { parseSessionEntries, buildContextEntries } = await import("@earendil-works/pi-coding-agent");
const { readFileSync } = await import("node:fs");
const fixture = new URL("../fixtures/forked-session.jsonl", import.meta.url).pathname;
const all = parseSessionEntries(readFileSync(fixture, "utf-8")).filter((e: any) => e.type !== "session");
const byId = new Map(all.map((e: any) => [e.id, e]));
const leaf = all[all.length - 1] as any;
const branch = buildContextEntries(all as any, leaf.id, byId as any);
const asText = (entries: any[]) => JSON.stringify(entries);
assert.match(asText(all), /ABANDONED/, "the fixture must contain an abandoned branch");
assert.doesNotMatch(asText(branch), /ABANDONED/, "the live branch must not contain the abandoned fork");
assert.equal(all.length - branch.length, 22);
});
test("a long goal cannot push the view past the broker limit", () => {
const view = buildView({ goal: "x".repeat(60000), status: "idle", entries: [assistant("hi")] });
assert.ok(Buffer.byteLength(view, "utf-8") <= MAX_VIEW_BYTES, `view was ${Buffer.byteLength(view)} bytes`);
});
+13 -2
View File
@@ -1,17 +1,28 @@
import { describe, expect, it } from "vitest";
import { judgeSystem, planDrafting, planningState, reminder, resync } from "../src/prompts.js";
import { alignmentPolicy, judgeSystem, planDrafting, planningState, reminder, resync, waivesAlignment } from "../src/prompts.js";
describe("planning prompt", () => {
it("requires fact finding or a focused question before a goal", () => {
expect(planDrafting).toContain("Use read-only repository tools or web search when either can\nresolve a fact.");
expect(planDrafting).toContain("ask the human to confirm your interpretation");
expect(planDrafting).toContain("approve an editorial or other preference choice");
expect(planDrafting).toContain("answer materially reduces uncertainty\nwhile discovering the right plan");
expect(planDrafting).toContain("ask at least THREE distinct task-specific alignment");
expect(planDrafting).toContain("questions in ONE chat round");
expect(planDrafting).toContain("Wait for the human's answers and use them");
expect(planDrafting).toContain("self-contained: state the relevant\ncontext, use the human's language and ASD-STE100");
expect(planDrafting).toContain("placeholder goal such as \"work out the thing\"");
expect(planDrafting).toContain("object, observable result, settled scope, and required approval");
});
it("waives questions only for an explicit current-objective instruction", () => {
for (const objective of ["fix it, no questions", "skip questions and implement", "do not ask me any questions", "no q's", "skip q's", "fix it; no qs please"]) {
expect(waivesAlignment(objective)).toBe(true);
expect(alignmentPolicy(waivesAlignment(objective))).toContain("THIS plan only");
}
for (const objective of ["next objective", "fix the exporter; do not skip questions", "don't skip questions", "add a 'skip questions' button", 'add a "no questions" mode', 'document "first; skip questions; then build"']) expect(waivesAlignment(objective), objective).toBe(false);
expect(alignmentPolicy(false)).toContain("previous plan does NOT apply");
});
it("restores the same rule after compaction", () => {
expect(planningState(".pi/plan/test.md")).toContain("web search\nwhen either can resolve a fact.");
expect(planningState(".pi/plan/test.md")).toContain("choice that needs their approval");
+185
View File
@@ -0,0 +1,185 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import piGoalsExtension from "../src/index.js";
// Point at the installed Pi package to exercise its real lifecycle, not the API mock.
const sdkRoot = process.env.PI_GOALS_TEST_SDK_ROOT ?? resolve("node_modules/@earendil-works/pi-coding-agent");
const sdk = await import(pathToFileURL(join(sdkRoot, "dist/index.js")).href);
const { loadExtensionFromFactory } = await import(pathToFileURL(join(sdkRoot, "dist/core/extensions/loader.js")).href);
const requireSdk = createRequire(join(sdkRoot, "package.json"));
const aiRoot = requireSdk.resolve.paths("@earendil-works/pi-ai")!.map(path => join(path, "@earendil-works/pi-ai")).find(path => existsSync(join(path, "package.json")))!;
const { convertResponsesMessages } = await import(pathToFileURL(join(aiRoot, "dist/api/openai-responses-shared.js")).href);
const sdkVersion = JSON.parse(readFileSync(join(sdkRoot, "package.json"), "utf8")).version;
// Optional read-only check against the real guard; no native endpoint or private checkpoint used.
const replay = process.env.PI_GOALS_TEST_REPLAY_ROOT
? await import(pathToFileURL(join(process.env.PI_GOALS_TEST_REPLAY_ROOT, "src/payload-rewrite.ts")).href) : undefined;
const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
const plan = (revision: string) => `# Plan ${revision}\n\n1. [/] goal: test saved reminders\n\n## Log\n- fixture log ${revision}\n\n## Appendix\nfixture appendix ${revision}\n`;
const compactReminder = (message: any) => typeof message.content === "string" && message.content.includes("The session was just compacted.");
async function setup(phase = "working") {
sdk.initTheme("dark", false);
const cwd = mkdtempSync(join(tmpdir(), "goals-reminder-sdk-"));
const sm = sdk.SessionManager.create(cwd, join(cwd, "sessions"));
const events: string[] = [];
const requests: any[][] = [];
const errors: unknown[] = [];
let responses: Array<{ tool?: boolean; high?: boolean; overflow?: boolean; queue?: boolean }> = [];
let compactions = 0;
const settingsManager = sdk.SettingsManager.inMemory({ compaction: { enabled: false, reserveTokens: 1000, keepRecentTokens: 100 }, retry: { enabled: false } });
const runtime = sdk.createExtensionRuntime();
const bus = sdk.createEventBus();
const extensions = [];
extensions.push(await loadExtensionFromFactory(piGoalsExtension, cwd, bus, runtime));
let session: any;
extensions.push(await loadExtensionFromFactory((pi: any) => {
pi.on("before_agent_start", () => { events.push("before_agent_start"); });
pi.on("session_compact", (event: any) => { events.push(`compact:${event.reason}:${event.willRetry}`); });
pi.on("session_before_compact", (event: any) => {
compactions++;
return { compaction: { summary: `Offline checkpoint ${compactions}`, firstKeptEntryId: event.preparation.firstKeptEntryId, tokensBefore: event.preparation.tokensBefore, details: { compactedWindow: [{ type: "compaction", encrypted_content: "offline-fixture-only" }] } } };
});
pi.registerTool({ name: "fixture_tool", label: "fixture", description: "Offline no-op", parameters: { type: "object", properties: {} }, execute: async () => ({ content: [{ type: "text", text: "fixture result" }], details: {} }) });
}, cwd, bus, runtime));
const resourceLoader = {
getExtensions: () => ({ extensions, errors: [], runtime }),
getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }), getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => "Offline reminder test", getSystemPromptSource: () => undefined,
getAppendSystemPrompt: () => [], getAppendSystemPromptSources: () => [], extendResources: () => {}, reload: async () => {},
};
const modelRuntime = await sdk.ModelRuntime.create({ authPath: join(cwd, "auth.json"), modelsPath: join(cwd, "models.json"), modelsStorePath: join(cwd, "models-store.json"), allowModelNetwork: false });
await modelRuntime.setRuntimeApiKey("openai", "offline-fixture-key");
const model = { ...modelRuntime.getModel("openai", "gpt-4.1"), contextWindow: 10000 };
expect(model.id).toBe("gpt-4.1");
const assistant = (content: any[], stopReason = "stop", input = 100) => ({ role: "assistant", content, api: model.api, provider: model.provider, model: model.id, stopReason, usage: { input, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: input + 1, cost: zeroCost }, timestamp: Date.now() });
sm.appendMessage({ role: "user", content: "Fixture history", timestamp: Date.now() - 1000 });
sm.appendMessage({ ...assistant([{ type: "text", text: "Fixture history response" }]), timestamp: Date.now() - 900 });
sm.appendCustomEntry("pi-goals-state", { defaultsVersion: 1, phase, planVersion: 1, stewardEnabled: false, autoIntervalMs: null, reviewRequested: false });
const planPath = join(cwd, ".pi/plan", `${sm.getSessionId()}-v1.md`);
mkdirSync(dirname(planPath), { recursive: true }); writeFileSync(planPath, plan("initial"));
({ session } = await sdk.createAgentSession({ cwd, agentDir: process.env.PI_CODING_AGENT_DIR, model, modelRuntime, sessionManager: sm, settingsManager, resourceLoader, tools: ["fixture_tool"] }));
session.subscribe((event: any) => {
if (event.type === "message_end" && event.message.role === "assistant" && event.message.errorMessage && event.message.errorMessage !== "maximum context length exceeded") errors.push(event.message.errorMessage);
});
const serialize = (messages: any[]) => convertResponsesMessages(model, { messages: sdk.convertToLlm(messages) }, new Set(["openai", "openai-codex", "opencode"]));
session.agent.streamFunction = async (_model: any, context: any) => {
const actual = serialize(context.messages);
const saved = serialize(sm.buildSessionContext().messages);
expect(actual).toEqual(saved); // Provider-visible history matches saved history AT request time.
const disk = sdk.SessionManager.open(sm.getSessionFile());
expect(serialize(disk.buildSessionContext().messages)).toEqual(saved);
const checkpoint = sm.getBranch().findLast((entry: any) => entry.type === "compaction");
if (replay && checkpoint) {
const args = { model, payload: { model: model.id, instructions: "Offline reminder test", input: actual }, branchEntries: sm.getBranch(), compactionEntry: checkpoint };
expect(replay.rewriteResponsesPayloadWithNativeReplay(args).ok).toBe(true);
// Negative control: the original ephemeral suffix must still be rejected by the guard.
expect(replay.rewriteResponsesPayloadWithNativeReplay({ ...args, payload: { ...args.payload, input: [...actual, { role: "user", content: [{ type: "input_text", text: "<system-reminder>unsaved plan</system-reminder>" }] }] } })).toMatchObject({ ok: false, reason: "expected-pi-replay-mismatch" });
}
requests.push(structuredClone(context.messages));
const next = responses.shift() ?? {};
if (next.queue) await session.steer("Queued user must retain order");
const message = assistant(next.tool ? [{ type: "toolCall", id: `fixture-${requests.length}`, name: "fixture_tool", arguments: {} }] : [{ type: "text", text: "Offline answer" }], next.overflow ? "error" : next.tool ? "toolUse" : "stop", next.high ? 9500 : 100);
if (next.overflow) { message.errorMessage = "maximum context length exceeded"; message.usage.input = 0; }
return { async *[Symbol.asyncIterator]() { yield next.overflow ? { type: "error", reason: "error", error: message } : { type: "done", reason: message.stopReason, message }; }, result: async () => message };
};
await session.bindExtensions({ onError: (error: unknown) => errors.push(error) });
return {
session, sm, events, requests, errors, planPath, settingsManager,
respond: (...next: typeof responses) => { responses = next; },
reminders: () => sm.getBranch().filter((entry: any) => entry.type === "custom_message" && compactReminder(entry)),
close: () => { session.dispose(); rmSync(cwd, { recursive: true, force: true }); },
};
}
describe(`saved goal reminders (Pi SDK ${sdkVersion}${replay ? ", real replay guard" : ""})`, () => {
it.each(["working", "planning"])("manual compact in %s: fresh saved reminder once on next natural prompt", async (phase) => {
const flow = await setup(phase);
try {
await flow.session.prompt("Initial request");
const priorSnapshotIds = new Set(flow.sm.getBranch().filter((entry: any) => entry.type === "custom_message").map((entry: any) => entry.id));
await flow.session.compact();
const checkpointId = flow.sm.getBranch().findLast((entry: any) => entry.type === "compaction").id;
expect(flow.events).toContain("compact:manual:false");
expect(flow.requests).toHaveLength(1);
writeFileSync(flow.planPath, plan("fresh-after-compact"));
await flow.session.prompt("Natural request");
const snapshots = flow.sm.getBranch().filter((entry: any) => entry.type === "custom_message" && entry.customType === (phase === "working" ? "pi-goals-plan-reminder" : "pi-goals-planning-context"));
const count = snapshots.length;
const latest = snapshots.at(-1);
expect(latest.display).toBe(false);
expect(priorSnapshotIds.has(latest.id)).toBe(false);
const branch = flow.sm.getBranch();
expect(branch.findIndex((entry: any) => entry.id === latest.id)).toBeGreaterThan(branch.findIndex((entry: any) => entry.id === checkpointId));
expect(flow.requests[1].some((message: any) => message.role === "user" && Array.isArray(message.content) && message.content.some((part: any) => part.type === "text" && part.text === latest.content))).toBe(true);
if (phase === "working") { expect(flow.reminders()).toHaveLength(1); expect(flow.reminders()[0].content).toContain("fixture appendix fresh-after-compact"); }
await flow.session.prompt("Another natural request");
expect(flow.sm.getBranch().filter((entry: any) => entry.type === "custom_message" && entry.customType === snapshots[0].customType)).toHaveLength(count);
expect(flow.requests).toHaveLength(3);
expect(flow.errors).toEqual([]);
} finally { flow.close(); }
});
it.each(["threshold", "overflow"])("post-run %s compact: no extra run, refresh at next natural prompt", async (reason) => {
const flow = await setup();
try {
flow.settingsManager.applyOverrides({ compaction: { enabled: true } });
flow.respond(reason === "threshold" ? { high: true } : { overflow: true }, {});
await flow.session.prompt("Run and compact");
expect(flow.events).toContain(`compact:${reason}:${reason === "overflow"}`);
expect(flow.requests).toHaveLength(reason === "threshold" ? 1 : 2);
expect(flow.reminders()).toHaveLength(0);
expect(flow.events.filter(event => event === "before_agent_start")).toHaveLength(1);
writeFileSync(flow.planPath, plan("next-natural"));
await flow.session.prompt("Next natural prompt");
expect(flow.reminders()).toHaveLength(1);
expect(flow.reminders()[0].content).toContain("fixture appendix next-natural");
expect(flow.errors).toEqual([]);
} finally { flow.close(); }
});
it("stale tool-loop reminder waits for a natural prompt and persists only the working set", async () => {
const flow = await setup();
try {
flow.respond(...Array.from({ length: 8 }, () => ({ tool: true })), {});
await flow.session.prompt("Long tool run");
expect(flow.requests).toHaveLength(9);
const count = () => flow.sm.getBranch().filter((entry: any) => entry.type === "custom_message" && entry.customType === "pi-goals-plan-reminder").length;
expect(count()).toBe(1); // startup only; no turn_end/ephemeral reminders
await flow.session.prompt("Natural prompt after staleness");
expect(count()).toBe(2);
const text = JSON.stringify(flow.requests.at(-1).at(-1));
expect(text).toContain("test saved reminders");
expect(text).not.toContain("fixture appendix");
await flow.session.prompt("No duplicate");
expect(count()).toBe(2);
expect(flow.errors).toEqual([]);
} finally { flow.close(); }
});
it.skipIf(Number(sdkVersion.split(".")[1]) < 85).each([false, true])("mid-run threshold, queued user=%s: defer without drops, duplicates, or extra response", async (queue) => {
const flow = await setup();
try {
flow.settingsManager.applyOverrides({ compaction: { enabled: true } });
flow.respond({ tool: true, high: true, queue }, {});
await flow.session.prompt("Use fixture tool");
expect(flow.events).toContain("compact:threshold:false");
expect(flow.requests).toHaveLength(2);
expect(JSON.stringify(flow.requests[1][0])).toContain("Offline checkpoint"); // compacted before the tool continuation, not just after the run
expect(flow.events.filter(event => event === "before_agent_start")).toHaveLength(1);
expect(flow.reminders()).toHaveLength(0);
if (queue) expect(JSON.stringify(flow.requests[1].at(-1))).toContain("Queued user must retain order");
writeFileSync(flow.planPath, plan("after-auto"));
await flow.session.prompt("Next natural prompt");
expect(flow.reminders()).toHaveLength(1);
expect(flow.reminders()[0].content).toContain("fixture appendix after-auto");
await flow.session.prompt("No duplicate");
expect(flow.reminders()).toHaveLength(1);
expect(flow.requests).toHaveLength(4);
expect(flow.errors).toEqual([]);
} finally { flow.close(); }
});
});
+95
View File
@@ -0,0 +1,95 @@
import { execFile } from "node:child_process";
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { type ModelChoice, type ModelRole, RoleModels } from "../src/role-models.js";
function harness(directory: string, current: ModelChoice = { provider: "initial", id: "model" }) {
let selected: any;
const ctx = { model: current, modelRegistry: { find: vi.fn((provider: string, id: string) => ({ provider, id })) }, ui: { notify: vi.fn() } };
const pi = {
on(_name: string, handler: any) { selected = handler; },
setModel: vi.fn(async (model: ModelChoice) => { const previousModel = ctx.model; ctx.model = model; if (previousModel.provider !== model.provider || previousModel.id !== model.id) selected({ source: "set", previousModel, model }, ctx); return true; }),
};
const models = new RoleModels(pi as unknown as ExtensionAPI, directory);
return {
models, ctx, pi,
enter: (role: ModelRole) => models.enter(role, ctx as unknown as ExtensionContext),
select: (source: "set" | "cycle" | "restore", model: ModelChoice) => { ctx.model = model; selected({ source, model }, ctx); },
};
}
const stored = (dir: string, role: string) => JSON.parse(readFileSync(join(dir, `${role}-model.json`), "utf8"));
describe("role-models storage and public model_select", () => {
it("inherits current on first use, remembers set/cycle independently, and ignores automatic set and restore", async () => {
const dir = join(process.env.PI_CODING_AGENT_DIR!, "pi-goals");
const h = harness(dir);
for (const role of ["planning", "worker", "supervisor"] as const) {
const inherited = { ...h.ctx.model };
expect(await h.enter(role)).toBe(true);
expect(stored(dir, role)).toEqual(inherited);
h.select(role === "planning" ? "set" : "cycle", { provider: role, id: `${role}-choice` });
}
for (const role of ["planning", "worker", "supervisor"] as const) {
expect(await h.enter(role)).toBe(true);
expect(h.ctx.model).toEqual({ provider: role, id: `${role}-choice` });
h.select("restore", { provider: "session", id: "old" });
expect(stored(dir, role)).toEqual({ provider: role, id: `${role}-choice` });
}
h.models.leave(); h.select("cycle", { provider: "outside", id: "unrelated" });
expect(stored(dir, "supervisor").provider).toBe("supervisor");
});
it("restores after a fresh instance/process and avoids cross-role lost updates", async () => {
const dir = join(process.env.PI_CODING_AGENT_DIR!, "pi-goals");
const h = harness(dir);
await h.enter("planning"); h.select("cycle", { provider: "planner", id: "persisted" });
await Promise.all(["worker", "supervisor"].map(role => promisify(execFile)(process.execPath, ["--import", "tsx", resolve("test/fixtures/role-model-process.ts"), dir, role, `${role}-provider`, "persisted"])));
const fresh = harness(dir, { provider: "unrelated", id: "start" });
for (const role of ["planning", "worker", "supervisor"] as const) {
await fresh.enter(role);
expect(fresh.ctx.model).toEqual({ provider: role === "planning" ? "planner" : `${role}-provider`, id: "persisted" });
expect(Object.keys(stored(dir, role))).toEqual(["provider", "id"]);
}
});
it.each(["unavailable", "unauthenticated"])("visibly pauses a %s remembered model without replacing it", async failure => {
const dir = join(process.env.PI_CODING_AGENT_DIR!, "pi-goals");
const h = harness(dir); await h.enter("worker");
h.select("set", { provider: "chosen", id: "keep" });
const fresh = harness(dir, { provider: "other-provider", id: "fallback" });
if (failure === "unavailable") fresh.ctx.modelRegistry.find.mockReturnValue(undefined as any);
else fresh.pi.setModel.mockResolvedValue(false);
expect(await fresh.enter("worker")).toBe(false);
expect(fresh.models.ready).toBe(false);
expect(fresh.ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Saved choice unchanged"), "error");
expect(fresh.ctx.model.provider).toBe("other-provider");
expect(stored(dir, "worker")).toEqual({ provider: "chosen", id: "keep" });
fresh.select("restore", { provider: "fallback", id: "automatic" });
expect(stored(dir, "worker").provider).toBe("chosen");
fresh.select("cycle", { provider: "explicit", id: "replacement" });
expect(fresh.models.ready).toBe(true);
expect(stored(dir, "worker").provider).toBe("explicit");
});
});
it("explicit use-current recovers the paused role when same-model selection emits no event", async () => {
const dir = join(process.env.PI_CODING_AGENT_DIR!, "pi-goals");
const h = harness(dir); await h.enter("worker"); h.select("set", { provider: "missing", id: "worker-choice" });
const fresh = harness(dir, { provider: "current", id: "working-model" });
fresh.ctx.modelRegistry.find.mockReturnValue(undefined as any);
expect(await fresh.enter("worker")).toBe(false);
await fresh.pi.setModel(fresh.ctx.model); // Real Pi suppresses this model_select.
expect(fresh.models.ready).toBe(false);
expect(stored(dir, "worker").provider).toBe("missing");
fresh.pi.setModel.mockResolvedValueOnce(false);
expect(await fresh.models.useCurrent(fresh.ctx as unknown as ExtensionContext)).toBe(false);
expect(stored(dir, "worker").provider).toBe("missing");
expect(await fresh.models.useCurrent(fresh.ctx as unknown as ExtensionContext)).toBe(true);
expect(fresh.models.activeRole).toBe("worker");
expect(fresh.models.ready).toBe(true);
expect(stored(dir, "worker")).toEqual({ provider: "current", id: "working-model" });
});
+50 -13
View File
@@ -1,5 +1,5 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
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";
@@ -49,13 +49,23 @@ function streamResponse(response: import("node:http").ServerResponse, delta: obj
}
describe("RPC review flow", () => {
it("opens Refine's editor before it starts the revision turn", async () => {
it.each([false, true])("alignment and Discuss before one Ready handoff; real-Pi same-current recovery=%s", async (recoverCurrent) => {
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-rpc-"));
const modelDir = join(cwd, ".agent", "pi-goals"); mkdirSync(modelDir, { recursive: true });
if (recoverCurrent) writeFileSync(join(modelDir, "worker-model.json"), JSON.stringify({ provider: "missing", id: "unavailable-worker" }));
let requestCount = 0;
let planPath = "";
const server = createServer((_request, response) => {
requestCount++;
if (requestCount === 1) {
if (requestCount === 1 || requestCount === 4) {
streamResponse(response, { content: "1. Should the output be a text file? 2. Keep the existing CLI only? 3. Does a saved PASS receipt prove success?" }, "stop");
return;
}
if (requestCount === 3 || requestCount === 6) {
streamResponse(response, { tool_calls: [{ index: 0, id: `review-${requestCount}`, type: "function", function: { name: "RequestPlanReview", arguments: "{}" } }] }, "tool_calls");
return;
}
if (requestCount === 2) {
streamResponse(response, {
tool_calls: [{
index: 0,
@@ -97,16 +107,43 @@ describe("RPC review flow", () => {
const sessionId = (state.data as { sessionId: string }).sessionId;
planPath = join(cwd, ".pi", "plan", `${sessionId}-v1.md`);
client.send({ type: "prompt", id: "goals", message: "/goals work out the thing" });
const review = await client.waitFor((message) => message.type === "extension_ui_request" && message.method === "select");
client.send({ type: "extension_ui_response", id: review.id, value: "Refine" });
const editor = await client.waitFor((message) => message.type === "extension_ui_request" && message.method === "editor");
expect(requestCount).toBe(2);
const revisionStart = client.messages.length;
client.send({ type: "extension_ui_response", id: editor.id, value: "Name the produced file." });
await client.waitFor((message) => message.type === "agent_end", revisionStart);
expect(requestCount).toBe(3);
client.send({ type: "prompt", id: "off", message: "/goals steward off" });
await client.waitFor(message => message.type === "response" && message.id === "off");
client.send({ type: "prompt", id: "goals", message: "/goals name the output file" });
await client.waitFor(message => message.type === "agent_end");
expect(requestCount).toBe(1);
expect(client.messages.some(message => message.method === "select")).toBe(false);
client.send({ type: "prompt", id: "answers", message: "Text file, existing CLI only, and a saved PASS receipt." });
const review = await client.waitFor(message => message.type === "extension_ui_request" && message.method === "select");
expect(review.options).toEqual(["Ready", "Discuss", "Edit", "Cancel"]);
const discussionAt = client.messages.length;
client.send({ type: "extension_ui_response", id: review.id, value: "Discuss" });
await client.waitFor(message => message.type === "agent_end", discussionAt);
expect(requestCount).toBe(4);
expect(client.messages.slice(discussionAt).some(message => message.method === "editor" || message.method === "select")).toBe(false);
const answerAt = client.messages.length;
client.send({ type: "prompt", id: "discuss-answer", message: "Use output.txt, no UI changes." });
await client.waitFor(message => message.type === "agent_end", answerAt);
expect(client.messages.slice(answerAt).some(message => message.method === "select")).toBe(false);
client.send({ type: "prompt", id: "finish-discussion", message: "Yes, that is enough; the draft is still right." });
const reviewedAgain = await client.waitFor(message => message.type === "extension_ui_request" && message.method === "select", discussionAt);
const readyAt = client.messages.length;
client.send({ type: "extension_ui_response", id: reviewedAgain.id, value: "Ready" });
if (recoverCurrent) {
await client.waitFor(message => message.type === "extension_ui_request" && message.method === "notify" && JSON.stringify(message).includes("worker model paused"), readyAt);
client.send({ type: "set_model", id: "same-current", provider: "offline", modelId: "test" });
expect((await client.waitFor(message => message.type === "response" && message.id === "same-current")).success).toBe(true);
expect(JSON.parse(readFileSync(join(modelDir, "worker-model.json"), "utf8"))).toEqual({ provider: "missing", id: "unavailable-worker" });
expect(client.messages.slice(readyAt).some(message => message.method === "select")).toBe(false);
client.send({ type: "prompt", id: "use-current", message: "/goals model current" });
const recoveryMenu = await client.waitFor(message => message.type === "extension_ui_request" && message.method === "select", readyAt);
expect(JSON.parse(readFileSync(join(modelDir, "worker-model.json"), "utf8"))).toEqual({ provider: "offline", id: "test" });
expect(JSON.parse(readFileSync(join(modelDir, "planning-model.json"), "utf8"))).toEqual({ provider: "offline", id: "test" });
client.send({ type: "extension_ui_response", id: recoveryMenu.id, value: "Ready" });
}
await client.waitFor(message => message.type === "agent_end", readyAt);
expect(client.messages.filter(message => message.type === "message_start" && JSON.stringify(message).includes("Work the goals"))).toHaveLength(1);
expect(requestCount).toBe(7);
} finally {
pi.kill();
server.close();
+105
View File
@@ -0,0 +1,105 @@
import { execFileSync, spawn } from "node:child_process";
import { existsSync, 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 { describe, expect, it } from "vitest";
const stream = (res: import("node:http").ServerResponse, delta: object, finish = "stop") => {
res.writeHead(200, { "content-type": "text/event-stream" });
for (const data of [{ choices: [{ index: 0, delta, finish_reason: null }] }, { choices: [{ index: 0, delta: {}, finish_reason: finish }] }]) res.write(`data: ${JSON.stringify(data)}\n\n`);
res.end("data: [DONE]\n\n");
};
describe("two real Pi sessions with the actual Intercom broker (Herdr mocked)", () => {
it("forks, pairs, reviews a goal and invokes a fresh offline evidence judge", async () => {
const cwd = mkdtempSync(join(tmpdir(), "goals-supervisor-rpc-"));
// Pack real production dependencies, then run outside every checkout with no install/symlink.
const packed = JSON.parse(execFileSync("npm", ["pack", "--json", "--ignore-scripts", "--cache", "/tmp/pi-goals-npm-cache", "--pack-destination", cwd], { cwd: resolve("."), encoding: "utf8" }))[0];
execFileSync("tar", ["-xzf", join(cwd, packed.filename), "-C", cwd]);
const packageRoot = join(cwd, "package");
const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
expect(manifest.bundledDependencies).toEqual(["pi-intercom", "@sting8k/pi-vcc"]);
expect(existsSync(join(packageRoot, "node_modules/pi-intercom/broker/broker.ts"))).toBe(true);
expect(existsSync(join(packageRoot, "src/internal/supervisor/index.ts"))).toBe(true);
expect(existsSync(join(packageRoot, "node_modules/@sting8k/pi-vcc/src/core/summarize.ts"))).toBe(true);
expect(packed.files.some((file: { path: string }) => /node_modules\/(?:@earendil-works|typebox)\//.test(file.path))).toBe(false);
expect(existsSync(join(packageRoot, "THIRD_PARTY_NOTICES.md"))).toBe(true);
const host = join(cwd, "herdr-test-host.ts");
writeFileSync(host, readFileSync(resolve("test/fixtures/herdr-test-host.ts"), "utf8").replace('"../../src/index.js"', JSON.stringify(join(packageRoot, "src/index.ts"))));
// Only the worker's Herdr exec is wrapped. Supervisor loads the untouched package manifest.
const resources = manifest.pi.extensions.flatMap((path: string) => ["-e", path === "./src/index.ts" ? host : join(packageRoot, path)]);
let planPath = ""; let reviewCalls = 0; let judgeCalls = 0;
const supervisorModels: string[] = []; const judgeModels: string[] = []; const workerModels: string[] = [];
const server = createServer((req, res) => {
let raw = ""; req.on("data", chunk => { raw += chunk; }); req.on("end", () => {
const body = JSON.parse(raw); const messages = body.messages; const last = messages.at(-1); const text = typeof last.content === "string" ? last.content : JSON.stringify(last.content);
const names = (body.tools ?? []).map((tool: any) => tool.function.name);
const call = (name: string, args: object) => stream(res, { tool_calls: [{ index: 0, id: `call-${Date.now()}`, type: "function", function: { name, arguments: JSON.stringify(args) } }] }, "tool_calls");
if (last.role === "tool" && messages.at(-2)?.tool_calls?.some((c: any) => c.function.name === "write")) return call("RequestPlanReview", {});
if (last.role === "tool") return stream(res, { content: "Check complete." });
if (names.includes("review_goal")) {
supervisorModels.push(body.model);
const request = text.match(/Goal sign-off request ([^ .]+)\./);
if (request) { reviewCalls++; return call("review_goal", { requestId: request[1], decision: "approve", reason: "This goal remains faithful to the plan." }); }
return call("let_it_run", { reason: "Ready selected; worker starting" });
}
if (text.includes("intercom status")) return call("intercom", { action: "status" });
if (names.includes("CompleteGoal")) {
if (text.includes("sign off first")) return call("CompleteGoal", { goal: "first" });
if (text.includes("We're in plan mode.") || text.includes("[PLANNING MODE]")) return call("write", { path: planPath, content: "# Plan\n\n## User-visible result\n\nTwo text files.\n\n## Goals\n\n1. [ ] goal: first\n - evidence: evidence.txt says PASS\n2. [ ] goal: second\n\n## Log\n" });
workerModels.push(body.model); return stream(res, { content: "Worker is ready." });
}
judgeCalls++; judgeModels.push(body.model); stream(res, { content: "## checks:\n- evidence.txt: `PASS`; the saved receipt passed\n\nVERDICT: accept\nmissing:" });
});
});
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("Offline HTTP server did not start");
const agentDir = join(cwd, ".agent"); mkdirSync(agentDir);
writeFileSync(join(agentDir, "models.json"), JSON.stringify({ providers: { offline: { baseUrl: `http://127.0.0.1:${address.port}`, apiKey: "test", api: "openai-completions", models: ["test", "planning", "worker", "supervisor", "judge"].map(id => ({ id, name: `Offline ${id}`, reasoning: false, input: ["text"], contextWindow: 200_000, maxTokens: 1000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } })) } } }));
const modelDir = join(agentDir, "pi-goals"); mkdirSync(modelDir);
for (const role of ["worker", "supervisor"]) writeFileSync(join(modelDir, `${role}-model.json`), JSON.stringify({ provider: "offline", id: role }));
writeFileSync(join(cwd, "evidence.txt"), "PASS\n");
const child = spawn(resolve("node_modules/.bin/pi"), ["--mode", "rpc", "--no-extensions", "--model", "offline/test", ...resources], {
cwd, env: { ...process.env, PI_CODING_AGENT_DIR: agentDir, PI_INTERCOM_SCOPE_ID: `test-${Date.now()}`, HERDR_ENV: "1", HERDR_PANE_ID: "test-worker-pane", PI_SUPERVISOR_DEBUG: "1" }, stdio: ["pipe", "pipe", "pipe"],
});
const events: any[] = []; let buffer = ""; let stderr = "";
const waits = new Set<(event: any) => void>();
child.stdout.on("data", chunk => { buffer += chunk; while (buffer.includes("\n")) { const at = buffer.indexOf("\n"); const line = buffer.slice(0, at); buffer = buffer.slice(at + 1); if (!line) continue; const event = JSON.parse(line); events.push(event); for (const fn of waits) fn(event); } });
child.stderr.on("data", chunk => { stderr += chunk; });
const send = (message: object) => child.stdin.write(`${JSON.stringify(message)}\n`);
const wait = (predicate: (event: any) => boolean, from = 0) => new Promise<any>((done, reject) => {
const found = events.slice(from).find(predicate); if (found) return done(found);
const timeout = setTimeout(() => { waits.delete(listener); reject(new Error(`RPC timeout; stderr: ${stderr}\nsupervisor: ${existsSync(join(cwd, "supervisor-stderr.log")) ? readFileSync(join(cwd, "supervisor-stderr.log"), "utf8") : "not started"}\nevents: ${JSON.stringify(events.filter(e => e.method === "notify" || e.type === "extension_error")) + JSON.stringify(events.slice(-2))}`)); }, 20_000);
const listener = (event: any) => { if (predicate(event)) { clearTimeout(timeout); waits.delete(listener); done(event); } }; waits.add(listener);
});
try {
send({ type: "get_state", id: "state" }); const state = await wait(e => e.type === "response" && e.id === "state");
planPath = join(cwd, ".pi/plan", `${state.data.sessionId}-v1.md`);
const diagnosticAt = events.length;
send({ type: "prompt", id: "diagnostic", message: "intercom status" });
const diagnostic = await wait(e => e.type === "tool_execution_end" && e.toolName === "intercom", diagnosticAt);
expect(JSON.stringify(diagnostic), "Actual Intercom must connect before testing goals").toContain("Connected: Yes");
await wait(e => e.type === "agent_end", diagnosticAt);
send({ type: "prompt", id: "plan", message: "/goals plan create the outputs" });
const menu = await wait(e => e.type === "extension_ui_request" && e.method === "select");
send({ type: "set_model", id: "planning-model", provider: "offline", modelId: "planning" });
expect((await wait(e => e.type === "response" && e.id === "planning-model")).success).toBe(true);
const readyAt = events.length;
send({ type: "extension_ui_response", id: menu.id, value: "Ready" });
await wait(e => e.type === "message_start" && JSON.stringify(e.message).includes("Work the goals"), readyAt);
await wait(e => e.type === "agent_end", readyAt);
send({ type: "prompt", id: "judge-model", message: "/goals judge offline/judge" });
await wait(e => e.type === "response" && e.id === "judge-model");
const signoffAt = events.length;
send({ type: "prompt", id: "signoff", message: "sign off first" });
await wait(e => e.type === "tool_execution_end" && e.toolName === "CompleteGoal", signoffAt);
expect(readFileSync(planPath, "utf8")).toContain("[x] goal: first"); expect(reviewCalls).toBe(1); expect(judgeCalls).toBe(1);
expect(readFileSync(planPath, "utf8")).toContain("[ ] goal: second");
expect(workerModels).toEqual(["worker"]);
expect(supervisorModels.length).toBeGreaterThan(0); expect(supervisorModels.every(model => model === "supervisor")).toBe(true);
expect(judgeModels).toEqual(["judge"]);
for (const role of ["planning", "worker", "supervisor"]) expect(JSON.parse(readFileSync(join(modelDir, `${role}-model.json`), "utf8"))).toEqual({ provider: "offline", id: role });
} finally { send({ type: "abort" }); child.kill(); server.close(); await new Promise(done => child.once("close", done)); rmSync(cwd, { recursive: true, force: true }); }
}, 55_000);
});
+8
View File
@@ -0,0 +1,8 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, vi } from "vitest";
let agentDir: string;
beforeEach(() => { agentDir = mkdtempSync(join(tmpdir(), "goals-model-prefs-")); vi.stubEnv("PI_CODING_AGENT_DIR", agentDir); });
afterEach(() => { rmSync(agentDir, { recursive: true, force: true }); vi.unstubAllEnvs(); });
+214
View File
@@ -0,0 +1,214 @@
import { EventEmitter } from "node:events";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { SessionManager } from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, it, vi } from "vitest";
import goals from "../src/index.js";
import { SUPERVISOR_ROLE } from "../src/supervisor.js";
const judge = vi.hoisted(() => ({ calls: [] as string[][] }));
vi.mock("node:child_process", async (original) => {
const actual = await original<typeof import("node:child_process")>();
return { ...actual, spawn: (_command: string, args: string[]) => {
judge.calls.push(args);
const process = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: new EventEmitter(), kill() {} });
queueMicrotask(() => { process.stdout.emit("data", "## checks:\n- evidence.txt: `PASS`; the saved check passed\n\nVERDICT: accept\nmissing:"); process.emit("close", 0); });
return process;
} };
});
const tick = () => new Promise(resolve => setImmediate(resolve));
afterEach(() => { vi.unstubAllEnvs(); judge.calls = []; });
describe("actual goals and supervisor package hooks (Herdr and judge mocked)", () => {
it.each(["completion", "replacement during activation", "steward off during activation", "missing worker model", "unauthenticated worker model", "clear while model unavailable", "off while model unavailable", "clear during recovery restore", "replacement during recovery restore"])("Ready forks once and preserves lifecycle ownership: %s", async (scenario) => {
const cwd = mkdtempSync(join(tmpdir(), "goals-supervisor-integration-"));
const peers: any[] = [];
const wires: any[] = [];
const herdrCalls: string[][] = [];
const modelDir = join(process.env.PI_CODING_AGENT_DIR!, "pi-goals"); mkdirSync(modelDir);
for (const role of ["worker", "supervisor"]) writeFileSync(join(modelDir, `${role}-model.json`), JSON.stringify({ provider: "offline", id: role }));
vi.stubEnv("HERDR_ENV", "1"); vi.stubEnv("HERDR_PANE_ID", "w1:p1");
function make(id: string, manager: SessionManager) {
const hooks = new Map<string, any[]>(); const commands = new Map<string, any>(); const tools = new Map<string, any>(); const listeners = new Map<string, Set<any>>();
const messages: string[] = []; const contexts: any[] = []; let active = ["read", "grep", "bash", "write", "edit"];
const peer: any = { id, manager, messages, contexts, commands, tools, compactions: 0, compactionModels: [], modelChanges: [], aborts: 0 };
const bus = {
on(name: string, fn: any) { const list = listeners.get(name) ?? new Set(); list.add(fn); listeners.set(name, list); return () => list.delete(fn); },
emit(name: string, payload: any) {
if (name === "intercom:extension-register") {
peer.receive = payload.onEvent;
payload.onReady({ snapshot: () => ({ connected: true, supported: true }), listSessions: async () => peers.map(p => ({ id: p.id, pid: p === peer ? process.pid : process.pid + 1, cwd, model: "offline/test" })), publish(wire: any) { if (wire.t === "plan_activate") expect(peer.ctx.model.id).not.toBe("planning"); wires.push(wire); for (const p of peers) queueMicrotask(() => p.receive({ type: "message", fromSessionId: peer.id, payload: wire })); } });
return true;
}
if (name === "processes:request:list") payload.reply([]);
if (name === "subagents:rpc:v1:request") {
const reply = () => bus.emit(`subagents:rpc:v1:reply:${payload.requestId}`, { requestId: payload.requestId, success: true, data: { fleet: { version: 1, totalActive: 0 } } });
if (peer.delayBackground) { peer.finishBackground = reply; return; }
reply();
}
for (const fn of listeners.get(name) ?? []) fn(payload);
},
};
const pi: any = {
setModel: async (model: any) => {
if (peer.noAuth && model.id === "worker") return false;
if (peer.deferWorkerRestore && model.id === "worker") {
peer.deferWorkerRestore = false;
await new Promise<void>(resolve => { peer.finishRestore = resolve; });
}
const previousModel = ctx.model; ctx.model = model; peer.modelChanges.push(model.id);
manager.appendModelChange(model.provider, model.id);
await peer.hook("model_select", { source: "set", model, previousModel }); return true;
},
events: bus, on(name: string, fn: any) { hooks.set(name, [...(hooks.get(name) ?? []), fn]); },
registerCommand(name: string, command: any) { commands.set(name, command); }, registerTool(tool: any) { tools.set(tool.name, tool); active.push(tool.name); },
appendEntry: (name: string, data: any) => manager.appendCustomEntry(name, data),
getActiveTools: () => active, setActiveTools: (names: string[]) => { active = names; },
getCommands: () => [{ name: "supervise", sourceInfo: { path: "internal-supervisor" } }],
getAllTools: () => [{ name: "subagent" }, { name: "intercom", sourceInfo: { path: "intercom-test-only" } }],
sendUserMessage: (text: string) => messages.push(text), sendMessage: (message: any) => contexts.push(message),
exec: async (command: string, args: string[]) => {
expect(command).toBe("herdr"); herdrCalls.push(args);
if (args[0] === "pane") return { code: 0, stdout: JSON.stringify({ result: { pane: { pane_id: "w1:p2" } } }), stderr: "" };
if (args[1] === "start") {
const sessionFile = args[args.indexOf("--session") + 1];
const supervisor = make("supervisor", SessionManager.open(sessionFile));
await supervisor.hook("session_start"); await tick();
}
return { code: 0, stdout: "{}", stderr: "" };
},
};
const ctx: any = { cwd, hasUI: true, isIdle: () => true, model: { provider: "offline", id: (manager.getBranch().findLast((entry: any) => entry.type === "model_change") as any)?.modelId ?? "test", contextWindow: 200_000 }, sessionManager: manager,
modelRegistry: { find: (provider: string, id: string) => peer.missing && id === "worker" ? undefined : ({ provider, id, contextWindow: 200_000 }) },
getContextUsage: () => ({ tokens: 50_000 }), compact({ onComplete }: any) { peer.compactions++; peer.compactionModels.push(ctx.model.id); onComplete({}); }, abort() { peer.aborts++; },
ui: { theme: { fg: (_: string, text: string) => text }, setWidget() {}, setStatus() {}, notify: vi.fn(), select: async () => "Ready" },
};
peer.pi = pi; peer.ctx = ctx; peer.hook = async (name: string, event = {}) => { for (const fn of hooks.get(name) ?? []) await fn(event, ctx); };
peers.push(peer); goals(pi); return peer;
}
const manager = SessionManager.create(cwd, join(cwd, "sessions"));
manager.appendMessage({ role: "user", content: "Keep the literal [x] and produce two files", timestamp: Date.now() });
manager.appendMessage({ role: "assistant", content: [{ type: "text", text: "Plan drafted" }], api: "openai-completions", provider: "offline", model: "test", stopReason: "stop", timestamp: Date.now(), usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } });
const worker = make("worker", manager);
try {
await worker.hook("session_start");
await worker.commands.get("goals").handler("plan judge the vendor options", worker.ctx);
worker.ctx.model = { provider: "offline", id: "planning", contextWindow: 200_000 };
manager.appendModelChange("offline", "planning");
await worker.hook("model_select", { source: "cycle", model: worker.ctx.model });
const path = join(cwd, ".pi", "plan", `${manager.getSessionId()}-v1.md`);
writeFileSync(path, '# Plan\n\nUser voice: render "[x]" literally\n\n1. [ ] goal: first\n2. [ ] goal: second\n\n## Log\n');
worker.delayBackground = scenario.includes("during activation");
worker.missing = scenario === "missing worker model" || scenario.includes("during recovery restore");
worker.noAuth = scenario === "unauthenticated worker model";
await worker.tools.get("RequestPlanReview").execute("", {}, undefined, undefined, worker.ctx);
const starting = worker.hook("agent_settled");
if (worker.delayBackground) {
await vi.waitFor(() => expect(worker.finishBackground).toBeTypeOf("function"));
expect(worker.messages.filter((m: string) => m.startsWith("Work the goals"))).toHaveLength(0);
const stopping = scenario === "steward off during activation";
await worker.commands.get("goals").handler(stopping ? "steward off" : "plan a replacement that has not received Ready", worker.ctx);
worker.finishBackground(); await starting; await tick();
expect(worker.messages.filter((m: string) => m.startsWith("Work the goals"))).toHaveLength(0);
expect(worker.ctx.model.id).toBe("planning");
expect(worker.modelChanges).toContain("worker");
const saved = manager.getBranch().findLast((entry: any) => entry.customType === "pi-goals-state") as any;
expect(saved.data).toMatchObject({ phase: "planning", planVersion: stopping ? 1 : 2, supervisor: null });
if (stopping) {
expect(saved.data.stewardEnabled).toBe(false);
worker.delayBackground = false;
await worker.hook("agent_settled"); // Ready is offered again; ordinary work can now start.
expect(worker.messages.filter((m: string) => m.startsWith("Work the goals"))).toHaveLength(1);
const retried = manager.getBranch().findLast((entry: any) => entry.customType === "pi-goals-state") as any;
expect(retried.data).toMatchObject({ phase: "working", planVersion: 1 });
}
expect(herdrCalls.filter(args => args[1] === "start")).toHaveLength(1);
return;
}
await starting; await tick();
const supervisor = peers[1];
if (worker.missing || worker.noAuth) {
const saved = manager.getBranch().findLast((entry: any) => entry.customType === "pi-goals-state") as any;
expect(saved.data).toMatchObject({ phase: "planning", modelRecovery: "worker" });
expect(wires.some(w => w.t === "plan_activate" || w.t === "view")).toBe(false);
expect(supervisor.messages).toEqual([]);
expect(worker.messages.some((m: string) => m.startsWith("Work the goals"))).toBe(false);
expect(worker.ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("worker model paused"), "error");
await worker.hook("session_start", { reason: "reload" });
expect(worker.ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("/goals model current"), "error");
const before = worker.messages.length;
worker.receive({ type: "message", fromSessionId: supervisor.id, payload: { t: "directive", to: worker.id, text: "Do not revive an inactive worker" } });
await tick(); expect(worker.messages).toHaveLength(before);
worker.missing = false; worker.noAuth = false;
if (scenario.includes("during recovery restore")) {
worker.deferWorkerRestore = true;
const startupCount = herdrCalls.length;
const recovering = worker.hook("agent_settled");
await vi.waitFor(() => expect(worker.finishRestore).toBeTypeOf("function"));
const clearing = scenario.startsWith("clear");
await worker.commands.get("goals").handler(clearing ? "clear" : "plan replacement has no Ready", worker.ctx);
worker.finishRestore(); await recovering; await tick();
expect(herdrCalls).toHaveLength(startupCount);
expect(wires.some(w => w.t === "plan_activate")).toBe(false);
expect(worker.messages.some((m: string) => m.startsWith("Work the goals"))).toBe(false);
expect(manager.getBranch().findLast((entry: any) => entry.customType === "pi-goals-state").data).toMatchObject({ phase: clearing ? null : "planning", planVersion: clearing ? null : 2, supervisor: null, modelRecovery: null });
expect(worker.ctx.model.id).toBe("planning");
return;
}
await worker.pi.setModel({ provider: "offline", id: "recovered-worker", contextWindow: 200_000 });
await vi.waitFor(() => expect(worker.messages.filter((m: string) => m.startsWith("Work the goals"))).toHaveLength(1));
expect(JSON.parse(readFileSync(join(modelDir, "worker-model.json"), "utf8"))).toEqual({ provider: "offline", id: "recovered-worker" });
expect(JSON.parse(readFileSync(join(modelDir, "planning-model.json"), "utf8"))).toEqual({ provider: "offline", id: "planning" });
expect(wires.filter(w => w.t === "plan_activate")).toHaveLength(1);
expect(herdrCalls.filter(args => args[1] === "start")).toHaveLength(1);
return;
}
if (scenario.includes("while model unavailable")) {
worker.noAuth = true;
await worker.hook("session_start", { reason: "reload" });
await worker.commands.get("goals").handler(scenario.startsWith("clear") ? "clear" : "steward off", worker.ctx);
await tick();
for (const peer of peers) expect(peer.manager.getBranch().findLast((e: any) => e.customType === "supervise-state").data.role).toBe("none");
const before = worker.messages.length;
worker.receive({ type: "message", fromSessionId: supervisor.id, payload: { t: "directive", to: worker.id, text: "An old directive must not revive work" } });
await tick(); expect(worker.messages).toHaveLength(before);
expect(supervisor.aborts).toBeGreaterThan(0);
return;
}
expect(supervisor).toBeDefined(); expect(supervisor.manager.getSessionFile()).not.toBe(manager.getSessionFile());
expect(supervisor.manager.getBranch().some((e: any) => e.customType === SUPERVISOR_ROLE)).toBe(true);
expect(supervisor.compactionModels).toEqual(["supervisor"]);
expect(supervisor.manager.getBranch().some((entry: any) => entry.type === "model_change" && entry.modelId === "worker")).toBe(false);
expect(supervisor.manager.getBranch().some((entry: any) => entry.type === "model_change" && entry.modelId === "planning")).toBe(true);
expect(worker.ctx.model.id).toBe("worker");
expect(JSON.parse(readFileSync(join(modelDir, "planning-model.json"), "utf8"))).toEqual({ provider: "offline", id: "planning" });
expect(supervisor.compactions).toBe(1); expect(worker.compactions).toBe(0);
expect(worker.messages.filter((m: string) => m.startsWith("Work the goals"))).toHaveLength(1);
expect(supervisor.pi.getActiveTools()).not.toContain("CompleteGoal");
await worker.commands.get("goals").handler("judge offline/judge", worker.ctx);
for (const goal of ["first", "second"]) {
const completion = worker.tools.get("CompleteGoal").execute("", { goal }, undefined, undefined, worker.ctx);
await tick(); const request = wires.findLast((w: any) => w.t === "goal_review");
expect(request.goal).toBe(goal);
await supervisor.tools.get("review_goal").execute("", { requestId: request.requestId, decision: "approve", reason: "Within the requested scope" });
const completed = await completion;
expect(completed.isError, JSON.stringify(completed)).toBe(false);
expect(readFileSync(path, "utf8")).toContain(`[x] goal: ${goal}`);
}
expect(judge.calls).toHaveLength(2); expect(judge.calls.every(args => args.includes("--no-extensions") && args.includes("offline/judge"))).toBe(true);
expect(worker.ctx.model.id).toBe("worker");
expect(supervisor.ctx.model.id).toBe("supervisor");
expect(wires.some(w => w.t === "done")).toBe(false);
await worker.commands.get("goals").handler("supervisor", worker.ctx);
expect(herdrCalls.at(-1)).toEqual(["agent", "focus", "w1:p2"]);
await supervisor.commands.get("goals").handler("worker", supervisor.ctx);
expect(herdrCalls.at(-1)).toEqual(["agent", "focus", "w1:p1"]);
const pending = worker.tools.get("CompleteGoal").execute("", { goal: "first" }, undefined, undefined, worker.ctx);
await tick(); await worker.commands.get("goals").handler("steward off", worker.ctx);
expect((await pending).isError).toBe(true); await tick();
expect(judge.calls).toHaveLength(2); expect(supervisor.aborts).toBeGreaterThan(0);
} finally { for (const peer of peers) await peer.hook("session_shutdown"); rmSync(cwd, { recursive: true, force: true }); }
}, 15_000);
});
+30
View File
@@ -0,0 +1,30 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, it, vi } from "vitest";
import { focusSupervisor, planHash, type SupervisorBinding, startSupervisor, supervisorRequest } from "../src/supervisor.js";
const binding: SupervisorBinding = { id: "pair", workerSession: "/worker.jsonl", supervisorSession: "/supervisor.jsonl", planPath: "/plan.md", workerPane: "w1:p1", supervisorPane: "w1:p2", everyTurns: 50, intervalMs: 3_600_000, compactTokens: 100_000 };
afterEach(() => vi.unstubAllEnvs());
describe("supervisor adapter", () => {
it("fails visibly when the supervisor API is absent", async () => {
const pi = { events: { emit() {} } } as unknown as ExtensionAPI;
await expect(supervisorRequest(pi, "status")).rejects.toThrow("Load the pi-goals package directory");
});
it("does not operate on a live Herdr session from outside Herdr", async () => {
vi.stubEnv("HERDR_ENV", "");
const exec = vi.fn();
await expect(focusSupervisor({ exec } as unknown as ExtensionAPI, binding, "supervisor")).rejects.toThrow("Start Pi inside Herdr");
expect(exec).not.toHaveBeenCalled();
});
it("does not spawn a duplicate when the recorded pane is unavailable", async () => {
vi.stubEnv("HERDR_ENV", "1"); vi.stubEnv("HERDR_PANE_ID", "w1:p1");
const exec = vi.fn(async () => ({ code: 1, stdout: "", stderr: '{"error":{"code":"pane_not_found","message":"Pane unavailable"}}' }));
const pi = { exec, events: { emit(_name: string, request: any) { request.handled = true; request.resolve({ connected: false, binding, workerId: "worker" }); } }, getCommands: () => [{ name: "supervise", sourceInfo: { path: "/supervise.ts" } }], getAllTools: () => [{ name: "intercom", sourceInfo: { path: "/intercom.ts" } }] } as unknown as ExtensionAPI;
const ctx = { cwd: "/project", sessionManager: { getSessionFile: () => "/worker.jsonl", getLeafId: () => "leaf" } } as unknown as ExtensionContext;
await expect(startSupervisor(pi, ctx, binding.planPath, binding, vi.fn(), new AbortController().signal)).rejects.toThrow("/supervisor.jsonl");
expect(exec.mock.calls).toHaveLength(1);
expect(exec.mock.calls[0]).toEqual(["herdr", ["agent", "focus", "w1:p2"], expect.anything()]);
});
it("hashes quoted requirements without normalizing their literal checkbox syntax", () => {
expect(planHash('Render "[x]"')).not.toBe(planHash('Render "[ ]"'));
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"paths": {
"@sting8k/pi-vcc/src/*": [
"./src/internal/supervisor/vcc.d.ts"
]
}
}
}
+3
View File
@@ -0,0 +1,3 @@
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { include: ["test/*.test.ts"], setupFiles: ["test/setup.ts"] } });