diff --git a/AGENTS.md b/AGENTS.md index fc2e5d4..e5bf57e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` 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. diff --git a/README.md b/README.md index e8363f9..4e49a0e 100644 --- a/README.md +++ b/README.md @@ -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 ` 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). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..2ce8f27 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -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. diff --git a/docs/reviews/2026-09-07_single-package-role-models.md b/docs/reviews/2026-09-07_single-package-role-models.md new file mode 100644 index 0000000..ef56e52 --- /dev/null +++ b/docs/reviews/2026-09-07_single-package-role-models.md @@ -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. + + diff --git a/docs/reviews/evidence/2026-09-07_single-package-final-validation.log b/docs/reviews/evidence/2026-09-07_single-package-final-validation.log new file mode 100644 index 0000000..9cd071a --- /dev/null +++ b/docs/reviews/evidence/2026-09-07_single-package-final-validation.log @@ -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 diff --git a/docs/slop/plans/20260907_single-package-role-models.md b/docs/slop/plans/20260907_single-package-role-models.md new file mode 100644 index 0000000..a727403 --- /dev/null +++ b/docs/slop/plans/20260907_single-package-role-models.md @@ -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. + + diff --git a/package-lock.json b/package-lock.json index b40636e..0f522e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,26 @@ { "name": "@wassname2/pi-goals", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wassname2/pi-goals", - "version": "0.2.1", + "version": "0.2.2", + "bundleDependencies": [ + "pi-intercom", + "@sting8k/pi-vcc" + ], "license": "MIT", + "dependencies": { + "@sting8k/pi-vcc": "0.5.0", + "pi-intercom": "0.10.0" + }, "devDependencies": { "@biomejs/biome": "^2.4.8", "@earendil-works/pi-coding-agent": "^0.84.1", "@types/node": "^20.0.0", + "tsx": "^4.20.0", "typebox": "^1.3.7", "typescript": "^5.0.0", "vitest": "^4.0.18" @@ -21,6 +30,525 @@ "typebox": "*" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "inBundle": true, + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz", + "integrity": "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.81", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.81.tgz", + "integrity": "sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.34.tgz", + "integrity": "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.29.tgz", + "integrity": "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.52.tgz", + "integrity": "sha512-vsPPM+nMbKJlUCFU+eoGZbdxdxDIAX9LbpjSXaR5Ufpmqgp8TdYQnoExhLu4T3umW/JIIPny1ydbhWidZZYokQ==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz", + "integrity": "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.10.tgz", + "integrity": "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "inBundle": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@biomejs/biome": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.2.tgz", @@ -196,12 +724,38 @@ "node": ">=14.21.3" } }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.4.tgz", + "integrity": "sha512-AClAZxf5+c4RRu44NJPS6wyQy+Nmq+Mzyyrdvm4ZVMNuixelO02RZX4G4Aq1F145Yzp43wnM5S+hLlSI7ypfVw==", + "inBundle": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.84.4", + "@google/genai": "1.52.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.40.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@earendil-works/pi-coding-agent": { "version": "0.84.1", "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.1.tgz", "integrity": "sha512-ncAqFrG+iybuPGOhMiZoEHkEzTpJgz3guYD32pD+M7ucc0WeHmauP6wa7qwP8V/KWvsZDVNa5XGsdZ7fkC7w7A==", - "dev": true, "hasShrinkwrap": true, + "inBundle": true, "license": "MIT", "dependencies": { "@earendil-works/pi-agent-core": "^0.84.1", @@ -240,7 +794,7 @@ "version": "0.91.1", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1" @@ -261,7 +815,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -276,7 +830,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", @@ -292,7 +846,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -307,7 +861,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -317,7 +871,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.222.0", @@ -329,7 +883,7 @@ "version": "3.1048.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -355,7 +909,7 @@ "version": "3.974.11", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", @@ -375,7 +929,7 @@ "version": "3.972.37", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -392,7 +946,7 @@ "version": "3.972.39", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -411,7 +965,7 @@ "version": "3.972.41", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -436,7 +990,7 @@ "version": "3.972.41", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -454,7 +1008,7 @@ "version": "3.972.42", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.37", @@ -477,7 +1031,7 @@ "version": "3.972.37", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -494,7 +1048,7 @@ "version": "3.972.41", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -513,7 +1067,7 @@ "version": "3.972.41", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -531,7 +1085,7 @@ "version": "3.972.16", "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", @@ -547,7 +1101,7 @@ "version": "3.972.12", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", @@ -563,7 +1117,7 @@ "version": "3.972.19", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -582,7 +1136,7 @@ "version": "3.997.9", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -604,7 +1158,7 @@ "version": "3.996.27", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", @@ -621,7 +1175,7 @@ "version": "3.1048.0", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -639,7 +1193,7 @@ "version": "3.973.8", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.1", @@ -653,7 +1207,7 @@ "version": "3.965.5", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -666,7 +1220,7 @@ "version": "3.972.24", "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@nodable/entities": "2.1.0", @@ -682,7 +1236,7 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -692,7 +1246,7 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, + "inBundle": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -701,7 +1255,7 @@ "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { "version": "0.84.1", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.1.tgz", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "@earendil-works/pi-ai": "^0.84.1", @@ -718,7 +1272,7 @@ "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { "version": "0.84.1", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.1.tgz", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -744,7 +1298,7 @@ "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-client": { "version": "0.84.1", "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.1.tgz", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "@earendil-works/pi-protocol": "^0.84.1" @@ -756,7 +1310,7 @@ "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-protocol": { "version": "0.84.1", "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.1.tgz", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "typebox": "1.3.7" @@ -768,7 +1322,7 @@ "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry": { "version": "0.84.1", "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.1.tgz", - "dev": true, + "inBundle": true, "license": "MIT", "engines": { "node": ">=22.19.0" @@ -777,7 +1331,7 @@ "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { "version": "0.84.1", "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.1.tgz", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", @@ -791,8 +1345,8 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", - "dev": true, "hasInstallScript": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "google-auth-library": "^10.3.0", @@ -816,7 +1370,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", - "dev": true, + "inBundle": true, "license": "MIT", "optional": true, "engines": { @@ -842,7 +1396,7 @@ "cpu": [ "arm64" ], - "dev": true, + "inBundle": true, "license": "MIT", "optional": true, "os": [ @@ -856,7 +1410,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", - "dev": true, + "inBundle": true, "license": "MIT", "optional": true, "os": [ @@ -873,7 +1427,7 @@ "cpu": [ "x64" ], - "dev": true, + "inBundle": true, "license": "MIT", "optional": true, "os": [ @@ -890,7 +1444,7 @@ "cpu": [ "arm64" ], - "dev": true, + "inBundle": true, "license": "MIT", "optional": true, "os": [ @@ -907,7 +1461,7 @@ "cpu": [ "arm64" ], - "dev": true, + "inBundle": true, "license": "MIT", "optional": true, "os": [ @@ -924,7 +1478,7 @@ "cpu": [ "riscv64" ], - "dev": true, + "inBundle": true, "license": "MIT", "optional": true, "os": [ @@ -941,7 +1495,7 @@ "cpu": [ "x64" ], - "dev": true, + "inBundle": true, "license": "MIT", "optional": true, "os": [ @@ -958,7 +1512,7 @@ "cpu": [ "x64" ], - "dev": true, + "inBundle": true, "license": "MIT", "optional": true, "os": [ @@ -975,7 +1529,7 @@ "cpu": [ "arm64" ], - "dev": true, + "inBundle": true, "license": "MIT", "optional": true, "os": [ @@ -992,7 +1546,7 @@ "cpu": [ "x64" ], - "dev": true, + "inBundle": true, "license": "MIT", "optional": true, "os": [ @@ -1006,7 +1560,7 @@ "version": "2.2.6", "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.40.0", @@ -1027,20 +1581,20 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/nodable" } ], + "inBundle": true, "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "engines": { "node": ">=8.0.0" @@ -1050,7 +1604,7 @@ "version": "1.41.1", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "engines": { "node": ">=14" @@ -1060,35 +1614,35 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "dev": true, + "inBundle": true, "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "dev": true, + "inBundle": true, "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "dev": true, + "inBundle": true, "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "dev": true, + "inBundle": true, "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "dev": true, + "inBundle": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.1" @@ -1098,42 +1652,42 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "dev": true, + "inBundle": true, "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "dev": true, + "inBundle": true, "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "dev": true, + "inBundle": true, "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "dev": true, + "inBundle": true, "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", - "dev": true, + "inBundle": true, "license": "Apache-2.0" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { "version": "3.24.3", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", @@ -1148,7 +1702,7 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.24.3", @@ -1163,7 +1717,7 @@ "version": "5.4.3", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.24.3", @@ -1178,7 +1732,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -1191,7 +1745,7 @@ "version": "4.7.3", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.24.3", @@ -1206,7 +1760,7 @@ "version": "5.4.3", "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.24.3", @@ -1221,7 +1775,7 @@ "version": "4.14.2", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -1234,7 +1788,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -1248,7 +1802,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -1262,7 +1816,7 @@ "version": "22.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1272,7 +1826,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, + "inBundle": true, "license": "MIT", "engines": { "node": ">= 14" @@ -1282,7 +1836,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, + "inBundle": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -1292,7 +1846,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -1307,13 +1860,14 @@ "url": "https://feross.org/support" } ], + "inBundle": true, "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "dev": true, + "inBundle": true, "license": "MIT", "engines": { "node": "*" @@ -1323,14 +1877,14 @@ "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "dev": true, + "inBundle": true, "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -1343,14 +1897,14 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "dev": true, + "inBundle": true, "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, + "inBundle": true, "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -1363,7 +1917,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1378,7 +1932,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "dev": true, + "inBundle": true, "license": "MIT", "engines": { "node": ">= 12" @@ -1388,7 +1942,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1406,7 +1960,7 @@ "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", - "dev": true, + "inBundle": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -1416,7 +1970,7 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -1426,20 +1980,20 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true, + "inBundle": true, "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], + "inBundle": true, "license": "MIT", "dependencies": { "path-expression-matcher": "^1.5.0", @@ -1450,13 +2004,13 @@ "version": "5.7.3", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", - "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], + "inBundle": true, "license": "MIT", "dependencies": { "@nodable/entities": "^2.1.0", @@ -1472,7 +2026,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, "funding": [ { "type": "github", @@ -1483,6 +2036,7 @@ "url": "https://paypal.me/jimmywarting" } ], + "inBundle": true, "license": "MIT", "dependencies": { "node-domexception": "^1.0.0", @@ -1496,7 +2050,7 @@ "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "fetch-blob": "^3.1.2" @@ -1509,7 +2063,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", @@ -1524,7 +2078,7 @@ "version": "8.1.2", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "gaxios": "^7.0.0", @@ -1539,7 +2093,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, + "inBundle": true, "license": "MIT", "engines": { "node": ">=18" @@ -1552,7 +2106,7 @@ "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, + "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.2.2", @@ -1570,7 +2124,7 @@ "version": "10.6.2", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", @@ -1588,7 +2142,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "engines": { "node": ">=14" @@ -1598,14 +2152,14 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, + "inBundle": true, "license": "ISC" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/grok-mermaid": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz", "integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -1615,7 +2169,7 @@ "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "dev": true, + "inBundle": true, "license": "BSD-3-Clause", "engines": { "node": "*" @@ -1625,7 +2179,7 @@ "version": "9.0.3", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", - "dev": true, + "inBundle": true, "license": "ISC", "dependencies": { "lru-cache": "^11.1.0" @@ -1638,7 +2192,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -1652,7 +2206,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -1666,7 +2220,7 @@ "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, + "inBundle": true, "license": "MIT", "engines": { "node": ">= 4" @@ -1676,14 +2230,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, + "inBundle": true, "license": "ISC" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, + "inBundle": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -1693,7 +2247,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "bignumber.js": "^9.0.0" @@ -1703,7 +2257,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", @@ -1717,7 +2271,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "buffer-equal-constant-time": "^1.0.1", @@ -1729,7 +2283,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "jwa": "^2.0.1", @@ -1740,14 +2294,14 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "dev": true, + "inBundle": true, "license": "Apache-2.0" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { "version": "11.4.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", - "dev": true, + "inBundle": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -1757,7 +2311,7 @@ "version": "18.0.5", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", - "dev": true, + "inBundle": true, "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -1770,7 +2324,7 @@ "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, + "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -1786,7 +2340,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, + "inBundle": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -1796,7 +2350,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, + "inBundle": true, "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { @@ -1804,7 +2358,6 @@ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": "Use your platform's native DOMException instead", - "dev": true, "funding": [ { "type": "github", @@ -1815,6 +2368,7 @@ "url": "https://paypal.me/jimmywarting" } ], + "inBundle": true, "license": "MIT", "engines": { "node": ">=10.5.0" @@ -1824,7 +2378,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "data-uri-to-buffer": "^4.0.0", @@ -1843,7 +2397,7 @@ "version": "6.26.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", - "dev": true, + "inBundle": true, "license": "Apache-2.0", "bin": { "openai": "bin/cli" @@ -1865,7 +2419,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "@types/retry": "0.12.0", @@ -1879,27 +2433,27 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "dev": true, + "inBundle": true, "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", - "dev": true, + "inBundle": true, "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], + "inBundle": true, "license": "MIT", "engines": { "node": ">=14.0.0" @@ -1909,7 +2463,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, + "inBundle": true, "license": "MIT", "engines": { "node": ">=8" @@ -1919,7 +2473,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, + "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -1936,7 +2490,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -1948,7 +2502,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, + "inBundle": true, "license": "MIT", "engines": { "node": ">= 4" @@ -1958,8 +2512,8 @@ "version": "7.6.5", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", - "dev": true, "hasInstallScript": true, + "inBundle": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", @@ -1982,7 +2536,7 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "dev": true, + "inBundle": true, "license": "MIT", "engines": { "node": ">= 4" @@ -1992,7 +2546,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -2007,13 +2560,14 @@ "url": "https://feross.org/support" } ], + "inBundle": true, "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "dev": true, + "inBundle": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2026,7 +2580,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -2039,7 +2593,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, + "inBundle": true, "license": "MIT", "engines": { "node": ">=8" @@ -2049,48 +2603,48 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, + "inBundle": true, "license": "ISC" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], + "inBundle": true, "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "dev": true, + "inBundle": true, "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, + "inBundle": true, "license": "0BSD" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", - "dev": true, + "inBundle": true, "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", - "dev": true, + "inBundle": true, "license": "MIT", "engines": { "node": ">=22.19.0" @@ -2100,14 +2654,14 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "inBundle": true, "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "dev": true, + "inBundle": true, "license": "MIT", "engines": { "node": ">= 8" @@ -2117,7 +2671,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, + "inBundle": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -2133,7 +2687,7 @@ "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "dev": true, + "inBundle": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -2155,13 +2709,13 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], + "inBundle": true, "license": "MIT", "engines": { "node": ">=16.0.0" @@ -2171,7 +2725,7 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, + "inBundle": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -2187,7 +2741,7 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, + "inBundle": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -2197,12 +2751,38 @@ "version": "3.25.2", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "dev": true, + "inBundle": true, "license": "ISC", "peerDependencies": { "zod": "^3.25.28 || ^4" } }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.4.tgz", + "integrity": "sha512-8e2CuxM+ht+hedQXTZmi5JVl6/xDK9RpSDL2+MbITevKYQhMZ/z6lJOTFgox3HQyGxO8mOZEtYGVeQNaD4OzqA==", + "inBundle": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.4.tgz", + "integrity": "sha512-nPUnwDkLtupPXnZQYrCwPFcuTydCDqTY6ZbFqhsL4S4kVq0AT418kPa/6uXwtaCD+MjBNBltb7ScTYX65yeE1w==", + "inBundle": true, + "license": "MIT", + "peer": true, + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -2237,6 +2817,474 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "inBundle": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -2273,6 +3321,81 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "inBundle": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "inBundle": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "inBundle": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "inBundle": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "inBundle": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "inBundle": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "inBundle": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "inBundle": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "inBundle": true, + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", @@ -2555,6 +3678,143 @@ "dev": true, "license": "MIT" }, + "node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz", + "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz", + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2562,6 +3822,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@sting8k/pi-vcc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@sting8k/pi-vcc/-/pi-vcc-0.5.0.tgz", + "integrity": "sha512-KJbOVUFbyghn6h+RD9bDXFNWkKNqpxaCpPQWceOuxMPe9ySpbEfaYnqO9CZUiCP3AFmQ5Ghnsg2B8pdKgY+0Hg==", + "inBundle": true, + "peerDependencies": { + "@earendil-works/pi-coding-agent": ">=0.74.0 <1.0.0", + "typebox": ">=1.1.24 <2.0.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -2602,12 +3872,20 @@ "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", - "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "inBundle": true, + "license": "MIT", + "peer": true + }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -2721,6 +3999,17 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "inBundle": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2731,6 +4020,55 @@ "node": ">=12" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "inBundle": true, + "license": "MIT", + "peer": true + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "inBundle": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "inBundle": true, + "license": "MIT", + "peer": true + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "inBundle": true, + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2748,6 +4086,36 @@ "dev": true, "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "inBundle": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "inBundle": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2758,6 +4126,17 @@ "node": ">=8" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/es-module-lexer": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", @@ -2765,6 +4144,48 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "inBundle": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2785,6 +4206,14 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "inBundle": true, + "license": "MIT", + "peer": true + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2803,12 +4232,51 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "inBundle": true, + "license": "MIT", + "peer": true, + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "inBundle": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, + "inBundle": true, "license": "MIT", "optional": true, "os": [ @@ -2818,6 +4286,163 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "inBundle": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "inBundle": true, + "license": "MIT", + "peer": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "inBundle": true, + "license": "MIT", + "peer": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "inBundle": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "inBundle": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "inBundle": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "inBundle": true, + "license": "MIT", + "peer": true, + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3091,6 +4716,14 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3101,6 +4734,28 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "inBundle": true, + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "inBundle": true, + "license": "MIT", + "peer": true + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -3120,6 +4775,48 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "inBundle": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "inBundle": true, + "license": "MIT", + "peer": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/obug": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", @@ -3134,6 +4831,49 @@ "node": ">=12.20.0" } }, + "node_modules/openai": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", + "inBundle": true, + "license": "Apache-2.0", + "peer": true, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "inBundle": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "inBundle": true, + "license": "MIT", + "peer": true + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3141,6 +4881,22 @@ "dev": true, "license": "MIT" }, + "node_modules/pi-intercom": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/pi-intercom/-/pi-intercom-0.10.0.tgz", + "integrity": "sha512-X6EiOfRbjDvfUBk1oPG3TPki1HCcmcY6P2tBtd6jInbQbZ4CPWo59ULxwd3ZVujozLNOoD24X5cizGPNauqHMA==", + "inBundle": true, + "license": "MIT", + "dependencies": { + "tsx": "^4.20.0" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", + "typebox": "*" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3190,6 +4946,42 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "inBundle": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "inBundle": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/rolldown": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", @@ -3224,6 +5016,28 @@ "@rolldown/binding-win32-x64-msvc": "1.1.4" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "inBundle": true, + "license": "MIT", + "peer": true + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3299,19 +5113,45 @@ "node": ">=14.0.0" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "inBundle": true, + "license": "MIT", + "peer": true + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "inBundle": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "inBundle": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } }, "node_modules/typebox": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", - "dev": true, + "inBundle": true, "license": "MIT" }, "node_modules/typescript": { @@ -3332,7 +5172,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "inBundle": true, "license": "MIT" }, "node_modules/vite": { @@ -3503,6 +5343,17 @@ } } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "inBundle": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3519,6 +5370,29 @@ "engines": { "node": ">=8" } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "inBundle": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index d5c5cde..7ab9389 100644 --- a/package.json +++ b/package.json @@ -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" + ] } diff --git a/src/index.ts b/src/index.ts index 0b41dae..f769e68 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 | /goals supervisor | /goals worker | /goals zoom | /goals | /goals clear | /goals auto [minutes|off] | /goals judge | /goals steward [on|off|status]`, + description: `Plan mode: draft goals into ${PLAN_SHAPE}, review, then work them. /goals plan | /goals model current | /goals supervisor | /goals worker | /goals zoom | /goals | /goals clear | /goals auto [minutes|off] | /goals judge | /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 { + if (reviewOpen || lifetime.signal.aborted) return; + reviewOpen = true; + try { await offerPlanReview(ctx); } finally { reviewOpen = false; } + } + async function offerPlanReview(ctx: ExtensionContext): Promise { 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) -------------------------------------------------------------------- diff --git a/src/internal/supervisor/background.ts b/src/internal/supervisor/background.ts new file mode 100644 index 0000000..d9a8867 --- /dev/null +++ b/src/internal/supervisor/background.ts @@ -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 { + 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(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`, + }; +} diff --git a/src/internal/supervisor/index.ts b/src/internal/supervisor/index.ts new file mode 100644 index 0000000..8ff2973 --- /dev/null +++ b/src/internal/supervisor/index.ts @@ -0,0 +1,1371 @@ +/** + * intercom-supervisor: a supervisor pi session watches a worker pi session and steers it. + * + * Internal to pi-goals. The package loads this in both sessions; Ready creates the supervisor. + * + * Wire: the pi-intercom extension channel carries data without starting a turn, so each side + * triggers its own turn locally with pi.sendUserMessage. The broker stamps fromSessionId from its + * own registry, so pairing on that ID cannot be forged by a payload. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { + IntercomExtensionChannel, + IntercomExtensionEvent, +} from "pi-intercom/extension-api.ts"; +import { Type } from "typebox"; +import { backgroundState } from "./background.js"; +import { type GoalDecision, type GoalReview, PLAN_API_EVENT, type PlanApiRequest, type PlanBinding, pendingReply, planHash, planText, validBinding } from "./plan-api.js"; + +/** + * Public event names from the bundled pi-intercom/extension-api.ts protocol. The channel types + * are imported without loading a second transport runtime. + */ +const INTERCOM_EXTENSION_REGISTER_EVENT = "intercom:extension-register"; +const INTERCOM_EXTENSION_REGISTRY_READY_EVENT = "intercom:extension-registry-ready"; +export const PROGRAMMATIC_PAIR_EVENT = "pi-supervise:pair:v1"; +export const WORKER_STATE_EVENT = "pi-supervise:worker-state:v1"; +export const WORKER_PAIRED_EVENT = "pi-supervise:worker-paired:v1"; +export const API_READY_EVENT = "pi-supervise:api-ready:v1"; + +export interface ProgrammaticPairRequest { + version: 1; + workerIntercomId: string; + goal: string; + resolve(): void; + reject(error: Error): void; +} + +import { + BRIEF, + DONE_BLOCKED, + GOAL_CHANGED, + isViewText, + LET_IT_RUN_ACK, + LET_IT_RUN_AGAIN, + loadSupervisorPrompt, + NO_GOAL, + REANCHOR, + REVIEW_NUDGE, + STEER_ACK, + TOOL_DONE, + TOOL_LET_IT_RUN, + TOOL_STEER, + VIEW_PRUNED, +} from "./prompts.js"; +import { + EMPTY_STATE, + isWire, + NAMESPACE, + OVERLAP_WARN, + overlap, + restoreState, + STATE_ENTRY, + STEER_MEMORY, + type SuperviseState, + type Wire, +} from "./protocol.js"; +import { childPiProcesses } from "./subagents.js"; +import { age, buildView, progressKey, sinceLastTurn, turnsSince } from "./view.js"; + +/** + * The worker's model and context use, for the view header, from its own broker presence record. + * + * contextPct is missing right after a compaction and on a session with no model selected, so it is + * left out rather than printed as 0, which would read as an empty context. + */ +function workerModel(info: { model: string; contextPct?: number }): string { + return info.contextPct === undefined ? info.model : `${info.model}, ${info.contextPct}% of its context used`; +} + +/** + * A goal that is one word containing a slash or a dot is a path, and the file is the goal. + * + * A goal worth grading against runs to paragraphs of acceptance evidence, and retyping it into a + * prompt each run is how the copy you steer by drifts from the copy you grade by. A missing file + * throws, with the path in the message. + */ +function readGoal(cwd: string, goal: string): string { + if (!/^\S+$/.test(goal) || !/[/.]/.test(goal)) return goal; + return readFileSync(resolve(cwd, goal), "utf8").trim(); +} + +/** A goal on one line, for a notice or a picker title. The goal itself is never cut. */ +function firstLine(goal: string, width = 60): string { + const line = goal.trim().split("\n")[0].trim(); + return line.length > width ? `${line.slice(0, width)}...` : line; +} + +/** + * The footer line, kept to two words plus a glyph. + * + * pi-powerline-footer appends extension statuses to the end of its own line, so this costs footer + * width for the whole session. The oracle's status can afford to be long because it shows only + * while a run is going. The paired session id is left out: it helps only with three sessions open, + * and you already know which terminal you are looking at. + */ +const STATUS_ID = "intercom-supervisor"; +const EYE = "\u{1F441}"; + +/** How long the supervisor waits for the worker to acknowledge a pair before giving up on it. */ +const PAIR_ACK_TIMEOUT_MS = 10_000; + +/** + * How long /supervise waits for the roll call. One round trip over a local unix socket, so this is + * mostly slack for a session busy in the middle of a turn. + */ +const ROLL_CALL_MS = 500; + +/** + * Set in every session pi-subagents starts (pi-args.ts:622, unconditional), so a child run can + * recognise itself and stay out of the roll call. + */ +const SUBAGENT_ENV = "PI_SUBAGENT_CHILD"; + +/** + * How often the supervisor looks at a worker that is still working. It also looks when the worker + * stops, whatever this is set to, so a short turn is never missed. + * + * One supervisor turn per interval per busy worker, so this is the token bill of watching. Half an + * hour is the wandering-past rate a human keeps; shorten it if you want a closer eye. + */ +const WATCH_INTERVAL_MS = 1_800_000; + +/** How often the timer checks whether a look is due. Sets how late a look can be, nothing else. */ +const WATCH_POLL_MS = 30_000; + +/** A multi-line goal is reinserted before this many supervisor reviews can pass without it. */ +const GOAL_REVIEW_INTERVAL = 5; + +/** How many identical timer looks are skipped before the supervisor is shown one anyway. */ +const LOOKS_SKIPPED_MAX = 3; + +/** + * A view without the one line that changes on its own. + * + * The status line carries seconds into the turn, so two views of a worker that has done nothing are + * never byte-identical. Everything else in a view comes from the worker's branch. + */ +const bodyOf = (view: string) => view.split("\n").filter((line) => !line.startsWith("status: ")).join("\n"); + +/** + * Tools taken away from a supervising session, and given back when supervision ends. + * + * The supervisor shares a working directory with the worker, so a supervisor that can write is a + * second agent editing the same files at the same time. It keeps read, grep and the rest, because + * checking a claim against a file is its job. If it wants a command run, it steers the worker. + * + * A deny list, not an allow list: a read tool named something unexpected stays available rather + * than silently disappearing. The kept list is printed, so a writer this misses is visible. + */ +const WRITER_TOOLS = new Set([ + "bash", "edit", "write", "multi_edit", "multiedit", "apply_patch", "notebook_edit", + "edit_file", "write_file", "quick_edit", "target_edit", +]); + +/** + * The tools only a supervisor should have. Hidden in every session that is not supervising. + * + * Both sessions load this extension and registration happens at load, so without this a plain + * worker is offered worker_view, steer, let_it_run and done. Observed 2026-08-12: a worker given an + * ordinary coding task spent twelve turns reasoning "these are supervisor tools ... so I might be + * the supervisor for a worker session", called worker_view, and read "the worker has not stopped + * since pairing" as proof of a pairing it never had. + */ +const SUPERVISOR_TOOLS = ["worker_view", "set_goal", "steer", "let_it_run", "done", "review_goal"]; + +export default function (pi: any, modelReady: () => boolean = () => true) { + let duplicateIntercom = false; + let channel: IntercomExtensionChannel | undefined; + let intercomRegistered = false; + let sessionInitialized = false; + let state: SuperviseState = { ...EMPTY_STATE }; + let latestView = ""; + let modelTurns = 0; + let publishing = false; + let settledCheck = false; + let stopping = false; + let bootstrapPending = false; + let compacting: Promise | undefined; + let pairingGeneration = 0; + let attached: ReturnType> | undefined; + let outgoingReview: (ReturnType> & { review: GoalReview }) | undefined; + let incomingReview: GoalReview | undefined; + /** Supervisor side: whether the worker was stopped in that view, which changes what let_it_run costs. */ + let workerStopped = false; + let ctx: any; + let ownId = ""; + /** Supervisor side: review views since the full multi-line goal was last inserted. */ + let reviewsSinceGoal = 0; + /** Worker side: the last progressKey, and how many reviews in a row have matched it. */ + let lastProgress = ""; + let staleReviews = 0; + /** Worker side: how many turns the supervisor has already been sent, so views carry only the new ones. */ + let sentTurns = 0; + /** Worker side: the routine look, and when the last view of any kind went out. */ + let watchTimer: ReturnType | undefined; + let lastLook = 0; + /** Worker side: the last view sent, less its status line, and how many timer looks matched it. */ + let lastViewBody = ""; + let looksSkipped = 0; + /** Supervisor side: cleared when the worker acknowledges the pair. */ + let pairTimer: ReturnType | undefined; + /** Supervisor side: the writer tools this session had, so reset gives back exactly those. */ + let removedWriters: string[] = []; + /** Supervisor side: who answered the roll call, collected only while /supervise is waiting. */ + let rollCall: Set | undefined; + /** A caller using PROGRAMMATIC_PAIR_EVENT waits for this explicit worker acknowledgement. */ + let pendingPair: { workerId: string; resolve(): void; reject(error: Error): void } | undefined; + let resolveIntercomConnected!: () => void; + const intercomConnected = new Promise((resolve) => { resolveIntercomConnected = resolve; }); + + /** PI_SUPERVISOR_DEBUG=1 traces the wire to stderr. The channel is invisible in transcripts. */ + const debug = (event: string, detail: unknown = {}) => { + if (process.env.PI_SUPERVISOR_DEBUG) console.error(`[intercom-supervisor] ${event} ${JSON.stringify(detail)}`); + }; + + function save() { + pi.appendEntry(STATE_ENTRY, state); + showStatus(); + } + + function reanchorPlan() { + if (!state.plan || state.role !== "supervisor") return; + const policy = loadSupervisorPrompt(ctx.cwd).prompt; + pi.sendMessage({ customType: "supervisor_plan", display: false, content: `${policy} + +Supervise this plan; use review_goal only for an explicit goal request. The human's Ready approved the plan. Do not start a second worker. + +Approved plan: +${state.goal} + +Current plan (${state.plan.planPath}): +${planText(state.plan)}` }, { triggerTurn: false }); + } + + async function compactSupervisor(force = false) { + if (!modelReady()) throw new Error("Supervisor model unavailable; select with /model before compaction"); + if (compacting) return compacting; + if (!state.plan || !ctx.isIdle()) return; + const tokens = ctx.getContextUsage?.()?.tokens; + const measured = typeof tokens === "number" && Number.isFinite(tokens); + if (force && measured && tokens <= 20_000) { + ctx.ui.notify("Supervisor fork is already small; compaction skipped.", "info"); + reanchorPlan(); + return; + } + const threshold = Math.min(state.plan.compactTokens, Math.max(1, (ctx.model?.contextWindow ?? 200_000) - 20_000)); + if (!force && (!measured || tokens < threshold)) return; + const generation = pairingGeneration; + const bindingId = state.plan.id; + compacting = new Promise((resolve, reject) => ctx.compact({ + customInstructions: "Preserve the user's intent, decisions and their reasons, unresolved choices, plan path, and supervisor interventions. You are becoming/remaining the supervisor, not the worker.", + onComplete: () => resolve(), onError: reject, + })); + try { await compacting; } + finally { compacting = undefined; } + if (!stopping && generation === pairingGeneration && state.plan?.id === bindingId && state.role === "supervisor") reanchorPlan(); + } + + function cancelGoalRequests(reason: string) { + attached?.finish(undefined, new Error(reason)); attached = undefined; + outgoingReview?.finish(undefined, new Error(reason)); outgoingReview = undefined; + if (incomingReview && state.pairedId) send({ t: "goal_cancel", to: state.pairedId, requestId: incomingReview.requestId, bindingId: incomingReview.bindingId }); + incomingReview = undefined; + } + + async function planRequest(request: PlanApiRequest): Promise { + if (duplicateIntercom) throw new Error("Duplicate standalone pi-intercom registration. Remove the old pi-intercom companion from Pi settings; pi-goals bundles it. Reload both sessions."); + if (request.method !== "stop" && !modelReady()) throw new Error("Role model unavailable; configure the saved model or explicitly select with /model, then retry."); + if (!ctx || stopping) throw new Error("Supervisor session is not ready"); + if (request.signal?.aborted) throw new Error("Supervisor request cancelled"); + if (["bootstrap", "status", "prepare"].includes(request.method) && !channel?.snapshot().connected) { + const connected = pendingReply(request.signal, () => {}, 30_000); + void intercomConnected.then(() => connected.finish()); + await connected.promise; + } + if (stopping || request.signal?.aborted) throw new Error("Supervisor request cancelled"); + if (!channel?.snapshot().connected || !channel.snapshot().supported) throw new Error("Bundled Intercom is unavailable. Load the pi-goals package directory, not only src/index.ts, and reload both sessions"); + if (request.method === "status") { + const live = await channel.listSessions(); + return { workerId: await resolveOwnId(), role: state.role, binding: state.plan, supervisorId: state.pairedId, + connected: state.role === "worker" && !!state.plan && live.some(s => s.id === state.pairedId) }; + } + if (request.method === "prepare") { + if (!validBinding(request.binding) || request.binding.workerSession !== ctx.sessionManager.getSessionFile()) throw new Error("Invalid plan binding for this worker session"); + if (state.role !== "none" || state.plan) throw new Error("A supervisor is already associated; stop it before replacing the plan"); + state = { ...state, plan: request.binding }; save(); + return { workerId: await resolveOwnId() }; + } + if (request.method === "bootstrap") { + if (!validBinding(request.binding) || !request.workerId) throw new Error("Invalid supervisor bootstrap"); + const binding = { ...request.binding, ...(state.plan?.id === request.binding.id ? state.plan : {}), supervisorSession: ctx.sessionManager.getSessionFile() }; + if (!binding.supervisorSession || binding.supervisorSession === binding.workerSession) throw new Error("Supervisor must use a distinct persisted fork"); + if (state.plan?.id === binding.id && state.plan.stopped) throw new Error("This supervisor was stopped. Start a new pairing from the worker."); + if (state.role === "supervisor" && state.plan?.id === binding.id && state.planInitialized && state.pairedId) { + reanchorPlan(); await rejoinOrDrop(); return state.plan; + } + bootstrapPending = true; + const generation = ++pairingGeneration; + const cancelled = () => stopping || generation !== pairingGeneration || state.plan?.id !== binding.id || state.plan.stopped || request.signal?.aborted; + state = { ...EMPTY_STATE, role: "supervisor", pairedId: request.workerId, plan: binding, goal: state.plan?.id === binding.id ? state.goal : planText(binding) }; + stripWriters(); showSupervisorTools(true); save(); + try { + await compactSupervisor(true); + if (cancelled()) throw new Error("Supervisor bootstrap cancelled"); + const worker = (await channel.listSessions()).find(s => s.id === request.workerId); + if (cancelled()) throw new Error("Supervisor bootstrap cancelled"); + if (!worker) throw new Error("The specified worker is no longer connected"); + await startPair(worker, state.goal, ctx, binding); + return binding; + } catch (error) { + if (!stopping && generation === pairingGeneration) reset("Supervisor initialization failed; retry from the worker"); + throw error; + } + finally { bootstrapPending = false; } + } + if (request.method === "stop") { + if (!state.plan || request.bindingId !== state.plan.id) throw new Error("No matching plan pairing to stop"); + if (state.plan) send({ t: "plan_stop", to: "*", bindingId: state.plan.id }); + if (state.pairedId) send({ t: "unpair", to: state.pairedId }); + reset("Plan supervision stopped", true); return {}; + } + if (!state.plan || request.bindingId !== state.plan.id) throw new Error("Plan pairing changed or is unavailable"); + if (request.method === "attached") { + if (state.role === "worker" && (await channel.listSessions()).some(s => s.id === state.pairedId)) return state.plan; + if (attached) throw new Error("Already waiting for the supervisor"); + const pending = pendingReply(request.signal, () => { attached = undefined; }); + attached = pending; + return pending.promise; + } + if (state.role !== "worker" || !state.pairedId) throw new Error("Supervisor is not paired; use /goals supervisor or turn the steward off"); + if (request.method === "activate") { + state = { ...state, plan: { ...state.plan, active: true } }; save(); + send({ t: "plan_activate", to: state.pairedId, bindingId: state.plan!.id }); + startWatch(); + // The worker's work handoff is queued already. Never label this as an abandoned stopped worker. + await publishView(ctx, "Ready selected; worker starting", sentTurns, false, true); + return {}; + } + if (request.method === "update") { send({ t: "plan_update", to: state.pairedId, bindingId: state.plan.id }); return {}; } + if (request.method === "review") { + if (outgoingReview) throw new Error("A goal review is already pending"); + if (!request.goal || request.planHash !== planHash(planText(state.plan))) throw new Error("The plan changed before goal review"); + const bindingId = state.plan.id; + const peer = state.pairedId; + const pending = pendingReply(request.signal, () => { + outgoingReview = undefined; + send({ t: "goal_cancel", to: peer, requestId: pending.requestId, bindingId }); + }); + const review = { requestId: pending.requestId, bindingId, goal: request.goal, planHash: request.planHash }; + outgoingReview = { ...pending, review }; + send({ t: "goal_review", to: peer, ...review }); + return pending.promise; + } + throw new Error("Unsupported plan operation"); + } + pi.events.on(PLAN_API_EVENT, (request: PlanApiRequest) => { + if (stopping || request?.version !== 1 || request.handled || typeof request.resolve !== "function" || typeof request.reject !== "function") return; + request.handled = true; + planRequest(request).then(request.resolve, (error: unknown) => request.reject!(error instanceof Error ? error : new Error(String(error)))); + }); + pi.on("session_shutdown", () => { + stopping = true; + pairingGeneration++; + cancelGoalRequests("Session reloaded or ended; retry the goal review"); + clearInterval(watchTimer); clearTimeout(pairTimer); + }); + + /** + * One footer line while a pairing is live, so both terminals say what they are. + * + * A pairing is otherwise invisible after the notice at the top scrolls away. wassname started a + * supervisor, saw it sitting at the prompt, and could not tell that it had stopped supervising. + * Mechanism borrowed from @diegopetrucci/pi-oracle, which puts its run in the same place. + */ + function showStatus() { + if (!ctx?.hasUI) return; + if (state.role === "none") { + ctx.ui.setStatus(STATUS_ID, undefined); + return; + } + const text = state.role === "supervisor" ? `watching ${state.steerRounds}` : "watched"; + ctx.ui.setStatus(STATUS_ID, ctx.ui.theme.fg("accent", `${EYE} `) + ctx.ui.theme.fg("dim", text)); + } + + function send(message: Wire) { + if (!channel) throw new Error("intercom-supervisor: intercom channel is not ready"); + channel.publish(message, { audience: "capable" }); + } + + /** + * Put words in the supervisor's own context without starting a turn. + * + * The brief, the reanchor and a goal change all say "a view follows", and the view is the thing + * to judge. A user message always starts a turn (pi types.d.ts:754), so the supervisor answered + * these with a verdict before any view existed: twice on 2026-08-13, and 98 times an hour before + * that. No wording stops it, because the turn should not be there at all. A custom message with + * triggerTurn false joins the context and waits for the view to wake it (agent-session.d.ts:398). + * display true keeps it on screen, where a user message used to be. - CLAUDE + */ + function tellSupervisor(text: string) { + pi.sendMessage({ customType: "supervisor_brief", content: text, display: true }, { triggerTurn: false }); + } + + function tellGoal() { + if (!state.goal.includes("\n")) return; + tellSupervisor(`\n${state.goal}\n`); + reviewsSinceGoal = 0; + } + + /** + * Ask every session whether it can be supervised, and collect the ones that say yes. + * + * The broker roster is not the candidate list. It carries child runs from pi-subagents, sessions + * that do not load this extension, sessions already paired, and registrations whose process has + * gone. Observed 2026-08-13: five of them at once in one directory, and /supervise refused to + * pick between them because every filter here worked by guessing from the outside. + * + * Each session knows its own answer, so it gives it. A child run reads SUBAGENT_ENV in its own + * environment; a paired one knows it is paired; a dead one cannot answer at all. No process tree, + * no naming convention. + */ + async function askWhoIsFree(): Promise> { + const found = new Set(); + rollCall = found; + send({ t: "who", to: "*" }); + await new Promise((resolve) => setTimeout(resolve, ROLL_CALL_MS)); + rollCall = undefined; + debug("roll call", { answered: [...found].map((id) => id.slice(0, 8)) }); + return found; + } + + /** + * Our own record in the broker registry. The broker records pid at registration, so we match on + * it. Read fresh every time: presence keeps model and context use up to date in here. + */ + async function ownSession(): Promise { + const sessions = await channel!.listSessions(); + const mine = sessions.find((s: any) => s.pid === process.pid); + if (!mine) throw new Error("intercom-supervisor: this session is not registered with the intercom broker"); + ownId = mine.id; + return mine; + } + + /** Our own intercom session ID, which never changes, so the first answer is kept. */ + async function resolveOwnId(): Promise { + return ownId || (await ownSession()).id; + } + + /** + * Show or hide the supervisor tools. setActiveTools ignores names it does not know, so the list + * is read back: a hide that leaves them visible is the confusion this exists to stop. + */ + function showSupervisorTools(on: boolean) { + const rest = pi.getActiveTools().filter((t: string) => !SUPERVISOR_TOOLS.includes(t)); + const enabled = SUPERVISOR_TOOLS.filter(name => name !== "review_goal" || state.plan); + pi.setActiveTools(on ? [...rest, ...enabled] : rest); + const now = pi.getActiveTools().filter((t: string) => SUPERVISOR_TOOLS.includes(t)); + if (on ? now.length !== enabled.length : now.length > 0) { + throw new Error( + `intercom-supervisor: setActiveTools did not ${on ? "add" : "remove"} the supervisor tools, left ${now.join(", ") || "none"}`, + ); + } + } + + /** + * Take the writing tools off this session. Supervising is a read-only job in a directory + * another agent is writing to. reset() hands them back. + * + * These live on the pi API, not on the command context. Reaching for the context is what let a + * supervisor make 36 bash calls in the session that ran /supervise: the old code wrote + * context.getActiveTools?.() ?? [], got undefined, kept an empty list, and skipped in silence. + * Unguarded now, and the list is read back, so a strip that does nothing throws instead. + */ + function stripWriters(): string[] { + const before: string[] = pi.getActiveTools(); + const kept = before.filter((t: string) => !WRITER_TOOLS.has(t.toLowerCase())); + // Remember the names removed, not the whole list. Other extensions add and remove their own + // tools while supervision runs (pi-context-prune adds context_prune, pi-telegram suspends its + // own), so restoring a snapshot taken hours ago would silently undo their decisions. + removedWriters = [...new Set([...removedWriters, ...before.filter((t: string) => WRITER_TOOLS.has(t.toLowerCase()))])]; + pi.setActiveTools(kept); + const still = pi.getActiveTools().filter((t: string) => WRITER_TOOLS.has(t.toLowerCase())); + if (still.length) throw new Error(`intercom-supervisor: setActiveTools did not remove ${still.join(", ")}`); + return kept; + } + + /** + * What a restored pairing means on the wire, checked once the channel exists. + * + * restoreState reads the transcript, which is right for the goal and the round count and wrong + * for pairedId: that addresses a live process, and after a resume or a /reload the process on + * the other end may be gone. A resumed session then sits idle claiming a pairing, says nothing + * on screen, and refuses /supervise until you work out it needs /supervise stop. + * + * The broker's registry is the truth about who exists now, so ask it. Either way this prints, + * because "supervising, waiting" and "the other session is gone" look identical otherwise. + */ + async function rejoinOrDrop() { + if (!modelReady() || duplicateIntercom || bootstrapPending || !state.role || !state.pairedId || (state.plan && state.role === "supervisor" && !state.planInitialized)) return; + const live = await channel!.listSessions(); + if (state.plan) { + // Pi session files identify the pair across process restarts; broker IDs identify live peers. + send({ t: "plan_hello", to: "*", bindingId: state.plan.id, role: state.role as "worker" | "supervisor", sessionFile: ctx.sessionManager.getSessionFile() }); + if (!live.some((s: any) => s.id === state.pairedId)) { + if (state.role === "supervisor") stripWriters(); + ctx.ui.notify("Plan supervisor peer is disconnected; retaining this session and waiting for reconnect.", "warning"); + return; + } + } + if (!live.some((s: any) => s.id === state.pairedId)) { + reset(`intercom-supervisor: ${state.pairedId.slice(0, 8)} is gone, so the pairing is dropped. Run /supervise to start again.`); + return; + } + if (state.role !== "supervisor") { + // A worker reloaded at the prompt takes no turn, so this is its only chance to start watching. + startWatch(); + ctx?.ui?.notify?.(`intercom-supervisor: still supervised by ${state.pairedId.slice(0, 8)}`, "info"); + return; + } + // The strip lives in the /supervise handler and savedTools is memory, so a resumed supervisor + // has bash and the edit tools back in a directory the worker is writing to. + stripWriters(); + reanchorPlan(); + // Say the goal and the answer shape again. A /reload is how a changed prompt reaches a running + // session, and the brief goes out only at pairing, so without this a wording fix needs a fresh + // pairing and loses the supervisor's memory of its own steers. + tellSupervisor(REANCHOR(state.goal, state.steerRounds)); + reviewsSinceGoal = 0; + // Ask for a view rather than wait for one. A supervisor that came back from a crash, a credit + // failure or a /reload holds a stale picture, and answering from a stale picture is how it + // invents a fact. Only the worker makes views, so it has to ask, and nobody should have to + // know that: restarting the session is the whole recovery. + send({ t: "look", to: state.pairedId }); + ctx?.ui?.notify?.(`intercom-supervisor: still supervising ${state.pairedId.slice(0, 8)}, asked it for a view`, "info"); + } + + function finishPair(error?: Error) { + const pending = pendingPair; + pendingPair = undefined; + if (!pending) return; + if (error) pending.reject(error); + else pending.resolve(); + } + + /** Everything that ends a pairing goes through here, so no stale view or timer survives it. */ + function reset(note: string, rememberStop = false) { + pairingGeneration++; + const stoppedPlan = state.plan && (state.plan.stopped || (rememberStop && state.role === "supervisor")) ? { ...state.plan, stopped: true, active: false } : undefined; + cancelGoalRequests(note); + clearTimeout(pairTimer); + pairTimer = undefined; + finishPair(new Error(note)); + clearInterval(watchTimer); + watchTimer = undefined; + if (removedWriters.length) { + pi.setActiveTools([...pi.getActiveTools(), ...removedWriters]); // give back what we took + removedWriters = []; + } + showSupervisorTools(false); + state = { ...EMPTY_STATE, ...(stoppedPlan ? { plan: stoppedPlan } : {}) }; + latestView = ""; + reviewsSinceGoal = 0; + lastProgress = ""; + staleReviews = 0; + sentTurns = 0; + save(); + ctx?.ui?.notify?.(note, "info"); + } + + // ---- inbound, one branch per role ------------------------------------------------------ + + async function onWire(from: string, wire: Wire) { + const me = await resolveOwnId(); + debug("wire in", { t: wire.t, from: from.slice(0, 8), forUs: wire.to === me, role: state.role }); + + // Answered before the addressed-to-us check below, because a roll call goes to everyone. + if (wire.t === "who") { + if (from !== me && state.role === "none" && !process.env[SUBAGENT_ENV]) send({ t: "here", to: from }); + return; + } + if (wire.t === "plan_stop" && state.role === "supervisor" && state.plan?.id === wire.bindingId && from === state.pairedId) { + ctx.abort(); reset("Plan supervision stopped", true); return; + } + if ((wire.t === "plan_hello" || wire.t === "plan_hello_ack") && state.plan && !state.plan.stopped && !bootstrapPending && (state.role === "worker" || (state.role === "supervisor" && state.planInitialized))) { + const expected = state.role === "worker" ? state.plan.supervisorSession : state.plan.workerSession; + if (wire.bindingId !== state.plan.id || wire.role === state.role || wire.sessionFile !== expected || (wire.to !== "*" && wire.to !== me)) return; + state = { ...state, pairedId: from }; save(); + if (wire.t === "plan_hello") send({ t: "plan_hello_ack", to: from, bindingId: wire.bindingId, role: state.role as "worker" | "supervisor", sessionFile: ctx.sessionManager.getSessionFile() }); + if (state.role === "worker") { attached?.finish(state.plan); attached = undefined; startWatch(); } + return; + } + if (wire.to !== me) return; + + // Collected only while /supervise is waiting, so a late answer lands nowhere and is dropped. + if (wire.t === "here") { + rollCall?.add(from); + return; + } + + if (wire.t === "pair") { + // A plan-owned worker accepts only its prepared pairing, not any peer in the directory. + if ((wire.plan || state.plan) && (!wire.plan || wire.plan.id !== state.plan?.id || wire.plan.workerSession !== ctx.sessionManager.getSessionFile())) return; + // Last pair wins. First-wins left a worker bound forever to a supervisor that had died, and + // told nobody. The loser is told, so neither side waits on a pairing it does not have. + if (state.pairedId && state.pairedId !== from) send({ t: "unpair", to: state.pairedId }); + // We are a worker now, so any pair we sent as a supervisor is void. Leaving that timer armed + // would kill this healthy pairing ten seconds later, blaming a session no longer involved. + clearTimeout(pairTimer); + pairTimer = undefined; + // A same-binding replay must not undo Ready already completed by this worker. + const plan = wire.plan ? { ...wire.plan, active: state.plan?.active ?? wire.plan?.active } : undefined; + state = { ...EMPTY_STATE, role: "worker", pairedId: from, goal: plan ? planText(plan) : wire.goal, ...(plan ? { plan } : {}) }; + latestView = ""; + lastProgress = ""; + staleReviews = 0; + save(); + send({ t: "paired", to: from, ...(state.plan ? { plan: state.plan } : {}) }); + if (state.plan) { attached?.finish(state.plan); attached = undefined; return; } + pi.events.emit(WORKER_PAIRED_EVENT, { supervisorIntercomId: from }); + ctx?.ui?.notify?.(`supervised by ${from.slice(0, 8)}: ${firstLine(wire.goal)}`, "info"); + // A first view goes with the acknowledgement. Without it the supervisor's opening turn has + // nothing to read, and it answers anyway: on 2026-08-13 it called let_it_run 98 times over + // "waiting for the first view". A worker paired while idle never settles, so waiting for + // agent_settled can mean waiting for ever. + await publishView(ctx, "sent at pairing"); + startWatch(); // paired while sitting at the prompt is the common case, and turn_start may never come + return; + } + + if (from !== state.pairedId) return; // ignore anything from a session we are not paired with + + if ("bindingId" in wire && wire.bindingId !== state.plan?.id) return; + if (wire.t === "plan_activate") { state = { ...state, plan: { ...state.plan!, active: true } }; save(); return; } + if (wire.t === "plan_update") { reanchorPlan(); return; } + if (wire.t === "goal_cancel") { + if (outgoingReview?.requestId === wire.requestId) outgoingReview.finish(undefined, new Error("Supervisor cancelled the review; retry")); + if (incomingReview?.requestId === wire.requestId) { incomingReview = undefined; ctx.abort(); } + return; + } + if (wire.t === "goal_decision") { + const pending = outgoingReview; + if (!pending || pending.review.requestId !== wire.requestId || pending.review.goal !== wire.goal || pending.review.planHash !== wire.planHash) return; + outgoingReview = undefined; + pending.finish(wire, planHash(planText(state.plan!)) !== wire.planHash ? new Error("Plan changed during supervisor review") : undefined); + return; + } + if (wire.t === "goal_review" && state.role === "supervisor") { + if (incomingReview || planHash(planText(state.plan!)) !== wire.planHash) { + send({ ...wire, t: "goal_decision", to: from, decision: "needs_work", reason: "Another review is pending or the plan changed; retry." }); return; + } + incomingReview = wire; + await compactSupervisor(); + if (incomingReview !== wire || stopping) return; + reanchorPlan(); + pi.sendUserMessage(`Goal sign-off request ${wire.requestId}. Goal: ${wire.goal} +Judge direction and scope against the approved plan and current evidence. A fresh judge will check artifacts independently. Call review_goal with this requestId and approve, needs_work, or needs_user. Do not call done: this is one goal, not the end of supervision.`, ctx.isIdle() ? undefined : { deliverAs: "followUp" }); + return; + } + if (wire.t === "paired" && state.role === "supervisor") { + if (state.plan) { + if (wire.plan?.id !== state.plan.id || pendingPair?.workerId !== from) return; + state = { ...state, plan: { ...state.plan, active: wire.plan.active }, planInitialized: true }; save(); + } + clearTimeout(pairTimer); + pairTimer = undefined; + finishPair(); + return; + } + + if (wire.t === "goal" && state.role === "worker") { + // The supervisor inferred a goal. The worker holds the copy the view header is built from, + // so without this the header says "not set" for the rest of the run. + state = { ...state, goal: wire.goal }; + save(); + ctx?.ui?.notify?.(`goal set by the supervisor: ${wire.goal}`, "info"); + return; + } + + if (wire.t === "look" && state.role === "worker") { + // Only the worker can make a view, so a supervisor that lost its place has to ask. It loses + // its place whenever its own turn ends without one: a crash, a credit failure, a /reload. + // Its answer to a stale context is to invent, so give it real data instead. + // From turn 0, not the diff. A supervisor only asks after a crash or a /reload, when its own + // copy is gone, and answering that with "0 new turns since your last look" leaves it inventing. + if (!state.plan || state.plan.active) await publishView(ctx, "sent because you asked for a view", 0); + return; + } + + if (wire.t === "directive" && state.role === "worker") { + if (!modelReady() || (state.plan && !state.plan.active)) return; + // Busy worker gets "steer": delivered after the current tool calls, before the next LLM call. + // "followUp" would make it finish the whole task first, so a correction arrives too late to + // correct anything. Idle worker takes no option, so the message starts a turn. + pi.sendUserMessage(`[supervisor] ${wire.text}`, ctx?.isIdle() ? undefined : { deliverAs: "steer" }); + return; + } + + if (wire.t === "view" && state.role === "supervisor") { + const generation = pairingGeneration; + const bindingId = state.plan?.id; + if (state.plan) { + if (!state.plan.active || !state.planInitialized) return; + await compactSupervisor(); + } + if (stopping || generation !== pairingGeneration || state.role !== "supervisor" || from !== state.pairedId || state.plan?.id !== bindingId || (state.plan && !state.plan.active)) return; + if (state.plan) reanchorPlan(); + latestView = wire.view; + workerStopped = wire.stopped; + if (reviewsSinceGoal >= GOAL_REVIEW_INTERVAL - 1) tellGoal(); + else reviewsSinceGoal += 1; + // A new view is a new look, so it gets its own verdict. agent_start alone is not enough: a + // followUp is consumed inside the running agent loop, so no second agent_start fires and the + // count carries over. That aborted the second honest verdict of a busy night. - CLAUDE + verdictsThisLook = 0; + // followUp, not steer: let the supervisor finish the decision it is making, then look again. + // With no option at all pi throws "Agent is already processing" and the view is lost, which + // on a half hour look means the supervisor skips a whole look for no visible reason. + pi.sendUserMessage( + REVIEW_NUDGE(wire.view, state.steerRounds, wire.stopped), + ctx?.isIdle() ? undefined : { deliverAs: "followUp" }, + ); + return; + } + + if (wire.t === "done" || wire.t === "unpair") { + if (state.plan && state.role === "supervisor") ctx.abort(); + reset(`supervision ended: ${wire.t === "done" ? wire.reason : "unpaired"}`, true); + } + } + + // ---- lifecycle ------------------------------------------------------------------------- + + // The other reset. This one covers a turn the human starts by typing; the view branch above + // covers a view, which is the usual way a look begins. + pi.on("agent_start", () => { + verdictsThisLook = 0; + }); + + /** + * A finished look keeps its verdict and loses the raw material behind it. + * + * Every look brings a view of about 1.4k tokens and a block of thinking about as long, and neither + * means anything once the verdict is written. Session 019ffa73, 16 hours and 75 looks: the context + * ran 37.9k -> 234.7k with no compaction, and was 56% view bodies and 32% old thinking by + * character count. What the supervisor actually needs is the 7% that is its own verdicts, e.g. + * "job 26 Evidence-b partially landed: post rho 0.726 > prompted 0.718 (was tied before rebuild), + * probe d flipped +0.059". Those are its running notes on the worker, and better memory than the + * turns it read to write them. + * + * Cache reads are $0.004/Mtok, so this was never about the bill. It is that a model judging one + * view should not be reading 234k tokens to do it. + * + * Views are incremental, each covering what is new since the last, so the newest few stay whole + * in case the current one reads against them. scripts/measure-prune.ts replays a real session. + */ + const LOOKS_KEPT = 3; + pi.on("context", (event: any) => { + if (state.role !== "supervisor") return; + const lastPlan = event.messages.findLastIndex((m: any) => m.customType === "supervisor_plan"); + const messages = event.messages.filter((m: any, i: number) => m.customType !== "supervisor_plan" || i === lastPlan); + const isView = (m: any) => + m.role === "user" && m.content?.some?.((c: any) => c.type === "text" && isViewText(c.text)); + + // Everything before the oldest look we keep whole is history. + const views = messages.flatMap((m: any, i: number) => (isView(m) ? [i] : [])); + if (views.length <= LOOKS_KEPT) return { messages }; + const cut = views[views.length - LOOKS_KEPT]; + + // New objects, never a mutation: event.messages is the live branch the session also reads from. + return { + messages: messages.flatMap((m: any, i: number) => { + if (i >= cut) return [m]; + if (isView(m)) return [{ ...m, content: [{ type: "text", text: VIEW_PRUNED }] }]; + if (m.role !== "assistant") return [m]; + const kept = (m.content ?? []).filter((c: any) => c.type !== "thinking"); + // A message that was only thinking has nothing left to say, and holds no tool call to orphan. + return kept.length ? [{ ...m, content: kept }] : []; + }), + }; + }); + + pi.on("session_compact", async (event: { willRetry?: boolean }, context: any) => { + ctx = context; + // Pi retries overflow recovery immediately after this event. Queue the rubric for its next + // ordinary turn, so the failed assistant remains final and can be removed. + if (state.role === "supervisor") { + if (state.plan && !event.willRetry) reanchorPlan(); + if (event.willRetry) reviewsSinceGoal = GOAL_REVIEW_INTERVAL - 1; + else tellGoal(); + } + }); + + const intercomRegistration = { + namespace: NAMESPACE, + ownerEligible: false, + onReady: (value: IntercomExtensionChannel) => { + if (channel && channel !== value) { + duplicateIntercom = true; + ctx?.ui?.notify?.("Duplicate standalone pi-intercom registration. Remove the old companion package; pi-goals bundles Intercom. Reload both sessions.", "error"); + return; + } + channel = value; + if (!value.snapshot().connected) return; + resolveIntercomConnected(); + if (ctx) rejoinOrDrop().catch((err: Error) => debug("rejoin failed", { error: err.message })); + }, + onEvent: (event: IntercomExtensionEvent) => { + if (event.type === "connection" && event.connected) { + resolveIntercomConnected(); + if (ctx) rejoinOrDrop().catch((err: Error) => debug("rejoin failed", { error: err.message })); + return; + } + if (event.type === "message" && isWire(event.payload)) { + // Not awaited by the caller, so a rejection here would be an unhandled rejection with no + // notice. resolveOwnId throws during a startup race. + onWire(event.fromSessionId, event.payload).catch((err: Error) => { + debug("wire dropped", { error: err.message }); + ctx?.ui?.notify?.(`intercom-supervisor: dropped a message, ${err.message}`, "error"); + }); + } + }, + }; + + function registerIntercom() { + if (intercomRegistered || channel) return; + intercomRegistered = pi.events.emit(INTERCOM_EXTENSION_REGISTER_EVENT, intercomRegistration); + } + + pi.events.on(INTERCOM_EXTENSION_REGISTRY_READY_EVENT, () => { + // A registry announces itself once. If we already registered, another standalone copy loaded. + if (intercomRegistered || channel) { + duplicateIntercom = true; + ctx?.ui?.notify?.("Duplicate standalone pi-intercom registration. Remove the old companion package; pi-goals bundles Intercom. Reload both sessions.", "error"); + return; + } + registerIntercom(); + }); + registerIntercom(); + + function initializeSession(context: any) { + ctx = context; + if (duplicateIntercom) ctx.ui.notify("Duplicate standalone pi-intercom registration. Remove the old companion package; pi-goals bundles Intercom. Reload both sessions.", "error"); + if (sessionInitialized) return; + sessionInitialized = true; + state = restoreState(context.sessionManager.getEntries()); + // A fresh fork may not yet have supervise-state, especially if model restoration paused it. + if (context.sessionManager.getBranch().some((entry: any) => entry.type === "custom" && entry.customType === "pi-goals-supervisor")) stripWriters(); + // Before anything else, because a worker that can see steer and worker_view starts guessing + // that it is a supervisor. rejoinOrDrop below may still drop the pairing and hide them again. + showSupervisorTools(state.role === "supervisor"); + showStatus(); // a resumed pairing has no notice to read, so the footer is all you get + if (channel?.snapshot().connected) { + resolveIntercomConnected(); + rejoinOrDrop().catch((err: Error) => debug("rejoin failed", { error: err.message })); + } + } + + pi.on("session_start", async (_event: unknown, context: any) => { + initializeSession(context); + registerIntercom(); + }); + pi.on("before_agent_start", async (_event: unknown, context: any) => { + initializeSession(context); + registerIntercom(); + }); + + /** + * Publish what the worker looks like right now. Used at pairing, on a timer and when asked. + * `since` is the turn the view starts at: the diff for a routine look, 0 when the supervisor + * needs the whole picture again. + */ + async function publishView(context: any, why: string, since = sentTurns, onlyIfChanged = false, starting = false) { + if (!modelReady() || publishing || outgoingReview || (state.plan && !state.plan.active)) return; + publishing = true; + const bindingId = state.plan?.id; + const peer = state.pairedId; + try { + // Claimed before the await, not after. ps takes long enough that a second timer tick would + // otherwise start its own look while this one is still waiting. + lastLook = Date.now(); + const entries = context.sessionManager.getBranch() as any; + // Checked here too. This view replaces the one done reads, so leaving it out would report + // "child pi processes still running: none" and unblock done while a subagent is running. + const background = state.plan ? await backgroundState(pi) : undefined; + const subagents = state.plan ? [] : await childPiProcesses(); + if (state.pairedId !== peer || state.plan?.id !== bindingId || stopping) return; + // Asked at pairing and on a rejoin, and the worker is often sitting at the prompt then. Saying + // "working" there sends the supervisor a check-in nudge about a worker that is waiting on it. + const idle = !starting && context.isIdle() && (!background || background.quiet); + // One clock for both ways of being stuck: at the prompt, and inside a command that never + // returns. It goes in the status line because bodyOf strips that line, so a number that moves + // on its own cannot defeat the unchanged-view skip below. + let view = buildView({ + goal: state.goal, + status: `${idle ? "stopped" : "working"}, ${why}, no new turn for ${age(sinceLastTurn(entries))}`, + entries, + since, + subagents, + model: workerModel(await ownSession()), + }); + if (background) view += `\ntracked background work: ${background.description}\n`; + // A timer look at a worker that has done nothing since the last one wakes the supervisor to read + // a view it has already read. Session 019ffa73: 13 of 92 verdicts were "check-in with no new + // turns ... nothing to judge". The supervisor loses nothing by not being asked, because the view + // is the same view. It is asked anyway after LOOKS_SKIPPED_MAX, since a worker that has not moved + // for hours is itself worth seeing, and the elapsed seconds in the status line say so. + if (onlyIfChanged) { + if (bodyOf(view) === lastViewBody && looksSkipped < LOOKS_SKIPPED_MAX) { + looksSkipped += 1; + debug("look skipped, nothing new since the last view", { looksSkipped }); + return; + } + looksSkipped = 0; + } + lastViewBody = bodyOf(view); + + modelTurns = 0; + settledCheck = false; + sentTurns = turnsSince(entries); + send({ t: "view", to: state.pairedId, view, stopped: idle }); + debug("published view", { why, to: state.pairedId, sentTurns, idle }); + } finally { publishing = false; } + } + + /** + * The timer look. A human supervising does not read every token; they wander past every few + * minutes and interrupt if the work has gone somewhere wrong. This is that, so the supervisor + * keeps roughly the perspective you would have with the two windows side by side. + * + * It runs whether or not the worker is working, and from the moment the worker is paired rather + * than from its next turn. Both of those are the same bug twice, an idle worker nobody is looking + * at. It used to stop on agent_settled, since a stopped worker cannot change, and on 2026-08-14 + * that left a stopped worker and a supervisor that had answered let_it_run sitting silent for two + * and a half hours. It used to start only on turn_start, so a worker that paired or reloaded while + * sitting at the prompt had no timer at all, which is the same silence reached from the other end. + */ + function startWatch() { + if (!modelReady() || state.role !== "worker" || !channel || watchTimer || (state.plan && !state.plan.active)) return; + watchTimer = setInterval(() => { + if (!modelReady()) return; + if (state.plan && settledCheck && ctx.isIdle()) { + void backgroundState(pi).then(background => { if (background.quiet) return publishView(ctx, "tracked background work finished"); }).catch(() => {}); + } + if (Date.now() - lastLook < (state.plan?.intervalMs ?? WATCH_INTERVAL_MS)) return; + // A stopped worker is always reported, never skipped as unchanged: an unchanged stopped worker + // is the state that needs a steer, and nothing else is going to bring it up. + const idle = ctx.isIdle(); + publishView(ctx, idle ? "looked at again" : "routine check in", sentTurns, !state.plan && !idle).catch((err: Error) => { + debug("timer look failed", { error: err.message }); + }); + }, WATCH_POLL_MS); + // Watching is not a reason for a process to stay alive. It ran from turn_start before, which no + // test reaches, so an interval from pairing time held node's test runner open for ever. + watchTimer.unref(); + } + + pi.on("turn_start", async (_event: unknown, context: any) => { + ctx = context; + // Set again, and before the role check, because a status set during session_start does not + // survive: the footer had not mounted yet, and nothing redraws it until the next save(). + showStatus(); + startWatch(); + }); + + pi.on("turn_end", async (_event: unknown, context: any) => { + ctx = context; + if (state.role !== "worker" || !state.plan?.active) return; + modelTurns += 1; + if (modelTurns >= state.plan.everyTurns) await publishView(ctx, "model-turn check in"); + }); + for (const event of ["processes:ended", "subagent:async-complete"]) pi.events.on(event, () => { + if (state.plan?.active && state.role === "worker" && ctx?.isIdle()) { + settledCheck = true; + void backgroundState(pi).then(background => { if (background.quiet) return publishView(ctx, "tracked background work finished"); }).catch(() => {}); + } + }); + + /** Fires only when no retry, compaction, or queued continuation will run, so the worker is truly done. */ + pi.on("agent_settled", async (_event: unknown, context: any) => { + ctx = context; + showStatus(); + debug("agent_settled", { role: state.role, hasChannel: Boolean(channel) }); + if (state.role !== "worker" || !channel) return; + if (state.plan) { + if (!state.plan.active || outgoingReview) return; + settledCheck = true; + if ((await backgroundState(pi)).quiet && Date.now() - lastLook > 1_000) await publishView(context, "worker settled"); + return; + } + // The timer keeps running. See the turn_start comment: stopping it here is what let the pairing + // go silent for two and a half hours after the supervisor answered let_it_run to a stopped worker. + try { + // A subagent runs as its own process and leaves no unanswered tool call, so a settled worker + // can still be spending. Report it and let the supervisor steer; do not wait here. + const subagents = await childPiProcesses(); + const entries = context.sessionManager.getBranch() as any; + const progress = progressKey(entries); + staleReviews = progress === lastProgress ? staleReviews + 1 : 0; + lastProgress = progress; + const view = buildView({ + goal: state.goal, + status: "stopped, just now, no new turn for 0s", + entries, + since: sentTurns, + stale: staleReviews, + subagents, + model: workerModel(await ownSession()), + }); + sentTurns = turnsSince(entries); + send({ t: "view", to: state.pairedId, view, stopped: true }); + lastLook = Date.now(); + debug("published view", { bytes: Buffer.byteLength(view), to: state.pairedId, stale: staleReviews, subagents }); + } catch (err) { + // Nothing awaits this handler, so rethrowing would be an unhandled rejection nobody sees, + // and the supervisor would silently never wake again. + debug("view publish failed", { error: (err as Error).message }); + context.ui?.notify?.(`intercom-supervisor: could not send the view, ${(err as Error).message}`, "error"); + } + }); + + // ---- supervisor side: one command and three tools --------------------------------------- + + function startPair(worker: any, goal: string, context: any, plan?: PlanBinding): Promise { + if (!channel) throw new Error("intercom-supervisor: intercom is not connected"); + if (state.role !== "none" && !(plan && bootstrapPending && state.pairedId === worker.id && state.plan?.id === plan.id)) throw new Error(`intercom-supervisor: already paired with ${state.pairedId.slice(0, 8)} as ${state.role}`); + const target = worker.name ?? worker.id.slice(0, 8); + state = { ...EMPTY_STATE, role: "supervisor", pairedId: worker.id, goal, ...(plan ? { plan } : {}) }; + latestView = ""; + reviewsSinceGoal = 0; + save(); + const kept = stripWriters(); + showSupervisorTools(true); + + const { prompt, source } = loadSupervisorPrompt(context.cwd); + tellSupervisor(BRIEF(prompt, goal, target)); + send({ t: "pair", to: state.pairedId, goal: plan ? "Plan-aware supervision" : goal, ...(plan ? { plan } : {}) }); + const acknowledgement = new Promise((resolve, reject) => { + pendingPair = { workerId: worker.id, resolve, reject }; + }); + pairTimer = setTimeout(() => { + send({ t: "unpair", to: state.pairedId }); + reset(`intercom-supervisor: ${target} never acknowledged. It probably does not load this extension.`); + }, PAIR_ACK_TIMEOUT_MS); + context.ui?.notify?.(`supervising ${target} (policy: ${source}, tools: ${kept.join(", ")})`, "info"); + return acknowledgement; + } + + async function pairPublishedWorker(workerIntercomId: string, goal: string, context: any): Promise { + await intercomConnected; + const me = await resolveOwnId(); + if (workerIntercomId === me) throw new Error("intercom-supervisor: cannot supervise this session"); + const worker = (await channel!.listSessions()).find((session: any) => session.id === workerIntercomId); + if (!worker) throw new Error(`intercom-supervisor: worker ${workerIntercomId.slice(0, 8)} is not connected`); + await startPair(worker, readGoal(context.cwd, goal), context); + } + + pi.events.on(WORKER_STATE_EVENT, (reply: (state: { intercomId: string; paired: boolean }) => void) => { + if (typeof reply !== "function") return; + resolveOwnId().then((intercomId) => reply({ intercomId, paired: state.role === "worker" })); + }); + pi.events.emit(API_READY_EVENT, { version: 1 }); + + pi.events.on(PROGRAMMATIC_PAIR_EVENT, (raw: unknown) => { + const request = raw as Partial; + if (request.version !== 1 || typeof request.workerIntercomId !== "string" || !request.workerIntercomId || typeof request.goal !== "string" || !request.goal.trim() || typeof request.resolve !== "function" || typeof request.reject !== "function") return; + pairPublishedWorker(request.workerIntercomId, request.goal, ctx) + .then(request.resolve, (error: unknown) => request.reject!(error instanceof Error ? error : new Error(String(error)))); + }); + + pi.registerCommand("supervise", { + description: "Supervise the other pi session here: /supervise [goal or path to a goal file], /supervise @name [goal], /supervise goal , /supervise look, /supervise stop", + handler: async (args: string, context: any) => { + ctx = context; + const text = args.trim(); + if (!channel) { + context.ui?.notify?.("intercom-supervisor: intercom is not connected", "error"); + return; + } + if (text === "stop") { + if (state.pairedId) send({ t: "unpair", to: state.pairedId }); + reset("supervision stopped", true); + return; + } + if (text === "look") { + if (state.role !== "supervisor") { + context.ui?.notify?.("intercom-supervisor: not supervising, so there is nothing to look at", "error"); + return; + } + send({ t: "look", to: state.pairedId }); + context.ui?.notify?.(`asked ${state.pairedId.slice(0, 8)} for a view`, "info"); + return; + } + // Change the goal without breaking the pairing. Stopping and pairing again is the only other + // way, and that throws away the supervisor's memory of its own steers. + if (text === "goal" || text.startsWith("goal ")) { + if (state.plan) { context.ui.notify("This goal comes from the worker's plan. Edit that plan rather than replacing the supervisor's copy.", "warning"); return; } + const goal = readGoal(context.cwd, text.slice(4).trim()); + if (state.role !== "supervisor") { + context.ui?.notify?.("intercom-supervisor: not supervising, so there is no goal to change", "error"); + return; + } + if (!goal) { + context.ui?.notify?.(`intercom-supervisor: the goal now is: ${state.goal || "not set"}`, "info"); + return; + } + state = { ...state, goal }; + save(); + send({ t: "goal", to: state.pairedId, goal }); // the worker heads every view with it + // Tell the supervisor now, and ask for a view, so it judges the new goal at once instead of + // waiting up to half an hour for the next look. + tellSupervisor(GOAL_CHANGED(goal)); + reviewsSinceGoal = 0; + send({ t: "look", to: state.pairedId }); + context.ui?.notify?.(`goal changed: ${goal}`, "info"); + return; + } + if (state.role !== "none") { + context.ui?.notify?.( + `intercom-supervisor: already paired with ${state.pairedId.slice(0, 8)} as ${state.role}. Run /supervise stop first.`, + "error", + ); + return; + } + + const me = await resolveOwnId(); + const listed = (await channel.listSessions()).filter((s: any) => s.id !== me); + // The id prefix is the start of the "pi --session " line pi prints in every terminal at + // startup, so it is something you can match against a window. + const describe = (rows: any[]) => + rows.map((s: any) => `${s.name ?? "(unnamed)"} ${s.id.slice(0, 8)} in ${s.cwd}`).join(", ") || "none"; + const first = text.split(/\s+/, 1)[0]; + + // A target is written @name, so nothing has to be guessed from a goal that has spaces in it. + // Before this, a first word that matched no session was silently swallowed into the goal: + // "/supervise LUCID do the thing" set the goal to "LUCID do the thing" and said nothing. + let worker: any; + let goal: string; + if (first.startsWith("@")) { + // Matched against every session, and no roll call: you named it, so it is the target, and + // the pair acknowledgement below is the test of whether it can take the job. + const want = first.slice(1); + const match = listed.filter((s: any) => s.name === want || s.id === want || s.id.startsWith(want)); + if (match.length !== 1) { + context.ui?.notify?.( + `intercom-supervisor: ${match.length} sessions match @${want}. Seen: ${describe(listed)}`, + "error", + ); + return; + } + worker = match[0]; + goal = readGoal(context.cwd, text.slice(first.length).trim()); + } else { + // Nothing named, so the whole line is the goal and this has to find the worker. + goal = readGoal(context.cwd, text); + const here = listed.filter((s: any) => s.cwd === context.cwd); + if (!here.length) { + context.ui?.notify?.(`intercom-supervisor: no other session in ${context.cwd}`, "error"); + return; + } + // The roll call answers who can actually take the job. One free session is the ordinary + // case, and it pairs with no question asked. + const free = await askWhoIsFree(); + const open = here.filter((s: any) => free.has(s.id)); + if (open.length === 1) { + worker = open[0]; + } else { + // Otherwise you pick. Everything here is on the list, including the sessions that stayed + // quiet, because "0 free sessions" is a dead end and a quiet session is sometimes the one + // you want: a worker that has not been reloaded since this extension changed cannot + // answer a roll call it does not know about. + const ordered = [...open, ...here.filter((s: any) => !free.has(s.id))]; + const labels = ordered.map( + (s: any) => `${s.name ?? "(unnamed)"} ${s.id.slice(0, 8)}${free.has(s.id) ? "" : " (no answer: child run, paired, gone, or not reloaded)"}`, + ); + // The goal is a title here, not the goal itself. Yours run to paragraphs of acceptance + // evidence, and the whole thing above a three-line picker is a wall to read past. + const picked = await context.ui.select(`which session works on "${firstLine(goal)}"`, labels); + if (picked === undefined) return; // cancelled, and the notice would say nothing new + worker = ordered[labels.indexOf(picked)]; + } + } + void startPair(worker, goal, context).catch(() => {}); + }, + }); + + /** + * How many verdicts this look has had, and the cut for a real runaway. + * + * Session 019ffa73, every look: let_it_run with a true reason, then a second let_it_run with a + * different true reason, then "This operation was aborted", sixteen times before 11:05Z. The + * second call is the model signing off, not a loop, and cutting it made an ordinary look end in + * an error line. So a repeat is answered instead (LET_IT_RUN_AGAIN), and abort() waits for a + * count no sign-off explains. Session 019ffa5f reached 645 calls, so the cut stays. + * + * Only let_it_run reaches nobody, so only it is safe to answer twice. steer and done act every + * time they are called, and the repeat warning in steer is what covers a duplicate there. + */ + const RUNAWAY_VERDICTS = 5; + let verdictsThisLook = 0; + const endLook = (context: any) => { + verdictsThisLook += 1; + if (verdictsThisLook > RUNAWAY_VERDICTS) context.abort(); + return verdictsThisLook === 1; + }; + + pi.registerTool({ + name: "review_goal", label: "Review goal", + description: "Answer the explicit goal sign-off request using its requestId. Approve trajectory/scope, request further work, or name a human decision. This does not complete the goal or stop supervision.", + parameters: Type.Object({ requestId: Type.String(), decision: Type.String({ enum: ["approve", "needs_work", "needs_user"] }), reason: Type.String() }), + execute: async (_id: string, params: { requestId: string; decision: GoalDecision["decision"]; reason: string }) => { + const review = incomingReview; + if (state.role !== "supervisor" || !review || review.requestId !== params.requestId) throw new Error("No matching pending goal request"); + if (!["approve", "needs_work", "needs_user"].includes(params.decision) || !params.reason.trim()) throw new Error("A decision and reason are required"); + if (planHash(planText(state.plan!)) !== review.planHash) throw new Error("Plan changed; ask the worker to retry"); + send({ ...review, ...params, t: "goal_decision", to: state.pairedId }); + incomingReview = undefined; + return { content: [{ type: "text", text: "Goal decision sent. Supervision remains active. End this response." }], terminate: true }; + }, + }); + + pi.registerTool({ + name: "worker_view", + label: "Worker view", + description: "Read the latest view of the worker session: goal, status, files touched, and recent turns.", + parameters: Type.Object({}), + // Guarded like the rest. Unguarded it answered "the worker has not stopped since pairing" to a + // session with no pairing at all, which read as confirmation to a worker that had wondered + // whether it was the supervisor. + execute: async () => { + if (state.role !== "supervisor") { + return { content: [{ type: "text", text: "Not supervising, so there is no worker and no view." }], isError: true }; + } + return { content: [{ type: "text", text: latestView || "No view received yet. The worker has not stopped since pairing." }] }; + }, + }); + + pi.registerTool({ + name: "set_goal", + label: "Set the goal", + description: + "Set the goal when the human did not give one. Infer it from the worker's view. This is announced to the human, who can override it.", + parameters: Type.Object({ goal: Type.String({ description: "One sentence, the outcome the worker must reach." }) }), + execute: async (_id: string, params: { goal: string }) => { + if (state.role !== "supervisor") { + return { content: [{ type: "text", text: "Not supervising." }], isError: true }; + } + if (state.plan) throw new Error("The plan belongs to pi-goals; steer the worker to propose a plan change"); + state = { ...state, goal: params.goal }; + reviewsSinceGoal = 0; + save(); + // The tool result records this inferred goal in the supervisor context. The worker needs it too. + send({ t: "goal", to: state.pairedId, goal: params.goal }); + // Announced, not silent: the supervisor's own reply is what reaches the human's phone. + ctx?.ui?.notify?.(`goal set by the supervisor: ${params.goal}`, "info"); + return { + content: [{ + type: "text", + text: `Goal set to: + + +${params.goal} + + +This is a goal you inferred, not one the human gave you. +Tell them in your reply, quoting it, so they can correct it.`, + }], + }; + }, + }); + + pi.registerTool({ + name: "steer", + label: "Steer worker", + description: TOOL_STEER, + parameters: Type.Object({ message: Type.String({ description: "One concrete next action, 1 to 3 sentences." }) }), + execute: async (_id: string, params: { message: string }, _signal: unknown, _update: unknown, context: any) => { + if (state.role !== "supervisor") { + return { content: [{ type: "text", text: "Not supervising. Run /supervise first." }], isError: true }; + } + // No goal means no basis to steer. Inventing work is the observed failure, so ask instead. + if (!state.goal.trim()) { + return { content: [{ type: "text", text: NO_GOAL }], isError: true }; + } + // Sent either way. Refusing a repeat would be a stopping rule, and a repeat is sometimes + // right; the supervisor gets told so it can change approach on the next round. + // Numbered from the run's total, not from the position in the window, so the number here + // means the same thing as the one in the review nudge and in "that is instruction N". + const first = state.steerRounds - state.recentSteers.length + 1; + const repeat = state.recentSteers + .map((old, i) => ({ old, n: first + i, score: overlap(old, params.message) })) + .sort((a, b) => b.score - a.score)[0]; + + send({ t: "directive", to: state.pairedId, text: params.message }); + state = { + ...state, + steerRounds: state.steerRounds + 1, + recentSteers: [...state.recentSteers, params.message].slice(-STEER_MEMORY), + }; + save(); + + const warning = repeat && repeat.score >= OVERLAP_WARN + ? `\nThis says much the same as instruction ${repeat.n}: "${repeat.old}"\nIf the next view shows nothing new, say what evidence makes repeating it worth another round, or change approach.` + : ""; + endLook(context); + return { content: [{ type: "text", text: `${STEER_ACK(state.steerRounds, state.pairedId)}${warning}` }] }; + }, + }); + + /** + * The third verdict, and the one that stops a fabricated steer. + * + * Observed 2026-08-12: with only steer and done on offer, a supervisor with nothing to say wrote + * "the harness demands a tool call ... the least-bad option is a steer that adds something new", + * ran a command that printed nothing, then reported a job was at "turn 47 of 80". The real log + * said 75 of 80. Forcing a verdict every look is what bought that number. + */ + pi.registerTool({ + name: "let_it_run", + label: "Let the worker run", + description: TOOL_LET_IT_RUN, + parameters: Type.Object({ reason: Type.String({ description: "Quote the exact worker-view text supporting no instruction. Do not infer future work or worker state." }) }), + execute: async (_id: string, params: { reason: string }, _signal: unknown, _update: unknown, context: any) => { + if (state.role !== "supervisor") { + return { content: [{ type: "text", text: "Not supervising." }], isError: true }; + } + const first = endLook(context); + return { content: [{ type: "text", text: first ? LET_IT_RUN_ACK(params.reason, workerStopped) : LET_IT_RUN_AGAIN }] }; + }, + }); + + pi.registerTool({ + name: "done", + label: "Finish supervision", + description: TOOL_DONE, + parameters: Type.Object({ reason: Type.String({ description: "The artifact path and the quoted line that proves it." }) }), + execute: async (_id: string, params: { reason: string }, _signal: unknown, _update: unknown, context: any) => { + if (state.role !== "supervisor") { + return { content: [{ type: "text", text: "Not supervising." }], isError: true }; + } + if (state.plan && (incomingReview || /^\s*(?:\d+\.|[-*])\s*\[[ /]\]\s*goal:/im.test(planText(state.plan)))) throw new Error("Open plan goals remain. Use review_goal for an individual goal request."); + if (state.plan && /tracked background work:.*unknown|tracked background work:.*(?:processes|subagents): [1-9]/.test(latestView)) throw new Error("Tracked background work is active or unknown"); + // "done" while a delegated tool call has no result is a false completion: the worker settled + // but its subagent or background job is still spending. This proves only that no tracked + // tool result is missing. A detached process is invisible to it. + const pending = latestView.match(/^tool calls with no result: (?!none)(.+)$/m) + ?? latestView.match(/^child pi processes still running: (?!none)(.+)$/m); + if (pending) { + return { + content: [{ type: "text", text: DONE_BLOCKED(pending[1]) }], + isError: true, + }; + } + send({ t: "done", to: state.pairedId, reason: params.reason }); + const rounds = state.steerRounds; + reset(`supervision finished: ${params.reason}`, true); + endLook(context); + return { content: [{ type: "text", text: `Supervision finished after ${rounds} instructions.` }] }; + }, + }); +} diff --git a/src/internal/supervisor/plan-api.ts b/src/internal/supervisor/plan-api.ts new file mode 100644 index 0000000..6ce23c3 --- /dev/null +++ b/src/internal/supervisor/plan-api.ts @@ -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(signal: AbortSignal | undefined, cancel: () => void, timeoutMs = 600_000) { + let finish!: (value?: T, error?: Error) => void; + const promise = new Promise((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 }; +} diff --git a/src/internal/supervisor/prompts.ts b/src/internal/supervisor/prompts.ts new file mode 100644 index 0000000..90511cc --- /dev/null +++ b/src/internal/supervisor/prompts.ts @@ -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: + * /.pi/SUPERVISOR.md, then /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 || "not given, so infer it from the first view you receive and call set_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 || "not set"} + + +${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} + + +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.`; diff --git a/src/internal/supervisor/protocol.ts b/src/internal/supervisor/protocol.ts new file mode 100644 index 0000000..9f0d3cc --- /dev/null +++ b/src/internal/supervisor/protocol.ts @@ -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; + 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) }; + } + } + return state; +} diff --git a/src/internal/supervisor/subagents.ts b/src/internal/supervisor/subagents.ts new file mode 100644 index 0000000..7379f34 --- /dev/null +++ b/src/internal/supervisor/subagents.ts @@ -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 { + 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 { + return (await piProcesses()).filter((p) => p.ppid === process.pid).map((p) => p.pid); +} diff --git a/src/internal/supervisor/vcc.d.ts b/src/internal/supervisor/vcc.d.ts new file mode 100644 index 0000000..2c7b494 --- /dev/null +++ b/src/internal/supervisor/vcc.d.ts @@ -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; created: Set }; +} +declare module "@sting8k/pi-vcc/src/extract/commits.ts" { + export function extractCommits(blocks: unknown[]): Array<{ hash?: string; message: string }>; +} diff --git a/src/internal/supervisor/view.ts b/src/internal/supervisor/view.ts new file mode 100644 index 0000000..aea09d9 --- /dev/null +++ b/src/internal/supervisor/view.ts @@ -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; +} +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(); + const answered = new Set(); + 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(); + 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. + ``, + goalPreview(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`; +} diff --git a/src/prompts.ts b/src/prompts.ts index a92a3ef..bf6aa26 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -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 { diff --git a/src/role-models.ts b/src/role-models.ts new file mode 100644 index 0000000..f482ec8 --- /dev/null +++ b/src/role-models.ts @@ -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 { + 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 { + 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--; } + } +} diff --git a/src/supervisor.ts b/src/supervisor.ts index dd10367..95f9fcf 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -29,7 +29,7 @@ export function supervisorRequest(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.`); diff --git a/test/fixtures/forked-session.jsonl b/test/fixtures/forked-session.jsonl new file mode 100644 index 0000000..3e426d3 --- /dev/null +++ b/test/fixtures/forked-session.jsonl @@ -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"}]}} diff --git a/test/fixtures/role-model-process.ts b/test/fixtures/role-model-process.ts new file mode 100644 index 0000000..0f279a7 --- /dev/null +++ b/test/fixtures/role-model-process.ts @@ -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); diff --git a/test/goals-flow.test.ts b/test/goals-flow.test.ts index a59ff4e..46e8a59 100644 --- a/test/goals-flow.test.ts +++ b/test/goals-flow.test.ts @@ -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, editorChoices: Array = [], @@ -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) { + 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, 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, 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(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 }); } +}); diff --git a/test/internal-supervisor/index.test.ts b/test/internal-supervisor/index.test.ts new file mode 100644 index 0000000..74b1e9e --- /dev/null +++ b/test/internal-supervisor/index.test.ts @@ -0,0 +1,1517 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const INTERCOM_EXTENSION_REGISTER_EVENT = "intercom:extension-register"; +const INTERCOM_EXTENSION_REGISTRY_READY_EVENT = "intercom:extension-registry-ready"; + +import extension, { PROGRAMMATIC_PAIR_EVENT } from "../../src/internal/supervisor/index.js"; +import { isWire, overlap, restoreState, STATE_ENTRY, STEER_MEMORY } from "../../src/internal/supervisor/protocol.js"; +import { buildView } from "../../src/internal/supervisor/view.js"; + +test("retries intercom registration when pi-intercom loads after pi-supervise", () => { + const bus = new EventEmitter(); + const registrations: unknown[] = []; + const pi = { + events: { + emit: (name: string, value: unknown) => bus.emit(name, value), + on: (name: string, handler: (...args: any[]) => void) => bus.on(name, handler), + }, + on() {}, + registerCommand() {}, + registerTool() {}, + appendEntry() {}, + getActiveTools: () => [], + setActiveTools() {}, + sendMessage() {}, + sendUserMessage() {}, + }; + extension(pi); + bus.on(INTERCOM_EXTENSION_REGISTER_EVENT, (registration) => registrations.push(registration)); + bus.emit(INTERCOM_EXTENSION_REGISTRY_READY_EVENT, { version: 1 }); + bus.emit(INTERCOM_EXTENSION_REGISTRY_READY_EVENT, { version: 1 }); + assert.equal(registrations.length, 1); +}); + +test("a directive with no text is rejected, so the worker never sees undefined", () => { + assert.equal(isWire({ t: "directive", to: "x", text: "do it" }), true); + assert.equal(isWire({ t: "directive", to: "x" }), false); + assert.equal(isWire({ t: "directive", to: "x", text: " " }), false); + assert.equal(isWire({ t: "view", to: "x" }), false); + assert.equal(isWire({ t: "pair", to: "x", goal: "g" }), true); +}); + +const WORKER_ID = "session-worker"; +const SUPER_ID = "session-supervisor"; + +/** Fake ExtensionAPI, same shape as pi-intercom's own test harness. */ +function harness( + ownId: string, + { + entries = [] as any[], + isIdle = true, + branch = undefined as any[] | undefined, + extraSessions = [] as any[], + /** Which sessions answer a roll call. The extras stay quiet, the way a child run does. */ + freeSessions = undefined as string[] | undefined, + /** Which option the human picks when /supervise has to ask. undefined is a cancelled dialog. */ + pick = undefined as number | undefined, + } = {}, +) { + const bus = new EventEmitter(); + const handlers = new Map any>>(); + const commands = new Map any>(); + const tools = new Map(); + const appended: Array<{ type: string; data: any }> = []; + const userMessages: Array<{ content: string; options?: any }> = []; + const contextMessages: Array<{ content: string; options?: any }> = []; + const published: any[] = []; + const notices: string[] = []; + const selects: Array<{ title: string; options: string[] }> = []; + const aborts: boolean[] = []; + let onEvent: (e: any) => void = () => {}; + + const peerId = ownId === WORKER_ID ? SUPER_ID : WORKER_ID; + const answering = freeSessions ?? [peerId]; + + const channel = { + namespace: "test", + snapshot: () => ({ connected: true, supported: true }), + publish: (payload: any) => { + published.push(payload); + // The other sessions answer the roll call, which is what /supervise waits for. + if (payload?.t === "who") { + for (const id of answering) { + queueMicrotask(() => onEvent({ type: "message", fromSessionId: id, payload: { t: "here", to: ownId } })); + } + } + }, + commitState: () => {}, + listSessions: async () => [ + // model and contextPct are on the real SessionInfo (pi-intercom/types.ts), pushed by presence. + // The view header reads them off our own record, so a session missing them fails a test here. + { id: ownId, pid: process.pid, name: ownId === WORKER_ID ? "worker" : "supervisor", cwd: process.cwd(), model: "test/tiny", contextPct: 12 }, + { + id: ownId === WORKER_ID ? SUPER_ID : WORKER_ID, + pid: process.pid + 1, + name: ownId === WORKER_ID ? "supervisor" : "worker", + cwd: process.cwd(), + model: "test/tiny", + }, + ...extraSessions, + ], + }; + + const pi = { + events: { + emit: (channelName: string, payload: any) => { + if (channelName === INTERCOM_EXTENSION_REGISTER_EVENT) { + onEvent = payload.onEvent; + payload.onReady(channel); + } + bus.emit(channelName, payload); + }, + on: (name: string, fn: any) => bus.on(name, fn), + }, + on: (event: string, fn: any) => { + handlers.set(event, [...(handlers.get(event) ?? []), fn]); + }, + registerCommand: (name: string, opts: any) => commands.set(name, opts.handler), + // A registered tool is active in pi, which is why a worker could see steer and worker_view. + registerTool: (tool: any) => { + tools.set(tool.name, tool); + activeTools = [...activeTools, tool.name]; + }, + appendEntry: (type: string, data: any) => appended.push({ type, data }), + sendUserMessage: (content: string, options?: any) => userMessages.push({ content, options }), + // pi's own sendMessage: a CustomMessageEntry, in the LLM context, and with triggerTurn false it + // starts no turn (session-manager.d.ts:85, agent-session.d.ts:398). + sendMessage: (message: any, options?: any) => contextMessages.push({ content: message.content, options }), + // On the pi API, NOT on the command context. Putting them on the context here is what hid a + // real bug: the live command handler threw "context.getActiveTools is not a function", and + // before that the optional call returned undefined and the strip skipped in silence. + getActiveTools: () => activeTools, + setActiveTools: (names: string[]) => { + activeTools = names; + }, + }; + + let activeTools = ["read", "grep", "list", "bash", "edit", "write"]; + const status = new Map(); + const ctx = { + cwd: process.cwd(), + isIdle: () => isIdle, + hasUI: true, + // pi's own ExtensionContext.abort(): stops the agent loop before its next model call. + abort: () => aborts.push(true), + ui: { + notify: (m: string) => notices.push(m), + // pi's own ExtensionUIContext.select: returns the chosen option, or undefined if cancelled. + select: async (title: string, options: string[]) => { + selects.push({ title, options }); + return pick === undefined ? undefined : options[pick]; + }, + // Same shape as @diegopetrucci/pi-oracle uses: setStatus(id, text) with theme.fg for colour. + setStatus: (id: string, text: string | undefined) => status.set(id, text), + theme: { fg: (_colour: string, text: string) => text }, + }, + sessionManager: { + getSessionId: () => ownId, + getEntries: () => entries, + // Deliberately different from getEntries, so a test can tell which one the view reads. + getBranch: () => branch ?? entries, + }, + }; + + return { + pi, + ctx, + tools, + appended, + userMessages, + contextMessages, + published, + notices, + selects, + aborts, + status, + async start() { + extension(pi as any); + for (const fn of handlers.get("session_start") ?? []) await fn({}, ctx); + }, + async settle() { + for (const fn of handlers.get("agent_settled") ?? []) await fn({}, ctx); + }, + async turnStart() { + for (const fn of handlers.get("turn_start") ?? []) await fn({}, ctx); + }, + /** One per run, before the first model call (pi-agent-core agent-loop.js:48). */ + async agentStart() { + for (const fn of handlers.get("agent_start") ?? []) await fn({}, ctx); + }, + async compact(willRetry = false) { + for (const fn of handlers.get("session_compact") ?? []) await fn({ willRetry }, ctx); + }, + async run(command: string, args: string) { + await commands.get(command)!(args, ctx); + }, + /** Deliver a wire message as if it came from another session, with a broker-stamped sender. */ + deliver(fromSessionId: string, payload: unknown) { + onEvent({ type: "message", fromSessionId, payload }); + }, + pair(workerIntercomId: string, goal: string) { + return new Promise((resolve, reject) => { + bus.emit(PROGRAMMATIC_PAIR_EVENT, { version: 1, workerIntercomId, goal, resolve, reject }); + }); + }, + /** Fired before every model call. Returns what the model would actually be sent. */ + async context(messages: any[]) { + let out = messages; + for (const fn of handlers.get("context") ?? []) out = (await fn({ messages: out }, ctx))?.messages ?? out; + return out; + }, + }; +} + +const message = (role: string, text: string) => ({ + type: "message", + message: { role, content: [{ type: "text", text }] }, +}); + +test("a directive from the paired supervisor becomes a real user message", async () => { + const worker = harness(WORKER_ID); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "make the table" }); + await new Promise((r) => setTimeout(r, 5)); + + worker.deliver(SUPER_ID, { t: "directive", to: WORKER_ID, text: "read the log first" }); + await new Promise((r) => setTimeout(r, 5)); + + assert.equal(worker.userMessages.length, 1); + assert.equal(worker.userMessages[0].content, "[supervisor] read the log first"); + // Idle worker takes no deliverAs, so the message triggers a turn instead of queueing forever. + assert.equal(worker.userMessages[0].options, undefined); +}); + +test("a directive to a busy worker interrupts, instead of waiting for the whole task", async () => { + // Seen in a real run: the supervisor corrected the worker mid-task and the correction never + // arrived, because followUp waits for the agent to finish everything. + const worker = harness(WORKER_ID, { isIdle: false }); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "g" }); + await new Promise((r) => setTimeout(r, 5)); + + worker.deliver(SUPER_ID, { t: "directive", to: WORKER_ID, text: "it is Cthulhu, not clutho" }); + await new Promise((r) => setTimeout(r, 5)); + + assert.equal(worker.userMessages.length, 1); + assert.deepEqual(worker.userMessages[0].options, { deliverAs: "steer" }); +}); + +test("a directive from an unpaired session is dropped", async () => { + const worker = harness(WORKER_ID); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "g" }); + await new Promise((r) => setTimeout(r, 5)); + + worker.deliver("session-impostor", { t: "directive", to: WORKER_ID, text: "rm -rf /" }); + await new Promise((r) => setTimeout(r, 5)); + + assert.deepEqual(worker.userMessages, []); +}); + +test("a second pair takes over, and the first supervisor is told it lost the worker", async () => { + // First-wins left a worker bound forever to a supervisor that had already died, and told nobody. + const worker = harness(WORKER_ID); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "first" }); + await new Promise((r) => setTimeout(r, 5)); + worker.deliver("session-second", { t: "pair", to: WORKER_ID, goal: "second" }); + await new Promise((r) => setTimeout(r, 5)); + + assert.deepEqual( + worker.published.filter((p) => p.t === "unpair"), + [{ t: "unpair", to: SUPER_ID }], + "the supervisor that lost the worker must hear about it", + ); + worker.deliver(SUPER_ID, { t: "directive", to: WORKER_ID, text: "obey me" }); + await new Promise((r) => setTimeout(r, 5)); + assert.deepEqual(worker.userMessages, [], "the old supervisor can no longer steer"); + + worker.deliver("session-second", { t: "directive", to: WORKER_ID, text: "now do this" }); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(worker.userMessages.length, 1, "the new supervisor can"); +}); + +test("only the paired worker can end a run", async () => { + // A worker that has moved to another supervisor sends unpair to the one it left. That must not + // reach a supervisor which has itself moved on, or a live run ends and nobody asked for it. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker g"); + sup.deliver("session-a-worker-we-left", { t: "unpair", to: SUPER_ID }); + sup.deliver("session-a-worker-we-left", { t: "done", to: SUPER_ID, reason: "I decided we are finished" }); + await new Promise((r) => setTimeout(r, 5)); + + const steer = await sup.tools.get("steer")!.execute("id", { message: "carry on" }, undefined, undefined, sup.ctx); + assert.ok(!steer.isError, `supervision must still be running, got ${steer.content[0].text}`); +}); + +test("the programmatic pairing API waits for the worker acknowledgement", async () => { + const supervisor = harness(SUPER_ID); + await supervisor.start(); + const paired = supervisor.pair(WORKER_ID, "make the table"); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(supervisor.published.filter((payload) => payload.t === "pair"), [{ t: "pair", to: WORKER_ID, goal: "make the table" }]); + supervisor.deliver(WORKER_ID, { t: "paired", to: SUPER_ID }); + await paired; +}); + +test("the worker acknowledges a pair, so the supervisor knows it was heard", async () => { + const worker = harness(WORKER_ID); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "g" }); + await new Promise((r) => setTimeout(r, 5)); + assert.deepEqual(worker.published.filter((p) => p.t === "paired"), [{ t: "paired", to: SUPER_ID }]); +}); + +test("a goal the supervisor inferred reaches the worker, which owns the view header", async () => { + const worker = harness(WORKER_ID, { entries: [message("user", "do the thing")] }); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "" }); + await new Promise((r) => setTimeout(r, 5)); + await worker.settle(); + assert.match(worker.published.find((p) => p.t === "view").view, /^\nnot set\n<\/goal>$/m, "no goal yet"); + + worker.deliver(SUPER_ID, { t: "goal", to: WORKER_ID, goal: "make the results table" }); + await new Promise((r) => setTimeout(r, 5)); + await worker.settle(); + const views = worker.published.filter((p) => p.t === "view"); + assert.match(views[views.length - 1].view, /^\nmake the results table\n<\/goal>$/m, "the header must follow set_goal"); +}); + +test("the second view carries only what happened after the first", async () => { + // Without the mark advancing, review N re-sends reviews 1..N-1 into a session that has them. + const entries = [message("user", "THE FIRST INSTRUCTION")]; + const worker = harness(WORKER_ID, { entries }); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "g" }); + // 300ms, not 5: the pairing view runs ps, and a pairing that lands after the second settle sets + // the sent-turns mark past the turn this test is looking for. That flaked about one run in four. + await new Promise((r) => setTimeout(r, 300)); + + await worker.settle(); + assert.match(worker.published.find((p) => p.t === "view").view, /THE FIRST INSTRUCTION/); + + entries.push(message("assistant", "THE SECOND THING")); + await worker.settle(); + const second = worker.published.filter((p) => p.t === "view").at(-1).view; + assert.match(second, /THE SECOND THING/); + assert.doesNotMatch(second, /THE FIRST INSTRUCTION/, "the supervisor already read this one"); +}); + +test("a message addressed to a different session is ignored", async () => { + const worker = harness(WORKER_ID); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: "some-other-worker", goal: "not for us" }); + await new Promise((r) => setTimeout(r, 5)); + + worker.deliver(SUPER_ID, { t: "directive", to: WORKER_ID, text: "hello" }); + await new Promise((r) => setTimeout(r, 5)); + assert.deepEqual(worker.userMessages, []); +}); + +test("on settle the worker publishes a view built from the live branch", async () => { + const worker = harness(WORKER_ID, { entries: [message("user", "do the thing"), message("assistant", "done")] }); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "the goal" }); + await new Promise((r) => setTimeout(r, 5)); + + await worker.settle(); + const view = worker.published.find((p) => p.t === "view"); + assert.ok(view, "expected a view publish"); + assert.equal(view.to, SUPER_ID); + assert.match(view.view, /\nthe goal\n<\/goal>/); + assert.match(view.view, /do the thing/); +}); + +test("the view is built from the live branch, not from every entry in the session", async () => { + // An abandoned fork leaves entries in getEntries that getBranch drops. Reading the wrong one + // makes the supervisor judge work that was undone. + const worker = harness(WORKER_ID, { + entries: [message("user", "ABANDONED after a rewind"), message("user", "kept on the live branch")], + branch: [message("user", "kept on the live branch")], + }); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "g" }); + await new Promise((r) => setTimeout(r, 5)); + + await worker.settle(); + const view = worker.published.find((p) => p.t === "view"); + assert.match(view.view, /kept on the live branch/); + assert.doesNotMatch(view.view, /ABANDONED after a rewind/); +}); + +test("an unpaired session publishes nothing on settle", async () => { + const lone = harness(WORKER_ID, { entries: [message("user", "hi")] }); + await lone.start(); + await lone.settle(); + assert.deepEqual(lone.published, []); +}); + +test("supervision never stops itself: no round limit at all", async () => { + // wassname: "I want no budget at all! I want long until human stops". Ending early is the + // failure this extension exists to prevent, so a cap would be a competing objective. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker do the thing"); + + const steer = sup.tools.get("steer")!; + for (let i = 0; i < 200; i++) { + const result = await steer.execute("id", { message: `instruction ${i}` }, undefined, undefined, sup.ctx); + assert.ok(!result.isError, `instruction ${i} must be allowed, got ${result.content[0].text}`); + } +}); + +test("goal, pairing and the steer count all survive a reload together", async () => { + const first = harness(SUPER_ID); + await first.start(); + await first.run("supervise", "@worker make the results table"); + const steer = first.tools.get("steer")!; + for (let i = 0; i < 3; i++) { + await steer.execute("id", { message: `instruction ${i}` }, undefined, undefined, first.ctx); + } + + const carried = first.appended + .filter((e) => e.type === STATE_ENTRY) + .map((e) => ({ type: "custom", customType: e.type, data: e.data })); + const restored = restoreState(carried); + assert.equal(restored.goal, "make the results table"); + assert.equal(restored.pairedId, WORKER_ID); + assert.equal(restored.steerRounds, 3); + assert.deepEqual(restored.recentSteers, ["instruction 0", "instruction 1", "instruction 2"]); +}); + +test("a view that arrives while the supervisor is thinking is queued, not dropped", async () => { + // pi throws "Agent is already processing" when sendUserMessage gets no delivery option, and the + // catch upstream turns that into a dropped message. On a half hour look the supervisor would + // silently skip a whole look. + const sup = harness(SUPER_ID, { isIdle: false }); + await sup.start(); + await sup.run("supervise", "@worker g"); + sup.userMessages.length = 0; + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view: "# Goal: g\n", stopped: true }); + await new Promise((r) => setTimeout(r, 5)); + + assert.equal(sup.userMessages.length, 1, "the view must still reach the supervisor"); + assert.deepEqual(sup.userMessages[0].options, { deliverAs: "followUp" }); +}); + +test("the nudge repeats neither the instructions already sent nor the verdict rules", async () => { + // Both used to be in every nudge. The steer calls are already in the supervisor's own context, + // and the verdict rules are in the tool descriptions, which the API sends at every model call. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker fix the tests"); + const steer = sup.tools.get("steer")!; + for (let i = 0; i < STEER_MEMORY + 3; i++) { + await steer.execute("id", { message: `instruction ${i}` }, undefined, undefined, sup.ctx); + } + + sup.userMessages.length = 0; + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view: "# Goal: fix the tests\n", stopped: true }); + await new Promise((r) => setTimeout(r, 5)); + + const nudge = sup.userMessages.at(-1)!.content; + assert.doesNotMatch(nudge, /instruction \d/, "the supervisor already has its own steer calls"); + assert.match(nudge, new RegExp(`${STEER_MEMORY + 3} instructions so far`), "the count is the cheap part, so it stays"); + assert.ok(nudge.length < 400, `the nudge is sent every look, so it stays short: ${nudge.length} chars`); +}); + +test("a multi-line goal returns to supervisor context every fifth review and after compaction", async () => { + const goal = "Build the causal evaluation.\nThe full rubric stays here."; + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", `@worker ${goal}`); + const atPairing = sup.contextMessages.length; + assert.match(sup.contextMessages.at(-1)!.content, /\nBuild the causal evaluation\.\nThe full rubric stays here\.\n<\/goal>/); + + for (let i = 0; i < 4; i++) { + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view: `view ${i}`, stopped: true }); + await new Promise((r) => setTimeout(r, 5)); + } + assert.equal(sup.contextMessages.length, atPairing, "the brief already gave the supervisor the full goal"); + + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view: "view 4", stopped: true }); + await new Promise((r) => setTimeout(r, 5)); + assert.match(sup.contextMessages.at(-1)!.content, /\nBuild the causal evaluation\.\nThe full rubric stays here\.\n<\/goal>/); + + const afterReview = sup.contextMessages.length; + await sup.compact(); + assert.equal(sup.contextMessages.length, afterReview + 1); + assert.match(sup.contextMessages.at(-1)!.content, /The full rubric stays here/); + + const afterCompaction = sup.contextMessages.length; + await sup.compact(true); + assert.equal(sup.contextMessages.length, afterCompaction, "overflow recovery removes its failed reply after session_compact"); + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view: "view after recovery", stopped: true }); + await new Promise((r) => setTimeout(r, 5)); + assert.match(sup.contextMessages.at(-1)!.content, /The full rubric stays here/, "the next ordinary review restores the rubric"); +}); + +test("a one-line goal is not redundantly reinserted", async () => { + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker fix the parser"); + const atPairing = sup.contextMessages.length; + + for (let i = 0; i < 5; i++) { + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view: `view ${i}`, stopped: true }); + await new Promise((r) => setTimeout(r, 5)); + } + assert.equal(sup.contextMessages.length, atPairing); +}); + +test("a check in and a worker that stopped ask for different things", async () => { + // Interrupting a working agent costs it its train of thought, so a check in leans on wait. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker fix the tests"); + + sup.userMessages.length = 0; + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view: "# Goal: g\n", stopped: false }); + await new Promise((r) => setTimeout(r, 5)); + assert.match(sup.userMessages.at(-1)!.content, /Call let_it_run unless the view gives concrete evidence/); + + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view: "# Goal: g\n", stopped: true }); + await new Promise((r) => setTimeout(r, 5)); + assert.match(sup.userMessages.at(-1)!.content, /The worker stopped/); +}); + +test("a loop still gets named after the supervisor compacts, from restored state", async () => { + // The nudge no longer lists past instructions, so this is the only thing left that catches a + // repeat once a compaction has taken the supervisor's own steer calls out of its context. + const first = harness(SUPER_ID); + await first.start(); + await first.run("supervise", "@worker fix the tests"); + await first.tools.get("steer")! + .execute("id", { message: "Run the failing parser tests and fix the first failure" }, undefined, undefined, first.ctx); + + const carried = first.appended + .filter((e) => e.type === STATE_ENTRY) + .map((e) => ({ type: "custom", customType: e.type, data: e.data })); + const after = harness(SUPER_ID, { entries: carried }); + await after.start(); + const again = await after.tools.get("steer")! + .execute("id", { message: "Please run those parser tests again and fix whatever failure comes first" }, undefined, undefined, after.ctx); + + assert.match(again.content[0].text, /says much the same as instruction 1/); +}); + +test("a session that does not answer the roll call is not offered as a worker", async () => { + // Real names from wassname's terminal 2026-08-13: pi-subagents registers every child run with + // the broker, so /supervise saw "5 other sessions" and refused to pick the one real worker. + // Steering a child run is meaningless anyway, it dies when its task ends. + const sup = harness(SUPER_ID, { + extraSessions: [ + { id: "019ff8ac-1", pid: 1, name: "general-purpose#f818354d", cwd: process.cwd() }, + { id: "019ff8ac-2", pid: 2, name: "general-purpose#18c778eb", cwd: process.cwd() }, + { id: "019ff807", pid: 3, name: "subagent-chat-019ff807-e4ce-7f4d", cwd: process.cwd() }, + ], + }); + await sup.start(); + await sup.run("supervise", "make the results table"); + + assert.deepEqual( + sup.published.filter((p) => p.t === "pair"), + [{ t: "pair", to: WORKER_ID, goal: "make the results table" }], + "the one session that answered is the worker, whatever the child runs are doing", + ); +}); + +test("a child run stays out of the roll call, so it can never be picked", async () => { + process.env.PI_SUBAGENT_CHILD = "1"; // pi-subagents sets this in every session it starts + try { + const child = harness(WORKER_ID); + await child.start(); + child.deliver(SUPER_ID, { t: "who", to: "*" }); + await new Promise((r) => setTimeout(r, 5)); + assert.deepEqual(child.published.filter((p) => p.t === "here"), []); + } finally { + delete process.env.PI_SUBAGENT_CHILD; + } +}); + +test("a session already paired stays out of the roll call, and a free one answers", async () => { + const worker = harness(WORKER_ID); + await worker.start(); + worker.deliver("session-asking", { t: "who", to: "*" }); + await new Promise((r) => setTimeout(r, 5)); + assert.deepEqual( + worker.published.filter((p) => p.t === "here"), + [{ t: "here", to: "session-asking" }], + "free, so it puts itself forward", + ); + + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "g" }); + await new Promise((r) => setTimeout(r, 5)); + worker.deliver("session-asking", { t: "who", to: "*" }); + await new Promise((r) => setTimeout(r, 5)); + assert.equal( + worker.published.filter((p) => p.t === "here").length, + 1, + "taken, so a second supervisor is not offered it", + ); +}); + +test("/supervise look asks the worker for a fresh view, rather than the supervisor guessing", async () => { + // A supervisor whose turn ended without a view has no way back: only the worker makes views. + // wassname hit this when the supervisor died on an OpenRouter 402, and poking it with "," made + // it work from stale context and invent facts. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker make the results table"); + await sup.run("supervise", "look"); + assert.deepEqual(sup.published.filter((p) => p.t === "look"), [{ t: "look", to: WORKER_ID }]); + + // And the worker answers with a view, so the round trip is real. + const worker = harness(WORKER_ID, { entries: [message("assistant", "WHAT I DID LAST")] }); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "g" }); + await new Promise((r) => setTimeout(r, 5)); + worker.deliver(SUPER_ID, { t: "look", to: WORKER_ID }); + // The view waits on a real ps call for child pi processes, which takes tens of milliseconds. + await new Promise((r) => setTimeout(r, 300)); + + const view = worker.published.filter((p) => p.t === "view").at(-1); + // The whole transcript, not the diff, even though the pairing view already sent it. A supervisor + // asks for a look when its own copy is gone, so "nothing new since your last look" is no answer. + assert.match(view.view, /WHAT I DID LAST/); + assert.equal(view.stopped, true, "this worker is idle, and the flag follows the worker not the trigger"); +}); + +test("let_it_run says the turn is over, so it is not called four times running", async () => { + // Observed 2026-08-12 in session 019ff458-4578: wait at 06:21:52, 06:22:13, 06:22:25 and + // 06:22:32, then "I keep calling wait in a loop ... I should end my turn now". A tool result + // reads as a prompt to act again, so the result has to say the turn is finished. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker make the results table"); + + const result = await sup.tools.get("let_it_run")!.execute("id", { reason: "job 291 is at 305 of 600" }, undefined, undefined, sup.ctx); + assert.match(result.content[0].text, /job 291 is at 305 of 600/, "the reason is still recorded"); + assert.match(result.content[0].text, /completed its verdict for the current worker view/); + // The result must name a way to end the turn. "Say nothing more" named none, and a model that + // may not write text can only call another tool, which is what session 019ffa73 did every look. + assert.match(result.content[0].text, /write one short line or no text/); + assert.doesNotMatch(result.content[0].text, /Say nothing more/); +}); + +test("a sign-off verdict is answered, not aborted, and a runaway is still cut", async () => { + // Session 019ffa73, 2026-08-13, sixteen times before 11:05Z: let_it_run "job 23 progressing normally + // (438/600 steps)", then let_it_run "waiting for job 23 to reach Evidence-b", then an empty + // assistant message with errorMessage "This operation was aborted". Both reasons were true, so + // the second call was the model signing off and the abort made a normal look look like a fault. + // Session 019ffa5f is the case the cut is still for: 645 calls. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker make the results table"); + await sup.agentStart(); + + const letItRun = sup.tools.get("let_it_run")!; + const first = await letItRun.execute("id", { reason: "job 23 at 438 of 600" }, undefined, undefined, sup.ctx); + assert.match(first.content[0].text, /job 23 at 438 of 600/); + + const again = await letItRun.execute("id", { reason: "waiting for Evidence-b" }, undefined, undefined, sup.ctx); + assert.equal(sup.aborts.length, 0, "a sign-off must not end the look in an error line"); + assert.match(again.content[0].text, /already recorded a verdict for the current worker view/); + assert.equal(again.isError, undefined, "a repeat did nothing, and doing nothing is not an error"); + + // A runaway is a count no sign-off explains. + for (const _ of [3, 4, 5]) await letItRun.execute("id", { reason: "and again" }, undefined, undefined, sup.ctx); + assert.equal(sup.aborts.length, 0, "five is still within reach of an ordinary answer"); + await letItRun.execute("id", { reason: "and again" }, undefined, undefined, sup.ctx); + assert.equal(sup.aborts.length, 1, "the sixth is a loop, so it is cut"); + + // The next view is a new look, and it gets a clean verdict of its own. + await sup.agentStart(); + await sup.tools.get("steer")!.execute("id", { message: "read the log" }, undefined, undefined, sup.ctx); + assert.equal(sup.aborts.length, 1, "the count is per look, not per pairing"); +}); + +test("every verdict result names the way to end the turn, steer included", async () => { + // Session 019ffa73 after the let_it_run wording was fixed at 11:05Z: 38 let_it_run looks ended + // cleanly, but all 22 steers ran "CALL:steer -> Steered. That is instruction N -> CALL:let_it_run + // -> Already recorded". The steer result named no exit, so a spare tool call was the only move + // left. Any result that ends a look has to carry END_TURN. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker make the results table"); + await sup.agentStart(); + + const steered = await sup.tools.get("steer")!.execute("id", { message: "read the log" }, undefined, undefined, sup.ctx); + assert.match(steered.content[0].text, /Supervisor instruction 1 was sent to worker session session-worker/); + assert.match(steered.content[0].text, /no further tool call/, "a steer result must name the exit too"); + + await sup.agentStart(); + const ran = await sup.tools.get("let_it_run")!.execute("id", { reason: "on track" }, undefined, undefined, sup.ctx); + assert.match(ran.content[0].text, /no further tool call/); +}); + +test("an old view is dropped from context once its verdict is in, and the verdict is kept", async () => { + // Session 019ffa73 over 16 hours: 80 views, 433k chars, and a context that went 37.9k -> 234.7k + // with no compaction. The views are the bulk and they go stale within minutes; the verdict + // reasons are 330 chars each and are what the supervisor actually needs to remember. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker make the results table"); + + const view = (n: number) => ({ role: "user", content: [{ type: "text", text: `The worker stopped.\n\njob ${n} running` }] }); + const judged = (n: number) => ({ + role: "assistant", + content: [{ type: "thinking", thinking: `long private reasoning about job ${n}` }, { type: "toolCall", name: "let_it_run", arguments: { reason: `job ${n} on track` } }], + }); + const verdict = (n: number) => ({ role: "toolResult", content: [{ type: "text", text: `Letting it run: job ${n} on track` }] }); + const history = [1, 2, 3, 4, 5].flatMap((n) => [view(n), judged(n), verdict(n)]); + + const sent = await sup.context([{ role: "user", content: [{ type: "text", text: "the brief" }] }, ...history]); + const flat = JSON.stringify(sent); + + assert.equal(sent[0].content[0].text, "the brief", "the policy is not a view, so it stays"); + assert.match(sent[1].content[0].text, /dropped once you had judged it/, "view 1 is old, so it goes"); + assert.doesNotMatch(flat, /reasoning about job 1/, "old thinking is raw material, not memory"); + assert.doesNotMatch(flat, /reasoning about job 2/); + assert.match(flat, /reasoning about job 5/, "the newest looks are untouched"); + + for (const n of [1, 2, 3, 4, 5]) { + assert.match(flat, new RegExp(`Letting it run: job ${n} on track`), `verdict ${n} is the memory, so it stays`); + assert.match(flat, new RegExp(`"reason":"job ${n} on track"`), `and so is the reason it was called with`); + } + // A pruned look still reads as one exchange: view stub, the verdict call, its result. + assert.equal(sent[2].content.length, 1, "only the thinking was taken out of the judged message"); + assert.equal(sent[2].content[0].type, "toolCall"); +}); + +test("a worker session never has its context rewritten", async () => { + // The pruner reads views, and only a supervisor has any. A worker running this extension must + // see its own transcript untouched. + const work = harness(WORKER_ID); + await work.start(); + const messages = [{ role: "user", content: [{ type: "text", text: "The worker stopped.\n\nlooks like a view" }] }]; + const sent = await work.context([...messages, ...messages, ...messages, ...messages]); + assert.equal(sent.length, 4); + assert.ok(sent.every((m: any) => m.content[0].text.includes("looks like a view")), "nothing was dropped"); +}); + +test("a view that arrives mid-answer starts a fresh look", async () => { + // A view sent while the supervisor is busy is queued as a followUp, and a followUp runs inside + // the agent loop already going, so agent_start does not fire again. Counting per agent run would + // charge the second view for the first, and answer it "already recorded" for a view it has not + // seen. The view branch resets the count, so the new view gets a verdict of its own. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker make the results table"); + await sup.agentStart(); + + const letItRun = sup.tools.get("let_it_run")!; + await letItRun.execute("id", { reason: "no new turns since the last look" }, undefined, undefined, sup.ctx); + + // Half an hour later, still the same agent loop. + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view: "job 24 Evidence-b queued", stopped: false }); + await new Promise((r) => setTimeout(r, 5)); + const fresh = await letItRun.execute("id", { reason: "waiting for job 24 Evidence-b to land" }, undefined, undefined, sup.ctx); + assert.match(fresh.content[0].text, /waiting for job 24 Evidence-b to land/, "a new view, so a full verdict"); + assert.doesNotMatch(fresh.content[0].text, /Already recorded/); + assert.equal(sup.aborts.length, 0); +}); + +test("a tool a worker cannot use never aborts its turn", async () => { + // The verdict tools are guarded by role. The guard returns before the count, so a stray call in + // an unpaired session can never reach the abort. + const lone = harness(SUPER_ID); + await lone.start(); + await lone.agentStart(); + for (const _ of [1, 2, 3]) { + const refused = await lone.tools.get("let_it_run")!.execute("id", { reason: "x" }, undefined, undefined, lone.ctx); + assert.equal(refused.isError, true); + } + assert.equal(lone.aborts.length, 0); +}); + +test("a resume onto a session that is gone drops the pairing and says so", async () => { + // What wassname hit: resume a supervisor, it sits idle still claiming a pairing, prints nothing, + // and refuses /supervise until you work out it wants /supervise stop. pairedId addresses a live + // process, so restoring it out of a transcript is restoring an address, not a fact. + const carried = [{ type: "custom", customType: STATE_ENTRY, data: { role: "supervisor", pairedId: "ghost-session", goal: "g", steerRounds: 3 } }]; + const resumed = harness(SUPER_ID, { entries: carried }); + await resumed.start(); + await new Promise((r) => setTimeout(r, 5)); + + assert.ok(resumed.notices.some((n) => /ghost-se is gone.*Run \/supervise/.test(n)), resumed.notices.join(" | ")); + // And the refusal is gone with it, so /supervise works straight away. + await resumed.run("supervise", "@worker do the thing"); + assert.ok(!resumed.notices.some((n) => /already paired/.test(n)), resumed.notices.join(" | ")); +}); + +test("a resume onto a live worker keeps supervising, and takes the writers back off", async () => { + const carried = [{ type: "custom", customType: STATE_ENTRY, data: { role: "supervisor", pairedId: WORKER_ID, goal: "g", steerRounds: 3 } }]; + const resumed = harness(SUPER_ID, { entries: carried }); + await resumed.start(); + await new Promise((r) => setTimeout(r, 5)); + + assert.ok(resumed.notices.some((n) => /still supervising/.test(n)), resumed.notices.join(" | ")); + // Asking beats waiting. A supervisor back from a crash or a /reload holds a stale picture, and + // only the worker makes views. Nobody should have to know that, so a restart is the whole cure. + assert.deepEqual(resumed.published.filter((p) => p.t === "look"), [{ t: "look", to: WORKER_ID }]); + // And the goal and the answer shape are said again, so a /reload is how a changed prompt lands. + // Without it, fixing the wording needs a fresh pairing, which loses the count of steers. + const anchor = resumed.contextMessages.at(-1)!.content; + assert.match(anchor, /Supervising again/); + assert.match(anchor, /\ng\n<\/goal>/); + assert.match(anchor, /3 instructions so far/); + assert.match(anchor, /one tool call: steer, done or let_it_run/); + // The strip lives in the /supervise handler, which a resume never runs. Without this the + // supervisor comes back with bash and edit in a directory the worker is writing to. + const back = resumed.pi.getActiveTools(); + assert.deepEqual(back.filter((t: string) => ["bash", "edit", "write"].includes(t)), [], "no writers"); + assert.ok(back.includes("steer") && back.includes("worker_view"), "and it can still supervise"); +}); + +test("state written before recentSteers existed still loads", () => { + const old = [{ type: "custom", customType: STATE_ENTRY, data: { role: "supervisor", pairedId: "w", goal: "g", steerRounds: 4 } }]; + const restored = restoreState(old); + assert.deepEqual(restored.recentSteers, []); + assert.equal(restored.steerRounds, 4); +}); + +test("done unpairs the worker, so it stops publishing views", async () => { + const worker = harness(WORKER_ID, { entries: [message("user", "hi")] }); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "g" }); + // Long enough for the pairing view, which waits on a real ps call. + await new Promise((r) => setTimeout(r, 300)); + const atPairing = worker.published.filter((p) => p.t === "view").length; + assert.equal(atPairing, 1, "pairing publishes a first view, so the supervisor has something to read"); + + await worker.settle(); + assert.equal(worker.published.filter((p) => p.t === "view").length, 2, "paired worker publishes"); + + worker.deliver(SUPER_ID, { t: "done", to: WORKER_ID, reason: "results.md line 3" }); + await new Promise((r) => setTimeout(r, 5)); + + await worker.settle(); + assert.equal( + worker.published.filter((p) => p.t === "view").length, + 2, + "after done the worker must not publish again", + ); +}); + +test("with no goal the supervisor cannot steer, it must ask the human", async () => { + // Seen in a real run: started with no goal, the supervisor invented a task for the worker. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker"); // a target and no goal + + const result = await sup.tools.get("steer")!.execute("id", { message: "do something" }, undefined, undefined, sup.ctx); + assert.ok(result.isError, "steering with no goal must be refused"); + assert.match(result.content[0].text, /No goal is set/); + assert.match(result.content[0].text, /set_goal/, "it must be told how to bind a goal it inferred"); +}); + +test("set_goal binds an inferred goal, and steering then works", async () => { + // The human likes goal inference at startup, but a silently invented goal is what went wrong in + // his first run. So inference has to go through a tool that announces what it chose. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "worker"); // no goal + + const set = await sup.tools + .get("set_goal")! + .execute("id", { goal: "print the numbers 1 to 3" }, undefined, undefined, sup.ctx); + assert.ok(!set.isError); + assert.match(set.content[0].text, /Tell them in your reply/); + assert.ok(sup.notices.some((n) => n.includes("print the numbers 1 to 3")), "the human must be told"); + // The worker owns the copy the view header is built from, so the goal has to go on the wire. + // Without this line the whole test passed while set_goal kept the goal to itself. + assert.deepEqual( + sup.published.filter((p) => p.t === "goal"), + [{ t: "goal", to: WORKER_ID, goal: "print the numbers 1 to 3" }], + ); + + const steer = await sup.tools.get("steer")!.execute("id", { message: "do it" }, undefined, undefined, sup.ctx); + assert.ok(!steer.isError, `steering should work once a goal is set, got ${steer.content[0].text}`); +}); + +test("a goal given at pair time still allows steering", async () => { + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker make the results table"); + + const result = await sup.tools.get("steer")!.execute("id", { message: "read the log" }, undefined, undefined, sup.ctx); + assert.ok(!result.isError, `expected a steer to be allowed, got ${result.content[0].text}`); +}); + +test("done is refused while the worker has an unanswered tool call", async () => { + // A settled worker whose subagent call has no result is still spending. "done" would be a lie. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker finish the sweep"); + // Built by buildView, not hand written, so the guard cannot drift away from the view wording. + const view = buildView({ + goal: "finish the sweep", + status: "idle", + entries: [{ type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "x", name: "subagent", arguments: {} }] } }], + }); + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view, stopped: true }); + await new Promise((r) => setTimeout(r, 5)); + + const blocked = await sup.tools.get("done")!.execute("id", { reason: "looks finished" }, undefined, undefined, sup.ctx); + assert.ok(blocked.isError, "done must be refused while a tool call has no result"); + assert.match(blocked.content[0].text, /still has work running \(subagent\)/); + + // Same guard, the other half: a subagent running as its own process leaves no unanswered call. + const detached = buildView({ goal: "finish the sweep", status: "idle", entries: [message("assistant", "all done")], subagents: [4242] }); + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view: detached, stopped: true }); + await new Promise((r) => setTimeout(r, 5)); + const stillBusy = await sup.tools.get("done")!.execute("id", { reason: "looks finished" }, undefined, undefined, sup.ctx); + assert.ok(stillBusy.isError, "done must be refused while a child pi process is running"); + assert.match(stillBusy.content[0].text, /still has work running \(4242\)/); +}); + +test("done is allowed once nothing is outstanding", async () => { + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker finish the sweep"); + const view = buildView({ goal: "finish the sweep", status: "idle", entries: [message("assistant", "all done")] }); + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view, stopped: true }); + await new Promise((r) => setTimeout(r, 5)); + + const ok = await sup.tools.get("done")!.execute("id", { reason: "results.md line 3 says X" }, undefined, undefined, sup.ctx); + assert.ok(!ok.isError, `expected done to be allowed, got ${ok.content[0].text}`); +}); + +test("steer refuses when the session is not supervising", async () => { + const lone = harness(SUPER_ID); + await lone.start(); + const result = await lone.tools.get("steer")!.execute("id", { message: "x" }, undefined, undefined, lone.ctx); + assert.ok(result.isError); +}); + +test("a reworded repeat of an earlier instruction is sent, and named back to the supervisor", async () => { + // Six remembered instructions do not stop a loop by themselves: rephrasing reads as new work. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker g"); + const steer = sup.tools.get("steer")!; + await steer.execute("id", { message: "Run the failing parser tests and fix the first failure" }, undefined, undefined, sup.ctx); + const again = await steer.execute( + "id", + { message: "Please run those parser tests again and fix whatever failure comes first" }, + undefined, + undefined, + sup.ctx, + ); + + assert.ok(!again.isError, "a repeat is never refused, because sometimes it is the right call"); + assert.match(again.content[0].text, /says much the same as instruction 1/); + assert.equal(sup.published.filter((p) => p.t === "directive").length, 2, "both were sent"); + + const fresh = await steer.execute("id", { message: "Read docs/results.md and quote the table" }, undefined, undefined, sup.ctx); + assert.doesNotMatch(fresh.content[0].text, /says much the same/); +}); + +test("overlap scores rewording high and a different instruction low", () => { + assert.ok(overlap("Run the failing parser tests and fix the first failure", "Please run those parser tests again and fix whatever failure comes first") > 0.4); + assert.ok(overlap("run the parser tests", "write the results table to docs") < 0.2); + // The limit worth knowing: a paraphrase that shares no vocabulary is invisible to this. + assert.ok(overlap("run the test suite and fix what breaks", "independently validate the implementation") < 0.1); +}); + +test("the view of the old worker cannot be used to judge the new one", async () => { + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker g"); + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view: "# Goal: old worker\n", stopped: true }); + await new Promise((r) => setTimeout(r, 5)); + await sup.run("supervise", "stop"); + await sup.run("supervise", "@worker g2"); + + const seen = await sup.tools.get("worker_view")!.execute("id", {}, undefined, undefined, sup.ctx); + assert.doesNotMatch(seen.content[0].text, /old worker/); +}); + +test("with one other session here, /supervise needs no target and the whole line is the goal", async () => { + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "make the results table"); + + const pair = sup.published.find((p) => p.t === "pair"); + assert.equal(pair.to, WORKER_ID, "the only other session in this directory is the worker"); + assert.equal(pair.goal, "make the results table", "no word of that is a target"); +}); + +test("naming the worker still works, and the rest of the line is the goal", async () => { + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker make the results table"); + assert.deepEqual(sup.published.find((p) => p.t === "pair"), { + t: "pair", + to: WORKER_ID, + goal: "make the results table", + }); +}); + +test("with two free sessions here, /supervise asks which one, and pairs with the choice", async () => { + const sup = harness(SUPER_ID, { + extraSessions: [{ id: "session-third", pid: 999, name: "other", cwd: process.cwd() }], + freeSessions: [WORKER_ID, "session-third"], + pick: 1, + }); + await sup.start(); + await sup.run("supervise", "make the results table"); + + assert.equal(sup.selects.length, 1, "guessing between two workers is worse than asking"); + assert.match(sup.selects[0].title, /make the results table/, "the goal, so you know what you are picking for"); + assert.deepEqual(sup.selects[0].options.length, 2); + assert.deepEqual( + sup.published.filter((p) => p.t === "pair"), + [{ t: "pair", to: "session-third", goal: "make the results table" }], + ); +}); + +test("a goal that is a path is read from the file, so it is not pasted every run", async () => { + // The goal is the thing the work is graded against, and pasting it into a prompt each run is how + // the copy you steer by drifts from the copy you grade by. + const dir = mkdtempSync(join(tmpdir(), "supervise-goal-")); + const path = join(dir, "GOAL.md"); + const goal = "**GOAL - preference learning.**\nEvidence a) a 256 token trace.\nEvidence b) NLL beats the hold."; + writeFileSync(path, `${goal}\n`); + + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", `@worker ${path}`); + assert.deepEqual(sup.published.filter((p) => p.t === "pair"), [{ t: "pair", to: WORKER_ID, goal }]); + + // A path that is not there stops the pairing, rather than setting the goal to the path itself. + const typo = harness(SUPER_ID); + await typo.start(); + await assert.rejects(() => typo.run("supervise", `@worker ${join(dir, "NOPE.md")}`), /NOPE\.md/); + assert.equal(typo.published.filter((p) => p.t === "pair").length, 0); + + // A goal with spaces is still a goal, even when a word in it has a dot. + const plain = harness(SUPER_ID); + await plain.start(); + await plain.run("supervise", "@worker make results.md say what won"); + assert.equal(plain.published.filter((p) => p.t === "pair")[0].goal, "make results.md say what won"); +}); + +test("a long goal is one short line above the picker, and reaches the worker whole", async () => { + // wassname's real goals are paragraphs of acceptance evidence. The whole thing as a picker title + // is a wall of text to read past, and he already knows it: he just typed it. + const goal = `**GOAL - preference learning and extrapolation.** +Learn a user's preferences online from interaction and extrapolate them: an +explicit, addressable latent user state that predicts what this user wants.`; + const sup = harness(SUPER_ID, { + extraSessions: [{ id: "session-third", pid: 999, name: "other", cwd: process.cwd() }], + freeSessions: [WORKER_ID, "session-third"], + pick: 0, + }); + await sup.start(); + await sup.run("supervise", goal); + + const title = sup.selects[0].title; + assert.ok(title.length < 100, `the title is a label, not the goal: ${title}`); + assert.match(title, /\*\*GOAL - preference learning and extrapolation\.\*\*/, "enough to tell two goals apart"); + assert.ok(!title.includes("\n"), "one line"); + // Cut for display only. What the worker judges itself against is never cut. + assert.equal(sup.published.filter((p) => p.t === "pair")[0].goal, goal); +}); + +test("a session that stayed quiet is still on the list, because 0 free is a dead end", async () => { + // Live 2026-08-13: five child runs answered nothing and the worker had not been reloaded, so a + // roll call alone left wassname with "0 free sessions" and no way through. + const sup = harness(SUPER_ID, { + extraSessions: [ + { id: "019ff8ac-1", pid: 1, name: "general-purpose#f818354d", cwd: process.cwd() }, + { id: "019ff8ac-2", pid: 2, name: "general-purpose#18c778eb", cwd: process.cwd() }, + ], + freeSessions: [], + pick: 0, + }); + await sup.start(); + await sup.run("supervise", "make the results table"); + + assert.equal(sup.selects[0].options.length, 3, "everything here, so nothing is hidden"); + assert.match(sup.selects[0].options[0], /no answer/, "and marked, so the odd ones look odd"); + assert.equal(sup.published.filter((p) => p.t === "pair").length, 1, "the pick is honoured"); +}); + +test("a cancelled picker pairs with nothing", async () => { + const sup = harness(SUPER_ID, { + extraSessions: [{ id: "session-third", pid: 999, name: "other", cwd: process.cwd() }], + freeSessions: [WORKER_ID, "session-third"], + }); + await sup.start(); + await sup.run("supervise", "make the results table"); + + assert.deepEqual(sup.published.filter((p) => p.t === "pair"), []); +}); + +test("supervising takes the writing tools away, and stopping gives them back", async () => { + // The supervisor shares a working directory with the worker, so a supervisor that can write is a + // second agent editing the same files. + const sup = harness(SUPER_ID); + await sup.start(); + const before = sup.pi.getActiveTools(); + await sup.run("supervise", "g"); + + const during = sup.pi.getActiveTools(); + assert.deepEqual( + during, + ["read", "grep", "list", "worker_view", "set_goal", "steer", "let_it_run", "done"], + "no bash, no edit, no write, and the supervisor tools appear", + ); + await sup.run("supervise", "stop"); + assert.deepEqual(sup.pi.getActiveTools(), before, "and back to what you had"); +}); + +test("stopping gives back the writers without undoing another extension's tools", async () => { + // pi-context-prune adds context_prune and pi-telegram suspends its own tools, both by reading the + // active list and changing one name (pi-context-prune/index.ts:376-388). Restoring a whole list + // saved hours earlier would silently undo whatever they decided in between. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "g"); + sup.pi.setActiveTools([...sup.pi.getActiveTools(), "context_prune"]); + + await sup.run("supervise", "stop"); + const after = sup.pi.getActiveTools(); + assert.ok(after.includes("context_prune"), "the other extension's tool is still there"); + assert.ok(after.includes("bash") && after.includes("edit") && after.includes("write"), "and the writers are back"); +}); + +test("a first word that names no session is refused, rather than folded into the goal", async () => { + // wassname typed "/supervise LUCID " when the session was named LUCID_worker. The old code + // matched nothing, fell back to the only session here, and set the goal to "LUCID ". + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@LUCID make the results table"); + + assert.deepEqual(sup.published, [], "no pairing on a target that matches nothing"); + assert.match(sup.notices.join("\n"), /0 sessions match @LUCID/); +}); + +test("a goal with spaces needs no target, and @name takes the rest of the line as the goal", async () => { + const plain = harness(SUPER_ID); + await plain.start(); + await plain.run("supervise", "make the results table and quote it"); + assert.equal(plain.published.find((p) => p.t === "pair").goal, "make the results table and quote it"); + + const named = harness(SUPER_ID); + await named.start(); + await named.run("supervise", "@worker make the results table and quote it"); + assert.deepEqual(named.published.find((p) => p.t === "pair"), { + t: "pair", + to: WORKER_ID, + goal: "make the results table and quote it", + }); +}); + +test("the brief starts no turn, so there is no answer before the first view", async () => { + // The whole loop: a user message is a turn, and a supervisor holding verdict tools with no view + // to read still answers. It called let_it_run 98 times on 2026-08-13, and twice more after the + // brief asked it not to. The brief is context now, and the view is what wakes it. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker make the results table"); + + assert.deepEqual(sup.userMessages, [], "nothing may start a supervisor turn before a view"); + const brief = sup.contextMessages.at(-1)!; + assert.match(brief.content, /You are now supervising the pi session "worker"/); + assert.deepEqual(brief.options, { triggerTurn: false }); + + // And the view is a user message, because that one is meant to be answered. + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view: "worker view", stopped: true }); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(sup.userMessages.length, 1); + assert.match(sup.userMessages[0].content, /worker view/); +}); + +test("/supervise goal changes the goal without breaking the pairing", async () => { + // The only other way is /supervise stop and a fresh pairing, which throws away the steer count. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker make the table"); + await sup.tools.get("steer")!.execute("id", { message: "run the suite" }, undefined, undefined, sup.ctx); + + await sup.run("supervise", "goal quote the evidence file instead"); + + assert.deepEqual(sup.published.filter((p) => p.t === "goal"), [ + { t: "goal", to: WORKER_ID, goal: "quote the evidence file instead" }, + ], "the worker holds the copy every view header is built from"); + assert.ok(sup.published.some((p) => p.t === "look"), "and a fresh view follows, so it judges now"); + assert.match(sup.contextMessages.at(-1)!.content, /changed the goal[\s\S]*quote the evidence file instead/); + assert.match(sup.status.get("intercom-supervisor")!, /watching 1/, "the steer count survives"); +}); + +test("the footer says which side of a pairing this session is, and clears when it ends", async () => { + // The notice at pairing scrolls away, and then a paired session looks like any other prompt. + // wassname could not tell that a supervisor had stopped supervising. + const sup = harness(SUPER_ID); + await sup.start(); + assert.equal(sup.status.get("intercom-supervisor"), undefined, "nothing to say when unpaired"); + + await sup.run("supervise", "make the table"); + assert.match(sup.status.get("intercom-supervisor")!, /watching 0/); + await sup.tools.get("steer")!.execute("id", { message: "run the suite" }, undefined, undefined, sup.ctx); + assert.match(sup.status.get("intercom-supervisor")!, /watching 1/); + + // A status set during session_start is lost, because the footer has not mounted yet. Observed + // 2026-08-12: a resumed supervisor showed nothing in wassname's footer. So set it every turn. + sup.status.clear(); + await sup.turnStart(); + assert.match(sup.status.get("intercom-supervisor")!, /watching 1/, "and again each turn"); + + await sup.run("supervise", "stop"); + assert.equal(sup.status.get("intercom-supervisor"), undefined); + + const worker = harness(WORKER_ID); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "g" }); + await new Promise((r) => setTimeout(r, 5)); + assert.match(worker.status.get("intercom-supervisor")!, /watched/, "the worker says so too"); +}); + +test("a session that is not supervising never sees the supervisor tools", async () => { + // Observed 2026-08-12: a worker on an ordinary coding task read its own tool list, reasoned + // "these are supervisor tools ... so I might be the supervisor for a worker session", and spent + // twelve turns on that before doing any work. + const worker = harness(WORKER_ID, { entries: [message("user", "do the thing")] }); + await worker.start(); + assert.deepEqual( + worker.pi.getActiveTools().filter((t: string) => ["worker_view", "set_goal", "steer", "let_it_run", "done"].includes(t)), + [], + "an unpaired session is not offered any of them", + ); + + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "g" }); + await new Promise((r) => setTimeout(r, 5)); + assert.ok(!worker.pi.getActiveTools().includes("steer"), "and a paired worker is not either"); +}); + +test("worker_view refuses when there is no worker, rather than implying a pairing", async () => { + // The old answer, "the worker has not stopped since pairing", told a session with no pairing at + // all that it had one. + const worker = harness(WORKER_ID); + await worker.start(); + const seen = await worker.tools.get("worker_view")!.execute("id", {}, undefined, undefined, worker.ctx); + assert.equal(seen.isError, true); + assert.match(seen.content[0].text, /Not supervising/); + assert.doesNotMatch(seen.content[0].text, /since pairing/); +}); + +test("the view names the worker's model and how full its context is", async () => { + // A supervisor steering a small model should give smaller steps, and a worker near the top of + // its context is about to compact and lose detail. Both come off the intercom presence record. + const worker = harness(WORKER_ID, { entries: [message("user", "do the thing")] }); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "g" }); + await new Promise((r) => setTimeout(r, 5)); + await worker.settle(); + + const view = worker.published.find((p) => p.t === "view"); + assert.match(view.view, /model: test\/tiny, 12% of its context used/); +}); + +test("supervising a second session is refused while the first is still paired", async () => { + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker g"); + const before = sup.published.length; + await sup.run("supervise", "@worker other goal"); + + assert.equal(sup.published.length, before, "no second pair goes out"); + assert.match(sup.notices.join("\n"), /already paired/); +}); + +test("the supervisor gets a look at a working worker every half hour, without being asked", async (t) => { + // A human supervising wanders past now and then. Waiting for the worker to stop is the thing + // this is meant to replace. + t.mock.timers.enable({ apis: ["setInterval", "Date"] }); + const worker = harness(WORKER_ID, { entries: [message("user", "do the thing")], isIdle: false }); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "g" }); + await new Promise((r) => setTimeout(r, 300)); // pairing publishes a view, and that runs ps + const atPairing = worker.published.filter((p) => p.t === "view").length; + + await worker.turnStart(); + t.mock.timers.tick(1_500_000); + assert.equal(worker.published.filter((p) => p.t === "view").length, atPairing, "25 minutes in is too early"); + + t.mock.timers.tick(400_000); + await new Promise((r) => setTimeout(r, 300)); // the look runs ps, so give the real clock a moment + const views = worker.published.filter((p) => p.t === "view").slice(atPairing); + assert.equal(views.length, 1, "past half an hour the supervisor gets a look"); + assert.equal(views[0].stopped, false, "this worker is mid-turn, so it is a check in"); + assert.match(views[0].view, /status: working, routine check in, no new turn for \d+[smh]/); + // A look that forgets the subagent line reports "none" and unblocks done while one is running. + assert.match(views[0].view, /^child pi processes still running: /m); + + await worker.settle(); + const after = worker.published.filter((p) => p.t === "view").length; + t.mock.timers.tick(600_000); + await new Promise((r) => setTimeout(r, 300)); // let any look that did start finish, so it counts + assert.equal(worker.published.filter((p) => p.t === "view").length, after, "ten minutes is still too early"); +}); + +test("a human message in the worker session is not a reason to stand back", async () => { + // The reason the supervisor gave for the 2h27m silence was "human is actively directing". + // wassname: "err no if I send a message it doesn't mean the supervisor should stop, I will stop + // them if I need to". So this must be said in both places: the brief, and the let_it_run + // description, which is the only one of the two that survives a supervisor compaction. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker make the results table"); + + assert.match(sup.contextMessages.at(-1)!.content, /not a handover, and it is not a reason to stand back/); + assert.match(sup.tools.get("let_it_run")!.description, /A human message does not end supervision/); + + // And on the view that carries a stopped worker, where the excuse actually got used. + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view: "worker view", stopped: true }); + await new Promise((r) => setTimeout(r, 5)); + assert.match(sup.userMessages[0].content, /human being present does not count as somebody driving it/); +}); + +test("letting a stopped worker run says plainly that the worker stays stopped", async () => { + // The other half of the two and a half hour silence. The supervisor answered let_it_run to a + // stopped worker while saying it was "waiting for the worker to re-queue". Nothing was going to + // re-queue. The result now says so, rather than the model having to know it. + const sup = harness(SUPER_ID); + await sup.start(); + await sup.run("supervise", "@worker make the results table"); + const letItRun = sup.tools.get("let_it_run")!; + + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view: "job 12 at step 40", stopped: false }); + await new Promise((r) => setTimeout(r, 5)); + const working = await letItRun.execute("id", { reason: "on track" }, undefined, undefined, sup.ctx); + assert.doesNotMatch(working.content[0].text, /does not start again by itself/, "a working worker needs no warning"); + + sup.deliver(WORKER_ID, { t: "view", to: SUPER_ID, view: "job 12 finished", stopped: true }); + await new Promise((r) => setTimeout(r, 5)); + const stopped = await letItRun.execute("id", { reason: "waiting for the worker to re-queue" }, undefined, undefined, sup.ctx); + assert.match(stopped.content[0].text, /A stopped worker does not resume\s+without a new user or supervisor message/); + assert.match(stopped.content[0].text, /send a concrete continuation\s+instruction/, "the warning names the action that can move the worker"); +}); + +test("a stopped worker is looked at again, so let_it_run cannot silence the pairing", async (t) => { + // Session 019ffa73, 2026-08-14. The worker stopped at 02:02:18Z. The supervisor saw the stop and + // answered let_it_run, "waiting for the worker to re-queue in the main group". Nothing then woke + // either side for two and a half hours, until wassname typed "why stop". let_it_run means do + // nothing, and doing nothing is exactly what a stopped worker does. + t.mock.timers.enable({ apis: ["setInterval", "Date"] }); + const worker = harness(WORKER_ID, { entries: [message("user", "do the thing")], isIdle: true }); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "g" }); + await new Promise((r) => setTimeout(r, 300)); + + await worker.turnStart(); + await worker.settle(); // the worker stops, and the supervisor answers let_it_run: no reply comes back + const afterStop = worker.published.filter((p) => p.t === "view").length; + + t.mock.timers.tick(1_900_000); + await new Promise((r) => setTimeout(r, 300)); + const views = worker.published.filter((p) => p.t === "view").slice(afterStop); + assert.equal(views.length, 1, "half an hour later the supervisor is asked about the stopped worker again"); + assert.equal(views[0].stopped, true, "and is told the worker is still stopped"); + // The status line carries the one number that says how bad the silence is. wassname's ask after + // the live incident: "be clear there have been no turns for this long, is it stuck". + assert.match(views[0].view, /status: stopped, looked at again, no new turn for \d+[smh]/); + + // Unchanged is not a reason to stay quiet here: an unchanged stopped worker is the thing to fix. + t.mock.timers.tick(1_900_000); + await new Promise((r) => setTimeout(r, 300)); + assert.equal(worker.published.filter((p) => p.t === "view").length, afterStop + 2, "and again after that"); +}); + +test("a worker that pairs at the prompt and never takes a turn is still watched", async (t) => { + // The same silence reached from the other end. The timer used to start only on turn_start, so a + // worker paired or reloaded while sitting at the prompt had no timer at all. Seen live on + // 2026-08-14: both sessions reloaded, the supervisor asked for a view, and nothing came back. + t.mock.timers.enable({ apis: ["setInterval", "Date"] }); + const worker = harness(WORKER_ID, { entries: [message("user", "do the thing")], isIdle: true }); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "g" }); + await new Promise((r) => setTimeout(r, 300)); + const atPairing = worker.published.filter((p) => p.t === "view").length; + + // No turnStart and no settle: this worker has done nothing since it was paired. + t.mock.timers.tick(1_900_000); + await new Promise((r) => setTimeout(r, 300)); + assert.equal( + worker.published.filter((p) => p.t === "view").length, + atPairing + 1, + "half an hour after pairing the supervisor is shown the worker, with no turn having happened", + ); +}); + +test("a worker that reloads at the prompt starts watching itself again", async (t) => { + // rejoinOrDrop is the only thing that runs on a reloaded worker that then sits idle. Without the + // timer start there, /reload silently ends the watching, which is the worst place to lose it: + // /reload is how a fix reaches a running pairing. + t.mock.timers.enable({ apis: ["setInterval", "Date"] }); + const carried = [{ type: "custom", customType: STATE_ENTRY, data: { role: "worker", pairedId: SUPER_ID, goal: "g", steerRounds: 0 } }]; + const worker = harness(WORKER_ID, { entries: [...carried, message("user", "do the thing")], isIdle: true }); + await worker.start(); + await new Promise((r) => setTimeout(r, 300)); + const atReload = worker.published.filter((p) => p.t === "view").length; + + t.mock.timers.tick(1_900_000); + await new Promise((r) => setTimeout(r, 300)); + assert.equal( + worker.published.filter((p) => p.t === "view").length, + atReload + 1, + "half an hour after the reload the supervisor is shown the idle worker", + ); +}); + +test("a timer look at a worker that has not moved is not sent, until it has been skipped three times", async (t) => { + // Session 019ffa73: 13 of 92 verdicts were the supervisor reading a view it had already read, + // e.g. "check-in with no new turns; worker still working, no evidence of trouble". An identical + // view carries no new information, so asking for a verdict on one only spends a model call. + t.mock.timers.enable({ apis: ["setInterval", "Date"] }); + const entries = [message("user", "do the thing")]; + const worker = harness(WORKER_ID, { entries, isIdle: false }); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "g" }); + await new Promise((r) => setTimeout(r, 300)); + await worker.turnStart(); + + const look = async () => { + t.mock.timers.tick(1_900_000); + await new Promise((r) => setTimeout(r, 300)); // the look runs ps on the real clock + return worker.published.filter((p) => p.t === "view").length; + }; + + const first = await look(); + assert.equal(await look(), first, "nothing changed, so the supervisor is not woken"); + assert.equal(await look(), first, "still nothing"); + assert.equal(await look(), first, "three skipped is the limit"); + assert.equal(await look(), first + 1, "a worker that has not moved for hours is worth seeing"); + + // The count resets, so the next unchanged look is skipped again rather than sent. + assert.equal(await look(), first + 1, "skipping starts over after the forced look"); + + entries.push(message("assistant", "I read the log and queued job 12.")); + assert.equal(await look(), first + 2, "a real new turn always goes out"); +}); + +test("the worker counts reviews in a row where nothing changed", async () => { + const entries = [message("user", "do the thing")]; + const worker = harness(WORKER_ID, { entries }); + await worker.start(); + worker.deliver(SUPER_ID, { t: "pair", to: WORKER_ID, goal: "g" }); + await new Promise((r) => setTimeout(r, 300)); // the pairing view runs ps + + await worker.settle(); + entries.push(message("assistant", "I will get to that shortly.")); + await worker.settle(); + entries.push(message("assistant", "Yes, I agree that is the right approach.")); + await worker.settle(); + + // The pairing view is not a review, so it is dropped here and does not count. + const views = worker.published.filter((p) => p.t === "view").slice(1); + assert.doesNotMatch(views[0].view, /reviews in a row/, "the first review has nothing to compare against"); + assert.match(views[2].view, /no new file or commit for 2 reviews in a row/); + + entries.push({ + type: "message", + message: { role: "assistant", content: [{ type: "toolCall", id: "w", name: "write", arguments: { path: "results.md" } }] }, + }); + await worker.settle(); + const after = worker.published.filter((p) => p.t === "view").at(-1)!; + assert.doesNotMatch(after.view, /reviews in a row/, "real work must clear the count, not just pause it"); +}); + +test("an unacknowledged pair gives up, and a takeover cancels that timer", async (t) => { + // A supervisor waiting for an acknowledgment can itself be taken over as a worker. The timer it + // armed then fired against the new partner and blamed a session no longer involved. + t.mock.timers.enable({ apis: ["setTimeout"] }); + + // Named with @, so /supervise skips the roll call. Its wait is a real setTimeout, which the + // mocked clock here would never fire, and the command would hang instead of pairing. + const lonely = harness(SUPER_ID); + await lonely.start(); + await lonely.run("supervise", "@worker"); // nobody will acknowledge + t.mock.timers.tick(20_000); + assert.deepEqual(lonely.published.at(-1), { t: "unpair", to: WORKER_ID }, "it must let the worker go"); + assert.match(lonely.notices.join("\n"), /never acknowledged/); + + const takenOver = harness(WORKER_ID); + await takenOver.start(); + await takenOver.run("supervise", "@supervisor"); // arms a timer nobody will answer + takenOver.deliver("session-third", { t: "pair", to: WORKER_ID, goal: "g" }); + await Promise.resolve(); + const sent = takenOver.published.length; + t.mock.timers.tick(20_000); + assert.equal(takenOver.published.length, sent, "no unpair may go out after the takeover"); + + takenOver.deliver("session-third", { t: "directive", to: WORKER_ID, text: "carry on" }); + await Promise.resolve(); + assert.deepEqual( + takenOver.userMessages.filter((m) => m.content.startsWith("[supervisor]")).map((m) => m.content), + ["[supervisor] carry on"], + "the pairing must still work", + ); +}); + +test("duplicate standalone Intercom registries are diagnosed and cannot bootstrap a plan", async () => { + const h = harness(WORKER_ID); + await h.start(); + h.pi.events.emit(INTERCOM_EXTENSION_REGISTRY_READY_EVENT, { version: 1 }); + await assert.rejects(new Promise((resolve, reject) => h.pi.events.emit("pi-supervise:plan:v1", { version: 1, method: "status", resolve, reject })), /Duplicate standalone pi-intercom/); + assert.ok(h.notices.some(note => note.includes("Remove the old companion"))); +}); diff --git a/test/internal-supervisor/plan.test.ts b/test/internal-supervisor/plan.test.ts new file mode 100644 index 0000000..4b0baad --- /dev/null +++ b/test/internal-supervisor/plan.test.ts @@ -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(); const listeners = new Map>(); const tools = new Map(); + const messages: any[] = []; const contexts: any[] = []; const commands = new Map(); + 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((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(); } +}); diff --git a/test/internal-supervisor/subagents.test.ts b/test/internal-supervisor/subagents.test.ts new file mode 100644 index 0000000..f1efd68 --- /dev/null +++ b/test/internal-supervisor/subagents.test.ts @@ -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(); +}); diff --git a/test/internal-supervisor/view.test.ts b/test/internal-supervisor/view.test.ts new file mode 100644 index 0000000..adff6b9 --- /dev/null +++ b/test/internal-supervisor/view.test.ts @@ -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 }> = []): 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, /\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, /\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`); +}); diff --git a/test/prompts.test.ts b/test/prompts.test.ts index da62fbd..eb8efc5 100644 --- a/test/prompts.test.ts +++ b/test/prompts.test.ts @@ -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 q’s 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"); diff --git a/test/role-models.test.ts b/test/role-models.test.ts new file mode 100644 index 0000000..cb59d72 --- /dev/null +++ b/test/role-models.test.ts @@ -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" }); +}); diff --git a/test/rpc-review.test.ts b/test/rpc-review.test.ts index 9c6a710..93a49bd 100644 --- a/test/rpc-review.test.ts +++ b/test/rpc-review.test.ts @@ -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(); diff --git a/test/rpc-supervisor.test.ts b/test/rpc-supervisor.test.ts index 7bbacb8..8293a1c 100644 --- a/test/rpc-supervisor.test.ts +++ b/test/rpc-supervisor.test.ts @@ -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(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); }); diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..ade8a8b --- /dev/null +++ b/test/setup.ts @@ -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(); }); diff --git a/test/supervisor-integration.test.ts b/test/supervisor-integration.test.ts index 16bffcb..f93fb19 100644 --- a/test/supervisor-integration.test.ts +++ b/test/supervisor-integration.test.ts @@ -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(); const commands = new Map(); const tools = new Map(); const listeners = new Map>(); 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(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"]); diff --git a/test/supervisor.test.ts b/test/supervisor.test.ts index a186b3c..d4b2f00 100644 --- a/test/supervisor.test.ts +++ b/test/supervisor.test.ts @@ -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", ""); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..61b2099 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@sting8k/pi-vcc/src/*": [ + "./src/internal/supervisor/vcc.d.ts" + ] + } + } +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..6b46904 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ test: { include: ["test/*.test.ts"], setupFiles: ["test/setup.ts"] } });