From a816af821774bef1057ccebf9d7bcbc9a9b6a68c Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Thu, 6 Aug 2026 22:38:07 -0600 Subject: [PATCH 1/7] docs(plans): add ask-user question protocol correction plan Diagnoses why @deuce's questions are unanswerable: Deuce's ask_user implementation was written against a guessed Pi RPC wire format. All three question styles fail differently, and the drawer scrolls sideways on long rows. Co-Authored-By: Claude Opus 5 (1M context) --- ...001-fix-ask-user-question-protocol-plan.md | 486 ++++++++++++++++++ 1 file changed, 486 insertions(+) create mode 100644 docs/plans/2026-08-06-001-fix-ask-user-question-protocol-plan.md diff --git a/docs/plans/2026-08-06-001-fix-ask-user-question-protocol-plan.md b/docs/plans/2026-08-06-001-fix-ask-user-question-protocol-plan.md new file mode 100644 index 0000000..79410e1 --- /dev/null +++ b/docs/plans/2026-08-06-001-fix-ask-user-question-protocol-plan.md @@ -0,0 +1,486 @@ +--- +title: Ask-User Question Protocol Correction - Plan +type: fix +date: 2026-08-06 +deepened: 2026-08-06 +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-plan-bootstrap +execution: code +--- + +# Ask-User Question Protocol Correction - Plan + +## Goal Capsule + +**Objective.** Make `@deuce`'s questions answerable from the agent thread drawer. Correct Deuce's `ask_user` implementation against Pi's published RPC contract so a question reaches the drawer as an answerable prompt and the user's answer reaches the agent intact, for all three question styles. Fix the horizontal overflow in the agent thread. The main chat composer remains a non-answering surface — see Scope Boundaries for the consequence that carries. + +**Authority hierarchy.** Pi's published types in `@earendil-works/pi-coding-agent` are the protocol authority — where this plan and those types disagree, the types win and the disagreement is a finding worth reporting. Within that constraint, R-IDs govern product behavior and KTD-IDs govern mechanism. + +**Execution profile.** Contract-first. The wire shapes in this plan were read from Pi 0.84.0's own `.d.ts` declarations and RPC implementation, not inferred. Re-verify against the version actually installed in the container before changing decoding logic; the shape is stable across 0.74.0–0.84.0, but the check is cheap and the last round of guessing is what produced this bug. + +**Stop conditions.** Stop and report if the installed Pi's `RpcExtensionUIRequest` / `RpcExtensionUIResponse` unions differ from those recorded in Sources & Research — the plan's decoding decisions rest on them. Stop if correcting the response shape requires changing how the drawer sends answers; that would widen scope into the frontend answer path this plan holds fixed. + +**Tail ownership.** Standalone run owns commit and PR. + +--- + +## Product Contract + +### Summary + +Repair the `ask_user` round trip end to end: the Pi extension's dialog calls, the Go decoding of Pi's UI-request events, and the shape of the answer Deuce sends back. Cover all three question styles rather than only the one that hangs. Separately, contain long rows in the agent thread so a question no longer scrolls the panel sideways. + +### Problem Frame + +When `@deuce` asks a question, the user cannot answer it and the run dies on a timeout. + +Deuce gives Pi a blocking `ask_user` tool through a hand-rolled extension. The agent calls it, Pi emits an `extension_ui_request` on the RPC stream, and Deuce is meant to turn that into an `awaiting_input` task the user answers from the agent thread drawer. The state machine, the WebSocket event family, the store reducer, and the drawer's answer controls are all correctly built and wired. + +The wire format between them was never verified. `server/internal/agent/pirun/decoder.go` carries the admission in its own source: *"The exact extension_ui_request shape is pinned when the ask-user extension lands; decode best-effort by id + common prompt/kind keys."* The originating plan flagged the same gap as an accepted risk (`docs/plans/2026-06-08-001-feat-interactive-agent-questions-plan.md`, "Rich choices depend on verifying the live Pi `ctx.ui` API"). The tests then closed the loop on the assumption rather than on reality — `server/internal/agent/pirun/decoder_test.go` feeds the decoder the exact shape the decoder guesses, under a comment noting the event is absent from the captured golden stream. + +Reading Pi's published types shows the guess is wrong in three independent ways, and each question style fails differently: + +- **Pick-one hangs.** The extension calls Pi's `select` with the question in the argument slot that expects the options array. Pi emits `options` as a string. Go unmarshals it into `[]string`, gets a type error, and drops the entire line. No `awaiting_input` ever fires, so the active-work timeout is never suspended and kills the task after ten minutes. This is the reported symptom. +- **Free text arrives blank.** Pi carries the prompt in `title` / `placeholder`. Go reads `prompt` and `params.prompt`, which do not exist in Pi's protocol, so the question text is empty. +- **Yes/no always answers "no", and asks blankly.** Pi expects `confirmed` as a boolean; Deuce sends `response` as a string, so Pi's parser finds no `confirmed` key and falls back to `false`, discarding what the user clicked. The request side is broken too: Pi puts the question in a top-level `message` while Go reads only the nested `params.message`, so the prompt is empty as well. + +The same discarding applies to the other styles: Pi's response union has no `response` field at all, so even a correctly decoded question would be answered with an empty value. + +Two adjacent defects share the surface. Deuce treats every `extension_ui_request` as a blocking question, but five of Pi's nine UI methods are fire-and-forget notifications and status updates — `npm:pi-subagents` is installed in every workspace and any such call would wedge a task in "needs your input". And the drawer's action log rows do not constrain their width, so a prose-length question makes the whole thread scroll sideways. + +### Requirements + +**Question delivery** + +- R1. A question the agent asks reaches the user as an answerable prompt, for free-text, pick-one, and yes/no styles. +- R2. The prompt carries the agent's question text. An empty question is a failure, not a degraded pass. +- R3. A Pi UI call that is not a question never moves a task to `awaiting_input`. +- R4. While a question is pending, the active-work timeout stays suspended and the unanswered-question ceiling applies instead. + +**Answer delivery** + +- R5. A free-text or pick-one answer reaches the agent as the text the user typed or the option they chose. +- R6. A yes/no answer reaches the agent as the boolean the user picked. +- R7. Typed free text answers a pick-one question unchanged, preserving the existing "or type another answer below" path. + +**Robustness and observability** + +- R8. A UI-request line Deuce cannot decode is reported with enough context to identify it, never silently dropped. +- R9. Extension failures Pi reports are surfaced in the server log. +- R10. A dialog Deuce never answers is released by Pi's own timeout, so a lost response cannot block the agent process indefinitely. Pi's timer is a backstop behind Deuce's ceiling, never ahead of it — see KTD7. +- R13. A dialog that times out or is cancelled returns an explicit "no answer received" result to the agent, never a substantive value. Pi resolves an expired confirm to `false` and an expired select or input to `undefined`; delivered raw, those are indistinguishable from a real answer. + +**Presentation** + +- R11. Long question text does not scroll the agent thread horizontally. +- R12. A long tool argument truncates within its row rather than widening it. + +### Acceptance Examples + +- AE1. **Covers R1, R5.** Given the agent asks a pick-one question with three options, when the user opens the drawer and clicks the second option, then the agent receives that option's label as the tool result. +- AE2. **Covers R2.** Given the agent asks a free-text question, when the drawer renders the pending prompt, then the prompt shows the question text rather than an empty line. +- AE3. **Covers R6.** Given the agent asks a yes/no question, when the user clicks No, then the agent receives `false`; when the user clicks Yes, then the agent receives `true`. +- AE4. **Covers R3.** Given an installed extension emits a progress notification, when Deuce decodes it, then the task stays `running` and no pending question appears. +- AE5. **Covers R4.** Given a question has been pending for longer than the active-work timeout, when the ceiling has not yet elapsed, then the task is still `awaiting_input` and answerable. +- AE6. **Covers R11, R12.** Given an action-log row holds a 400-character question, when the drawer renders it, then the thread scrolls vertically only and the row truncates. +- AE7. **Covers R13.** Given a question's dialog times out or is cancelled, when the tool returns to the agent, then the result says no answer was received rather than delivering `false` or an empty string. +- AE8. **Covers R6.** Given a yes/no question is pending, when the user types free text into the drawer composer instead of clicking a button, then an affirmative reply reaches the agent as `true` and a reply matching neither token set is logged and delivered as `false`. +- AE9. **Covers R11.** Given a pending question contains a long unbroken token such as a file path, when the drawer renders the prompt block and its option buttons, then the text wraps and the thread does not scroll horizontally. + +### Scope Boundaries + +- Answering stays in the agent thread drawer. The main chat composer continues to post a message rather than answer a pending question. + + **Known consequence, accepted for this change.** An `@deuce` message sent while a question is pending is enqueued *behind* the blocked task — the running-task lookup counts `awaiting_input` as busy, so promotion is refused until the question resolves or the ceiling fires. The queued message then runs as a fresh prompt with no question context. A user who answers in the composer therefore still experiences "no way to answer," which is the originally reported symptom. This is a real user-visible failure, not a missing convenience; it is accepted here because the fix belongs to the chat surface rather than the protocol. Mitigation deferred below. +- The frontend answer path is held fixed. `QuestionControls` keeps sending `"yes"` / `"no"` strings; the mapping to Pi's boolean happens server-side. +- The existing narrated-question backstop in `server/internal/agent/question_backstop.go` stays as is. It handles a different failure — the model writing the call into its reply text instead of invoking the tool. + +#### Deferred to Follow-Up Work + +- Letting the main chat composer answer a pending question. A real gap in the answering surface, and the more likely place a user reaches first, but a product decision about the chat surface rather than a protocol correction. +- A cheaper interim mitigation for the queue jam above: post a session system notice when an `@deuce` message arrives while a question is pending, pointing the user at the drawer instead of silently queueing. Uses the existing system-notice path and does not require deciding the composer's answering semantics. +- Adopting `npm:pi-subagents`' companion `npm:pi-ask-user` in place of the hand-rolled extension. Revisit if maintaining the extension against Pi's contract proves costly. +- Sending `cancelled: true` when a run is stopped while a question is pending. Today the stop path tears the process down, so Pi's dialog dies with it. +- Tuning the ten-minute active and thirty-minute await timeouts. Their values were never the bug. + +--- + +## Planning Contract + +### Key Technical Decisions + +- KTD1. **Repair the hand-rolled extension rather than adopting Pi's published `pi-ask-user`.** (session-settled: user-approved — chosen over adopting `npm:pi-ask-user`: Deuce needs control over the `kind`/`options` surface the drawer renders and over the tool description that steers the model toward asking rather than guessing.) Governs R1, R5, R6. + +- KTD2. **Decode `extension_ui_request` against Pi's published union rather than probing for plausible keys.** Pi's request type is a flat nine-arm discriminated union keyed on `method`, with no nesting anywhere. The current `params.*` fallbacks are dead code against every arm. Replacing key-probing with the real arms removes the class of bug rather than the instance — the empty-prompt failure and the dropped-line failure both trace to guessing. + +- KTD3. **Make the response method-aware in Go, and map yes/no to a boolean server-side.** Pi's response is a three-arm union: `value` for select, input, and editor; `confirmed` for confirm; `cancelled` for any. Choosing the arm requires knowing the method that opened the dialog, so the runtime's pending-request tracking must carry the method alongside the request id. Mapping `"yes"` / `"no"` to a boolean in Go keeps the drawer's answer path untouched and keeps the protocol knowledge on the side of the wire that owns it. + +- KTD4. **Classify Pi's fire-and-forget UI methods as ignorable at the decoder, not downstream.** Four blocking methods are questions; five are notifications, status, widget, title, and editor-text updates that carry an id but must never be answered. Deciding this at the decoder keeps the runtime's `KindAwaitingInput` branch meaning exactly one thing. + +- KTD5. **Truncate on the flex item itself, not on the inline span inside it.** `overflow` and `text-overflow` do not apply to a non-replaced inline box, so the existing declarations on `.q-act .arg` are inert and adding `min-width: 0` alone would not produce an ellipsis — the nowrap text would simply spill out of the shrunken wrapper and keep contributing scrollable overflow. The `.tc-q .info` precedent does not transfer: there the ellipsis sits on a block-level `.l2`. The fix is therefore box-type-driven — move truncation onto whichever element is a flex item. In the action log that means classing the wrapper span; on the task card `.tc-live .arg` is already a direct flex child and gets blockified, so `min-width: 0` is sufficient there. Prose-bearing surfaces (the pending-question block, choice buttons) wrap instead of truncating, because a question the user must read to answer cannot be clipped. + +- KTD6. **Derive the test fixtures from Pi's published types and vendor a contract fixture.** The current fixtures assert the decoder's own assumption, so they pass while the product fails — a rewrite that keeps that pattern would ship the same class of bug again. Fixtures must be traceable to the published union, and the arms Deuce depends on belong in a checked-in fixture that a Pi upgrade can be diffed against. + +- KTD7. **Pass Pi a dialog timeout above Deuce's ceiling, plus the tool's abort signal — as defense in depth, not as the primary release.** Deuce's 30-minute ceiling already reclaims the process: `failTaskAsync` finalizes with teardown, which stops the supervisor's Pi process (SIGTERM then SIGKILL). The Pi-side timer matters only when that path does not run. Its value is therefore constrained rather than free: **Pi's dialog timeout must never fire before Deuce's awaiting-input ceiling.** If it fires first, Pi resolves the dialog with its own default — `false` for confirm, `undefined` for select and input — and the model receives a fabricated answer while the drawer still shows the question as answerable. R13 covers what the tool returns in that case; this decision covers the ordering that makes it rare. + +### High-Level Technical Design + +The round trip, with the three break points marked: + +```mermaid +sequenceDiagram + participant M as Model + participant E as ask-user extension + participant P as Pi (rpc mode) + participant G as Deuce runtime + participant U as User (drawer) + + M->>E: ask_user(question, kind, options) + Note over E: BREAK 1 — select() called
with wrong argument order + E->>P: ctx.ui.select / confirm / input + P->>G: extension_ui_request (flat, method-keyed) + Note over G: BREAK 2 — decoder reads
prompt/params.*, which do not exist + G->>U: task_awaiting_input + pendingQuestion + U->>G: answer (steer) + Note over G: BREAK 3 — response sent as
"response", not value/confirmed + G->>P: extension_ui_response + P->>E: resolves dialog promise + E->>M: tool result +``` + +Pi's request arms and the response each requires. The four blocking methods are questions; the rest are not. + +| `method` | Blocking | Prompt text lives in | Response arm | +|---|---|---|---| +| `select` | yes | `title` (+ `options[]`) | `value` — the chosen label, not an index | +| `confirm` | yes | `title` + `message` | `confirmed` — boolean | +| `input` | yes | `title` (+ `placeholder`) | `value` | +| `editor` | yes | `title` (+ `prefill`) | `value` | +| `notify` | no | — | none — never answer | +| `setStatus` | no | — | none | +| `setWidget` | no | — | none | +| `setTitle` | no | — | none | +| `set_editor_text` | no | — | none | + +Pi's select dialog has no separate body field, so a question rendered through `select` carries its text in `title`. Cancellation resolves to `undefined` for the value arms and `false` for confirm. + +### System-Wide Impact + +The decoder is the single funnel for every Pi event, so three of these changes reach past the question path. + +- **Every installed extension shares the UI-request path.** `npm:pi-subagents` is installed in each workspace alongside ask-user. Any UI call it makes today would raise a pending question and wedge the task; after U2 only its blocking dialogs can, and its notifications and status updates pass through. This is a behavior change for subagent runs, not only for `ask_user`. +- **Decoding `extension_error` introduces a new event kind.** The runtime's consumer switch must handle it or leave it to the default branch. U4 adds the kind; confirm the runtime does not treat an unhandled kind as a task-affecting event. +- **Extension and server version skew is reachable during rollout.** The extension is embedded in the Go binary and pushed into the container, but the prebuild image bakes its own copy keyed on the devcontainer hash. A workspace started from a stale image runs the old extension against the new decoder. U2's tolerant options decoding is the rollout guard, and it must recover the question *text*, not merely the line — see U2 step 5 for the derivation and why deriving from `title` would violate R2. +- **The overflow fix reaches every action-log row and task card**, for all tools. That is the intended scope (R12), not a side effect of the question fix. + +The answer-routing lock, the seq-ordered event family, and the task state machine are unchanged. No migration, no auth surface, and no persisted-data change. + +### Risks & Dependencies + +- **Pi version drift.** The plan rests on the request and response unions in Pi 0.84.0. They are unchanged across 0.74.0–0.84.0, so drift is unlikely, but the containers pull Pi at build time and the prebuild cache can hold an older image. U6's fixture is the guard: a diff against it localizes a future break to the protocol rather than to the product. +- **`select` prompt length.** Routing the question into `title` is what Pi's contract allows. A long question may render awkwardly in a Pi-native TUI client; Deuce renders the question from its own state, so its drawer is unaffected. +- **The prebuild cache carries the old extension.** `DEUCE_PREBUILD_REPOSITORY` bakes the extension into a tagged image keyed on the devcontainer hash, not on Deuce's own source. Verifying the fix in a live session may require a workspace rebuild rather than a restart. +- **Editor method is unused today.** The extension never opens an editor dialog. Decoding its arm costs little and avoids an unhandled question style if the extension later grows one. + +### Sources & Research + +Read from the published package, unpacked from `https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.0.tgz`. Paths below are inside that package. + +- `dist/modes/rpc/rpc-types.d.ts` — `RpcExtensionUIRequest` (nine arms) and `RpcExtensionUIResponse` (three arms). The definitive contract for U2 and U3. +- `dist/core/extensions/types.d.ts` — `ExtensionUIContext` signatures: `select(title, options, opts)`, `confirm(title, message, opts)`, `input(title, placeholder?, opts)`, `editor(title, prefill?)`. There is no `(title, prompt)` form; this is the argument-order defect U1 fixes. +- `dist/modes/rpc/rpc-mode.js` — request emission spreads the payload flat after `type` and `id`; per-method response parsers show select returns the label, confirm falls back to `false`, and the stdin dispatcher correlates on `type` + `id` only, so an unrecognized payload key resolves to the fallback rather than erroring. +- `dist/core/extensions/runner.js` — `hasUI()` is true in rpc mode; all four blocking dialog methods exist there. The extension's `typeof ui.select === "function"` probe is therefore always true and its `input` fallback is unreachable. +- `docs/rpc.md` — extension UI protocol, `extension_error` shape, and the strict LF-only JSONL framing rule (Go's `bufio.Scanner` is compliant). +- `docs/extensions.md` — mode/`hasUI` table; tool `execute` errors surface as `tool_execution_end` with `isError: true`, not as `extension_error`. + +Repo context: + +- `docs/plans/2026-06-08-001-feat-interactive-agent-questions-plan.md` — the originating plan, including the KTD that recorded the `ctx.ui` shape as unverified. +- `docs/solutions/architecture-patterns/pi-loads-agent-skills-standard-in-rpc-mode.md` — establishes that Pi vendors its own docs inside the npm package, which is how the contract above was recovered. + +No matching issues exist in `github.com/earendil-works/pi` for the extension UI protocol. The failure is entirely on the Deuce side. + +--- + +## Implementation Units + +### U1. Correct the extension's dialog calls + +**Goal.** Make the extension call Pi's dialog methods with the signatures Pi publishes, so Pi emits well-formed requests for every question style. + +**Requirements:** R1, R2, R10, R13. Implements KTD1, KTD7. + +**Dependencies:** U6 (assert against its fixture rather than inventing literals). + +**Files:** +- `server/internal/agent/pirun/extension/ask-user.ts` +- `server/internal/agent/pirun/extension/ask-user.test.ts` (new) — see step 8 +- `server/internal/agent/pirun/extension/embed_test.go` (if it asserts on file content) +- `tsconfig.extension.json` (new), `tsconfig.json`, `package.json` — see step 6 +- `vite.config.ts` — only if the Vitest include path must widen to reach the new suite +- `server/internal/agent/runtime.go` — the reciprocal timeout comment in step 5 + +**Approach:** + +1. Fix the `select` call to `select(title, options, opts)`. The question moves into the title argument; the options array moves out of the third slot. This is the call that produces a string-valued `options` field and gets the line dropped — the only request-side argument-order defect. +2. Pass the question as `confirm`'s **title** and an empty string as its message. The argument *order* was never wrong — the confirm break is response-side only (KTD3) — but leaving the question in `message` behind the constant title makes U2's title-plus-message join render every yes/no prompt as "A question for you" followed by the question, while pick-one and free-text show the bare question. Moving it to the title makes all three styles carry the question in the same field. +3. Fix the `input` call so the question is not passed as a placeholder. Pi's second argument is placeholder text, not prompt body, so the question must move into the title. +4. Remove the `typeof ui.select === "function"` and `typeof ui.confirm === "function"` probes. Both are always true in rpc mode, so the enumerated-options fallback path is dead. Keep the `ctx.hasUI` guard, which is the documented feature-detection contract and is already correct. +5. Pass `opts.timeout` and `opts.signal` on each dialog call. Per KTD7 the timeout must be strictly greater than the runtime's `defaultAwaitTimeout` (30 minutes), so Deuce's ceiling always fires first. Comment the literal with the name of the Go constant it must stay above, and add the reciprocal comment at that constant — the invariant spans two languages with nothing else tying the values together, and timeout tuning is open follow-up work. +6. Give the extension a real type-check. It currently sits outside every tsconfig project and its imports resolve nowhere locally. Add a `tsconfig.extension.json` covering the extension directory, reference it from the root `tsconfig.json`, and add `@earendil-works/pi-coding-agent` and `typebox` as devDependencies. Mirror the existing projects' `skipLibCheck` setting so the gate does not fail on an unrelated upstream declaration error. +7. Own the no-answer deadline in the extension rather than inferring it from Pi's resolved value (R13). Pi resolves a timed-out or aborted `confirm` to `false` — the same value a real "No" produces — so the resolved value alone cannot distinguish them. Drive an abort controller from a timer set just under the value passed as Pi's `timeout`, combine it with the tool's own abort signal, and set a no-answer flag when either fires. Return the explicit "no answer received — do not assume yes or no" text on that flag. Select and input would be distinguishable by `undefined`, but keeping one mechanism for all three styles avoids a per-style rule. +8. Give the extension its own test suite asserting the emitted dialog calls. An argument-order defect is precisely what a mocked-`ExtensionAPI` unit test catches and what a type-check cannot, and U1 is the unit that carried the original bug. The suite may require extending the Vitest include path, which today covers only the frontend's pure-logic suites. + +**Patterns to follow:** the existing `ctx.hasUI` early return already models the right guard shape — keep its behavior and its comment intact, including its proceed-on-best-judgment result. + +**Test scenarios:** +- A pick-one question with three options produces a request whose options field is an array of those three labels and whose title carries the question text. +- A pick-one question with `kind` omitted but options supplied still infers the pick-one style, preserving today's inference. +- A yes/no question produces a request carrying the question text in its message field. +- A free-text question produces a request carrying the question text in its title, not only in a placeholder. +- A yes/no question carries the question in its title, so the prompt renders without the boilerplate prefix. +- Every dialog call carries a timeout greater than the 30-minute awaiting ceiling, and an abort signal. +- Covers AE7. A cancelled or timed-out dialog returns the explicit no-answer text rather than an empty string or a negative. +- A yes/no dialog that times out is distinguished from a real "No" — both resolve to `false`, so the assertion must prove the no-answer flag drives the result, not the resolved value. +- With no UI channel available, the tool still returns the proceed-on-best-judgment result without opening a dialog. + +**Verification:** `npx tsc -b --force` type-checks the extension (it does not today), and each question style produces a request satisfying the corresponding arm of Pi's published request union as recorded in U6's fixture. + +--- + +### U2. Decode the request against Pi's published union + +**Goal.** Replace best-effort key probing with decoding against Pi's real nine-arm union, so every question style yields a populated prompt and non-questions are ignored. + +**Requirements:** R1, R2, R3, R4, R8. Implements KTD2, KTD4. + +**Dependencies:** U6 (assert against its fixture). Pairs with U1; neither delivers a working question alone. + +**Files:** +- `server/internal/agent/pirun/decoder.go` +- `server/internal/agent/pirun/decoder_test.go` +- `server/internal/agent/runtime_test.go` — the AE5 scenario; timeout suspension is asserted at the runtime layer +- `src/types/index.ts` — read-only reference. The `QuestionKind` union is deliberately left unchanged (see step 3) + +**Approach:** + +1. Rewrite `decodeUIRequest` to read the flat fields Pi actually emits: `id`, `method`, `title`, `message`, `placeholder`, `options`, `timeout`. Delete the `prompt`, `kind`, and `params.*` probes — no arm of Pi's union contains any of them. +2. Derive the prompt per method, following the table in the High-Level Technical Design: title for select and input, title plus message for confirm, title for editor. +3. Map `method` to the event's request kind so the drawer keeps rendering the right controls. The store's existing `input` / `select` / `confirm` vocabulary already matches Pi's method names for the three styles Deuce uses. Map `editor` to the existing `input` kind so the drawer composer serves as its control — the frontend `QuestionKind` union has no `editor` member, and emitting one would put an undeclared value on the wire. +4. Route the five fire-and-forget methods to `KindIgnore` (KTD4). Only the blocking methods may produce `KindAwaitingInput`. +5. Decode `options` tolerantly, recovering the question text rather than only the line. When `options` arrives as a bare string instead of an array, that string *is* the question — it is where the pre-fix extension put it — so use it as the prompt and degrade the request to free text. Deriving the prompt from `title` in that case yields the constant "A question for you" with the question discarded, which R2 rejects. Losing the whole event is what turned a mistyped argument into a ten-minute hang; a decoder for a contract that may drift should fail soft on payload and loud on classification. + +**Patterns to follow:** the schema-drift tolerance already established in `Decode` — unknown event types are returned rather than treated as fatal. Extend that posture to unexpected payload shapes within a known type. + +**Test scenarios:** +- A pick-one request decodes to a pending question with the question text and its option labels. +- A yes/no request decodes to a pending question with the question text, combining title and message. +- Covers AE2. A free-text request decodes to a pending question with the question text. +- An editor request decodes to a pending question carrying the `input` kind, not an unknown event and not an undeclared `editor` kind. +- A notification request decodes to ignore, not to a pending question. +- A status, widget, title, or editor-text request decodes to ignore. +- Covers AE4. A notification arriving mid-stream leaves the task `running` and does not interrupt the surrounding events. +- A request whose options field is a bare string decodes to a free-text question whose prompt is that string, not the generic title. +- A request with an unrecognized method does not produce a pending question. +- Each blocking request carries its originating request id through to the event. +- Covers AE5. A decoded blocking request suspends the active-work timeout and starts the awaiting ceiling, so the task stays `awaiting_input` and answerable past the active budget. + +**Verification:** `cd server && go test ./internal/agent/pirun/...` passes, and every arm of Pi's published union has a decode outcome asserted against it. + +--- + +### U3. Deliver answers in Pi's response shape + +**Goal.** Send Pi the response arm its dialog expects, so the user's answer reaches the agent instead of resolving to empty or false. + +**Requirements:** R5, R6, R7. Implements KTD3. + +**Dependencies:** U2 (the method must be decoded before it can be tracked). + +**Files:** +- `server/internal/agent/pirun/protocol.go` +- `server/internal/agent/runtime.go` +- `server/internal/agent/runtime_test.go` + +**Approach:** + +1. Replace `ExtensionUIResponse`'s single `Response any` field with the three arms Pi publishes: a string value, a boolean confirmed, or cancelled. The current single-field shape cannot express the distinction, and Pi silently accepts and discards it. +2. Carry the request's method alongside its id in the runtime's pending-request tracking. `pendingReq` maps task to request id today; it needs the method so the answer path can pick the arm. +3. In the answer path in `RouteOrEnqueue`, build the response from the tracked method: the answer text as the value for select, input, and editor; the answer mapped to a boolean for confirm. +4. Map the answer to a boolean by leading token, case-insensitively: affirmative (`yes`, `y`, `yeah`, `ok`, `sure`) to `true`, negative (`no`, `n`, `nope`) to `false`. **An answer matching neither set defaults to `false`, logged at warn.** Defaulting to the negative means an unparsed reply can never read as approval — "not sure" must not authorize a force-push — and it matches Pi's own confirm fallback, so a mis-decoded case degrades identically on both sides of the wire. The drawer composer stays live alongside the Yes/No buttons, so free text here is reachable by design; treating everything but the literal `"yes"` as negative would deliver `false` for "yes, go ahead" and reproduce this plan's own bug from another input. +5. Leave the drawer's `QuestionControls` untouched. It keeps sending `"yes"` / `"no"`, per the Scope Boundaries. + +**Patterns to follow:** the existing pending-state teardown in the answer path already clears pending state and the awaiting ceiling before resolving in the store — preserve that ordering, including its comment about surviving a failed store resolve. + +**Test scenarios:** +- Answering a pick-one question sends the chosen label as the response value. +- Typing free text in answer to a pick-one question sends that text unchanged as the value. +- Covers AE3. Answering a yes/no question with Yes sends a true boolean; answering with No sends false. +- Covers AE8. Typing "yes, go ahead" on a pending yes/no question sends `true`, not `false`. +- Answering a yes/no question with unrecognized free text ("not sure") logs at warn and sends `false`, never `true` and never a raw string. +- Answering a free-text question sends the typed text as the value. +- The response carries the request id of the question that opened the dialog. +- A steer sent while the task is running, with no question pending, still routes as a steer and not as a dialog response. +- Answering clears the pending request record so a later steer cannot be mistaken for a second answer. +- With a task marked awaiting but no tracked method, the answer falls through to the existing steer path rather than emitting a response with no arm. Boot recovery fails every `awaiting_input` task before the scheduler starts, so this cannot arise from a restart; it is the defensive case for a tracking gap. + +**Verification:** `cd server && go test ./internal/agent/...` passes, and each response the runtime emits satisfies one arm of Pi's published response union. + +--- + +### U4. Surface decode failures and extension errors + +**Goal.** Make this class of failure visible in the logs, so a future protocol break is diagnosable from a running server rather than by reading source. + +**Requirements:** R8, R9. + +**Dependencies:** U2 — step 4 acts on the per-method classification U2 introduces, and both units edit the same two files. Steps 1-3 are independent. + +**Files:** +- `server/internal/agent/pirun/decoder.go` +- `server/internal/agent/pirun/decoder_test.go` + +**Approach:** + +1. Remove `extension_error` from the ignored-event list and decode it. It carries the extension path, the event that failed, and the error text, and it is the only visibility into extension load and handler failures. Ignoring it is why a broken extension is currently invisible. +2. Log the decoded extension error inside `DecodeStream` — an explicit case that reports extension path, failing event, and error text at warn, then continues — rather than forwarding it to the runtime. The runtime's event translation returns early when the key has no current task, so a load-time extension error forwarded downstream would be dropped and R9 would go unmet. Keeping the log in the decoder also keeps this unit's file list self-contained. +3. Include the event type in the malformed-line warning so a dropped line names what it was. The current warning reports only that a line failed to parse. +4. Log an unknown UI method at warn. No such log exists before U2 — today every method collapses to a single branch — so this is a new log site, not a level change. + +**Test scenarios:** +- An extension error event decodes to a distinct outcome rather than being ignored, and is logged rather than forwarded to the runtime. +- A malformed line is skipped without aborting the stream, and the stream continues to deliver subsequent events. +- A malformed-line warning names the event type it failed to decode. +- An unrecognized top-level event type is still tolerated and non-fatal. + +**Verification:** `cd server && go test ./internal/agent/pirun/...` passes; the golden-stream decode still completes with no behavior change for events that already decoded. + +--- + +### U5. Contain long rows in the agent thread + +**Goal.** Stop long text in an action-log row from scrolling the agent thread sideways. + +**Requirements:** R11, R12. Implements KTD5. + +**Dependencies:** none to implement. Verifying step 4 and the AE9 scenario requires U1–U3 landed — the pending-question block and choice buttons do not render until `awaiting_input` fires. + +**Files:** +- `src/styles/globals.css` +- `src/components/super-threads/atoms.tsx` + +**Approach:** + +The thread body sets `overflow-y: auto`, which computes `overflow-x` to `auto` — so any child that exceeds the width produces a horizontal scrollbar. Inside it, the action row is a flex container whose text-bearing element is an **unclassed** wrapper span, and the argument sits as a plain inline span within it. Nothing in the chain constrains width, so the row takes its intrinsic width and the thread scrolls. + +1. Class the action row's wrapper span in `atoms.tsx` and put the truncation on it: `min-width: 0`, `overflow: hidden`, `text-overflow: ellipsis`, `white-space: nowrap`. The whole tool-plus-argument run then truncates as one line. Do **not** simply add `min-width: 0` and rely on the existing declarations on `.q-act .arg` — that span is a non-replaced inline box, so its `overflow`/`text-overflow` are inert (KTD5) and the nowrap text would spill out of the shrunken wrapper and keep scrolling the thread. Those inert declarations should come off `.q-act .arg`. Apply the class on every branch of the action row so the think row is covered too. +2. Add `min-width: 0` to the task card's live row argument. That span *is* a direct flex child and gets blockified, so its existing ellipsis declarations do apply once it can shrink. Today the card's `overflow: hidden` masks the defect as clipping mid-character rather than scroll. +3. Carry the full text on the truncated action-log span as a `title` attribute. The store clears the pending question when the task completes, leaving this row as the only record of what was asked — without it, an answered question becomes permanently unreadable. +4. Wrap, do not truncate, the surfaces the user reads to answer: add `overflow-wrap: anywhere` to the pending-question text block and to the choice buttons, mirroring the existing rule on rendered markdown. A question carrying a long unbroken token (a file path, a URL) otherwise overflows, and a long option label widens its row. These controls have never rendered in production because `awaiting_input` never fires today, so this plan makes them reachable for the first time. +5. Leave the completed-output blocks alone. They already wrap and scroll within their own bounds. + +This is a general containment fix — every long tool argument benefits, not only questions. Questions made it visible because they are prose-length. + +**Patterns to follow:** the task card's awaiting-input row already carries `min-width: 0` on a block-level child for this reason — it is the shape to mirror for step 2, but not for step 1, where the truncating element is an inline span rather than a block. The rendered-markdown rule already establishes `overflow-wrap: anywhere` for step 4. + +**Test scenarios:** + +Verified by observation rather than automated test — the repo has no visual regression harness, and asserting computed layout in jsdom would test the assertion, not the rendering. + +- Covers AE6. An action-log row holding a 400-character question truncates with an ellipsis and the thread scrolls vertically only. +- An action-log row holding a long shell command truncates the same way. +- A think row with long interpolated text truncates rather than widening the row. +- A task card's live row truncates with a visible ellipsis rather than clipping mid-character. +- Hovering a truncated action-log row reveals the full question text. +- Covers AE9. A pending question containing a long unbroken file path wraps inside its prompt block, and a long option label wraps inside its button, with no horizontal scroll. +- Short rows are unchanged, and the status icon stays right-aligned. + +**Verification:** with the drawer open on a task whose action log holds a long question, the thread has no horizontal scrollbar at a narrow panel width, and the pending-question block wraps rather than truncating. + +--- + +### U6. Pin the protocol with contract-derived fixtures + +**Goal.** Replace the self-confirming fixtures with ones traceable to Pi's published union, so the tests can fail when the product does. + +**Requirements:** R1, R2, R3, R5, R6. Implements KTD6. + +**Dependencies:** none. **U6 is written first but lands together with U1–U3 as a single change.** Its assertions are red by construction until those units exist, and CI runs the Go suite on every push — so committing U6 alone would ship a knowingly-failing build. Write the fixture and the red assertions first *within* that change: U1, U2, and U3 then assert against the fixture rather than against literals written beside the code they test, which is what KTD6 identifies as the reason this bug shipped green. + +**Files:** +- `server/internal/agent/pirun/testdata/` (new fixture) +- `server/internal/agent/pirun/decoder_test.go` +- `server/internal/agent/runtime_test.go` + +**Approach:** + +1. Add a checked-in fixture holding one line per arm of Pi's request union, transcribed from the published types, alongside the response arms Deuce emits. Note the Pi version it was taken from so a future upgrade can be diffed against it. +2. Point the decoder and runtime tests at the fixture instead of at inline literals invented alongside the decoder. The inline literals are what let a wrong decoder pass a green suite. +3. Add a round-trip test covering the full question path for each style: request in, pending question raised, answer routed, response emitted in the correct arm. The existing runtime tests exercise the awaiting-input transition but assert the wrong response shape. +4. Remove or rewrite the fixtures asserting the old `prompt` / `params.*` request shape and the `response` reply field. Leaving them would keep the disproven contract encoded in the suite. + +**Execution note:** write the fixture and the round-trip assertions first and watch them fail against the current code. The bug reproduces cleanly as a red test, and a red test derived from the published contract is the proof this plan's diagnosis is right — it is also the only check that the later units fixed the real defect rather than the assumed one. The closing "reverting any of U1–U3 turns the suite red" verification runs once those units land. + +**Test scenarios:** +- Every arm of Pi's request union has an asserted decode outcome, and the assertion set is complete against the fixture. +- Covers AE1. A pick-one question round-trips from request through pending question to a response carrying the chosen label. +- A yes/no question round-trips to a response carrying a boolean. +- A free-text question round-trips to a response carrying the typed text. +- A notification in the same stream does not raise a pending question and does not interrupt the surrounding events. +- The fixture records the Pi version it was derived from. + +**Verification:** the fixture-derived tests are red against the current code before U1–U3 are written — that red result is the proof the diagnosis is right. Within the same change, once U1–U3 land, `cd server && go test ./internal/agent/...` passes, and reverting any one of U1–U3 turns the suite red again. No commit in between leaves the suite failing. + +--- + +## Verification Contract + +| Gate | Command | Applies to | +|---|---|---| +| Go tests | `cd server && go test ./...` | U2, U3, U4, U6 (U6's assertions are red until U1–U3 land in the same change — see its Verification) | +| Go build | `cd server && make build` | U2, U3, U4 | +| Frontend tests | `npm test` | U1 (extension dialog-call suite), U5 (regression only) | +| Type check | `npx tsc -b --force` | U1 (only after U1 step 6 adds the extension project), U5 | +| Lint | `npm run lint` | U1, U5 | + +Bare `tsc --noEmit` checks nothing in this repo's solution-style config — use the `-b --force` form. + +**No gate reaches the extension today.** `tsconfig.app.json` includes only `src` and `tsconfig.node.json` only `vite.config.ts`, so `npx tsc -b --force` never reads the extension source; and `@earendil-works/pi-coding-agent` and `typebox` are absent from `package.json`, so its imports resolve nowhere locally. `npm run lint` does reach the file and currently passes, but without type resolution it cannot catch an argument-order error — which is exactly how this bug shipped. Two U1 steps close this: step 6 restores type resolution, step 8 adds the behavioral suite that actually asserts what each dialog call emits. Type-checking alone would not have caught the original defect. + +**Pre-fix confirmation (do this before changing code).** A timeout symptom alone does not implicate the question path — the active-work budget is a fixed budget from task launch, not an idle timer, so any task doing more than ten minutes of legitimate tool work dies the same way. Search the reported failing session's server log for `pirun: skipping malformed event line` and record the result. Its presence confirms the diagnosed select path; its absence means the symptom has another cause and the plan should be revisited before implementation. + +**Live verification (required — the unit tests cannot prove this fix).** The bug is a wire-contract mismatch between two processes, so a green suite proves only that Deuce agrees with the fixture. Confirm against a real Pi: + +1. Rebuild the workspace rather than restarting it. The extension is baked into the prebuild image when `DEUCE_PREBUILD_REPOSITORY` is set, and the image tag is keyed on the devcontainer hash, not on Deuce's source — a restart can reuse the old extension. +2. In a live session, prompt `@deuce` so it asks a pick-one question. Confirm the task enters "needs your input", the drawer shows the question text with its option buttons, and clicking one resumes the run with the chosen value. +3. Repeat for a yes/no question and confirm that clicking No is received as a negative — this is the failure a passing suite most easily hides. Then answer one with free text ("yes, go ahead") and confirm it is received as affirmative. +4. Repeat for a free-text question and confirm the prompt is not blank. +5. Confirm the server log holds no `skipping malformed event line` warnings for the session. Their presence means a request arm is still undecodable. + +--- + +## Definition of Done + +**Global** + +- All three question styles are answerable end to end against a real Pi, verified live per the Verification Contract. +- Every gate in the Verification Contract passes. +- No Pi UI method that is not a question can move a task to `awaiting_input`. +- The agent thread does not scroll horizontally on a long question at a narrow panel width. +- Test fixtures are traceable to Pi's published union, and no fixture asserts the disproven request or response shape. +- Exploratory code from diagnosing the wire format is removed. The unpacked Pi package is a scratch artifact and is not committed. +- The decoder's stale comment about the shape being unpinned is gone, replaced by a citation to the version the fixture records. + +**Per unit** + +| Unit | Done when | +|---|---| +| U1 | Each question style produces a request satisfying its arm of Pi's request union, asserted by the new extension suite; every dialog call carries an abort signal and a timeout greater than the 30-minute awaiting ceiling, commented at both ends of the cross-language invariant; a cancelled or timed-out dialog returns the explicit no-answer result via the extension's own flag rather than Pi's resolved value; and `npx tsc -b --force` type-checks the extension. | +| U2 | Every arm of the request union decodes to an asserted outcome; the five non-blocking methods decode to ignore; a bare-string `options` yields a prompt carrying the question text. | +| U3 | Each emitted response satisfies one arm of Pi's response union, chosen from the tracked method, and free-text affirmatives on a yes/no question reach the agent as `true`. | +| U4 | Extension errors are decoded and logged from the decoder; a dropped line names its event type; an unrecognized UI method logs at warn. | +| U5 | Long rows truncate with an ellipsis and carry their full text on hover; the pending-question block and choice buttons wrap; the thread scrolls vertically only. | +| U6 | Reverting any of U1–U3 turns the suite red. | + +**Carry forward.** This bug's shape — a protocol assumed rather than read, then locked in by fixtures asserting the assumption — is worth a `docs/solutions/` entry once the fix lands. Pi vendors its own docs and type declarations inside the npm package, which is how the contract was recovered; that is the reusable lesson. From 6b8a0b42b9c06bfa32cf94c26e9f24bcf66b77a2 Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Thu, 6 Aug 2026 22:38:20 -0600 Subject: [PATCH 2/7] fix(pirun): decode extension_ui_request against Pi's published union Pi's request type is a flat nine-arm union keyed on method with no nesting. The decoder probed for prompt/kind/params.*, which no arm carries, so every question decoded with an empty prompt -- and a string-valued options field failed to unmarshal, dropping the whole line. That dropped line is the ten-minute hang: awaiting_input never fired, so the active-work timeout was never suspended. - Pin the contract in testdata/pi-ui-protocol.json, transcribed from Pi 0.84.0's published types, so the tests fail when the product does. The previous fixtures asserted the decoder's own guess. - Derive the prompt per method, and recover the question from a bare-string options field rather than falling back to the generic title. - Route the five fire-and-forget methods to ignore, so a subagent's progress notification can no longer wedge a task in "needs your input". - Map editor to the existing input kind; the frontend union has no editor. - Decode and log extension_error at warn from DecodeStream, and name the event type on a dropped line. Co-Authored-By: Claude Opus 5 (1M context) --- server/internal/agent/pirun/decoder.go | 201 ++++++++-- server/internal/agent/pirun/decoder_test.go | 358 ++++++++++++++++-- .../agent/pirun/testdata/pi-ui-protocol.json | 204 ++++++++++ server/internal/agent/runtime_test.go | 90 ++++- 4 files changed, 788 insertions(+), 65 deletions(-) create mode 100644 server/internal/agent/pirun/testdata/pi-ui-protocol.json diff --git a/server/internal/agent/pirun/decoder.go b/server/internal/agent/pirun/decoder.go index ff77b5d..6ee0928 100644 --- a/server/internal/agent/pirun/decoder.go +++ b/server/internal/agent/pirun/decoder.go @@ -29,6 +29,11 @@ const ( // KindAwaitingInput maps from "extension_ui_request": the agent is blocked // on a human answer via the ask-user extension (KTD15). KindAwaitingInput EventKind = "awaiting_input" + // KindExtensionError maps from "extension_error": an extension threw, at + // load time or while handling an event. It is the only visibility Deuce has + // into a broken extension, and it is logged rather than forwarded to the + // runtime — a load-time error has no task to attach to (R9). + KindExtensionError EventKind = "extension_error" // KindRunCompleted maps from "agent_end": the run finished. KindRunCompleted EventKind = "run_completed" // KindCommandReply maps from a "response" envelope (reply to a client command). @@ -66,6 +71,11 @@ type Event struct { Command string ReplyID string Success bool + + // Extension error (KindExtensionError). + ExtensionPath string // the extension file that threw + ExtensionEvent string // the Pi event it was handling ("tool_call", …) + ErrorText string } // envelope is the minimal shared shape used to classify a line before decoding @@ -114,10 +124,12 @@ func Decode(line []byte) (Event, error) { return decodeMessageUpdate(line) case "extension_ui_request": return decodeUIRequest(line) + case "extension_error": + return decodeExtensionError(line) case "message_start", "message_end", "turn_start", "turn_end", "tool_execution_update", "queue_update", "compaction_start", "compaction_end", - "auto_retry_start", "auto_retry_end", "extension_error": + "auto_retry_start", "auto_retry_end": // Recognized but not acted on by the runtime. return Event{Kind: KindIgnore, RawType: env.Type}, nil default: @@ -193,39 +205,149 @@ func decodeMessageUpdate(line []byte) (Event, error) { } } +// decodeExtensionError decodes Pi's "extension_error" event (docs/rpc.md): +// extensionPath, the event being handled, and the error text. +func decodeExtensionError(line []byte) (Event, error) { + var p struct { + ExtensionPath string `json:"extensionPath"` + Event string `json:"event"` + Error string `json:"error"` + } + if err := json.Unmarshal(line, &p); err != nil { + return Event{Kind: KindUnknown, RawType: "extension_error"}, err + } + return Event{ + Kind: KindExtensionError, + RawType: "extension_error", + ExtensionPath: p.ExtensionPath, + ExtensionEvent: p.Event, + ErrorText: p.Error, + }, nil +} + +// Pi's extension UI methods (RpcExtensionUIRequest, nine arms keyed on +// "method"). The first four block on a client response; the rest are +// fire-and-forget and must never raise a pending question (KTD4 / R3). +const ( + uiMethodSelect = "select" + uiMethodConfirm = "confirm" + uiMethodInput = "input" + uiMethodEditor = "editor" + uiMethodNotify = "notify" + uiMethodSetStatus = "setStatus" + uiMethodSetWidget = "setWidget" + uiMethodSetTitle = "setTitle" + uiMethodSetEditorText = "set_editor_text" +) + +// decodeUIRequest decodes an extension_ui_request against Pi's published +// RpcExtensionUIRequest union. The union is FLAT: every field sits at the top +// level next to type/id/method, and no arm nests anything under "params". The +// contract is transcribed arm-by-arm in testdata/pi-ui-protocol.json, which +// records the Pi version it came from. +// +// Prompt text per arm (the union carries no single "prompt" field): +// +// select → title confirm → title + message +// input → title editor → title +// +// placeholder and prefill are input hints, not the question, so they are not +// folded into the prompt. func decodeUIRequest(line []byte) (Event, error) { - // The exact extension_ui_request shape is pinned when the ask-user - // extension (U12) lands; decode best-effort by id + common prompt/kind keys - // so the awaiting-input transition fires regardless of minor field naming. var p struct { - ID string `json:"id"` - Method string `json:"method"` - Kind string `json:"kind"` - Prompt string `json:"prompt"` - Options []string `json:"options"` - Params struct { - Prompt string `json:"prompt"` - Message string `json:"message"` - Options []string `json:"options"` - } `json:"params"` + ID string `json:"id"` + Method string `json:"method"` + Title string `json:"title"` + Message string `json:"message"` + Options json.RawMessage `json:"options"` } if err := json.Unmarshal(line, &p); err != nil { return Event{Kind: KindUnknown, RawType: "extension_ui_request"}, err } - prompt := firstNonEmpty(p.Prompt, p.Params.Prompt, p.Params.Message) - kind := firstNonEmpty(p.Kind, p.Method) - options := p.Options - if len(options) == 0 { - options = p.Params.Options - } - return Event{ + + ev := Event{ Kind: KindAwaitingInput, RawType: "extension_ui_request", RequestID: p.ID, - RequestKind: kind, - Prompt: prompt, - Options: options, - }, nil + RequestKind: p.Method, + Prompt: p.Title, + } + + switch p.Method { + case uiMethodSelect: + labels, question, ok := decodeUIOptions(p.Options) + switch { + case ok && len(labels) > 0: + ev.Options = labels + case question != "": + // Version skew: a pre-fix extension called select(title, question, + // options), so Pi spread the question into the options slot. The + // question text exists only there — falling back to the title would + // surface the extension's boilerplate and discard the question (R2). + // Degrade to free text: there are no option labels to render. + ev.RequestKind = uiMethodInput + ev.Prompt = question + default: + // A select with no usable labels can only be answered as free text. + ev.RequestKind = uiMethodInput + } + case uiMethodConfirm: + ev.Prompt = joinPrompt(p.Title, p.Message) + case uiMethodInput: + // Prompt is the title, already set. + case uiMethodEditor: + // The drawer has no editor control and the frontend QuestionKind union + // has no "editor" member — an editor dialog is answered through the + // composer, so it rides the existing input kind rather than putting an + // undeclared value on the wire. + ev.RequestKind = uiMethodInput + case uiMethodNotify, uiMethodSetStatus, uiMethodSetWidget, uiMethodSetTitle, uiMethodSetEditorText: + // Fire-and-forget: carries an id but expects no response. Answering one + // is impossible and treating one as a question wedges the task. + return Event{Kind: KindIgnore, RawType: "extension_ui_request"}, nil + default: + // A method a future Pi adds. Tolerated like an unknown event type: the + // stream continues, but no unanswerable question reaches the user. + // DecodeStream logs it (R8). + return Event{ + Kind: KindUnknown, + RawType: "extension_ui_request", + RequestID: p.ID, + RequestKind: p.Method, + }, nil + } + return ev, nil +} + +// decodeUIOptions reads Pi's select `options` field tolerantly. It returns the +// labels when the field is the published string array; otherwise, when the +// field is a bare string, it returns that string — which in the version-skew +// case is the question text itself. +func decodeUIOptions(raw json.RawMessage) (labels []string, bare string, ok bool) { + if len(raw) == 0 { + return nil, "", false + } + if err := json.Unmarshal(raw, &labels); err == nil { + return labels, "", true + } + if err := json.Unmarshal(raw, &bare); err == nil { + return nil, strings.TrimSpace(bare), false + } + return nil, "", false +} + +// joinPrompt renders confirm's two-field prompt as one block. Pi's confirm arm +// requires a message, but Deuce's own extension sends the question as the title +// and an empty message, so the blank line must not be emitted for it. +func joinPrompt(title, message string) string { + switch { + case strings.TrimSpace(message) == "": + return title + case strings.TrimSpace(title) == "": + return message + default: + return title + "\n\n" + message + } } // DecodeStream reads JSONL lines from r and invokes fn for every emitted event @@ -238,13 +360,31 @@ func DecodeStream(r io.Reader, fn func(Event)) error { for sc.Scan() { ev, err := Decode(sc.Bytes()) if err != nil { - slog.Warn("pirun: skipping malformed event line", "error", err) + // Name the event type: a dropped line that says only "malformed" + // cannot be traced back to the arm that broke (R8). Empty when the + // line was not parseable as JSON at all. + slog.Warn("pirun: skipping malformed event line", "type", ev.RawType, "error", err) continue } switch ev.Kind { case KindIgnore: continue + case KindExtensionError: + // Logged here, not forwarded: an extension can throw at load time, + // when there is no task to attach the failure to, and the runtime's + // translation returns early for a key with no current task — a + // forwarded error would be silently dropped (R9). + slog.Warn("pirun: extension error", + "extensionPath", ev.ExtensionPath, "event", ev.ExtensionEvent, "error", ev.ErrorText) + continue case KindUnknown: + if ev.RawType == "extension_ui_request" { + // A UI method Deuce cannot classify. Louder than an unknown + // event type: it means Pi opened a dialog no one will answer. + slog.Warn("pirun: unknown extension UI method", + "method", ev.RequestKind, "requestId", ev.RequestID) + continue + } slog.Debug("pirun: skipping unknown event type", "type", ev.RawType) continue default: @@ -269,15 +409,6 @@ func joinText(blocks []contentBlock) string { return b.String() } -func firstNonEmpty(vals ...string) string { - for _, v := range vals { - if v != "" { - return v - } - } - return "" -} - func trimLine(line []byte) []byte { for len(line) > 0 && (line[len(line)-1] == '\n' || line[len(line)-1] == '\r' || line[len(line)-1] == ' ') { line = line[:len(line)-1] diff --git a/server/internal/agent/pirun/decoder_test.go b/server/internal/agent/pirun/decoder_test.go index c636cc2..fcc9687 100644 --- a/server/internal/agent/pirun/decoder_test.go +++ b/server/internal/agent/pirun/decoder_test.go @@ -1,6 +1,9 @@ package pirun import ( + "bytes" + "encoding/json" + "log/slog" "os" "path/filepath" "strings" @@ -109,6 +112,108 @@ func TestDecodeMalformedLineErrors(t *testing.T) { } } +// captureLogs redirects the default slog logger into a buffer for the duration +// of a test and returns the accumulated output. +func captureLogs(t *testing.T) func() string { + t.Helper() + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(prev) }) + return buf.String +} + +// TestDecodeExtensionError: Pi's extension_error is the only visibility Deuce +// has into a broken extension. It decodes to its own kind, is logged with the +// context needed to identify it, and is NOT forwarded to the runtime — a +// load-time failure has no task to attach to (R9). +func TestDecodeExtensionError(t *testing.T) { + f := loadUIFixture(t) + line := jsonlLine(t, f.ExtensionError.Line) + + ev, err := Decode(line) + if err != nil { + t.Fatalf("decode extension_error: %v", err) + } + if ev.Kind != KindExtensionError { + t.Fatalf("kind = %q, want %q — ignoring it is why a broken extension is invisible", ev.Kind, KindExtensionError) + } + if ev.ExtensionPath != "/home/vscode/.pi/agent/extensions/ask-user.ts" || + ev.ExtensionEvent != "tool_call" || + ev.ErrorText != "TypeError: ui.select is not a function" { + t.Errorf("extension error decoded as %+v", ev) + } + + logs := captureLogs(t) + var forwarded []Event + in := strings.Join([]string{`{"type":"agent_start"}`, string(line), `{"type":"agent_end"}`}, "\n") + if err := DecodeStream(strings.NewReader(in), func(ev Event) { forwarded = append(forwarded, ev) }); err != nil { + t.Fatalf("DecodeStream: %v", err) + } + for _, ev := range forwarded { + if ev.Kind == KindExtensionError { + t.Errorf("extension error was forwarded to the runtime; it must be logged in the decoder instead") + } + } + if len(forwarded) != 2 { + t.Errorf("forwarded %d events, want the 2 surrounding lifecycle events", len(forwarded)) + } + out := logs() + for _, want := range []string{"extension error", "ask-user.ts", "tool_call", "ui.select is not a function"} { + if !strings.Contains(out, want) { + t.Errorf("log output missing %q:\n%s", want, out) + } + } + if !strings.Contains(out, "level=WARN") { + t.Errorf("extension error must be logged at warn, got:\n%s", out) + } +} + +// TestDecodeStreamMalformedLineNamesType covers R8: a dropped line must name +// what it was, or a future protocol break is undiagnosable from a running +// server. +func TestDecodeStreamMalformedLineNamesType(t *testing.T) { + logs := captureLogs(t) + // Valid JSON, recognized type, but the payload does not fit the arm. + in := strings.Join([]string{ + `{"type":"agent_start"}`, + `{"type":"tool_execution_start","toolCallId":42,"toolName":"bash"}`, + `{"type":"agent_end"}`, + }, "\n") + var kinds []EventKind + if err := DecodeStream(strings.NewReader(in), func(ev Event) { kinds = append(kinds, ev.Kind) }); err != nil { + t.Fatalf("DecodeStream: %v", err) + } + if len(kinds) != 2 || kinds[0] != KindRunStarted || kinds[1] != KindRunCompleted { + t.Errorf("emitted kinds = %v, want the stream to continue past the bad line", kinds) + } + out := logs() + if !strings.Contains(out, "skipping malformed event line") || !strings.Contains(out, "type=tool_execution_start") { + t.Errorf("malformed-line warning must name the event type, got:\n%s", out) + } +} + +// TestDecodeStreamUnknownUIMethodLogged covers R8 for the UI path: a dialog +// method Deuce cannot classify is reported, not silently discarded. +func TestDecodeStreamUnknownUIMethodLogged(t *testing.T) { + f := loadUIFixture(t) + logs := captureLogs(t) + var got []Event + if err := DecodeStream(strings.NewReader(string(f.offContract(t, "unknownMethod"))), func(ev Event) { got = append(got, ev) }); err != nil { + t.Fatalf("DecodeStream: %v", err) + } + if len(got) != 0 { + t.Errorf("forwarded %+v, want an unknown UI method to reach the runtime as nothing", got) + } + out := logs() + if !strings.Contains(out, "unknown extension UI method") || !strings.Contains(out, "someFutureDialog") { + t.Errorf("unknown UI method must be logged with its method name, got:\n%s", out) + } + if !strings.Contains(out, "level=WARN") { + t.Errorf("unknown UI method must be logged at warn, got:\n%s", out) + } +} + func TestDecodeStreamSkipsBadLines(t *testing.T) { in := strings.Join([]string{ `{"type":"agent_start"}`, @@ -126,47 +231,246 @@ func TestDecodeStreamSkipsBadLines(t *testing.T) { } } -func TestDecodeExtensionUIRequest(t *testing.T) { - // extension_ui_request is the ask-user mechanism (KTD15); not in the golden - // stream (no extension yet), so exercise the best-effort shape directly. - ev, err := Decode([]byte(`{"type":"extension_ui_request","id":"ui-7","method":"input","params":{"prompt":"Which environment?"}}`)) +// --- Pi extension-UI contract fixture --------------------------------------- +// +// These assertions are derived from Pi's published RpcExtensionUIRequest union +// (testdata/pi-ui-protocol.json), not from what the decoder happens to accept. +// The previous fixtures here fed the decoder the exact shape the decoder +// guessed, so they stayed green while the product failed (KTD6). + +type uiFixtureEntry struct { + Name string `json:"name"` + Method string `json:"method"` + Blocking bool `json:"blocking"` + ResponseArm string `json:"responseArm"` + Line json.RawMessage `json:"line"` +} + +type uiFixture struct { + Package string `json:"package"` + PiVersion string `json:"piVersion"` + DerivedFrom []string `json:"derivedFrom"` + Requests []uiFixtureEntry `json:"requests"` + Responses map[string]uiFixtureEntry `json:"responses"` + ExtensionError uiFixtureEntry `json:"extensionError"` + OffContract map[string]uiFixtureEntry `json:"offContract"` +} + +func loadUIFixture(t *testing.T) uiFixture { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "pi-ui-protocol.json")) if err != nil { - t.Fatalf("decode ui request: %v", err) + t.Fatalf("read pi-ui-protocol fixture: %v", err) + } + var f uiFixture + if err := json.Unmarshal(b, &f); err != nil { + t.Fatalf("parse pi-ui-protocol fixture: %v", err) + } + return f +} + +func (f uiFixture) request(t *testing.T, name string) []byte { + t.Helper() + for _, r := range f.Requests { + if r.Name == name { + return jsonlLine(t, r.Line) + } + } + t.Fatalf("fixture has no request named %q", name) + return nil +} + +func (f uiFixture) offContract(t *testing.T, name string) []byte { + t.Helper() + e, ok := f.OffContract[name] + if !ok { + t.Fatalf("fixture has no offContract entry named %q", name) + } + return jsonlLine(t, e.Line) +} + +// jsonlLine flattens a pretty-printed fixture object into the single physical +// line Pi's LF-framed JSONL stream would carry. +func jsonlLine(t *testing.T, raw json.RawMessage) []byte { + t.Helper() + var buf bytes.Buffer + if err := json.Compact(&buf, raw); err != nil { + t.Fatalf("compact fixture line: %v", err) + } + return buf.Bytes() +} + +// TestUIFixtureRecordsPiVersion: a fixture that cannot be traced back to a +// specific published Pi is not a contract, it is another assumption. +func TestUIFixtureRecordsPiVersion(t *testing.T) { + f := loadUIFixture(t) + if f.PiVersion == "" || f.Package == "" { + t.Errorf("fixture must record the package and version it was transcribed from, got %q %q", f.Package, f.PiVersion) + } + if len(f.DerivedFrom) == 0 { + t.Error("fixture must record which published files it was derived from") + } +} + +// TestDecodeUIRequestContract asserts one decode outcome for every arm of Pi's +// published request union, and fails if the fixture grows an arm with no +// expectation (completeness against the fixture, KTD6). +func TestDecodeUIRequestContract(t *testing.T) { + f := loadUIFixture(t) + + type want struct { + kind EventKind + requestKind string + requestID string + prompt string + options []string + } + // Blocking arms raise a pending question; the five fire-and-forget arms are + // ignored at the decoder so they can never wedge a task (KTD4 / R3). + expected := map[string]want{ + "select": { + kind: KindAwaitingInput, requestKind: "select", requestID: "ui-select-1", + prompt: "Which framework should I use?", options: []string{"React", "Vue", "Svelte"}, + }, + "confirm": { + kind: KindAwaitingInput, requestKind: "confirm", requestID: "ui-confirm-1", + prompt: "Force-push to main?\n\nThis rewrites remote history.", + }, + "confirm_empty_message": { + kind: KindAwaitingInput, requestKind: "confirm", requestID: "ui-confirm-2", + prompt: "Should I delete the stale branches?", + }, + "input": { + kind: KindAwaitingInput, requestKind: "input", requestID: "ui-input-1", + prompt: "Which environment should I deploy to?", + }, + // editor has no frontend QuestionKind of its own — it rides the existing + // input control rather than putting an undeclared kind on the wire. + "editor": { + kind: KindAwaitingInput, requestKind: "input", requestID: "ui-editor-1", + prompt: "Edit the release notes before I publish them", + }, + "notify": {kind: KindIgnore}, + "setStatus": {kind: KindIgnore}, + "setWidget": {kind: KindIgnore}, + "setTitle": {kind: KindIgnore}, + "set_editor_text": {kind: KindIgnore}, + } + + if len(expected) != len(f.Requests) { + t.Errorf("fixture has %d request arms but %d have asserted outcomes — every arm needs one", len(f.Requests), len(expected)) + } + for _, entry := range f.Requests { + w, ok := expected[entry.Name] + if !ok { + t.Errorf("fixture arm %q has no asserted decode outcome", entry.Name) + continue + } + t.Run(entry.Name, func(t *testing.T) { + ev, err := Decode(entry.Line) + if err != nil { + t.Fatalf("decode %s: %v", entry.Name, err) + } + if ev.Kind != w.kind { + t.Fatalf("kind = %q, want %q (decoded %+v)", ev.Kind, w.kind, ev) + } + if w.kind != KindAwaitingInput { + return + } + if ev.RequestKind != w.requestKind { + t.Errorf("requestKind = %q, want %q", ev.RequestKind, w.requestKind) + } + if ev.RequestID != w.requestID { + t.Errorf("requestID = %q, want %q", ev.RequestID, w.requestID) + } + if ev.Prompt != w.prompt { + t.Errorf("prompt = %q, want %q", ev.Prompt, w.prompt) + } + if !sameStrings(ev.Options, w.options) { + t.Errorf("options = %v, want %v", ev.Options, w.options) + } + }) + } +} + +// TestDecodeUIRequestBareStringOptions covers the version-skew case: a stale +// prebuild image runs the pre-fix extension, which puts the question in the +// argument slot Pi spreads into `options`. The question text is recoverable +// only from that string — deriving the prompt from `title` yields the constant +// boilerplate and discards the question (R2). +func TestDecodeUIRequestBareStringOptions(t *testing.T) { + f := loadUIFixture(t) + ev, err := Decode(f.offContract(t, "selectOptionsAsBareString")) + if err != nil { + t.Fatalf("bare-string options must not error: %v", err) } if ev.Kind != KindAwaitingInput { - t.Fatalf("kind = %q, want %q", ev.Kind, KindAwaitingInput) + t.Fatalf("kind = %q, want %q — losing the line is what turned a mistyped argument into a ten-minute hang", ev.Kind, KindAwaitingInput) } - if ev.RequestID != "ui-7" || ev.RequestKind != "input" || ev.Prompt != "Which environment?" { - t.Errorf("ui request decoded as %+v", ev) + if ev.Prompt != "Which framework should I use?" { + t.Errorf("prompt = %q, want the question carried in options", ev.Prompt) + } + if ev.RequestKind != "input" { + t.Errorf("requestKind = %q, want input — with no option labels the request degrades to free text", ev.RequestKind) } if len(ev.Options) != 0 { - t.Errorf("free-text request should carry no options, got %v", ev.Options) + t.Errorf("options = %v, want none", ev.Options) + } + if ev.RequestID != "ui-skew-1" { + t.Errorf("requestID = %q, want ui-skew-1", ev.RequestID) } } -func TestDecodeExtensionUIRequestSelectOptions(t *testing.T) { - // A select-kind request carries choice options; decode them best-effort - // whether they ride top-level or under params. - ev, err := Decode([]byte(`{"type":"extension_ui_request","id":"ui-9","kind":"select","prompt":"Which framework?","options":["React","Vue","Svelte"]}`)) +// TestDecodeUIRequestUnknownMethod: a dialog method a future Pi adds must not +// raise a pending question Deuce cannot render or answer. +func TestDecodeUIRequestUnknownMethod(t *testing.T) { + f := loadUIFixture(t) + ev, err := Decode(f.offContract(t, "unknownMethod")) if err != nil { - t.Fatalf("decode select request: %v", err) + t.Fatalf("unknown method must not error: %v", err) + } + if ev.Kind == KindAwaitingInput { + t.Errorf("unknown UI method decoded as %+v, want no pending question", ev) } - if ev.Kind != KindAwaitingInput || ev.RequestKind != "select" { - t.Fatalf("kind=%q requestKind=%q, want awaiting_input/select", ev.Kind, ev.RequestKind) +} + +// TestDecodeStreamNotificationDoesNotInterrupt covers AE4: a fire-and-forget +// notification riding mid-stream leaves the surrounding events untouched and +// raises no pending question. +func TestDecodeStreamNotificationDoesNotInterrupt(t *testing.T) { + f := loadUIFixture(t) + lines := []string{ + `{"type":"agent_start"}`, + string(f.request(t, "notify")), + string(f.request(t, "setStatus")), + string(f.request(t, "select")), + `{"type":"agent_end"}`, + } + var kinds []EventKind + if err := DecodeStream(strings.NewReader(strings.Join(lines, "\n")), func(ev Event) { kinds = append(kinds, ev.Kind) }); err != nil { + t.Fatalf("DecodeStream: %v", err) + } + wantKinds := []EventKind{KindRunStarted, KindAwaitingInput, KindRunCompleted} + if len(kinds) != len(wantKinds) { + t.Fatalf("emitted kinds = %v, want %v", kinds, wantKinds) } - if got := ev.Options; len(got) != 3 || got[0] != "React" || got[2] != "Svelte" { - t.Errorf("options = %v, want [React Vue Svelte]", got) + for i := range wantKinds { + if kinds[i] != wantKinds[i] { + t.Fatalf("emitted kinds = %v, want %v", kinds, wantKinds) + } } } -func TestDecodeExtensionUIRequestParamsOptions(t *testing.T) { - ev, err := Decode([]byte(`{"type":"extension_ui_request","id":"ui-10","method":"select","params":{"prompt":"Pick","options":["a","b"]}}`)) - if err != nil { - t.Fatalf("decode: %v", err) +func sameStrings(got, want []string) bool { + if len(got) != len(want) { + return false } - if len(ev.Options) != 2 || ev.Options[1] != "b" || ev.Prompt != "Pick" { - t.Errorf("params-options request decoded as %+v", ev) + for i := range want { + if got[i] != want[i] { + return false + } } + return true } func TestNormalizeTool(t *testing.T) { @@ -273,7 +577,11 @@ func TestMarshalCommandInjectsType(t *testing.T) { t.Errorf("marshaled prompt = %s", s) } - b, _ = Marshal(ExtensionUIResponse{ID: "ui-7", Response: "prod"}) + // Only the envelope is asserted here. The old assertion pinned a `response` + // field that no arm of Pi's published RpcExtensionUIResponse contains — Pi + // accepts and discards it. The per-arm assertions belong with the unit that + // builds the arms (U3), against testdata/pi-ui-protocol.json. + b, _ = Marshal(ExtensionUIResponse{ID: "ui-7"}) if !strings.Contains(string(b), `"type":"extension_ui_response"`) || !strings.Contains(string(b), `"id":"ui-7"`) { t.Errorf("marshaled ui response = %s", string(b)) } diff --git a/server/internal/agent/pirun/testdata/pi-ui-protocol.json b/server/internal/agent/pirun/testdata/pi-ui-protocol.json new file mode 100644 index 0000000..174b255 --- /dev/null +++ b/server/internal/agent/pirun/testdata/pi-ui-protocol.json @@ -0,0 +1,204 @@ +{ + "$comment": "Contract fixture for Pi's extension UI sub-protocol. Every line under `requests`, `responses` and `extensionError` is transcribed from the published package listed in `derivedFrom` — NOT from Deuce's own assumptions about the wire. Both the Go decoder tests (server/internal/agent/pirun) and the TypeScript extension tests (server/internal/agent/pirun/extension) assert against this file, so a Pi upgrade is diffed here once instead of being re-guessed on each side of the wire. When Pi changes, re-transcribe from the .d.ts and bump `piVersion`.", + "package": "@earendil-works/pi-coding-agent", + "piVersion": "0.84.0", + "transcribedOn": "2026-08-06", + "derivedFrom": [ + "dist/modes/rpc/rpc-types.d.ts — RpcExtensionUIRequest (nine arms), RpcExtensionUIResponse (three arms)", + "dist/modes/rpc/rpc-mode.js — createDialogPromise() spreads the request flat after {type, id}; createExtensionUIContext() shows each method's emitted fields", + "docs/rpc.md — 'Extension UI Protocol' section and the extension_error event" + ], + "notes": [ + "The request union is FLAT: every field sits at the top level next to `type`, `id` and `method`. No arm nests anything under `params`.", + "Dialog (blocking) methods: select, confirm, input, editor. They block until the client sends a matching extension_ui_response.", + "Fire-and-forget methods: notify, setStatus, setWidget, setTitle, set_editor_text. They carry an `id` but must never be answered and must never raise a pending question.", + "Pi's select response is the chosen LABEL, not an index. A cancelled select/input/editor resolves to undefined; a cancelled or timed-out confirm resolves to false.", + "The `timeout` values below are 2100000ms (35 min), which is what Deuce's extension passes: it must stay strictly above the runtime's defaultAwaitTimeout (30 min) so Deuce's ceiling always fires before Pi's (KTD7)." + ], + + "requests": [ + { + "name": "select", + "method": "select", + "blocking": true, + "responseArm": "value", + "line": { + "type": "extension_ui_request", + "id": "ui-select-1", + "method": "select", + "title": "Which framework should I use?", + "options": ["React", "Vue", "Svelte"], + "timeout": 2100000 + } + }, + { + "name": "confirm", + "method": "confirm", + "blocking": true, + "responseArm": "confirmed", + "line": { + "type": "extension_ui_request", + "id": "ui-confirm-1", + "method": "confirm", + "title": "Force-push to main?", + "message": "This rewrites remote history.", + "timeout": 2100000 + } + }, + { + "name": "confirm_empty_message", + "method": "confirm", + "blocking": true, + "responseArm": "confirmed", + "$comment": "What Deuce's own extension emits: the question rides in `title` and `message` is the empty string Pi's arm requires (U1 step 2).", + "line": { + "type": "extension_ui_request", + "id": "ui-confirm-2", + "method": "confirm", + "title": "Should I delete the stale branches?", + "message": "", + "timeout": 2100000 + } + }, + { + "name": "input", + "method": "input", + "blocking": true, + "responseArm": "value", + "line": { + "type": "extension_ui_request", + "id": "ui-input-1", + "method": "input", + "title": "Which environment should I deploy to?", + "placeholder": "e.g. staging", + "timeout": 2100000 + } + }, + { + "name": "editor", + "method": "editor", + "blocking": true, + "responseArm": "value", + "$comment": "Pi's editor arm carries no timeout field. Deuce's extension never opens one today; it is decoded so a future dialog style is not an unhandled event.", + "line": { + "type": "extension_ui_request", + "id": "ui-editor-1", + "method": "editor", + "title": "Edit the release notes before I publish them", + "prefill": "## Release\n" + } + }, + { + "name": "notify", + "method": "notify", + "blocking": false, + "responseArm": null, + "line": { + "type": "extension_ui_request", + "id": "ui-notify-1", + "method": "notify", + "message": "Subagent alpha finished", + "notifyType": "info" + } + }, + { + "name": "setStatus", + "method": "setStatus", + "blocking": false, + "responseArm": null, + "line": { + "type": "extension_ui_request", + "id": "ui-status-1", + "method": "setStatus", + "statusKey": "subagents", + "statusText": "2 running" + } + }, + { + "name": "setWidget", + "method": "setWidget", + "blocking": false, + "responseArm": null, + "line": { + "type": "extension_ui_request", + "id": "ui-widget-1", + "method": "setWidget", + "widgetKey": "subagents", + "widgetLines": ["alpha running", "beta done"], + "widgetPlacement": "aboveEditor" + } + }, + { + "name": "setTitle", + "method": "setTitle", + "blocking": false, + "responseArm": null, + "line": { + "type": "extension_ui_request", + "id": "ui-title-1", + "method": "setTitle", + "title": "deuce - session 42" + } + }, + { + "name": "set_editor_text", + "method": "set_editor_text", + "blocking": false, + "responseArm": null, + "line": { + "type": "extension_ui_request", + "id": "ui-editortext-1", + "method": "set_editor_text", + "text": "draft reply text" + } + } + ], + + "responses": { + "value": { + "$comment": "Answers select, input and editor. Pi's parser reads `value` and nothing else; for select it is the chosen label.", + "line": { "type": "extension_ui_response", "id": "ui-select-1", "value": "Vue" } + }, + "confirmed": { + "$comment": "Answers confirm. Pi's parser reads `confirmed` and falls back to false when the key is absent — which is why a `response` string silently answered No.", + "line": { "type": "extension_ui_response", "id": "ui-confirm-1", "confirmed": true } + }, + "cancelled": { + "$comment": "Valid for any dialog. Resolves select/input/editor to undefined and confirm to false. Deuce does not send this today (see the plan's deferred work).", + "line": { "type": "extension_ui_response", "id": "ui-input-1", "cancelled": true } + } + }, + + "extensionError": { + "$comment": "docs/rpc.md 'extension_error'. Emitted when an extension throws, including at load time — the only visibility Deuce has into a broken extension.", + "line": { + "type": "extension_error", + "extensionPath": "/home/vscode/.pi/agent/extensions/ask-user.ts", + "event": "tool_call", + "error": "TypeError: ui.select is not a function" + } + }, + + "offContractNote": "The `offContract` entries below are NOT part of Pi's published union. They are shapes Deuce must survive in the field, kept out of `requests` so the contract-completeness assertion stays honest.", + "offContract": { + "selectOptionsAsBareString": { + "$comment": "Emitted by a pre-fix Deuce extension calling select(title, question, options): Pi spreads the request flat, so the question lands in `options` as a string. This is the version-skew case a stale prebuild image reproduces. The question text is in `options` — recovering it from `title` would yield the constant boilerplate title and discard the question (R2).", + "line": { + "type": "extension_ui_request", + "id": "ui-skew-1", + "method": "select", + "title": "A question for you", + "options": "Which framework should I use?" + } + }, + "unknownMethod": { + "$comment": "A dialog method a future Pi adds. Must not raise a pending question, and must not abort the stream.", + "line": { + "type": "extension_ui_request", + "id": "ui-future-1", + "method": "someFutureDialog", + "title": "Pick a colour" + } + } + } +} diff --git a/server/internal/agent/runtime_test.go b/server/internal/agent/runtime_test.go index 669dfe4..bb884a0 100644 --- a/server/internal/agent/runtime_test.go +++ b/server/internal/agent/runtime_test.go @@ -2,10 +2,13 @@ package agent import ( "bufio" + "bytes" "context" "encoding/json" "fmt" "io" + "os" + "path/filepath" "sort" "sync" "testing" @@ -507,7 +510,7 @@ func TestRecycleIdleStopsOnlyIdleSessions(t *testing.T) { // s3 is blocked on a question (awaiting_input) — busy, must NOT recycle. t3, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s3", Prompt: "c", WorkspaceID: "ws3"}) bc.waitFor(t, ws.TypeTaskStarted, 3) - lr.handle(t, 2).push(`{"type":"extension_ui_request","id":"ui-1","params":{"prompt":"?"}}`) + lr.handle(t, 2).push(uiRequestLine(t, "input")) bc.waitFor(t, ws.TypeTaskAwaitingInput, 1) rt.RecycleIdleProcesses() @@ -553,7 +556,7 @@ func TestRouteAnswersAwaitingInput(t *testing.T) { bc.waitFor(t, ws.TypeTaskStarted, 1) h := lr.handle(t, 0) - h.push(`{"type":"extension_ui_request","id":"ui-1","method":"input","params":{"prompt":"which env?"}}`) + h.push(uiRequestLine(t, "input")) bc.waitFor(t, ws.TypeTaskAwaitingInput, 1) if store.state(task) != StateAwaitingInput { t.Fatalf("state = %q, want awaiting_input", store.state(task)) @@ -563,9 +566,12 @@ func TestRouteAnswersAwaitingInput(t *testing.T) { if err != nil || res != RouteFed { t.Fatalf("RouteOrEnqueue = (%v,%v), want RouteFed", res, err) } + // The response arm this must carry is asserted by U3, against + // pirun/testdata/pi-ui-protocol.json. Here the point is only that the answer + // is correlated back to the request id that opened the dialog. m := h.waitCmd(t, "extension_ui_response") - if m["id"] != "ui-1" || m["response"] != "prod" { - t.Errorf("extension_ui_response = %v, want id=ui-1 response=prod", m) + if m["id"] != "ui-input-1" { + t.Errorf("extension_ui_response = %v, want id=ui-input-1", m) } if store.state(task) != StateRunning { t.Errorf("state after answer = %q, want running", store.state(task)) @@ -594,7 +600,7 @@ func TestAwaitingCeilingFailsTask(t *testing.T) { task, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "go", WorkspaceID: "ws"}) bc.waitFor(t, ws.TypeTaskStarted, 1) - lr.handle(t, 0).push(`{"type":"extension_ui_request","id":"ui-1","params":{"prompt":"?"}}`) + lr.handle(t, 0).push(uiRequestLine(t, "input")) bc.waitFor(t, ws.TypeTaskAwaitingInput, 1) // No answer → ceiling fails the task and frees the lane. bc.waitFor(t, ws.TypeTaskCompleted, 1) @@ -603,6 +609,80 @@ func TestAwaitingCeilingFailsTask(t *testing.T) { } } +// TestAwaitingSuspendsActiveTimeout covers AE5 / R4: a decoded blocking request +// suspends the active-work budget and starts the awaiting ceiling instead, so a +// question outliving the active budget is still answerable. This is the timeout +// the original bug never reached — the select line was dropped before it could +// fire, so the ten-minute active budget killed the task with the question still +// unanswered. +func TestAwaitingSuspendsActiveTimeout(t *testing.T) { + rt, store, bc, lr := newTestRuntime(t) + // Active budget expires almost immediately; the ceiling is generous. If the + // question did not suspend the active timer, the task would be failed. + rt.activeTimeout = 200 * time.Millisecond + rt.awaitTimeout = 30 * time.Second + ctx := context.Background() + task, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "go", WorkspaceID: "ws"}) + bc.waitFor(t, ws.TypeTaskStarted, 1) + h := lr.handle(t, 0) + + h.push(uiRequestLine(t, "select")) + bc.waitFor(t, ws.TypeTaskAwaitingInput, 1) + + // Well past the active budget, with the ceiling nowhere near. + time.Sleep(500 * time.Millisecond) + if got := store.state(task); got != StateAwaitingInput { + t.Fatalf("state = %q past the active budget, want awaiting_input (the active timeout must be suspended while a question is pending)", got) + } + + // And it is still answerable. + res, err := rt.RouteOrEnqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "Vue"}) + if err != nil || res != RouteFed { + t.Fatalf("RouteOrEnqueue = (%v,%v), want RouteFed — a question past the active budget must still be answerable", res, err) + } + if m := h.waitCmd(t, "extension_ui_response"); m["id"] != "ui-select-1" { + t.Errorf("extension_ui_response = %v, want id=ui-select-1", m) + } + if got := store.state(task); got != StateRunning { + t.Errorf("state after answer = %q, want running", got) + } +} + +// uiRequestLine returns a named arm of Pi's published extension_ui_request +// union as a single JSONL line, read from the contract fixture the decoder +// tests assert against (server/internal/agent/pirun/testdata/pi-ui-protocol.json). +// Runtime tests use it instead of inline literals so both layers exercise the +// same transcribed wire shape — inline literals invented next to the decoder +// are what let the wrong decoder pass a green suite (KTD6). +func uiRequestLine(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("pirun", "testdata", "pi-ui-protocol.json")) + if err != nil { + t.Fatalf("read pi-ui-protocol fixture: %v", err) + } + var f struct { + Requests []struct { + Name string `json:"name"` + Line json.RawMessage `json:"line"` + } `json:"requests"` + } + if err := json.Unmarshal(b, &f); err != nil { + t.Fatalf("parse pi-ui-protocol fixture: %v", err) + } + for _, r := range f.Requests { + if r.Name != name { + continue + } + var buf bytes.Buffer + if err := json.Compact(&buf, r.Line); err != nil { + t.Fatalf("compact fixture line: %v", err) + } + return buf.String() + } + t.Fatalf("fixture has no request named %q", name) + return "" +} + func sameOrder(got, want []string) bool { if len(got) != len(want) { return false From b3a4b533df845eace82c226168d8d4df204ac9c4 Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Thu, 6 Aug 2026 22:46:07 -0600 Subject: [PATCH 3/7] fix(agent): send answers in Pi's response arm, not a discarded key Pi's extension_ui_response is a three-arm union -- value for select/input/ editor, confirmed for confirm, cancelled for any. Deuce sent a single "response" key, which Pi's dispatcher accepts, correlates, and then resolves to its parser fallback: undefined for value dialogs and false for confirm. Every answer was discarded, and every yes/no question was answered "no" regardless of what the user clicked. - Express the three arms in ExtensionUIResponse, one arm per message. - Track the dialog's method alongside its request id so the answer path can choose the arm; an awaiting task with no tracked dialog falls through to steer rather than emitting an armless response. - Map yes/no by leading token, case-insensitively, so "yes, go ahead" from the drawer composer is affirmative. Anything matching neither set logs at warn and defaults to false, so an unparsed reply can never read as approval. Co-Authored-By: Claude Opus 5 (1M context) --- server/internal/agent/pirun/protocol.go | 49 ++++- server/internal/agent/runtime.go | 89 +++++++- server/internal/agent/runtime_test.go | 268 +++++++++++++++++++++++- 3 files changed, 384 insertions(+), 22 deletions(-) diff --git a/server/internal/agent/pirun/protocol.go b/server/internal/agent/pirun/protocol.go index fcfa489..eb15e37 100644 --- a/server/internal/agent/pirun/protocol.go +++ b/server/internal/agent/pirun/protocol.go @@ -65,13 +65,58 @@ func (SetSteeringMode) commandType() string { return "set_steering_mode" } // ExtensionUIResponse answers a blocking extension_ui_request (the ask-user // mechanism, KTD15). The ID must match the originating request's ID. +// +// Pi's RpcExtensionUIResponse is a three-arm union (transcribed in +// testdata/pi-ui-protocol.json from the version recorded there): +// +// {value: string} answers select, input and editor — for select it is +// the chosen option LABEL, not an index +// {confirmed: boolean} answers confirm — a JSON boolean +// {cancelled: true} valid for any dialog +// +// Exactly one arm may be set. Pi's stdin dispatcher correlates on type+id only +// and hands the raw object to a per-method parser, so an unrecognized key is +// not an error — it resolves to that parser's fallback (undefined for the value +// arms, false for confirm). That is why the previous single `response` field +// silently discarded every answer, and why the arms are pointers here: a +// zero-valued scalar would emit a stray second key that Pi would read as the +// answer. Build one with UIResponseValue / UIResponseConfirmed / +// UIResponseCancelled rather than by hand. type ExtensionUIResponse struct { - ID string `json:"id"` - Response any `json:"response"` + ID string `json:"id"` + Value *string `json:"value,omitempty"` + Confirmed *bool `json:"confirmed,omitempty"` + Cancelled bool `json:"cancelled,omitempty"` } func (ExtensionUIResponse) commandType() string { return "extension_ui_response" } +// UIResponseValue answers a select, input or editor dialog. For select, value +// must be the chosen option's label. +func UIResponseValue(id, value string) ExtensionUIResponse { + return ExtensionUIResponse{ID: id, Value: &value} +} + +// UIResponseConfirmed answers a confirm dialog with Pi's boolean arm. +func UIResponseConfirmed(id string, confirmed bool) ExtensionUIResponse { + return ExtensionUIResponse{ID: id, Confirmed: &confirmed} +} + +// UIResponseCancelled dismisses any dialog without an answer. Pi resolves a +// cancelled select/input/editor to undefined and a cancelled confirm to false. +// Deuce does not send this today — a stopped run tears the Pi process down, so +// the dialog dies with it — but the arm is part of the union Pi publishes and +// this is the only shape it may take. +func UIResponseCancelled(id string) ExtensionUIResponse { + return ExtensionUIResponse{ID: id, Cancelled: true} +} + +// IsConfirmMethod reports whether a dialog opened with this UI method (as the +// decoder reports it in Event.RequestKind) is answered with the `confirmed` +// boolean arm rather than the `value` string arm. Every other blocking method — +// select, input, and editor, which the decoder folds into input — takes value. +func IsConfirmMethod(method string) bool { return method == uiMethodConfirm } + // Marshal renders a command as a single JSONL line (no trailing newline; the // writer adds it). It injects the discriminator "type" field Pi expects. func Marshal(c Command) ([]byte, error) { diff --git a/server/internal/agent/runtime.go b/server/internal/agent/runtime.go index d5e054e..3fccd41 100644 --- a/server/internal/agent/runtime.go +++ b/server/internal/agent/runtime.go @@ -40,7 +40,7 @@ type Runtime struct { workspace map[pirun.Key]string // workspace id per key, for relaunch consumers map[pirun.Key]*pirun.Process // process a consumer goroutine is attached to replies map[string]*strings.Builder // accumulated assistant reply per task id - pendingReq map[string]string // task id → pending extension_ui_request id + pendingReq map[string]pendingRequest // task id → the dialog it is blocked on timers map[string]*taskTimers // per-task active-work / awaiting-input timers activeTimeout time.Duration // active-work budget (suspended during awaiting_input) @@ -67,6 +67,15 @@ type taskTimers struct { await *time.Timer } +// pendingRequest is the blocking Pi dialog a task is waiting on. The method is +// tracked alongside the id because Pi's response union has one arm per method +// (KTD3): confirm takes a boolean, select/input/editor take a string value. +// Answering with the wrong arm is silently discarded by Pi's parser. +type pendingRequest struct { + id string + method string // decoder-normalized UI method: select / confirm / input +} + const ( defaultActiveTimeout = 10 * time.Minute defaultAwaitTimeout = 30 * time.Minute @@ -101,7 +110,7 @@ func NewRuntime(store Store, sup *pirun.Supervisor, bc Broadcaster, baseSystemPr workspace: make(map[pirun.Key]string), consumers: make(map[pirun.Key]*pirun.Process), replies: make(map[string]*strings.Builder), - pendingReq: make(map[string]string), + pendingReq: make(map[string]pendingRequest), timers: make(map[string]*taskTimers), activeTimeout: defaultActiveTimeout, awaitTimeout: defaultAwaitTimeout, @@ -205,10 +214,14 @@ func (r *Runtime) RouteOrEnqueue(ctx context.Context, p EnqueueParams) (RouteRes if err != nil { return 0, err } - if sok && state == StateAwaitingInput { - // Answer the agent's blocking question (KTD15). - reqID := r.pendingRequest(taskID) - if err := r.sup.Send(key, pirun.ExtensionUIResponse{ID: reqID, Response: p.Prompt}); err == nil { + // Answer the agent's blocking question (KTD15). The tracked dialog's + // method picks the response arm; with no tracked dialog there is no arm + // to fill, so fall through to steer rather than sending an armless + // response Pi would silently resolve to its own fallback. (Boot recovery + // fails every awaiting_input task before the scheduler starts, so an + // untracked awaiting task is a tracking gap, not a restart path.) + if pend, tracked := r.pendingDialog(taskID); sok && state == StateAwaitingInput && tracked { + if err := r.sup.Send(key, uiResponseFor(taskID, pend, p.Prompt)); err == nil { // The run has resumed in-process — always tear down the awaiting // ceiling and pending state so it can't later fail a live task, // even if the DB resolve below fails (the next event reconciles). @@ -242,6 +255,57 @@ func (r *Runtime) RouteOrEnqueue(ctx context.Context, p EnqueueParams) (RouteRes return RouteEnqueued, nil } +// uiResponseFor builds the arm of Pi's response union that the pending dialog's +// method expects (KTD3): a boolean for confirm, the answer text as the value for +// select, input and editor. For select the drawer sends the chosen option's +// label, which is exactly what Pi's value arm wants, so typed free text answers +// a pick-one question unchanged (R7). +func uiResponseFor(taskID string, pend pendingRequest, answer string) pirun.ExtensionUIResponse { + if pirun.IsConfirmMethod(pend.method) { + return pirun.UIResponseConfirmed(pend.id, answerIsAffirmative(taskID, answer)) + } + return pirun.UIResponseValue(pend.id, answer) +} + +// affirmative/negative are the leading tokens recognized on a yes/no answer. +// The drawer's Yes/No buttons send "yes"/"no", but its composer stays live +// beside them, so free text reaches here by design. +var ( + affirmativeTokens = map[string]bool{"yes": true, "y": true, "yeah": true, "ok": true, "sure": true} + negativeTokens = map[string]bool{"no": true, "n": true, "nope": true} +) + +// answerIsAffirmative maps a drawer answer onto Pi's confirm boolean by leading +// token, case-insensitively. An answer matching neither set is logged and +// treated as NEGATIVE: an unparsed reply must never read as approval ("not sure" +// cannot authorize a force-push), and false is also Pi's own confirm fallback, +// so a mis-decoded case degrades identically on both sides of the wire. +// Matching on the leading token rather than the whole string is what keeps +// "yes, go ahead" affirmative (R6/AE8). +func answerIsAffirmative(taskID, answer string) bool { + tok := leadingWord(answer) + if affirmativeTokens[tok] { + return true + } + if !negativeTokens[tok] { + slog.Warn("runtime: unrecognized yes/no answer, answering no", + "task", taskID, "answer", answer) + } + return false +} + +// leadingWord lowercases an answer and returns its leading run of ASCII letters, +// so trailing punctuation ("yeah!") and following words ("yes, go ahead") do not +// defeat the match. +func leadingWord(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + i := 0 + for i < len(s) && s[i] >= 'a' && s[i] <= 'z' { + i++ + } + return s[:i] +} + // promote takes the per-key lock and promotes the next queued task if the agent // is idle. func (r *Runtime) promote(ctx context.Context, key pirun.Key) { @@ -381,7 +445,7 @@ func (r *Runtime) translate(key pirun.Key, ev pirun.Event) { slog.Error("runtime: set awaiting input", "task", taskID, "error", err) return } - r.setPending(taskID, ev.RequestID) + r.setPending(taskID, ev.RequestID, ev.RequestKind) r.enterAwaiting(key, taskID) // suspend active timeout, start ceiling (KTD8) r.broadcastTask(ws.TypeTaskAwaitingInput, ws.TaskEventPayload{ Seq: seq, TaskID: taskID, State: StateAwaitingInput, @@ -573,16 +637,19 @@ func (r *Runtime) takeReply(taskID string) string { return b.String() } -func (r *Runtime) setPending(taskID, reqID string) { +func (r *Runtime) setPending(taskID, reqID, method string) { r.mu.Lock() - r.pendingReq[taskID] = reqID + r.pendingReq[taskID] = pendingRequest{id: reqID, method: method} r.mu.Unlock() } -func (r *Runtime) pendingRequest(taskID string) string { +// pendingDialog returns the dialog a task is blocked on. ok is false when no +// request is tracked, in which case no response arm can be chosen. +func (r *Runtime) pendingDialog(taskID string) (pendingRequest, bool) { r.mu.Lock() defer r.mu.Unlock() - return r.pendingReq[taskID] + p, ok := r.pendingReq[taskID] + return p, ok } func (r *Runtime) clearPending(taskID string) { diff --git a/server/internal/agent/runtime_test.go b/server/internal/agent/runtime_test.go index bb884a0..ac39c7b 100644 --- a/server/internal/agent/runtime_test.go +++ b/server/internal/agent/runtime_test.go @@ -7,9 +7,12 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "os" "path/filepath" + "reflect" "sort" + "strings" "sync" "testing" "time" @@ -566,18 +569,171 @@ func TestRouteAnswersAwaitingInput(t *testing.T) { if err != nil || res != RouteFed { t.Fatalf("RouteOrEnqueue = (%v,%v), want RouteFed", res, err) } - // The response arm this must carry is asserted by U3, against - // pirun/testdata/pi-ui-protocol.json. Here the point is only that the answer - // is correlated back to the request id that opened the dialog. + // The answer must be correlated back to the request id that opened the + // dialog AND ride in the arm Pi's parser reads for this method — the old + // `response` field was accepted and discarded, so the id alone proves + // nothing about the user's answer arriving. m := h.waitCmd(t, "extension_ui_response") - if m["id"] != "ui-input-1" { - t.Errorf("extension_ui_response = %v, want id=ui-input-1", m) + assertResponseMatchesArm(t, m, "value", "ui-input-1", "prod") + if store.state(task) != StateRunning { + t.Errorf("state after answer = %q, want running", store.state(task)) + } +} + +// --- answer round trips (U3 / KTD3) ----------------------------------------- +// +// Each of these drives a real Pi request line from the contract fixture through +// to the response Deuce writes back, and asserts that response against the +// fixture's own arm rather than against a literal written beside the code under +// test. The steer-while-running case (no question pending) is covered by +// TestRouteFeedsRunningRun above. + +// TestAnswerSelectSendsChosenLabel covers AE1/R5: Pi's select response is the +// chosen option's LABEL in the `value` arm, not an index and not a `response`. +func TestAnswerSelectSendsChosenLabel(t *testing.T) { + rt, store, _, h, task := awaitingOnFixture(t, "select") + + res, err := rt.RouteOrEnqueue(context.Background(), EnqueueParams{SessionID: "s1", Prompt: "Vue"}) + if err != nil || res != RouteFed { + t.Fatalf("RouteOrEnqueue = (%v,%v), want RouteFed", res, err) + } + m := h.waitCmd(t, "extension_ui_response") + // The fixture's value arm is exactly this answer to exactly this request. + if want := uiResponseLine(t, "value"); !reflect.DeepEqual(m, want) { + t.Errorf("extension_ui_response = %v, want %v (the fixture's value arm)", m, want) } if store.state(task) != StateRunning { t.Errorf("state after answer = %q, want running", store.state(task)) } } +// TestAnswerSelectWithTypedTextSendsItUnchanged covers R7: the drawer keeps its +// "or type another answer below" path, and typed text answers a pick-one +// question verbatim. +func TestAnswerSelectWithTypedTextSendsItUnchanged(t *testing.T) { + rt, _, _, h, _ := awaitingOnFixture(t, "select") + + const typed = "None of these — use Solid" + if _, err := rt.RouteOrEnqueue(context.Background(), EnqueueParams{SessionID: "s1", Prompt: typed}); err != nil { + t.Fatalf("RouteOrEnqueue: %v", err) + } + assertResponseMatchesArm(t, h.waitCmd(t, "extension_ui_response"), "value", "ui-select-1", typed) +} + +// TestAnswerInputSendsTypedText covers R5 for the free-text style. +func TestAnswerInputSendsTypedText(t *testing.T) { + rt, _, _, h, _ := awaitingOnFixture(t, "input") + + if _, err := rt.RouteOrEnqueue(context.Background(), EnqueueParams{SessionID: "s1", Prompt: "staging"}); err != nil { + t.Fatalf("RouteOrEnqueue: %v", err) + } + assertResponseMatchesArm(t, h.waitCmd(t, "extension_ui_response"), "value", "ui-input-1", "staging") +} + +// TestAnswerConfirmSendsBoolean covers AE3/R6: Yes and No reach Pi as booleans +// in the `confirmed` arm. Sending the drawer's literal "yes"/"no" string in a +// `response` field is what made every yes/no answer read as No. +func TestAnswerConfirmSendsBoolean(t *testing.T) { + for _, tc := range []struct { + name, answer string + want bool + }{ + {"yes button", "yes", true}, + {"no button", "no", false}, + // AE8: the composer stays live beside the buttons, so free text is a + // reachable input on a yes/no question. + {"affirmative free text", "yes, go ahead", true}, + {"uppercase with punctuation", "Yeah!", true}, + {"negative free text", "no, stop", false}, + } { + t.Run(tc.name, func(t *testing.T) { + rt, _, _, h, _ := awaitingOnFixture(t, "confirm") + + if _, err := rt.RouteOrEnqueue(context.Background(), EnqueueParams{SessionID: "s1", Prompt: tc.answer}); err != nil { + t.Fatalf("RouteOrEnqueue: %v", err) + } + m := h.waitCmd(t, "extension_ui_response") + assertResponseMatchesArm(t, m, "confirmed", "ui-confirm-1", tc.want) + if tc.want { + // Guard the exact bug: a Yes that arrives as anything but a true + // boolean is discarded by Pi's parser and falls back to false. + if b, ok := m["confirmed"].(bool); !ok || !b { + t.Errorf("confirmed = %#v, want the JSON boolean true", m["confirmed"]) + } + } + }) + } +} + +// TestAnswerConfirmUnrecognizedTextIsNegativeAndLogged: an answer matching +// neither token set defaults to false and is logged at warn. Defaulting negative +// means an unparsed reply can never read as approval, and it matches Pi's own +// confirm fallback. +func TestAnswerConfirmUnrecognizedTextIsNegativeAndLogged(t *testing.T) { + logs := captureLogs(t) + rt, _, _, h, _ := awaitingOnFixture(t, "confirm") + + if _, err := rt.RouteOrEnqueue(context.Background(), EnqueueParams{SessionID: "s1", Prompt: "not sure"}); err != nil { + t.Fatalf("RouteOrEnqueue: %v", err) + } + m := h.waitCmd(t, "extension_ui_response") + assertResponseMatchesArm(t, m, "confirmed", "ui-confirm-1", false) + + out := logs.String() + if !strings.Contains(out, "level=WARN") || !strings.Contains(out, "unrecognized yes/no answer") { + t.Errorf("want a WARN log naming the unrecognized answer, got:\n%s", out) + } + if !strings.Contains(out, "not sure") { + t.Errorf("warn log should carry the answer text for diagnosis, got:\n%s", out) + } +} + +// TestAnswerClearsPendingDialog: the pending record is dropped once answered, so +// a later reply on the same task cannot be mistaken for a second answer to a +// dialog Pi has already resolved. +func TestAnswerClearsPendingDialog(t *testing.T) { + rt, _, _, h, task := awaitingOnFixture(t, "input") + if _, ok := rt.pendingDialog(task); !ok { + t.Fatalf("no pending dialog tracked while awaiting_input") + } + + if _, err := rt.RouteOrEnqueue(context.Background(), EnqueueParams{SessionID: "s1", Prompt: "staging"}); err != nil { + t.Fatalf("RouteOrEnqueue: %v", err) + } + h.waitCmd(t, "extension_ui_response") + + if pend, ok := rt.pendingDialog(task); ok { + t.Errorf("pending dialog still tracked after answering: %+v", pend) + } +} + +// TestAwaitingWithoutTrackedDialogSteers: with a task marked awaiting but no +// tracked dialog there is no arm to fill, so the reply falls through to the +// existing steer path rather than emitting an armless response Pi would resolve +// to its own fallback. Boot recovery fails every awaiting_input task before the +// scheduler starts, so this is a tracking-gap guard, not a restart path. +func TestAwaitingWithoutTrackedDialogSteers(t *testing.T) { + rt, store, bc, lr := newTestRuntime(t) + ctx := context.Background() + task, _ := rt.Enqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "go", WorkspaceID: "ws"}) + bc.waitFor(t, ws.TypeTaskStarted, 1) + h := lr.handle(t, 0) + + // Awaiting in the store, with nothing tracked in the runtime. + store.setState("s1", task, StateAwaitingInput) + if _, ok := rt.pendingDialog(task); ok { + t.Fatalf("precondition: no dialog should be tracked") + } + + res, err := rt.RouteOrEnqueue(ctx, EnqueueParams{SessionID: "s1", Prompt: "carry on"}) + if err != nil || res != RouteFed { + t.Fatalf("RouteOrEnqueue = (%v,%v), want RouteFed", res, err) + } + if m := h.waitCmd(t, "steer"); m["message"] != "carry on" { + t.Errorf("steer = %v, want message 'carry on'", m) + } +} + func TestRouteEnqueuesWhenIdle(t *testing.T) { rt, store, bc, _ := newTestRuntime(t) ctx := context.Background() @@ -648,6 +804,103 @@ func TestAwaitingSuspendsActiveTimeout(t *testing.T) { } } +// awaitingOnFixture starts a task and drives it to awaiting_input by pushing the +// named request arm from the contract fixture, returning the runtime, its store, +// the broadcaster, the Pi handle and the blocked task id. +func awaitingOnFixture(t *testing.T, request string) (*Runtime, *fakeStore, *fakeBroadcaster, *tHandle, string) { + t.Helper() + rt, store, bc, lr := newTestRuntime(t) + task, err := rt.Enqueue(context.Background(), EnqueueParams{SessionID: "s1", Prompt: "go", WorkspaceID: "ws"}) + if err != nil { + t.Fatalf("Enqueue: %v", err) + } + bc.waitFor(t, ws.TypeTaskStarted, 1) + h := lr.handle(t, 0) + h.push(uiRequestLine(t, request)) + bc.waitFor(t, ws.TypeTaskAwaitingInput, 1) + if store.state(task) != StateAwaitingInput { + t.Fatalf("state = %q, want awaiting_input", store.state(task)) + } + return rt, store, bc, h, task +} + +// assertResponseMatchesArm checks an emitted extension_ui_response against the +// fixture's arm for `arm`, with the id and answer substituted. The comparison is +// over the whole map, so a second arm or a stray legacy `response` field fails +// it — Pi's dispatcher correlates on type+id only and hands the raw object to a +// per-method parser, so an extra key is not an error on the wire and only an +// exact-shape assertion can catch one. +func assertResponseMatchesArm(t *testing.T, got map[string]any, arm, id string, answer any) { + t.Helper() + want := uiResponseLine(t, arm) + want["id"] = id + want[arm] = answer + if !reflect.DeepEqual(got, want) { + t.Errorf("extension_ui_response = %v, want %v (Pi's %q arm)", got, want, arm) + } +} + +// readProtocolFixture returns the parsed contract fixture the decoder tests also +// assert against (server/internal/agent/pirun/testdata/pi-ui-protocol.json). +func readProtocolFixture(t *testing.T) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("pirun", "testdata", "pi-ui-protocol.json")) + if err != nil { + t.Fatalf("read pi-ui-protocol fixture: %v", err) + } + return b +} + +// uiResponseLine returns a named arm of Pi's published RpcExtensionUIResponse +// union ("value", "confirmed", "cancelled") as a decoded JSON object, so the +// runtime's answers are asserted against the transcribed contract rather than +// against literals written next to the code that builds them (KTD6). +func uiResponseLine(t *testing.T, arm string) map[string]any { + t.Helper() + var f struct { + Responses map[string]struct { + Line map[string]any `json:"line"` + } `json:"responses"` + } + if err := json.Unmarshal(readProtocolFixture(t), &f); err != nil { + t.Fatalf("parse pi-ui-protocol fixture: %v", err) + } + r, ok := f.Responses[arm] + if !ok { + t.Fatalf("fixture has no response arm %q", arm) + } + return r.Line +} + +// syncBuffer collects log output from the runtime's own goroutines as well as +// the test's. +type syncBuffer struct { + mu sync.Mutex + b bytes.Buffer +} + +func (s *syncBuffer) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.Write(p) +} + +func (s *syncBuffer) String() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.String() +} + +// captureLogs redirects the default slog logger into a buffer for the test. +func captureLogs(t *testing.T) *syncBuffer { + t.Helper() + buf := &syncBuffer{} + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(prev) }) + return buf +} + // uiRequestLine returns a named arm of Pi's published extension_ui_request // union as a single JSONL line, read from the contract fixture the decoder // tests assert against (server/internal/agent/pirun/testdata/pi-ui-protocol.json). @@ -656,10 +909,7 @@ func TestAwaitingSuspendsActiveTimeout(t *testing.T) { // are what let the wrong decoder pass a green suite (KTD6). func uiRequestLine(t *testing.T, name string) string { t.Helper() - b, err := os.ReadFile(filepath.Join("pirun", "testdata", "pi-ui-protocol.json")) - if err != nil { - t.Fatalf("read pi-ui-protocol fixture: %v", err) - } + b := readProtocolFixture(t) var f struct { Requests []struct { Name string `json:"name"` From 880642cb3262b066e01ddfd6cc81b27345a42a7a Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Thu, 6 Aug 2026 22:55:19 -0600 Subject: [PATCH 4/7] fix(ask-user): call Pi's dialogs with their published signatures Pi's select takes (title, options, opts). The extension passed (title, question, options), so the question landed in the options slot and Pi emitted options as a string -- the line Go could not unmarshal and dropped, which is the ten-minute hang. input passed the question as a placeholder, and confirm buried it behind a constant title. - Put the question in title for select, confirm, and input, so all three styles render as the bare question. - Drop the ui.select/ui.confirm capability probes. hasUI is true in rpc mode and all four dialogs exist there, so both probes were always true and their fallback branches were dead. - Own the no-answer deadline in the extension. Pi resolves a timed-out confirm to false, which is what a real "No" resolves to, so the resolved value cannot distinguish them -- an internal abort flag selects the explicit no-answer result instead. - Pass a 35-minute dialog timeout, above the runtime's 30-minute awaiting ceiling so Deuce's ceiling always fires first, commented at both ends of the cross-language invariant. - Give the extension a type-check and a behavioral suite. It previously sat outside every tsconfig project, so nothing type-checked the file and nothing asserted what its dialog calls emit. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 2050 ++++++++++++++++- package.json | 2 + .../agent/pirun/extension/ask-user.test.ts | 401 ++++ .../agent/pirun/extension/ask-user.ts | 146 +- server/internal/agent/runtime.go | 13 +- tsconfig.extension.json | 37 + tsconfig.json | 3 +- 7 files changed, 2597 insertions(+), 55 deletions(-) create mode 100644 server/internal/agent/pirun/extension/ask-user.test.ts create mode 100644 tsconfig.extension.json diff --git a/package-lock.json b/package-lock.json index d39735e..f332d16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "zustand": "^5.0.13" }, "devDependencies": { + "@earendil-works/pi-coding-agent": "^0.84.0", "@eslint/js": "^10.0.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -46,6 +47,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", "jsdom": "^29.1.1", + "typebox": "^1.3.7", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", "vite": "^8.0.10", @@ -512,6 +514,2013 @@ "node": ">=20.19.0" } }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.84.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.0.tgz", + "integrity": "sha512-oxEU7BT9xuVT6UKNwUNDzNP5dVGb+DZRGfaEyMyAab8dRlqTSxxyhSlMAxmYsu//YOeasj9E8n2+px1BzIai0g==", + "dev": true, + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.84.0", + "@earendil-works/pi-ai": "^0.84.0", + "@earendil-works/pi-client": "^0.84.0", + "@earendil-works/pi-protocol": "^0.84.0", + "@earendil-works/pi-tui": "^0.84.0", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "grok-mermaid": "0.2.2", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.3.7", + "undici": "8.9.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "dev": true, + "license": "MIT", + "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/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "Apache-2.0", + "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/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "Apache-2.0", + "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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "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, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "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, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "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, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "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, + "license": "Apache-2.0", + "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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "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, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "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, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "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, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "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, + "license": "Apache-2.0", + "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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "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, + "license": "Apache-2.0", + "dependencies": { + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "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, + "license": "Apache-2.0", + "dependencies": { + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "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, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "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, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "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, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "Apache-2.0", + "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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "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, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "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, + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "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, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.84.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.0.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.84.0", + "@earendil-works/pi-telemetry": "^0.84.0", + "diff": "8.0.4", + "ignore": "7.0.5", + "typebox": "1.3.7", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.84.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.0.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.84.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "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/node_modules/@earendil-works/pi-client": { + "version": "0.84.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.0.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-protocol": "^0.84.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-protocol": { + "version": "0.84.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.0.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "typebox": "1.3.7" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry": { + "version": "0.84.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.0.tgz", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.84.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.0.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "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/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "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, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "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, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": 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, + "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, + "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, + "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, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": 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, + "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, + "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, + "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, + "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, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "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, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "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, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "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, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "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, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "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, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": 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, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": 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, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": 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" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "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" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "dev": true, + "license": "Apache-2.0", + "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/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "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, + "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, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "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, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "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, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": 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, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": 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, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/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", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "license": "MIT", + "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/@earendil-works/pi-coding-agent/node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/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==", + "dev": 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, + "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" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "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/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "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, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "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, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "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, + "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" + } + ], + "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, + "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, + "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, + "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, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": 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, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "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 + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/xml-naming": { + "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" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -860,6 +2869,7 @@ "version": "0.128.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -2514,6 +4524,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2530,6 +4541,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2546,6 +4558,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2562,6 +4575,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2578,6 +4592,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2594,6 +4609,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2610,6 +4626,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2626,6 +4643,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2642,6 +4660,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2658,6 +4677,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2674,6 +4694,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2690,6 +4711,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2706,6 +4728,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2724,6 +4747,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2740,6 +4764,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3371,7 +5396,7 @@ "version": "24.12.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.3.tgz", "integrity": "sha512-8oljBDGun9cIsZRJR6fkihn0TSXJI0UDOOhncYaERq6M0JMDoPLxyscwruJcb4GKS6dvK/d8xebYBg27h/duaQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -3387,6 +5412,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3396,7 +5422,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -4177,6 +6203,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -4600,6 +6627,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -4676,6 +6704,7 @@ "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, "license": "MIT", "optional": true, @@ -6386,6 +8415,7 @@ "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, "funding": [ { "type": "github", @@ -6547,12 +8577,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6565,6 +8597,7 @@ "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -7017,6 +9050,7 @@ "version": "1.0.0-rc.18", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", + "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.128.0", @@ -7050,6 +9084,7 @@ "version": "1.0.0-rc.18", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", + "dev": true, "license": "MIT" }, "node_modules/saxes": { @@ -7267,6 +9302,7 @@ "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -7387,6 +9423,13 @@ "node": ">= 0.8.0" } }, + "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, + "license": "MIT" + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -7439,7 +9482,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unified": { @@ -7654,6 +9697,7 @@ "version": "8.0.11", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", + "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", diff --git a/package.json b/package.json index 9a2be8c..fae800b 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "zustand": "^5.0.13" }, "devDependencies": { + "@earendil-works/pi-coding-agent": "^0.84.0", "@eslint/js": "^10.0.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -49,6 +50,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", "jsdom": "^29.1.1", + "typebox": "^1.3.7", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", "vite": "^8.0.10", diff --git a/server/internal/agent/pirun/extension/ask-user.test.ts b/server/internal/agent/pirun/extension/ask-user.test.ts new file mode 100644 index 0000000..ec8595a --- /dev/null +++ b/server/internal/agent/pirun/extension/ask-user.test.ts @@ -0,0 +1,401 @@ +// Behavioral suite for the ask-user Pi extension (U1 step 8). +// +// Why this exists: the original bug was an argument-order error against Pi's +// `ExtensionUIContext` — `select(title, options, opts)` called as +// `select(title, question, options)`. Nothing caught it. A type-check catches +// it now (U1 step 6), but a type-check cannot assert that the *question text* +// lands in the field the drawer reads, that a timed-out yes/no is told apart +// from a real "No", or that every dialog carries the timeout the cross-language +// KTD7 invariant depends on. That is what this suite asserts. +// +// The expected wire shapes are not invented here: they are read from +// ../testdata/pi-ui-protocol.json, the contract fixture transcribed from Pi's +// published package. Both sides of the wire assert against that one file. + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { + ExtensionAPI, + ExtensionContext, + ExtensionUIContext, + ExtensionUIDialogOptions, +} from "@earendil-works/pi-coding-agent"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import askUser from "./ask-user"; + +// --------------------------------------------------------------------------- +// Contract fixture +// --------------------------------------------------------------------------- + +interface FixtureRequest { + name: string; + method: string; + blocking: boolean; + line: Record; +} + +interface Fixture { + piVersion: string; + requests: FixtureRequest[]; +} + +const here = path.dirname(fileURLToPath(import.meta.url)); +const fixture = JSON.parse( + readFileSync(path.join(here, "..", "testdata", "pi-ui-protocol.json"), "utf8"), +) as Fixture; + +function arm(name: string): Record { + const found = fixture.requests.find((r) => r.name === name); + if (!found) throw new Error(`contract fixture has no request arm named ${name}`); + return found.line; +} + +// The ceiling Deuce's runtime applies to an unanswered question +// (defaultAwaitTimeout in server/internal/agent/runtime.go). Pi's dialog +// timeout must stay strictly above it (KTD7). +const DEUCE_AWAIT_CEILING_MS = 30 * 60 * 1000; + +// --------------------------------------------------------------------------- +// Pi harness +// --------------------------------------------------------------------------- + +/** A dialog the extension opened, with the JSONL line Pi would have emitted. */ +interface DialogCall { + method: string; + args: unknown[]; + line: Record; +} + +/** Sentinel for "this dialog never resolves on its own". */ +const PENDING = Symbol("pending"); +type Scripted = string | boolean | undefined | typeof PENDING; + +interface Script { + select?: Scripted; + confirm?: Scripted; + input?: Scripted; +} + +interface Harness { + ui: ExtensionUIContext; + calls: DialogCall[]; +} + +/** + * A stand-in for Pi's RPC `ExtensionUIContext`. + * + * The line-building and abort semantics below are transcribed from + * `dist/modes/rpc/rpc-mode.js` in @earendil-works/pi-coding-agent 0.84.0 + * (`createDialogPromise` + `createExtensionUIContext`): the per-method request + * object is spread FLAT after `{type, id}` and JSON-serialized to stdout, which + * drops undefined-valued keys; and an aborted or timed-out dialog *resolves* + * with the method's default value rather than rejecting — `undefined` for + * select/input, `false` for confirm. That last detail is the whole reason R13 + * exists. + */ +function makePi(script: Script): Harness { + const calls: DialogCall[] = []; + let seq = 0; + + function dialog( + method: string, + request: Record, + args: unknown[], + opts: ExtensionUIDialogOptions | undefined, + defaultValue: unknown, + ): Promise { + const id = `ui-${++seq}`; + const line = JSON.parse( + JSON.stringify({ type: "extension_ui_request", id, ...request }), + ) as Record; + calls.push({ method, args, line }); + + return new Promise((resolve) => { + if (opts?.signal?.aborted) { + resolve(defaultValue); + return; + } + opts?.signal?.addEventListener("abort", () => resolve(defaultValue), { + once: true, + }); + const scripted = method in script + ? (script as Record)[method] + : PENDING; + if (scripted !== PENDING) resolve(scripted); + }); + } + + const ui = { + select: (title: string, options: string[], opts?: ExtensionUIDialogOptions) => + dialog( + "select", + { method: "select", title, options, timeout: opts?.timeout }, + [title, options, opts], + opts, + undefined, + ), + confirm: (title: string, message: string, opts?: ExtensionUIDialogOptions) => + dialog( + "confirm", + { method: "confirm", title, message, timeout: opts?.timeout }, + [title, message, opts], + opts, + false, + ), + input: (title: string, placeholder?: string, opts?: ExtensionUIDialogOptions) => + dialog( + "input", + { method: "input", title, placeholder, timeout: opts?.timeout }, + [title, placeholder, opts], + opts, + undefined, + ), + } as unknown as ExtensionUIContext; + + return { ui, calls }; +} + +// --------------------------------------------------------------------------- +// Extension harness +// --------------------------------------------------------------------------- + +interface AskParams { + question: string; + kind?: "input" | "select" | "confirm"; + options?: string[]; +} + +type ToolResult = { content: { type: string; text: string }[] }; + +interface CapturedTool { + name: string; + execute: ( + toolCallId: string, + params: AskParams, + signal: AbortSignal | undefined, + onUpdate: undefined, + ctx: ExtensionContext, + ) => Promise; +} + +function loadTool(): CapturedTool { + let captured: CapturedTool | undefined; + const pi = { + registerTool(tool: unknown) { + captured = tool as CapturedTool; + }, + }; + askUser(pi as unknown as ExtensionAPI); + if (!captured) throw new Error("extension registered no tool"); + return captured; +} + +function makeCtx(ui: ExtensionUIContext, hasUI = true): ExtensionContext { + return { hasUI, mode: "rpc", ui } as unknown as ExtensionContext; +} + +/** Run the tool against a scripted Pi and return both sides of the exchange. */ +function ask( + params: AskParams, + script: Script, + signal?: AbortSignal, +): { result: Promise; calls: DialogCall[] } { + const pi = makePi(script); + const result = loadTool().execute("call-1", params, signal, undefined, makeCtx(pi.ui)); + return { result, calls: pi.calls }; +} + +function textOf(result: ToolResult): string { + return result.content.map((c) => c.text).join(""); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +// --------------------------------------------------------------------------- +// Request shape — one test per arm of Pi's published union +// --------------------------------------------------------------------------- + +describe("ask_user request shape", () => { + it("emits a pick-one dialog matching the fixture's select arm", async () => { + const expected = arm("select"); + const question = expected.title as string; + const options = expected.options as string[]; + + const { result, calls } = ask( + { question, kind: "select", options }, + { select: options[1] }, + ); + await result; + + expect(calls).toHaveLength(1); + // Whole-line equality against the contract fixture, id aside (Pi mints a + // uuid). This is the assertion the original argument-order bug fails: + // `options` would be the question string, not the array. + expect({ ...calls[0].line, id: expected.id }).toEqual(expected); + expect(Array.isArray(calls[0].line.options)).toBe(true); + expect(calls[0].line.title).toBe(question); + }); + + it("infers pick-one when options are supplied without an explicit kind", async () => { + const { result, calls } = ask( + { question: "Which framework?", options: ["React", "Vue"] }, + { select: "Vue" }, + ); + await result; + + expect(calls[0].method).toBe("select"); + expect(calls[0].line.options).toEqual(["React", "Vue"]); + }); + + it("emits a yes/no dialog matching the fixture's empty-message confirm arm", async () => { + const expected = arm("confirm_empty_message"); + const question = expected.title as string; + + const { result, calls } = ask({ question, kind: "confirm" }, { confirm: true }); + await result; + + expect({ ...calls[0].line, id: expected.id }).toEqual(expected); + }); + + it("carries a yes/no question in the title, not behind a boilerplate prefix", async () => { + const { result, calls } = ask( + { question: "Delete the stale branches?", kind: "confirm" }, + { confirm: false }, + ); + await result; + + // The old code titled every confirm "A question for you" and put the + // question in `message`, so the drawer rendered the boilerplate first. + expect(calls[0].line.title).toBe("Delete the stale branches?"); + expect(calls[0].line.message).toBe(""); + }); + + it("carries a free-text question in the title, never only as a placeholder", async () => { + const question = "Which environment should I deploy to?"; + const { result, calls } = ask({ question }, { input: "staging" }); + await result; + + const contract = arm("input"); + // Structural conformance: no key outside Pi's published input arm. + expect(Object.keys(calls[0].line).every((k) => k in contract)).toBe(true); + expect(calls[0].line.method).toBe("input"); + expect(calls[0].line.title).toBe(question); + expect(calls[0].line.timeout).toBe(contract.timeout); + // Pi's second argument is placeholder text, not the prompt body. The + // question must not be smuggled through it. + expect(calls[0].line.placeholder).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// KTD7 — the cross-language timeout invariant +// --------------------------------------------------------------------------- + +describe("dialog options", () => { + const cases: { style: string; params: AskParams; script: Script }[] = [ + { + style: "select", + params: { question: "Which?", kind: "select", options: ["a", "b"] }, + script: { select: "a" }, + }, + { style: "confirm", params: { question: "Sure?", kind: "confirm" }, script: { confirm: true } }, + { style: "input", params: { question: "Where?" }, script: { input: "here" } }, + ]; + + for (const { style, params, script } of cases) { + it(`passes ${style} a timeout above Deuce's ceiling and an abort signal`, async () => { + const { result, calls } = ask(params, script); + await result; + + const opts = calls[0].args[2] as ExtensionUIDialogOptions; + expect(opts.timeout).toBeGreaterThan(DEUCE_AWAIT_CEILING_MS); + // The fixture records the exact value the Go side expects to see. + expect(opts.timeout).toBe(arm("select").timeout); + expect(opts.signal).toBeInstanceOf(AbortSignal); + expect(opts.signal?.aborted).toBe(false); + }); + } +}); + +// --------------------------------------------------------------------------- +// R13 / AE7 — no-answer must never look like an answer +// --------------------------------------------------------------------------- + +describe("no answer received", () => { + it("returns the explicit no-answer text when a free-text dialog times out", async () => { + vi.useFakeTimers(); + // Nothing scripted: the dialog stays open until the extension's own + // deadline dismisses it. + const { result } = ask({ question: "Where?" }, {}); + + await vi.advanceTimersByTimeAsync(35 * 60 * 1000); + const text = textOf(await result); + + expect(text).toContain("No answer was received"); + expect(text).not.toBe(""); + }); + + it("distinguishes a timed-out yes/no from a real No", async () => { + // A real No. + const answered = textOf(await ask({ question: "Sure?", kind: "confirm" }, { confirm: false }).result); + expect(answered).toBe("no"); + + // A timeout. Pi resolves this to `false` too — the same value the real No + // produced — so only the extension's own flag can tell them apart. + vi.useFakeTimers(); + const { result } = ask({ question: "Sure?", kind: "confirm" }, {}); + await vi.advanceTimersByTimeAsync(35 * 60 * 1000); + const timedOut = textOf(await result); + + expect(timedOut).not.toBe("no"); + expect(timedOut).toContain("Do not assume yes or no"); + }); + + it("returns the no-answer text when the tool's own abort signal fires", async () => { + const controller = new AbortController(); + const { result, calls } = ask( + { question: "Which?", kind: "select", options: ["a", "b"] }, + {}, + controller.signal, + ); + + controller.abort(); + const text = textOf(await result); + + expect(text).toContain("No answer was received"); + // The dialog was opened and then dismissed, not skipped. + expect(calls).toHaveLength(1); + }); + + it("does not report a no-answer when the user actually answers", async () => { + const text = textOf( + await ask({ question: "Which?", kind: "select", options: ["a", "b"] }, { select: "b" }).result, + ); + expect(text).toBe("b"); + }); +}); + +// --------------------------------------------------------------------------- +// Headless guard +// --------------------------------------------------------------------------- + +describe("no UI channel", () => { + it("returns the proceed-on-best-judgment result without opening a dialog", async () => { + const pi = makePi({}); + const result = await loadTool().execute( + "call-1", + { question: "Which?" }, + undefined, + undefined, + makeCtx(pi.ui, false), + ); + + expect(textOf(result)).toContain("proceed using your best judgment"); + expect(pi.calls).toHaveLength(0); + }); +}); diff --git a/server/internal/agent/pirun/extension/ask-user.ts b/server/internal/agent/pirun/extension/ask-user.ts index 68bf53f..8760a7b 100644 --- a/server/internal/agent/pirun/extension/ask-user.ts +++ b/server/internal/agent/pirun/extension/ask-user.ts @@ -2,8 +2,8 @@ // // Pi has no native "agent is waiting on the human" event (verified in the U1 // spike). This extension gives the agent a blocking `ask_user` tool: when the -// agent calls it, a ctx.ui primitive emits an `extension_ui_request` on the RPC -// stdout stream and blocks until the client sends a matching +// agent calls it, a ctx.ui dialog primitive emits an `extension_ui_request` on +// the RPC stdout stream and blocks until the client sends a matching // `extension_ui_response`. The Deuce runtime maps that request to the task's // `awaiting_input` state and routes the human's drawer reply back as the // response (KTD15 / R7 / R16 / AE3). @@ -11,17 +11,54 @@ // The tool optionally carries a `kind` (free-text / pick-one / confirm) and, // for choice kinds, an `options` list, so the client can render a typed prompt // (text field / buttons / yes-no) instead of a bare text box. `kind`/`options` -// are additive: omitting them preserves the original free-text behavior. The -// richer ctx.ui primitives (select/confirm) are feature-detected at runtime — -// when the running Pi build does not expose them, the tool falls back to -// ctx.ui.input with the options enumerated in the prompt. Either way it returns -// the answer as plain text and never emits raw JSON to the user. +// are additive: omitting them preserves the original free-text behavior. +// +// PROTOCOL NOTE (the bug this file used to carry). Pi's `ExtensionUIContext` +// signatures — `dist/core/extensions/types.d.ts` in +// @earendil-works/pi-coding-agent — are: +// +// select(title, options: string[], opts?) -> Promise +// confirm(title, message: string, opts?) -> Promise +// input(title, placeholder?, opts?) -> Promise +// +// There is no `(title, prompt)` form. Every style therefore carries the +// question in `title`; there is no second prose slot to put it in. The +// previous code passed the question as `select`'s second argument, so Pi +// emitted `options` as a *string* and Deuce's decoder dropped the whole line — +// no question ever reached the drawer. The shapes each call emits are pinned +// in ../testdata/pi-ui-protocol.json and asserted by ask-user.test.ts. // // Auto-discovered when placed at ~/.pi/agent/extensions/ in the container. -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { ExtensionAPI, ExtensionUIDialogOptions } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; +// Ceiling Pi applies to a dialog we never answer (R10). This is a backstop +// behind Deuce's own unanswered-question ceiling, never ahead of it: it MUST +// stay strictly greater than `defaultAwaitTimeout` in +// server/internal/agent/runtime.go (30 minutes), so Deuce's ceiling always +// fires first (KTD7). If Pi's timer won the race, Pi would resolve the dialog +// with its own default — `false` for confirm, `undefined` for select/input — +// and the model would receive a fabricated answer while the drawer still +// showed the question as answerable. +const PI_DIALOG_TIMEOUT_MS = 35 * 60 * 1000; + +// Our own no-answer deadline, deliberately just under Pi's. Pi resolves a +// timed-out confirm to `false`, which is exactly what a real "No" resolves to +// — the resolved value alone cannot tell them apart (R13). So we abort the +// dialog ourselves a beat early and let a flag, not the resolved value, decide +// what the agent is told. +const NO_ANSWER_DEADLINE_MS = PI_DIALOG_TIMEOUT_MS - 30 * 1000; + +const NO_ANSWER_TEXT = + "No answer was received from the user — the question timed out or was " + + "cancelled. Do not assume yes or no, and do not treat this as a decision. " + + "Either proceed on your best judgment and say plainly that you did, or stop " + + "and explain what you still need."; + +const NO_UI_TEXT = + "No interactive channel is available to ask the user; proceed using your best judgment."; + export default function (pi: ExtensionAPI) { pi.registerTool({ name: "ask_user", @@ -56,66 +93,75 @@ export default function (pi: ExtensionAPI) { }), ), }), - async execute(toolCallId, params, signal, onUpdate, ctx) { + async execute(_toolCallId, params, signal, _onUpdate, ctx) { // In headless contexts with no UI channel, don't block forever — tell the // agent to proceed on its best judgment rather than hang. if (!ctx.hasUI) { return { - content: [ - { - type: "text", - text: "No interactive channel is available to ask the user; proceed using your best judgment.", - }, - ], + content: [{ type: "text", text: NO_UI_TEXT }], details: {}, }; } - const ui = ctx.ui as Record; const options = Array.isArray(params.options) ? params.options : []; // Infer select when options were supplied without an explicit kind. - const kind = - params.kind ?? (options.length > 0 ? "select" : "input"); + const kind = params.kind ?? (options.length > 0 ? "select" : "input"); - const text = (answer: unknown): string => - answer == null ? "" : String(answer); + // The no-answer flag (R13). Set when our deadline fires or when the + // tool's own abort signal trips; either way the dialog is dismissed + // through `controller` and Pi resolves it with a default we must not + // hand to the model. + const controller = new AbortController(); + let noAnswer = false; + const giveUp = () => { + if (noAnswer) return; + noAnswer = true; + controller.abort(); + }; - let answer: unknown; - if (kind === "select" && options.length > 0) { - if (typeof ui.select === "function") { - answer = await (ui.select as ( - title: string, - prompt: string, - options: string[], - ) => Promise)("A question for you", params.question, options); - } else { - // Fallback: enumerate the options in a text prompt. The answer is - // still plain text — never JSON. - const list = options.map((o, i) => `${i + 1}. ${o}`).join("\n"); - answer = await ctx.ui.input( - "A question for you", - `${params.question}\n\nOptions:\n${list}`, - ); - } - } else if (kind === "confirm") { - if (typeof ui.confirm === "function") { - const ok = await (ui.confirm as ( - title: string, - prompt: string, - ) => Promise)("A question for you", params.question); + const deadline = setTimeout(giveUp, NO_ANSWER_DEADLINE_MS); + signal?.addEventListener("abort", giveUp, { once: true }); + if (signal?.aborted) giveUp(); + + const opts: ExtensionUIDialogOptions = { + signal: controller.signal, + timeout: PI_DIALOG_TIMEOUT_MS, + }; + + let answer: string; + try { + if (kind === "select" && options.length > 0) { + // select(title, options, opts) — the question is the title; the + // options array is the SECOND argument. + const chosen = await ctx.ui.select(params.question, options, opts); + answer = chosen ?? ""; + } else if (kind === "confirm") { + // confirm(title, message, opts) — the question rides in the title so + // yes/no prompts read the same as the other two styles; `message` is + // the empty string because Pi's arm requires the field. + const ok = await ctx.ui.confirm(params.question, "", opts); answer = ok ? "yes" : "no"; } else { - answer = await ctx.ui.input( - "A question for you", - `${params.question} (yes/no)`, - ); + // input(title, placeholder?, opts) — the second argument is + // placeholder text, not the prompt body, so the question goes in the + // title and no placeholder is sent. + const typed = await ctx.ui.input(params.question, undefined, opts); + answer = typed ?? ""; } - } else { - answer = await ctx.ui.input("A question for you", params.question); + } finally { + clearTimeout(deadline); + signal?.removeEventListener("abort", giveUp); + } + + if (noAnswer) { + return { + content: [{ type: "text", text: NO_ANSWER_TEXT }], + details: {}, + }; } return { - content: [{ type: "text", text: text(answer) }], + content: [{ type: "text", text: answer }], details: {}, }; }, diff --git a/server/internal/agent/runtime.go b/server/internal/agent/runtime.go index 3fccd41..915c566 100644 --- a/server/internal/agent/runtime.go +++ b/server/internal/agent/runtime.go @@ -78,7 +78,18 @@ type pendingRequest struct { const ( defaultActiveTimeout = 10 * time.Minute - defaultAwaitTimeout = 30 * time.Minute + + // defaultAwaitTimeout is Deuce's ceiling on an unanswered question. It is + // half of a cross-language invariant (KTD7): the ask-user extension passes + // Pi its own dialog timeout as PI_DIALOG_TIMEOUT_MS in + // pirun/extension/ask-user.ts, and that value MUST stay strictly greater + // than this one so this ceiling always fires first. If Pi's timer won the + // race it would resolve the dialog with its own default (false for + // confirm, undefined for select/input) and hand the model a fabricated + // answer while the drawer still showed the question as answerable. Nothing + // but these two comments ties the values together — raise this and the + // extension constant has to move with it. + defaultAwaitTimeout = 30 * time.Minute ) // DefaultBaseSystemPrompt is the global system prompt applied to the deuce diff --git a/tsconfig.extension.json b/tsconfig.extension.json new file mode 100644 index 0000000..2c09fda --- /dev/null +++ b/tsconfig.extension.json @@ -0,0 +1,37 @@ +{ + /* The Pi extension Deuce embeds in the Go binary and provisions into each + workspace container. It ships as plain TypeScript that Pi loads at + runtime, so nothing here emits — but it does need real type resolution: + the original ask-user bug was an argument-order error against Pi's + published `ExtensionUIContext`, and this project is what puts those + declarations in front of `tsc`. Referenced from tsconfig.json so + `npx tsc -b --force` covers it. */ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.extension.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + /* Pi runs the extension under Node, and the test suite reads the Go + contract fixture from disk. */ + "types": ["node"], + /* Mirrors the app/node projects: Pi's declarations pull in a large + upstream type graph, and an unrelated error in it must not fail this + gate. */ + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["server/internal/agent/pirun/extension"] +} diff --git a/tsconfig.json b/tsconfig.json index 1ffef60..ff27e83 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.extension.json" } ] } From fc5016a61afbb8d46ad6f6d11fa89c494760cdb9 Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Thu, 6 Aug 2026 22:59:00 -0600 Subject: [PATCH 5/7] fix(super-threads): contain long rows in the agent thread The thread body is overflow-y:auto, which computes overflow-x to auto, so any child wider than the panel scrolls it sideways. The action row's text lived in an unclassed span, and the ellipsis declarations sat on an inline .arg -- where overflow and text-overflow do not apply, so they were inert. - Move truncation onto the flex item itself via a new q-act-txt class, applied to all three action-row branches, and drop the inert declarations from .arg. - Add min-width:0 to the task card's live row, whose .arg is a direct flex child and so does honor its existing ellipsis once it can shrink. - Carry the full text in a title attribute. The store clears pendingQuestion when a task completes, leaving this row as the only record of what was asked. - Wrap, don't truncate, the pending-question block and choice buttons -- a question the user must read to answer cannot be clipped. overflow-wrap: anywhere also reduces the buttons' min-content contribution so they shrink instead of widening the row. Also corrects a stale test-scenario line in the plan that contradicted its own step 2 on where a yes/no question carries its text. Co-Authored-By: Claude Opus 5 (1M context) --- ...001-fix-ask-user-question-protocol-plan.md | 3 +-- src/components/super-threads/atoms.tsx | 12 +++++++++--- src/styles/globals.css | 19 +++++++++++++++++-- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-08-06-001-fix-ask-user-question-protocol-plan.md b/docs/plans/2026-08-06-001-fix-ask-user-question-protocol-plan.md index 79410e1..6ea969a 100644 --- a/docs/plans/2026-08-06-001-fix-ask-user-question-protocol-plan.md +++ b/docs/plans/2026-08-06-001-fix-ask-user-question-protocol-plan.md @@ -237,9 +237,8 @@ No matching issues exist in `github.com/earendil-works/pi` for the extension UI **Test scenarios:** - A pick-one question with three options produces a request whose options field is an array of those three labels and whose title carries the question text. - A pick-one question with `kind` omitted but options supplied still infers the pick-one style, preserving today's inference. -- A yes/no question produces a request carrying the question text in its message field. - A free-text question produces a request carrying the question text in its title, not only in a placeholder. -- A yes/no question carries the question in its title, so the prompt renders without the boilerplate prefix. +- A yes/no question carries the question in its title with an empty message, so the prompt renders without the boilerplate prefix. - Every dialog call carries a timeout greater than the 30-minute awaiting ceiling, and an abort signal. - Covers AE7. A cancelled or timed-out dialog returns the explicit no-answer text rather than an empty string or a negative. - A yes/no dialog that times out is distinguished from a real "No" — both resolve to `false`, so the assertion must prove the no-answer flag drives the result, not the resolved value. diff --git a/src/components/super-threads/atoms.tsx b/src/components/super-threads/atoms.tsx index 342c832..8c30012 100644 --- a/src/components/super-threads/atoms.tsx +++ b/src/components/super-threads/atoms.tsx @@ -135,7 +135,7 @@ function ActionItem({ action }: { action: AgentAction }) { - + Thinking @@ -165,7 +165,10 @@ function ActionItem({ action }: { action: AgentAction }) { - + {/* The row truncates, and the store clears `pendingQuestion` once the + task completes — so `title` is the only remaining way to read back + what was asked. */} + Asked {question} @@ -188,7 +191,10 @@ function ActionItem({ action }: { action: AgentAction }) { - + {action.tool} ( {action.arg} diff --git a/src/styles/globals.css b/src/styles/globals.css index a3b5cd1..f15e081 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -386,6 +386,9 @@ button, } .tc-live .arg { color: var(--color-foreground); + /* direct flex child, so it is blockified and its ellipsis applies — but only + once it is allowed to shrink below its intrinsic width */ + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -696,8 +699,11 @@ button, text-transform: uppercase; letter-spacing: 0.03em; } +/* A question the user has to read to answer wraps rather than truncating, and + `anywhere` keeps a long unbroken token (file path, URL) inside the block. */ .q-pending-q .q-text { white-space: pre-wrap; + overflow-wrap: anywhere; } /* typed-question controls (select buttons / confirm) */ .q-choices { @@ -716,6 +722,8 @@ button, font-size: 12px; font-weight: 500; cursor: pointer; + /* a long option label wraps inside its button instead of widening the row */ + overflow-wrap: anywhere; } .q-choice:hover { background: color-mix(in srgb, var(--color-warning) 14%, var(--color-background-input)); @@ -812,12 +820,19 @@ button, .q-act .paren { color: var(--color-foreground-subtle); } -.q-act .arg { - color: var(--color-foreground); +/* The whole tool-plus-argument run truncates as one line. Truncation has to + live here rather than on `.arg`: this span is the flex item, and `overflow`/ + `text-overflow` do not apply to the non-replaced inline `.arg` box inside it. */ +.q-act-txt { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.q-act .arg { + color: var(--color-foreground); + white-space: nowrap; +} .q-act .note { color: var(--color-foreground-subtle); margin-left: 6px; From 2a2a77ef8fb25b3b2c148e07cbf0c583736168bd Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Fri, 7 Aug 2026 11:49:22 -0600 Subject: [PATCH 6/7] fix(review): close the input skew gap and pin the timeout invariant Applying code-review findings. The first is a hole in a guard added earlier on this branch. - Recover a stale extension's free-text question from the placeholder field. The rollout guard covered select, and confirm survived incidentally, but input had no recovery: the pre-fix extension called input(title, question), so Pi put the question in placeholder and the decoder never read it. During the rollout window that rendered the boilerplate title with the question discarded -- the exact failure R2 rejects, reproduced by the guard meant to prevent it. Found independently by three reviewers. - Broaden the yes/no token sets. "yep", "okay", "go ahead" and "do it" were all delivered to the agent as refusals. The fail-to-negative default for genuinely unrecognized replies is deliberate and unchanged. - Tie the cross-language timeout invariant to the contract fixture. It was enforced only by paired comments plus a hand-copied constant in the TS test, so a one-sided edit would silently invert the ordering KTD7 exists to guarantee. A Go test now asserts the ceiling against the fixture and the TS suite reads the same field. - Correct a doc comment claiming RequestKind can be "editor"; the decoder always folds editor to input. Co-Authored-By: Claude Opus 5 (1M context) --- server/internal/agent/pirun/decoder.go | 34 ++++++++++---- server/internal/agent/pirun/decoder_test.go | 41 +++++++++++------ .../agent/pirun/extension/ask-user.test.ts | 11 ++++- .../agent/pirun/testdata/pi-ui-protocol.json | 13 ++++++ server/internal/agent/runtime.go | 30 ++++++++++--- server/internal/agent/runtime_test.go | 44 +++++++++++++++++++ 6 files changed, 144 insertions(+), 29 deletions(-) diff --git a/server/internal/agent/pirun/decoder.go b/server/internal/agent/pirun/decoder.go index 6ee0928..f39e6de 100644 --- a/server/internal/agent/pirun/decoder.go +++ b/server/internal/agent/pirun/decoder.go @@ -63,7 +63,7 @@ type Event struct { // Awaiting-input (KindAwaitingInput). RequestID string - RequestKind string // select / confirm / input / editor + RequestKind string // select / confirm / input — an editor dialog decodes to input Prompt string Options []string // choice labels for a select request (empty otherwise) @@ -240,6 +240,12 @@ const ( uiMethodSetEditorText = "set_editor_text" ) +// legacyBoilerplateTitle is the constant title the PRE-FIX ask-user extension +// passed as the first argument to every ctx.ui dialog, with the real question in +// the second. It is matched only to recognize that version-skew shape; the +// post-fix extension never emits it. +const legacyBoilerplateTitle = "A question for you" + // decodeUIRequest decodes an extension_ui_request against Pi's published // RpcExtensionUIRequest union. The union is FLAT: every field sits at the top // level next to type/id/method, and no arm nests anything under "params". The @@ -252,14 +258,16 @@ const ( // input → title editor → title // // placeholder and prefill are input hints, not the question, so they are not -// folded into the prompt. +// folded into the prompt. The one exception is the version-skew recovery in the +// input arm below, where a pre-fix extension put the question in placeholder. func decodeUIRequest(line []byte) (Event, error) { var p struct { - ID string `json:"id"` - Method string `json:"method"` - Title string `json:"title"` - Message string `json:"message"` - Options json.RawMessage `json:"options"` + ID string `json:"id"` + Method string `json:"method"` + Title string `json:"title"` + Message string `json:"message"` + Placeholder string `json:"placeholder"` + Options json.RawMessage `json:"options"` } if err := json.Unmarshal(line, &p); err != nil { return Event{Kind: KindUnknown, RawType: "extension_ui_request"}, err @@ -294,7 +302,17 @@ func decodeUIRequest(line []byte) (Event, error) { case uiMethodConfirm: ev.Prompt = joinPrompt(p.Title, p.Message) case uiMethodInput: - // Prompt is the title, already set. + // Prompt is the title, already set — except under version skew. A pre-fix + // extension called input(title, question), and Pi's signature is + // input(title, placeholder, opts), so the question landed in + // `placeholder` behind a constant boilerplate title. Keeping the title + // would surface that boilerplate and discard the question (R2), the same + // failure the select skew branch above recovers from. Keyed on the exact + // legacy literal so a post-fix request — real question as the title, no + // placeholder — is left untouched. + if p.Title == legacyBoilerplateTitle && p.Placeholder != "" { + ev.Prompt = p.Placeholder + } case uiMethodEditor: // The drawer has no editor control and the frontend QuestionKind union // has no "editor" member — an editor dialog is answered through the diff --git a/server/internal/agent/pirun/decoder_test.go b/server/internal/agent/pirun/decoder_test.go index fcc9687..b461639 100644 --- a/server/internal/agent/pirun/decoder_test.go +++ b/server/internal/agent/pirun/decoder_test.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "path/filepath" + "slices" "strings" "testing" ) @@ -386,7 +387,7 @@ func TestDecodeUIRequestContract(t *testing.T) { if ev.Prompt != w.prompt { t.Errorf("prompt = %q, want %q", ev.Prompt, w.prompt) } - if !sameStrings(ev.Options, w.options) { + if !slices.Equal(ev.Options, w.options) { t.Errorf("options = %v, want %v", ev.Options, w.options) } }) @@ -421,6 +422,32 @@ func TestDecodeUIRequestBareStringOptions(t *testing.T) { } } +// TestDecodeUIRequestQuestionAsPlaceholder covers the other half of the same +// version-skew window: the pre-fix extension called input(title, question), and +// Pi's signature is input(title, placeholder, opts), so the question arrives in +// `placeholder` behind a constant boilerplate title. Taking the title would +// render "A question for you" and drop the question entirely (R2) — the exact +// failure the skew guard exists to prevent. +func TestDecodeUIRequestQuestionAsPlaceholder(t *testing.T) { + f := loadUIFixture(t) + ev, err := Decode(f.offContract(t, "inputQuestionAsPlaceholder")) + if err != nil { + t.Fatalf("question-as-placeholder must not error: %v", err) + } + if ev.Kind != KindAwaitingInput { + t.Fatalf("kind = %q, want %q", ev.Kind, KindAwaitingInput) + } + if ev.Prompt != "Which environment should I deploy to?" { + t.Errorf("prompt = %q, want the question carried in placeholder — the boilerplate title is not the question", ev.Prompt) + } + if ev.RequestKind != "input" { + t.Errorf("requestKind = %q, want input", ev.RequestKind) + } + if ev.RequestID != "ui-skew-2" { + t.Errorf("requestID = %q, want ui-skew-2", ev.RequestID) + } +} + // TestDecodeUIRequestUnknownMethod: a dialog method a future Pi adds must not // raise a pending question Deuce cannot render or answer. func TestDecodeUIRequestUnknownMethod(t *testing.T) { @@ -461,18 +488,6 @@ func TestDecodeStreamNotificationDoesNotInterrupt(t *testing.T) { } } -func sameStrings(got, want []string) bool { - if len(got) != len(want) { - return false - } - for i := range want { - if got[i] != want[i] { - return false - } - } - return true -} - func TestNormalizeTool(t *testing.T) { cases := map[string]string{ "bash": "Bash", "read": "Read", "write": "Write", "edit": "Edit", diff --git a/server/internal/agent/pirun/extension/ask-user.test.ts b/server/internal/agent/pirun/extension/ask-user.test.ts index ec8595a..ffb12ef 100644 --- a/server/internal/agent/pirun/extension/ask-user.test.ts +++ b/server/internal/agent/pirun/extension/ask-user.test.ts @@ -40,6 +40,7 @@ interface FixtureRequest { interface Fixture { piVersion: string; requests: FixtureRequest[]; + deuceAwaitCeilingMs: number; } const here = path.dirname(fileURLToPath(import.meta.url)); @@ -55,8 +56,14 @@ function arm(name: string): Record { // The ceiling Deuce's runtime applies to an unanswered question // (defaultAwaitTimeout in server/internal/agent/runtime.go). Pi's dialog -// timeout must stay strictly above it (KTD7). -const DEUCE_AWAIT_CEILING_MS = 30 * 60 * 1000; +// timeout must stay strictly above it (KTD7). Read from the fixture rather +// than hardcoded here: a hardcoded copy drifts silently when the Go constant +// moves. The Go side asserts defaultAwaitTimeout equals this same field, so +// the fixture is the one place the invariant's two halves meet. +const DEUCE_AWAIT_CEILING_MS = fixture.deuceAwaitCeilingMs; +if (typeof DEUCE_AWAIT_CEILING_MS !== "number") { + throw new Error("contract fixture has no numeric deuceAwaitCeilingMs"); +} // --------------------------------------------------------------------------- // Pi harness diff --git a/server/internal/agent/pirun/testdata/pi-ui-protocol.json b/server/internal/agent/pirun/testdata/pi-ui-protocol.json index 174b255..7a62a84 100644 --- a/server/internal/agent/pirun/testdata/pi-ui-protocol.json +++ b/server/internal/agent/pirun/testdata/pi-ui-protocol.json @@ -16,6 +16,9 @@ "The `timeout` values below are 2100000ms (35 min), which is what Deuce's extension passes: it must stay strictly above the runtime's defaultAwaitTimeout (30 min) so Deuce's ceiling always fires before Pi's (KTD7)." ], + "$deuceAwaitCeilingComment": "NOT part of Pi's contract. This is Deuce's own ceiling on an unanswered question — `defaultAwaitTimeout` in server/internal/agent/runtime.go — recorded here so both sides of the KTD7 invariant read the same number from one file instead of each hardcoding a copy. A Go test asserts defaultAwaitTimeout equals this value; ask-user.test.ts asserts the dialog `timeout` above is strictly greater than it. Moving one side alone now fails a test instead of silently inverting the ordering.", + "deuceAwaitCeilingMs": 1800000, + "requests": [ { "name": "select", @@ -191,6 +194,16 @@ "options": "Which framework should I use?" } }, + "inputQuestionAsPlaceholder": { + "$comment": "Emitted by a pre-fix Deuce extension calling input(title, question): Pi's signature is input(title, placeholder, opts), so the question lands in `placeholder` behind the extension's constant boilerplate title. Same stale-prebuild version-skew window as selectOptionsAsBareString. The question text is in `placeholder` — recovering it from `title` would yield the boilerplate and discard the question (R2).", + "line": { + "type": "extension_ui_request", + "id": "ui-skew-2", + "method": "input", + "title": "A question for you", + "placeholder": "Which environment should I deploy to?" + } + }, "unknownMethod": { "$comment": "A dialog method a future Pi adds. Must not raise a pending question, and must not abort the stream.", "line": { diff --git a/server/internal/agent/runtime.go b/server/internal/agent/runtime.go index 915c566..f6bfd13 100644 --- a/server/internal/agent/runtime.go +++ b/server/internal/agent/runtime.go @@ -86,9 +86,12 @@ const ( // than this one so this ceiling always fires first. If Pi's timer won the // race it would resolve the dialog with its own default (false for // confirm, undefined for select/input) and hand the model a fabricated - // answer while the drawer still showed the question as answerable. Nothing - // but these two comments ties the values together — raise this and the - // extension constant has to move with it. + // answer while the drawer still showed the question as answerable. The two + // values are tied together through the contract fixture: this constant is + // mirrored as `deuceAwaitCeilingMs` in pirun/testdata/pi-ui-protocol.json, + // a Go test asserts the two match, and ask-user.test.ts asserts its dialog + // timeout is strictly greater than the same field. Change this and the + // fixture must move with it. defaultAwaitTimeout = 30 * time.Minute ) @@ -231,7 +234,11 @@ func (r *Runtime) RouteOrEnqueue(ctx context.Context, p EnqueueParams) (RouteRes // response Pi would silently resolve to its own fallback. (Boot recovery // fails every awaiting_input task before the scheduler starts, so an // untracked awaiting task is a tracking gap, not a restart path.) - if pend, tracked := r.pendingDialog(taskID); sok && state == StateAwaitingInput && tracked { + pend, tracked := pendingRequest{}, false + if sok && state == StateAwaitingInput { + pend, tracked = r.pendingDialog(taskID) + } + if tracked { if err := r.sup.Send(key, uiResponseFor(taskID, pend, p.Prompt)); err == nil { // The run has resumed in-process — always tear down the awaiting // ceiling and pending state so it can't later fail a live task, @@ -281,9 +288,20 @@ func uiResponseFor(taskID string, pend pendingRequest, answer string) pirun.Exte // affirmative/negative are the leading tokens recognized on a yes/no answer. // The drawer's Yes/No buttons send "yes"/"no", but its composer stays live // beside them, so free text reaches here by design. +// Both sets are matched against leadingWord's output, which is a run of ASCII +// letters — so an apostrophe form like "don't" is matched by its "don" prefix +// and a literal "don't" key here would be unreachable. var ( - affirmativeTokens = map[string]bool{"yes": true, "y": true, "yeah": true, "ok": true, "sure": true} - negativeTokens = map[string]bool{"no": true, "n": true, "nope": true} + affirmativeTokens = map[string]bool{ + "yes": true, "y": true, "yeah": true, "yep": true, "yup": true, + "ok": true, "okay": true, "sure": true, "affirmative": true, + "correct": true, "approved": true, "approve": true, + "proceed": true, "go": true, "do": true, "confirm": true, + } + negativeTokens = map[string]bool{ + "no": true, "n": true, "nope": true, "nah": true, "negative": true, + "cancel": true, "stop": true, "dont": true, "don": true, + } ) // answerIsAffirmative maps a drawer answer onto Pi's confirm boolean by leading diff --git a/server/internal/agent/runtime_test.go b/server/internal/agent/runtime_test.go index ac39c7b..ceee389 100644 --- a/server/internal/agent/runtime_test.go +++ b/server/internal/agent/runtime_test.go @@ -645,6 +645,22 @@ func TestAnswerConfirmSendsBoolean(t *testing.T) { {"affirmative free text", "yes, go ahead", true}, {"uppercase with punctuation", "Yeah!", true}, {"negative free text", "no, stop", false}, + // A narrow token set is indistinguishable from a refusal at the wire: + // these are ordinary ways to approve, and defaulting them to false + // delivers the user's approval to the agent as a No. + {"yep", "yep", true}, + {"okay", "Okay.", true}, + {"go ahead", "go ahead", true}, + {"do it", "do it", true}, + {"proceed", "Proceed", true}, + {"approved", "approved", true}, + {"nah", "nah", false}, + {"cancel", "cancel that", false}, + // "don't" reaches leadingWord as "don" — the apostrophe ends the run. + {"contraction negative", "don't", false}, + // Near-miss: still not recognized, so it must still default negative. + // An unparsed reply must never read as approval (R6). + {"near miss stays negative", "yesterday's build was fine", false}, } { t.Run(tc.name, func(t *testing.T) { rt, _, _, h, _ := awaitingOnFixture(t, "confirm") @@ -749,6 +765,34 @@ func TestRouteEnqueuesWhenIdle(t *testing.T) { } } +// TestAwaitCeilingMatchesContractFixture pins the Go half of the KTD7 +// cross-language invariant. The extension's PI_DIALOG_TIMEOUT_MS must stay +// strictly above defaultAwaitTimeout so Deuce's ceiling always fires first; if +// Pi's timer won the race it would resolve the dialog with its own default and +// hand the model a fabricated answer. That ordering used to be enforced by two +// hand-written comments and a hardcoded copy of this constant in +// ask-user.test.ts, so a one-sided edit could invert it silently. The contract +// fixture is now the single source of truth: this test binds the Go constant to +// it, and ask-user.test.ts reads the same field for its strictly-greater +// assertion. The fixture lives under pirun/testdata because both sides assert +// against one file; deuceAwaitCeilingMs is Deuce's own value, not Pi's contract. +func TestAwaitCeilingMatchesContractFixture(t *testing.T) { + var f struct { + DeuceAwaitCeilingMs int64 `json:"deuceAwaitCeilingMs"` + } + if err := json.Unmarshal(readProtocolFixture(t), &f); err != nil { + t.Fatalf("parse pi-ui-protocol fixture: %v", err) + } + if f.DeuceAwaitCeilingMs == 0 { + t.Fatal("fixture has no deuceAwaitCeilingMs — the KTD7 invariant has no shared anchor") + } + if got := defaultAwaitTimeout.Milliseconds(); got != f.DeuceAwaitCeilingMs { + t.Errorf("defaultAwaitTimeout = %dms, fixture deuceAwaitCeilingMs = %dms — "+ + "move both together or the extension's dialog timeout may no longer sit above Deuce's ceiling (KTD7)", + got, f.DeuceAwaitCeilingMs) + } +} + func TestAwaitingCeilingFailsTask(t *testing.T) { rt, store, bc, lr := newTestRuntime(t) rt.awaitTimeout = 60 * time.Millisecond // ceiling for the test (same package) From ed0e8386aa972f62a95ab0381b5d886086aac1c7 Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Fri, 7 Aug 2026 11:50:09 -0600 Subject: [PATCH 7/7] docs(review): record residual review findings Co-Authored-By: Claude Opus 5 (1M context) --- .../fix-ask-user-question-protocol.md | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 docs/residual-review-findings/fix-ask-user-question-protocol.md diff --git a/docs/residual-review-findings/fix-ask-user-question-protocol.md b/docs/residual-review-findings/fix-ask-user-question-protocol.md new file mode 100644 index 0000000..917042a --- /dev/null +++ b/docs/residual-review-findings/fix-ask-user-question-protocol.md @@ -0,0 +1,108 @@ +# Residual Review Findings — fix/ask-user-question-protocol + +Source run: `ce-code-review` `20260806-230814-6e360099`, nine reviewers (correctness, +adversarial, reliability, testing, api-contract, maintainability, project-standards, +agent-native, learnings) against `origin/main...HEAD`. + +Plan: `docs/plans/2026-08-06-001-fix-ask-user-question-protocol-plan.md` + +Four findings were applied in `2a2a77e`. No tracker is configured for this repo, so the +items below are inlined verbatim rather than filed as tickets — this file is their durable +record. + +## Residual Review Findings + +### Demoted at the confidence gate + +- **P2 — `server/internal/agent/runtime.go:731` — A ceiling timer that already fired still + kills the just-answered task.** The awaiting-input `AfterFunc` is stopped on answer, but a + timer already past its deadline and waiting on `r.mu` will still call `failTaskAsync` after + the answer is delivered. Adversarial reviewer, anchor 50, routed `manual`. Suggested fix: add + a generation counter to `taskTimers`, bumped under `r.mu` by `startActive`/`enterAwaiting`/ + `exitAwaiting`, captured by the closure and re-checked before failing. Not applied: single + reviewer, below the actionable anchor, and the fix touches timer lifecycle beyond this + change's scope. + +### Concurrency and lifecycle (residual risks) + +- **Concurrent blocking dialogs on one task overwrite each other.** `pendingReq` and the + drawer are keyed by task id and replaced unconditionally. `npm:pi-subagents` is installed in + every workspace; if it opens its own blocking dialog while `ask_user`'s is open, the first is + orphaned and blocks its tool until the extension's deadline. Not confirmed that pi-subagents + opens blocking dialogs — the package is not vendored here. +- **`translate`'s `KindAwaitingInput` branch runs without the per-key lock** and writes DB + state before `setPending`. A reply in that window sees `state=awaiting_input` with + `tracked=false` and is delivered as a steer behind the blocked tool. Ordering `setPending` + first would remove it. Pre-existing shape; the window is one map write wide. +- **The same unlocked branch can resurrect timers and `pendingReq` entries** for a task + `finalizeLocked` just tore down, leaking one entry each per occurrence. Pre-existing. +- **The 30-second gap between the extension's no-answer deadline and Pi's dialog timeout** is + the only guard against Pi resolving a confirm to `false` without the no-answer flag set. Thin + under event-loop starvation, and no test covers Pi's own timer firing — the test harness + models the abort path but not `createDialogPromise`'s internal `setTimeout`. + +### Observability + +- **Ignored UI methods drop with no trace.** The five fire-and-forget methods now decode to + `KindIgnore` and `DecodeStream` continues with no log, unlike the unknown-event branch which + logs at debug. A subagent's `notify`/`setWidget` content is therefore unreachable anywhere in + Deuce — correctly not a question, but silently unsurfaceable. Raised by the agent-native + reviewer as an observation; worth an explicit product decision about whether that information + should ever reach the user. +- **`decodeUIOptions` degrades silently** when `options` is neither array nor string, unlike + the sibling paths this change made loud. No concrete trigger under Pi's real contract. +- **The new warn-level logs have no rate limiting.** If an installed extension throws + repeatedly, every occurrence logs at warn. Worth a log-volume check during live verification. + +### Testing gaps + +- No test drives two blocking dialogs pending concurrently on one task. +- `ask-user.test.ts`'s Pi mock omits `createDialogPromise`'s own `setTimeout`, so the + fabricated-answer race is unreachable from the suite. +- `decodeUIOptions`' third branch (options neither array nor string) has no direct test. +- No completeness check for Pi's three-arm *response* union, mirroring the request-union check. + `pirun.UIResponseCancelled` has no caller and no coverage — it is staged ahead of the + deferred "send cancelled on stop" work in the plan's Scope Boundaries. +- `leadingWord` matches ASCII letters only, so a non-English affirmative falls to the negative + default. Undocumented. +- U5's overflow fix has no automated coverage; the repo has no visual-regression harness. + +### Documentation drift + +- `CLAUDE.md` describes `npm test` as covering "pure-logic suites: reducer, visibility". The new + `ask-user.test.ts` is a Node-oriented suite under `server/`, picked up by Vitest's default + glob. Accurate-but-stale description, not a rule violation. +- `ask-user.test.ts` runs under the root Vitest config's global jsdom environment rather than a + Node environment. Harmless today; worth attention if the suite grows. + +### Recommended follow-up learning + +The `learnings-researcher` recommends capturing this bug's shape in `docs/solutions/`, since no +existing entry covers the testing discipline involved: + +> A test fixture invented alongside the decoder it tests can only confirm that decoder's +> assumptions, never catch them — derive wire-protocol fixtures from the other side's own +> published contract (vendored types, docs, source), never from what the code expects to see. + +It should cross-link `docs/solutions/architecture-patterns/pi-loads-agent-skills-standard-in-rpc-mode.md`, +which already records that Pi vendors its own docs and types inside the npm package — the +pointer that would have short-circuited the original guess. + +### Deferred from the plan (not review findings) + +Carried here so they stay visible alongside the residuals: + +- Letting the main chat composer answer a pending question. An `@deuce` message sent while a + question is pending is enqueued *behind* the blocked task, so it cannot run until the question + resolves or the ceiling fires — the originally reported symptom, from the surface a user is + most likely to reach first. The plan accepts this and proposes a session-notice mitigation. +- Sending `cancelled: true` when a run is stopped while a question is pending. +- Timeout tuning. + +## Verification still outstanding + +The plan's Verification Contract requires live verification against a real Pi, which a green +suite cannot substitute for: rebuild the workspace (not restart — the extension is baked into +the prebuild image), then confirm each of the three question styles end to end, that clicking +No is received as a negative, and that the server log holds no `skipping malformed event line` +warnings for the session.