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.
This commit is contained in:
wassname2
2026-09-08 08:45:26 +08:00
parent 8e44738773
commit d5729ac106
36 changed files with 8119 additions and 305 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.
+95 -27
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,13 +67,22 @@ 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`.
satisfied. Every human reply 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.
@@ -85,9 +94,12 @@ supervision is enabled. Use `/goals steward off` or `/goals auto off` to opt out
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.
This branch requires the matching
plan-aware `pi-intercom-supervisor` branch, `pi-intercom`, and Pi running inside Herdr. Both extensions
must be loaded in the worker; pi-goals passes their resolved paths to the supervisor.
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
@@ -95,10 +107,10 @@ worker. The initial supervisor view can steer; it is not another mandatory plan-
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 existing supervisor provides incremental VCC views and retains its decisions. The plan-aware
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. Standalone supervisor settings are unchanged. Auto-continue is
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
@@ -124,13 +136,10 @@ fresh judge's model; `/goals steward status` reports supervision. Use `/goals pl
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, load the two feature checkouts explicitly in a Herdr-managed Pi session (replace
paths as needed; this does not change global package settings):
For a local trial, start inside Herdr with just this checkout:
```bash
pi -e /path/to/pi-intercom-supervisor/src/index.ts \
-e /path/to/pi-intercom-supervisor/node_modules/pi-intercom/index.ts \
-e /path/to/pi-goals/src/index.ts
pi -e /path/to/pi-goals
```
Then draft a plan and select Ready; no enable command is needed. Initialization failure stays in
@@ -138,27 +147,86 @@ planning and names the unavailable component; resolve it in the supervisor pane,
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
# Cross-package tests require the updated supervisor checkout:
PI_GOALS_SUPERVISOR_SOURCE=/path/to/pi-intercom-supervisor/src/index.ts npm test
npm run build
```
The cross-package hook test uses actual extension code and a native persisted fork, with Herdr and
the evidence judge mocked. `test/rpc-supervisor.test.ts` starts two real Pi RPC sessions, the actual
Intercom broker and a fresh judge against a local offline model, with Herdr alone mocked. It requires
Unix sockets. Neither test proves visual pane navigation; live Herdr UAT is still required.
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,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,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. -->
+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"
]
}
+124 -22
View File
@@ -42,16 +42,21 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync
import { basename, join, resolve } from "node:path";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
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";
@@ -132,21 +137,29 @@ 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;
/** Interval for continuing active goals when supervision is disabled. */
autoIntervalMs: number | null;
autoPaused: boolean;
/** Real supervisor session, enabled by default and paired through pi-intercom-supervisor. */
/** Real supervisor session, enabled by default and paired through bundled Intercom. */
stewardEnabled: boolean;
supervisor: SupervisorBinding | null;
}
export default function piGoalsExtension(pi: ExtensionAPI): void {
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,
@@ -202,7 +215,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
}
function scheduleAutoContinue(ctx: ExtensionContext, delayMs = state.autoIntervalMs): void {
if (state.stewardEnabled || supervisorOnly) { clearAutoTimer(); return; }
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(() => {
@@ -248,8 +261,10 @@ 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;
}
@@ -300,9 +315,16 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
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();
@@ -311,6 +333,16 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
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"); }
@@ -319,16 +351,26 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
pi.sendUserMessage(handoff, { deliverAs: "followUp" });
} catch (error) {
if (signal.aborted) return;
state = { ...state, phase: "planning" }; persist(); updateWidget(ctx);
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 (operation === controller) operation = null; }
} 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 plan <objective> | /goals supervisor | /goals worker | /goals zoom | /goals <objective> | /goals clear | /goals auto [minutes|off] | /goals judge <model> | /goals steward [on|off|status]`,
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) => {
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");
@@ -355,12 +397,14 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
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;
}
@@ -434,9 +478,13 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
return;
}
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,
};
@@ -449,8 +497,8 @@ 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" });
},
});
@@ -483,7 +531,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
pi.on("before_agent_start", async (_event, ctx) => {
if ((state.phase !== "planning" && state.phase !== "starting") || !planningContextPending) return;
planningContextPending = false;
const content = planningState(planPath(ctx));
const content = planningState(planPath(ctx), state.questionsWaived);
return { message: { customType: PLANNING_CONTEXT, content, display: false } };
});
@@ -494,7 +542,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
const messages = inPlanGate ? event.messages : event.messages.filter((message) => (message as { customType?: string }).customType !== PLANNING_CONTEXT);
if (inPlanGate && planningContextPending) {
planningContextPending = false;
const text = planningState(planPath(ctx));
const text = planningState(planPath(ctx), state.questionsWaived);
return { messages: [...messages, { role: "user" as const, content: [{ type: "text" as const, text }], timestamp: Date.now() }] };
}
const text = dueInjection(ctx, readPlan(ctx));
@@ -505,6 +553,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
// 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;
@@ -538,6 +587,7 @@ 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 (!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;
@@ -556,12 +606,18 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
});
// 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);
@@ -573,13 +629,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") {
@@ -593,11 +649,13 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
state = {
...state,
phase: null,
modelRecovery: null,
planVersion: null,
supervisor: null,
};
persist();
updateWidget(ctx);
models.leave();
ctx.ui.notify("Plan discarded.", "info");
return;
}
@@ -606,20 +664,33 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
await startPlanSupervisor(ctx);
return;
}
state = { ...state, phase: "working" };
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(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"));
initializeSupervisor(pi, ctx, bootstrap, lifetime.signal);
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
@@ -632,6 +703,9 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
state = {
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: useNewDefaults || saved?.autoIntervalMs === undefined ? AUTO_DEFAULT_INTERVAL_MS : saved.autoIntervalMs,
@@ -644,6 +718,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
planningContextPending = state.phase === "planning" || state.phase === "starting";
resyncReason = state.phase === "working" ? "New session." : null;
updateWidget(ctx);
if (state.phase && !await models.enter(state.phase === "working" || state.modelRecovery ? "worker" : "planning", ctx)) return;
scheduleAutoContinue(ctx);
});
@@ -653,6 +728,19 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
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({
@@ -724,6 +812,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`;
}
+31 -9
View File
@@ -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,7 +146,7 @@ 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
@@ -148,16 +154,32 @@ When the goals are drafted, present them and say the plan is final. Do not begin
* 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 {
+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--; }
}
}
+3 -5
View File
@@ -29,7 +29,7 @@ export function supervisorRequest<T>(pi: ExtensionAPI, method: string, params: R
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("Load the plan-aware pi-intercom-supervisor and pi-intercom packages in both sessions, then reload Pi."));
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."));
});
}
@@ -76,9 +76,7 @@ export async function startSupervisor(
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 supervisorSource = pi.getCommands().find(command => command.name === "supervise")?.sourceInfo?.path;
const intercomSource = pi.getAllTools().find(tool => tool.name === "intercom")?.sourceInfo?.path;
if (!supervisorSource || !intercomSource) throw new Error("Cannot resolve the loaded supervisor and Intercom extensions; load both before Ready.");
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,
@@ -115,7 +113,7 @@ export async function startSupervisor(
void waiting.catch(() => {});
try {
await herdr(pi, ["agent", "start", `supervisor-${binding.id.slice(0, 8)}`, "--kind", "pi", "--pane", pane, "--", "--session", binding.supervisorSession!,
"-e", supervisorSource, "-e", intercomSource, "-e", fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./index.ts" : "./index.js", import.meta.url))], signal);
"-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"}]}}
+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);
+166 -38
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> = [],
@@ -31,6 +33,8 @@ function setup(
};
const ctx = {
cwd,
model: { provider: "offline", id: "test" },
modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) },
hasUI: true,
isIdle: () => true,
sessionManager: { getSessionId: () => "session-a", getSessionFile: () => join(cwd, "session-a.jsonl"), getEntries: () => entries, getBranch: () => entries },
@@ -38,7 +42,7 @@ function setup(
theme: { fg: (_kind: string, text: string) => text },
setStatus: () => {},
setWidget: () => {},
notify: () => {},
notify: vi.fn(),
select: async () => {
events.push("select");
return selectChoices.shift();
@@ -49,9 +53,13 @@ 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),
events: bus,
@@ -62,7 +70,12 @@ function setup(
sendUserMessage: (message: string) => messages.push({ content: message }),
};
piGoalsExtension(pi as unknown as ExtensionAPI);
return { bus, commands, ctx, cwd, entries, events, hooks, messages, tools };
return { pi, bus, commands, ctx, cwd, entries, events, hooks, messages, tools };
}
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", () => {
@@ -120,14 +133,14 @@ describe("/goals draft flow", () => {
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 flow.hooks.get("agent_settled")({}, flow.ctx);
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 Refine, and records editor notes", async () => {
const flow = setup(["Refine"], ["Keep two columns.\nDo not add a filter."]);
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 });
@@ -141,9 +154,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.");
@@ -188,27 +202,29 @@ 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 () => {
@@ -219,7 +235,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: 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);
@@ -239,7 +255,7 @@ describe("/goals draft flow", () => {
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 flow.hooks.get("agent_settled")({}, flow.ctx);
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 {
@@ -256,7 +272,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();
@@ -273,7 +289,7 @@ describe("/goals draft flow", () => {
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);
@@ -302,18 +318,18 @@ describe("/goals draft flow", () => {
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 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 {
@@ -330,11 +346,11 @@ describe("/goals draft flow", () => {
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 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);
@@ -380,3 +396,115 @@ describe("/goals draft flow", () => {
}
});
});
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");
+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();
+37 -10
View File
@@ -1,29 +1,45 @@
import { spawn } from "node:child_process";
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 { dirname, join, resolve } from "node:path";
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
const source = process.env.PI_GOALS_SUPERVISOR_SOURCE;
const intercom = source ? resolve(dirname(source), "../node_modules/pi-intercom/index.ts") : "";
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.skipIf(!source || !existsSync(intercom))("two real Pi sessions with the actual Intercom broker (Herdr mocked)", () => {
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" });
@@ -32,17 +48,19 @@ describe.skipIf(!source || !existsSync(intercom))("two real Pi sessions with the
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" });
return stream(res, { content: "Worker is ready." });
workerModels.push(body.model); return stream(res, { content: "Worker is ready." });
}
judgeCalls++; stream(res, { content: "## checks:\n- evidence.txt: `PASS`; the saved receipt passed\n\nVERDICT: accept\nmissing:" });
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: [{ id: "test", name: "Offline", reasoning: false, input: ["text"], contextWindow: 200_000, maxTokens: 1000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }] } } }));
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", "-e", source!, "-e", intercom, "-e", resolve("test/fixtures/herdr-test-host.ts")], {
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 = "";
@@ -64,15 +82,24 @@ describe.skipIf(!source || !existsSync(intercom))("two real Pi sessions with the
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"); const readyAt = events.length;
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(); });
+88 -13
View File
@@ -1,5 +1,5 @@
import { EventEmitter } from "node:events";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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";
@@ -18,28 +18,28 @@ vi.mock("node:child_process", async (original) => {
} };
});
const supervisorSource = process.env.PI_GOALS_SUPERVISOR_SOURCE;
const tick = () => new Promise(resolve => setImmediate(resolve));
afterEach(() => { vi.unstubAllEnvs(); judge.calls = []; });
describe.skipIf(!supervisorSource)("actual goals and supervisor package hooks (Herdr and judge mocked)", () => {
it.each(["completion", "replacement during activation", "steward off during activation"])("Ready forks once and preserves lifecycle ownership: %s", async (scenario) => {
const supervise = (await import(/* @vite-ignore */ supervisorSource!)).default;
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, aborts: 0 };
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) { wires.push(wire); for (const p of peers) queueMicrotask(() => p.receive({ type: "message", fromSessionId: peer.id, payload: wire })); } });
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([]);
@@ -52,11 +52,21 @@ describe.skipIf(!supervisorSource)("actual goals and supervisor package hooks (H
},
};
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: supervisorSource } }],
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[]) => {
@@ -70,12 +80,13 @@ describe.skipIf(!supervisorSource)("actual goals and supervisor package hooks (H
return { code: 0, stdout: "{}", stderr: "" };
},
};
const ctx: any = { cwd, hasUI: true, isIdle: () => true, model: { provider: "offline", id: "test", contextWindow: 200_000 }, sessionManager: manager,
getContextUsage: () => ({ tokens: 50_000 }), compact({ onComplete }: any) { peer.compactions++; onComplete({}); }, abort() { peer.aborts++; },
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); supervise(pi); goals(pi); return peer;
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() });
@@ -84,9 +95,15 @@ describe.skipIf(!supervisorSource)("actual goals and supervisor package hooks (H
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 !== "completion";
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"));
@@ -95,6 +112,8 @@ describe.skipIf(!supervisorSource)("actual goals and supervisor package hooks (H
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) {
@@ -110,11 +129,65 @@ describe.skipIf(!supervisorSource)("actual goals and supervisor package hooks (H
}
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");
@@ -124,7 +197,9 @@ describe.skipIf(!supervisorSource)("actual goals and supervisor package hooks (H
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"))).toBe(true);
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"]);
+1 -1
View File
@@ -7,7 +7,7 @@ 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 plan-aware");
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", "");
+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"] } });