diff --git a/.agents/skills/ln-build/SKILL.md b/.agents/skills/ln-build/SKILL.md index 40f166d1..df012268 100644 --- a/.agents/skills/ln-build/SKILL.md +++ b/.agents/skills/ln-build/SKILL.md @@ -150,12 +150,19 @@ Before finishing reconciliation, perform a quick cross-skill check: if a later a ### Retire derivative artifacts -After reconciliation, garbage-collect exhausted temporary files instead of leaving breadcrumbs or tombstones: +After reconciliation, garbage-collect exhausted temporary files instead of leaving breadcrumbs or tombstones, but deletion is narrowly scoped. -- `HANDOFF.md` — keep only if unfinished volatile transfer state still exists; otherwise delete it -- `memory/CARDS.md` — keep only while queued scope cards still remain; otherwise delete it -- `memory/REFACTOR.md` — keep only while unfinished refactor steps still depend on it; otherwise delete it -- Do not create archive copies, numbered handoffs, or completion-pointer files +Default deletion target: + +- `memory/CARDS.md` — delete only when the execution queue is fully exhausted, superseded, or empty after reconciliation. + +Other volatile artifacts are **review-before-delete**, not automatic cleanup: + +- `HANDOFF.md` — delete only when it contains no unfinished transfer state and no future-context inventory that is not already captured in `memory/SPEC.md`, `memory/PLAN.md`, an active scope card, or a stable design memo. +- `memory/REFACTOR.md` — delete only when every listed refactor step is done/dropped and no future sequence depends on it. +- Provisional docs outside `memory/` (for example `docs/**/provisional*.md`, handoff plans, spike plans, or exploration inventories) — do **not** delete during `ln-build` cleanup unless the user explicitly asks or you first prove that all remaining future-facing inventory has been absorbed elsewhere. If only the current card is done but the artifact still contains later affordances, open questions, or scoping input, update it instead of deleting it. + +Before deleting anything other than `memory/CARDS.md`, name the file, state why no future agent would need it, and prefer asking the user when uncertain. Do not create archive copies, numbered handoffs, or completion-pointer files. ## Routing diff --git a/assets/brunch-logo-quad-56x18-240.ansi b/assets/brunch-logo-quad-56x18-240.ansi new file mode 100644 index 00000000..b5dc6e78 --- /dev/null +++ b/assets/brunch-logo-quad-56x18-240.ansi @@ -0,0 +1,19 @@ +[?25l   +   +   +  ▀▀▀▘ ▀▀▀▀ ▝▀▀▀  +  ▀▀▀▘ ▗▀▘ ▀▀▀▀▀▀▀▘▘  ▀▀▀▀▀▀▖  +  ▘▝▀▀▀▀▀▀▗▗  ▀▀ ▀▀▀ ▀▀▀ ▖ ▀ ▀▀  +  ▀ ▀ ▐ ▀▀▀ ▀▀▀ ▀▀▀▐▐  ▗▀▀▀ ▀▖  +  ▐▀▀▀▀ ▖ ▐▐▀▀ ▀▀ ▀▀▀▀▀▀▀ ▐▐  ▗▘ ▀▀▀ ▝  +  ▝▀ ▀▀▀▀▀▀▀ ▖▀▀ ▀▀▀▀▀▀ ▀▀▀  ▗▘ ▀ ▀▀▀▀▀▀▀▀▗  +  ▘▀▀▀▀▀▀▀▀▀▀▀ ▀▀ ▀ ▀▀ ▀▀ ▀▀▀▀▀▀▀▀▀▀▗  +  ▘▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▝▝▀▀▀▀▀▀▀▀▀▀▘▘ ▀▀▀▀▀▀▀▀▀▀▀ ▀  +  ▝▀  ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀  +  ▀▀▀▀▀▀▀ ▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▗▗▀  +  ▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀  +  ▀ ▀▀▀▀▖ ▀  +   +   +   +[?25h \ No newline at end of file diff --git a/assets/brunch-logo-quad-56x18.ansi b/assets/brunch-logo-quad-56x18.ansi new file mode 100644 index 00000000..0dbafe1b --- /dev/null +++ b/assets/brunch-logo-quad-56x18.ansi @@ -0,0 +1,19 @@ +[?25l   +   +     +   ▀▀▀▘▘▀▀▀▀▝▝▀▀▀   +   ▀▀▀▀▘▘▘ ▗▀▝▘▗▀▀▀▀▀▀▀▀▀▀▀▀▝ ▀▀▀▀▀▀▖   +  ▘▝▀▀▀▀▀▀▀▗▘▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▝▖▀▀▀▀▀  ▀▀   +  ▗▀▀▀▀▀▖▝▀▐▐▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▐▐▝▖▐▗▗▀▀▀▗▝▖  +  ▐▀▀▀▀▀▀▀▀▐▐▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▖▐▐ ▐▐▗▘▀▀▀▀▀▝  +   ▝▀▀▀▀▀▀▀▀▀▝▖▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▗▗▘▝▀▀▀▀▀▀▀▀▀▀▗  +  ▘▀▀▀▀▀▀▀▀▀▀▀▖▝▀ ▖▀▀▀▀▀▀▀▀▘▗▗▝▀▘▘▀▀▀▀▀▀▀▀▀▀▀▗   +  ▘▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▝▝▀▀▀▀▀▀▀▀▀▀▘▝▗▀▀▀▀▀▀▀▀▀▀▀▝▀  +  ▝▀ ▖▖▗▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▝▀   +   ▀▀▀▀▀▀▀  ▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▗▗▀  +   ▀▗ ▀▀▀▀▀▀▀▀▀▀▀  ▀   +   ▀  ▀▀▀▀  ▀   +   +   +   +[?25h \ No newline at end of file diff --git a/assets/brunch.png b/assets/brunch.png new file mode 100644 index 00000000..c24918a0 Binary files /dev/null and b/assets/brunch.png differ diff --git a/docs/architecture/pi-ui-extension-patterns-provisional-plan.md b/docs/architecture/pi-ui-extension-patterns-provisional-plan.md new file mode 100644 index 00000000..cc5a9b5a --- /dev/null +++ b/docs/architecture/pi-ui-extension-patterns-provisional-plan.md @@ -0,0 +1,88 @@ +# Pi UI Extension Patterns — Offer-First Custom UI Working Plan + +This file is a trimmed working inventory for the remaining FE-744 gap. It is not canonical product contract; durable conclusions belong in `memory/SPEC.md`, `memory/PLAN.md`, and `docs/architecture/pi-ui-extension-patterns.md`. + +## Why this is still live + +Command containment, Brunch chrome, startup no-resume, and `/brunch-workspace` are proven enough for now. The unresolved POC seam is different: + +> Brunch sessions must work offer-first: a system/assistant-originated structured offer should act like the assistant turn, render as custom UI in place of the default input surface, and persist the user's structured response before the next agent turn. + +This is not generic UI polish. It is the mechanism behind elicitation-first sessions, typed responses, review-cycle decisions, and fixture-controllable prompt/response exchanges. + +## Pi evidence already relevant + +- `docs/usage.md`: the editor can be replaced temporarily by built-in UI or custom extension UI. +- `docs/tui.md`: `ctx.ui.custom()` can replace the editor area with a custom component and return typed data; overlays are optional, not required. +- `docs/tui.md` Pattern 7: `ctx.ui.setEditorComponent()` can replace the main input editor with a custom editor implementation. +- `examples/extensions/question.ts`: single-choice options plus a "Type something" escape hatch using `ctx.ui.custom()` and `Editor`. +- `examples/extensions/questionnaire.ts`: multi-question/tabbed choice UI with optional custom text answers. +- `examples/extensions/message-renderer.ts`: `registerMessageRenderer()` displays custom messages, but display rendering alone does not collect a response. +- `docs/rpc.md` / extension docs: `ctx.ui.custom()` is TUI-only/degraded in RPC, so semantic pending-offer state must have an RPC/web response path independent of the TUI component. + +## Target seam to prove + +### Offer-first custom interaction loop + +1. Brunch appends or sends a structured custom message/entry representing an unresolved offer, for example `brunch.offer` / `brunch.establishment_offer` / `brunch.review_set_proposal`. +2. The custom entry is visible in the transcript through a message renderer or transcript row. +3. While that offer is unresolved, Brunch replaces the default input surface with an offer-response UI. +4. The response UI supports the POC interaction kernel: + - single-choice selection, + - multi-choice selection, + - optional freeform additional input, + - cancel/skip where allowed. +5. The user's response is persisted as a structured custom entry, not just returned from ephemeral UI. +6. The response either triggers the next agent turn or is available to `prepareNextTurn` / the next prompt path as the user's response to the offer. +7. RPC/web answer the same semantic pending offer through product methods or supported dialog fallbacks; they do not depend on TUI-only `ctx.ui.custom()`. + +## Active slice candidate + +**Name:** Offer-first custom UI loop + +**Goal:** Prove that a transcript-native unresolved offer can replace ambient free input with a typed custom response surface and persist the response as session truth. + +**Likely implementation shape:** + +- Define a minimal offer payload type with `id`, `lens`, prompt text, response mode (`single | multiple | freeform-plus-choice`), options, and response policy. +- Add a Brunch-owned TUI helper, e.g. `requestOfferResponse(ctx, offer)`, modeled on Pi's `question.ts` / `questionnaire.ts` examples. +- Add a renderer for the offer custom entry so the assistant/system offer appears as the current prompt in transcript history. +- Add response persistence as a Brunch custom entry, e.g. `brunch.offer_response`, tied to the offer id. +- For RPC/fixture paths, expose a product method or supported built-in dialog fallback that submits the same response payload. + +**Acceptance:** + +- A fixture/demo session can start with no ambient user prompt and present an assistant/system offer first. +- The default freeform editor is replaced while the offer is pending. +- The user can choose one option, choose multiple options, or choose/type optional additional text depending on offer mode. +- The response persists in Pi JSONL as a structured Brunch custom entry linked to the offer id. +- Elicitation exchange projection treats the offer entry as the prompt side and the response entry as the response side. +- RPC/fixture driver can answer the offer through a semantic path even if rich TUI custom UI is unavailable. +- No graph mutation or review acceptance bypasses `CommandExecutor`; this slice proves interaction capture, not graph writes. + +## Residual catalog still carried forward + +| Need | Status after current evidence | Carry-forward | +| --- | --- | --- | +| Single-choice offer UI | Pi example-proven; Brunch offer loop not yet proven | Active slice | +| Multi-choice offer UI | Pi example can be adapted; Brunch semantics not yet proven | Active slice or immediate follow-up | +| Freeform-plus-choice | Pi `question.ts` proves the pattern | Active slice | +| Structured offer custom entries | Transcript/persistence model exists; offer-response loop not yet wired | Active slice | +| Message rendering for offers | Pi `message-renderer.ts` proves display; response collection is separate | Active slice | +| Review-set approve/request/reject | Depends on offer-response loop | M5 follow-up when `acceptReviewSet` exists | +| Establishment-offer orientation expansion | Depends on offer-response loop; must remain user-invoked, not default exhaustive menu | M5/M7 follow-up | +| RPC controllability | `ctx.ui.custom()` gap is known | Active slice must provide semantic response path | +| Mouse-clickable action buttons | Unproven and not required for POC if keyboard navigation works | Defer | +| Strict built-in command suppression | Requires Pi command/keybinding policy | Separate follow-up, not this slice | + +## Open questions + +- Should the first offer UI use transient `ctx.ui.custom()` only, or should Brunch replace the editor component while a pending offer exists and restore it after response? +- Which custom entry name is canonical for generic responses: `brunch.offer_response`, `brunch.elicitation_response`, or a more specific family? +- Does submitting an offer response call `pi.sendUserMessage()` with a textual summary, append a context-participating custom message, or both? +- How much of the offer is visible to the LLM as structured context versus displayed only to the user? +- What is the thinnest RPC method family for pending-offer discovery and response submission? + +## Retirement rule + +Retire this file only after the offer-first custom UI loop is either implemented and reconciled into `docs/architecture/pi-ui-extension-patterns.md` / SPEC / PLAN, or intentionally moved into a named M5 frontier slice. Do not delete it merely because command containment or chrome work is complete. diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md new file mode 100644 index 00000000..cee05ce3 --- /dev/null +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -0,0 +1,260 @@ +# Pi UI Extension Patterns + +This memo records evidence for the `pi-ui-extension-patterns` frontier. It is intentionally evidence-tiered: source audit, raw Pi harness observations, Brunch-host proof, RPC controllability, and remaining assumptions are separate. + +## Current verdicts + +| Area | Verdict | Required before downstream work? | Evidence tier | +| --- | --- | --- | --- | +| Built-in slash autocomplete allowlist | feasible-with-cost | desirable before M5 UI polish; not enough for policy | source audit | +| Built-in exact slash execution allowlist | requires-pi-change for strict suppression | required before claiming strict product-shell containment; not required for graph-command safety if dangerous effects are blocked separately | source audit + raw RPC probe | +| Branch-flow effect blocking (`/fork`, `/clone`, `/tree`) | proven for lifecycle/API effect cancellation; residual pre-cancel UI exposure remains | required for I19-L and already partly used by Brunch | source audit + raw RPC probe | +| Extension command collision override | not-feasible | product commands must avoid built-in names unless Pi adds policy | source audit | +| RPC-visible chrome/status degradation | proven for status/widget/title; no-op for header/footer/working indicator | informs fixture-driver expectations | Brunch wrapper unit oracle + raw RPC probe | +| Dynamic Brunch chrome wrapper | proven for deterministic product-state projection and TUI mounting | required before downstream M5/M6/M7 affordance wrappers call Pi UI primitives | Brunch-host tests + raw TUI transcript proof | +| Startup workspace switcher | proven for Brunch-owned pre-Pi activation with no implicit transcript resume | required for I22-L | Brunch coordinator/UI tests + `runbooks/verify-startup-no-resume.sh` pty oracle | +| In-session workspace switcher command | implemented/proven at command-handler seam; manual TUI walkthrough still useful | unlocks reusable switcher beyond startup | Brunch extension command tests + coordinator store oracle | +| Typed custom UI (`ctx.ui.custom`) | feasible/proven for Brunch workspace decisions; richer question/questionnaire surfaces remain Pi-example evidence only | informs M5 review/lens affordances | Brunch command tests + Pi docs/examples | + +## Evidence inventory + +- **Pi version/source:** `pi --version` reports `0.75.4`; audited installed docs under `npm-mariozechner-pi-coding-agent/0.73.1` whose package version is `0.75.4`, plus source at `~/Clones/earendil-works/pi/packages/coding-agent`. +- **Source audit oracle:** `src/core/slash-commands.ts`, `src/modes/interactive/interactive-mode.ts`, `src/core/agent-session.ts`, `src/core/extensions/runner.ts`, `docs/extensions.md`, `docs/rpc.md`, and `docs/keybindings.md`. +- **Raw Pi harness oracle:** temporary project extension `.pi/extensions/brunch-command-probe.ts` was loaded with `pi --mode rpc --no-session -e .pi/extensions/brunch-command-probe.ts`, then deleted after probing. This proves extension command handling, `input` handling, lifecycle cancellation, and RPC-visible `setStatus` / string `setWidget` events. It does **not** prove interactive autocomplete visual behavior. +- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions/brunch/index.ts`, with private surface modules for chrome (`chrome.ts`), session-boundary binding (`session-boundary.ts`), branch policy (`branch-policy.ts`), and the in-session workspace switch command (`workspace-command.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, restores the default footer with `setFooter(undefined)`, writes compact `setStatus`, expanded string-array `setWidget`, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; workspace-command tests prove decision UI remains separate from coordinator activation and uses the default `ctx.ui.custom()` component-replacement path rather than experimental overlay options. +- **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e "`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. +- **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e ` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. + +## Command inventory and containment matrix + +Policy buckets: + +- **allow/product-owned:** acceptable only when routed through Brunch-owned behavior or harmless in product shell. +- **hide:** should not appear as a default Brunch affordance. +- **block effect:** dangerous downstream effect must be cancelled even if UI exposure remains. +- **requires Pi policy:** strict command suppression needs a Pi upstream/API seam. + +| Command / source | Pi execution path | Brunch policy | Suppression seam | Blocker seam | Residual exposure | API ask | +| --- | --- | --- | --- | --- | --- | --- | +| `/settings` | `InteractiveMode.setupEditorSubmitHandler()` opens generic Pi settings | hide | autocomplete wrapper can hide suggestions | none found | exact command still opens settings in interactive mode | command policy needed for strict block | +| `/model` | interactive built-in; `Ctrl+L` also opens selector; `Ctrl+P` cycles model | hide or replace with Brunch policy | autocomplete/keybinding config can reduce visibility | no extension cancel hook; `model_select` is notification-only | exact slash and keybindings can expose model policy surface | command/keybinding policy needed if strict | +| `/scoped-models` | interactive built-in selector | hide | autocomplete wrapper | none found | exact command opens Pi selector | command policy needed | +| `/export` | interactive built-in export | hide unless Brunch adopts it deliberately | autocomplete wrapper | none found | exact command can export Pi session | command policy needed if disallowed | +| `/import` | interactive built-in import/resume flow | hide/block until Brunch validates session binding | autocomplete wrapper | no general import hook found; switch hooks may cover resulting session switch only | import UI can start before any cancel path | command policy needed | +| `/share` | interactive built-in gist share | hide/block | autocomplete wrapper | none found | exact command exposes non-Brunch sharing | command policy needed | +| `/copy` | interactive built-in clipboard copy | allow-with-low-risk or hide | autocomplete wrapper | none found | harmless but Pi-branded | optional | +| `/name` | interactive built-in session naming | hide/replace with Brunch session naming | autocomplete wrapper | none found | can mutate Pi display name outside Brunch vocabulary | command policy desirable | +| `/session` | interactive info pane | hide or allow diagnostic-only | autocomplete wrapper | none found | exposes Pi session stats/identity | optional/desirable | +| `/changelog` | interactive Pi changelog | hide | autocomplete wrapper | none found | exact command exposes Pi product surface | command policy desirable | +| `/hotkeys` | interactive Pi hotkeys | hide or replace with Brunch hotkeys | autocomplete wrapper | none found | exact command exposes Pi actions including branch actions | command policy desirable | +| `/fork` | interactive built-in branch creation after selector | hide + block effect | autocomplete wrapper | `session_before_fork` can cancel | selector/UI may appear before cancel depending path; exact command remains visible | command policy desirable; effect block available | +| `/clone` | interactive built-in branch duplication | hide + block effect | autocomplete wrapper | `session_before_fork` can cancel | command accepted before cancellation notice | command policy desirable; effect block available | +| `/tree` | interactive built-in branch navigator | hide + block effect | autocomplete wrapper | `session_before_tree` can cancel/customize | tree UI may start before cancellation path | command policy desirable; effect block available | +| `/login` / `/logout` | interactive OAuth selectors | hide unless Brunch owns provider setup | autocomplete wrapper | none found | exposes Pi provider auth surface | command policy needed if disallowed | +| `/new` | interactive session replacement | replace with Brunch same-spec coordinator flow | autocomplete wrapper | `session_before_switch` can cancel raw new-session effect | exact command still starts Pi new-session path before cancellation | command policy or Brunch command replacement needed | +| `/compact` | interactive/manual compaction | allow only after Brunch context policy exists | autocomplete wrapper | `session_before_compact` can cancel/customize | exact command starts Pi compaction UI/path before cancellation | command policy desirable | +| `/resume` | interactive session picker | hide/block unless Brunch validates binding | autocomplete wrapper | `session_before_switch` can cancel selected switch | generic picker exposure remains | command policy desirable | +| `/reload` | interactive resource reload | allow for dev, hide in product | autocomplete wrapper | none found; extension command `ctx.reload()` exists for custom reload | exact command reloads Pi resources/extensions | command policy optional for POC, desirable for product shell | +| `/quit` | interactive shutdown | allow | autocomplete wrapper not needed | n/a | Pi command name acceptable or replace later | no | +| Hidden debug/easter egg commands (`/debug`, `/arminsayshi`, `/dementedelves`) | hardcoded in `setupEditorSubmitHandler()` but not advertised in `BUILTIN_SLASH_COMMANDS` | hide/block | not in normal autocomplete inventory | none found | exact command remains callable if known | command policy needed for strict block | +| Extension commands | `AgentSession.prompt()` checks extension commands before `input` | allow only Brunch-owned names | register only Brunch commands | handler routes writes through Brunch handlers / `CommandExecutor` | built-in name collisions do not override built-ins | no if product-named | +| Prompt templates | autocomplete + expansion after `input` | hide unless Brunch owns prompt surface | settings/resources policy; `input` can handle before expansion | `input` can intercept template text before expansion | not built-in interactive command risk | optional | +| Skill commands (`/skill:name`) | autocomplete if `enableSkillCommands`; expansion after `input` | hide in Brunch POC | disable skill commands or autocomplete wrapper | `input` can intercept before expansion | generic Pi skill surface | optional if disabled | +| RPC-only session commands (`new_session`, `switch_session`, `fork`, `clone`, `compact`) | RPC command handlers | Brunch RPC should expose named product methods instead | not slash autocomplete | lifecycle hooks cancel session replacement/fork effects | raw Pi RPC is not Brunch public API | Brunch wrapper/policy, not Pi interactive policy | +| Keybindings: model select/cycle, session new/tree/fork/resume, double-Escape tree/fork | `setupKeyHandlers()` and settings | hide/block branch/model/session generic flows | keybindings config can unbind some defaults; settings can set double-Escape to `none` | lifecycle hooks for session replacement/fork/tree | keyboard route can bypass slash autocomplete visibility | command/keybinding policy desirable | + +## Autocomplete and execution findings + +### Autocomplete filtering + +`InteractiveMode.createBaseAutocompleteProvider()` builds a `CombinedAutocompleteProvider` from: + +1. `BUILTIN_SLASH_COMMANDS`, +2. prompt templates, +3. extension commands that do not conflict with built-ins, +4. skill commands when `settingsManager.getEnableSkillCommands()` is true. + +`setupAutocompleteProvider()` then applies extension-provided autocomplete wrappers. `docs/extensions.md` documents `ctx.ui.addAutocompleteProvider((current) => ...)`, including delegation to the previous provider for file/path completion and custom `#` completions. Therefore a Brunch allowlist wrapper should be able to hide disallowed slash suggestions while delegating file/path and future `#` mention completion. + +**Limit:** this is visibility suppression only. It does not change exact slash execution. + +### Exact slash execution + +`InteractiveMode.setupEditorSubmitHandler()` handles built-ins directly before normal `AgentSession.prompt()` flow. `AgentSession.prompt()` handles extension commands first, then emits `input`, then expands skills/templates. Therefore extension `input` interception cannot reliably block exact interactive built-ins such as `/settings`, `/model`, `/fork`, `/tree`, `/new`, `/compact`, `/resume`, or `/quit`, because they have already been consumed by interactive mode. + +Raw RPC probe corroborates the order split rather than replacing the source audit: + +- `/brunch-probe` extension command executed immediately and emitted RPC `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`. +- `/brunch-block-me` was not an extension command; the `input` hook handled it and skipped agent execution. +- `/settings` in RPC mode was not a built-in command; it entered normal prompt flow as user text. This confirms built-ins are interactive-only; it does not prove interactive suppression. + +### Extension command collisions + +`InteractiveMode.getBuiltInCommandConflictDiagnostics()` warns on extension commands with built-in names and skips conflicting built-in-name extension commands from autocomplete. `ExtensionRunner.resolveRegisteredCommands()` suffixes duplicate extension commands (`name:1`, `name:2`). Extension commands therefore cannot override `/model`, `/settings`, or other built-ins. Brunch commands should use product names unless Pi grows a command-policy seam. + +## Branch-flow guard evidence + +Lifecycle hooks provide effect blocking for branch/session transitions even though they do not fully suppress the generic Pi UI surface. + +- `session_before_fork` cancels `/fork`, `/clone`, and RPC `fork`/`clone` effects. +- `session_before_tree` cancels `/tree` navigation effects. +- `session_before_switch` cancels `/new`, `/resume`, RPC `new_session`, and RPC `switch_session` effects. +- `session_before_compact` can cancel/customize `/compact`, but compaction policy is not identical to branch policy. + +Raw RPC probe results with the temporary extension: + +```json +{"id":"new","type":"response","command":"new_session","success":true,"data":{"cancelled":true}} +{"id":"clone","type":"response","command":"clone","success":true,"data":{"cancelled":true}} +``` + +The same probe emitted corresponding `notify` requests (`cancel switch new`, `cancel fork/clone`). No Brunch product transcript fixture was created; the probe used `--no-session`. + +## Brunch extension layout and dynamic chrome proof + +The Brunch extension entrypoint is intentionally a registration map. It composes private modules by Pi surface/responsibility: + +- `chrome.ts` owns `BrunchChromeState`, formatting, and `renderBrunchChrome()`. +- `session-boundary.ts` owns coordinator refresh calls on session-boundary events. +- `branch-policy.ts` owns `session_before_tree` / `session_before_fork` cancellation. +- `workspace-command.ts` owns the product slash command and replacement-session lifecycle. + +`renderBrunchChrome(ctx.ui, state)` is the product-named wrapper downstream affordances should call instead of scattering raw Pi UI calls. The current surface allocation is deliberate: + +- header: product identity plus active spec/session (`brunch specification workspace`, spec title, real activated session id/label); +- status: compact persistent phase/coherence/reconciliation-need summary; +- widget: expanded diagnostics (cwd, chat mode, stage, active lens, worker statuses, latest establishment offer when present); +- title: compact Brunch-owned terminal title derived from activated workspace state; +- footer: restored to Pi default via `setFooter(undefined)` because Brunch does not currently need to replace the whole footer. + +The wrapper uses plain, narrow-terminal-safe text/glyphs (`Brunch`, `·`) and does not depend on Pi branding/footer text as the primary product surface. Header rendering is TUI-only; status/widget/title provide deterministic state strings for tests and RPC-compatible clients. Brunch currently restores Pi's default footer and leaves Pi's working indicator untouched instead of carrying empty/custom chrome abstractions. `session_start` reconstructs chrome from the supplied product snapshot, and replacement-session binding still runs through the existing session-boundary hooks before rendering. Reload/session replacement therefore requires callers to provide a fresh product snapshot; the wrapper does not own durable state. + +Observed behavior: + +| Scenario | Result | Evidence | +| --- | --- | --- | +| Idle TUI mount | Header, status, diagnostic widget, title, and default-footer restoration are called from one snapshot; raw TUI transcript shows Brunch header/widget text visible. | `src/brunch-tui.test.ts`; temp `script` transcript needle check | +| Streaming/progress update | Wrapper formats stage/worker state deterministically in status/widget; Brunch leaves the interactive working indicator on Pi defaults until a concrete side-task/reviewer spinner is product-proven. | `src/brunch-tui.test.ts`; temp RPC JSONL probe | +| `/reload` / extension reload | Chrome is not durable inside Pi UI; reload must rerun extension setup and call `renderBrunchChrome` with a fresh Brunch snapshot. | source/API behavior; wrapper is stateless by design | +| Session replacement / selected-session reopen | Existing Brunch extension calls the session-boundary binding hook on `session_start`, `before_agent_start`, and assistant `message_start`; `session_start` then renders chrome for the supplied workspace snapshot. The Brunch workspace command activates decisions through the coordinator, calls `ctx.switchSession()`, and renders fresh chrome/notification only through `withSession` replacement context. | `src/brunch-tui.test.ts` | +| RPC degradation | `setStatus`, string-array `setWidget`, `setTitle`, and `notify` emit RPC `extension_ui_request` events; `setHeader`, `setFooter`, and `setWorkingIndicator` are RPC no-ops. Fixture drivers should assert status/widget events, not TUI-only header/footer. | Pi RPC source + temp RPC JSONL probe | + +## Startup/splash logo asset decision + +Brunch should render the startup/splash logo as TUI chrome, not as a session message, so it does not persist in the transcript/log. For the preferred blocky aesthetic, the selected rendering is a pre-generated Chafa Unicode-symbol asset rather than runtime image rendering: + +- Source PNG copied from the legacy Brunch app to `assets/brunch.png`. +- Preferred splash asset: `assets/brunch-logo-quad-56x18.ansi`. +- Lower-color fallback asset: `assets/brunch-logo-quad-56x18-240.ansi`. +- `package.json` includes `assets` in published package files so runtime code can read these files directly. + +The selected generator command for the preferred asset is: + +```sh +chafa -f symbols \ + --symbols=quad \ + --colors=full \ + --color-space=din99d \ + --color-extractor=median \ + --bg=black \ + --size=56x18 \ + assets/brunch.png > assets/brunch-logo-quad-56x18.ansi +``` + +Runtime should **not** invoke Chafa on startup. The logo should be deterministic, cheap to render, and independent of host-installed CLI tools. Chafa is therefore a maintainer/dev tool at most, not a runtime dependency. Startup chrome should choose `brunch-logo-quad-56x18.ansi` when truecolor is available, otherwise `brunch-logo-quad-56x18-240.ansi`; for very limited terminals, a plain `brunch` wordmark is sufficient rather than carrying 16-color or 8-color assets. + +## Workspace switcher implementation evidence + +Startup now runs through Brunch-owned inventory and activation before Pi `InteractiveMode` starts. `.brunch/state.json` accelerates defaults but does not implicitly resume the prior transcript; the pure `workspace-switcher` UI returns `continue` / `openSession` / `newSession` / `newSpec` / `cancel`, and `WorkspaceSessionCoordinator.activateWorkspace()` owns all session creation/opening, binding, and state-file effects. + +The executable pty oracle is `runbooks/verify-startup-no-resume.sh`. It builds the project, seeds a scratch workspace with a unique stale transcript sentinel, launches `brunch --mode tui` under `script`, strips ANSI/control sequences, and asserts the first captured startup screen contains workspace-switcher markers and not the stale transcript text. This is a middle-loop/manual oracle, not part of `npm run verify`, because pty behavior is host-sensitive. + +The in-session command is product-named `/brunch-workspace`. Its handler waits for idle, inspects inventory, renders the same typed workspace-switcher component with the default `ctx.ui.custom()` component-replacement flow, activates the returned decision through the coordinator, and then calls `ctx.switchSession()` only for the already-activated target file. Post-switch chrome and notification use the `withSession` replacement context only; cancel and `needs_human` decisions notify without switching. This does not override `/resume`, `/new`, or other built-ins. Overlay/modal custom-UI patterns remain deferred to later review-set, orientation, or picker surfaces only when a concrete product interaction needs them. + +## Pi example evidence not yet Brunch integration proof + +Reviewed Pi docs/examples remain useful for downstream M5/M6/M7 affordance design, but they are not interchangeable with Brunch-host proof: + +| Example/source affordance | Evidence status | Brunch interpretation | +| --- | --- | --- | +| `question` / `questionnaire` typed UI patterns | Pi example/source evidence | Suitable model for future structured elicitation/review surfaces; Brunch has only proven typed custom workspace decisions so far. | +| `shutdown-command` | Pi example evidence | Confirms commands can drive lifecycle actions; Brunch has not added a product shutdown command beyond allowing Pi quit. | +| `structured-output` | Pi example evidence | Relevant to future agent/tool result rendering, not current workspace-switcher proof. | +| `titlebar-spinner` / working indicator examples | Pi example evidence only | Brunch leaves Pi's working indicator untouched; custom spinner styling is deferred until a live side-task/reviewer spinner is product-proven. | +| `custom-header` / `custom-footer` | Raw Pi TUI proof plus Brunch wrapper tests | Brunch uses header for product identity and restores the default footer; replacing the footer should remain intentional. | +| `status-line` / `border-status-editor` | Pi example plus Brunch wrapper tests | Supports compact persistent state; Brunch currently uses `setStatus` and widget diagnostics, not a custom editor/border. | + +## RPC controllability observations relevant to command containment and chrome + +Raw Pi RPC success is not Brunch integration proof, but it matters for the fixture-driver oracle: + +- Extension command handlers are RPC-invocable via `prompt` for extension command names. +- `ctx.ui.setStatus()` emits RPC `extension_ui_request` with method `setStatus`. +- `ctx.ui.setWidget()` emits RPC `extension_ui_request` with method `setWidget` when the widget is a string array. +- `ctx.ui.setTitle()` emits RPC `extension_ui_request` with method `setTitle`. +- `ctx.ui.notify()` emits RPC `extension_ui_request` with method `notify`. +- `ctx.ui.setHeader()`, `ctx.ui.setFooter()`, and `ctx.ui.setWorkingIndicator()` are TUI-only in current Pi RPC mode and should be treated as no-ops for fixture-driver expectations. +- Built-in interactive slash commands are not included in RPC `prompt` handling as built-ins; Brunch must not infer interactive command safety from RPC prompt behavior. + +## Minimum Pi API ask + +Strict Brunch product-shell containment needs an upstream command/keybinding policy seam. A minimal shape would be either session/interactive-mode options or extension API: + +```ts +pi.setCommandPolicy({ + hiddenBuiltins: ["settings", "model", "scoped-models", "export", "import", "share", "fork", "clone", "tree", "login", "logout", "new", "resume"], + blockedBuiltins: ["fork", "clone", "tree", "new", "resume", "settings", "model"], + onBlockedBuiltin: async (name, ctx) => ctx.ui.notify(`/${name} is not available in Brunch`, "warning"), +}); +``` + +Equivalent launch-time option: + +```ts +allowedBuiltInCommands: ["compact", "reload", "quit"] +``` + +The policy must run before interactive-mode built-in dispatch and before autocomplete construction. Ideally it should also expose a keybinding-action policy for `app.model.*` and `app.session.*` actions so keyboard paths cannot bypass slash visibility. + +## Offer-first custom UI gap + +The remaining live FE-744 gap is not generic UI polish. Brunch still needs an offer-first interaction loop: a system/assistant-originated structured offer should act like the assistant turn, render as transcript-visible custom message state, replace the default input surface with custom response UI, and persist the user's structured response before the next agent turn. + +Pi source/docs already give strong evidence for the primitive: + +- `docs/usage.md` states that the editor can be temporarily replaced by custom extension UI. +- `docs/tui.md` documents `ctx.ui.custom()` for editor-area replacement and `ctx.ui.setEditorComponent()` for replacing the main input editor. +- `examples/extensions/question.ts` proves single-choice plus optional freeform input. +- `examples/extensions/questionnaire.ts` proves multi-question/multi-step choice UI with custom answers. +- `examples/extensions/message-renderer.ts` proves custom transcript display, but display alone does not collect a response. + +The seam Brunch must still prove is the composition: transcript-native unresolved offer → input-replacing custom UI → persisted structured response → projection as an elicitation exchange. The trimmed working plan remains in `docs/architecture/pi-ui-extension-patterns-provisional-plan.md` until that loop is implemented or deliberately moved into a named M5 slice. + +| Residual affordance | Current posture | Carry-forward obligation | +| --- | --- | --- | +| Offer-first session loop | Missing and POC-critical. | A session can begin from a system/assistant offer without ambient user chat; unresolved offers own the input surface until answered. | +| Structured custom message as UI driver | Display is Pi-example-proven; response collection still needs Brunch composition. | Persist the offer as a Brunch custom entry, render it in transcript history, and mount response UI from the pending offer state. | +| Single-choice / multi-choice / freeform-plus-choice response | Pi examples prove the component patterns. | Build a Brunch-owned response helper over those patterns and persist `brunch.offer_response`-shaped data. | +| Review-set decisions | Depends on the offer-response loop. | Approve routes to one `acceptReviewSet` command; request-changes appends a successor proposal; reject persists a response entry. | +| Pickers and orientation views | Workspace switcher proves pure decision UI. | Reuse the same decision-returning shape; coordinator or command-layer code owns mutations. | +| RPC/fixture controllability | `ctx.ui.custom()` is not automatically RPC-controllable. | Critical fixture paths need Brunch RPC methods or built-in dialog fallbacks over the same semantic pending offer. | +| Live Pi harness probes | Useful for fast source/API validation but not Brunch-host proof. | Keep scratch extensions temporary, record evidence tier, and promote only product-named wrappers that survive the spike. | + +## Downstream posture + +- For the POC, Brunch can plausibly proceed if it hides disallowed commands from autocomplete and blocks branch/session effects with lifecycle hooks, **provided product documentation does not claim strict built-in suppression**. +- Dynamic Brunch chrome is strong enough to make the primary idle/working TUI surface read as Brunch-owned in a local proof, but exact built-in commands remain a residual shell-containment risk for product review. +- `I19-L` remains protected by effect blocking and transcript-reader fail-fast behavior, not by complete command invisibility. +- M5/M6/M7 should route Brunch actions through Brunch-owned command names and handlers; extension command collisions are not an override mechanism. +- M5/M6/M7 chrome/status affordances should call Brunch product wrappers (`renderBrunchChrome` or successors) instead of raw Pi `ctx.ui.*` primitives. +- Future switcher/review/elicitation commands should follow `/brunch-workspace`: product-owned names, typed default `ctx.ui.custom()` decision components unless richer modal behavior is specifically needed, coordinator/command-layer activation, and replacement-session work only through `withSession` contexts. +- A strict upstream Pi command-policy API is required before Brunch can honestly claim Pi's generic shell is unavailable rather than merely discouraged/guarded. + +## Open evidence gaps + +- Interactive autocomplete filtering was source-proven but not visually observed in a TUI session from this API-only run. +- Exact interactive `/fork`, `/tree`, `/new`, and `/resume` pre-cancel UI exposure should be manually observed in Brunch TUI or a controlled Pi TUI before product signoff. +- Keybinding unbinding/configuration strategy remains source-audited only; no Brunch-owned keybinding settings wrapper has been tested. +- The startup no-resume oracle is executable and passed locally, but it is intentionally not a default CI gate because pty/script behavior is host-sensitive. +- The in-session `/brunch-workspace` command is unit-proven at the handler/replacement-context seam; a qualitative manual TUI walkthrough should still confirm interaction feel and final chrome/session id in a live Pi runtime. +- Dynamic chrome was visually proven in a raw Pi TUI harness and unit-proven in Brunch; a full Brunch-host manual walkthrough remains useful before product signoff because the temp TUI proof did not exercise real coordinator-derived graph/lens/coherence data. diff --git a/docs/reference/pi-extensions.md b/docs/reference/pi-extensions.md new file mode 100644 index 00000000..6426096f --- /dev/null +++ b/docs/reference/pi-extensions.md @@ -0,0 +1,2596 @@ +> pi can create extensions. Ask it to build one for your use case. + +# Extensions + +Extensions are TypeScript modules that extend pi's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more. + +> **Placement for /reload:** Put extensions in `~/.pi/agent/extensions/` (global) or `.pi/extensions/` (project-local) for auto-discovery. Use `pi -e ./path.ts` only for quick tests. Extensions in auto-discovered locations can be hot-reloaded with `/reload`. + +**Key capabilities:** +- **Custom tools** - Register tools the LLM can call via `pi.registerTool()` +- **Event interception** - Block or modify tool calls, inject context, customize compaction +- **User interaction** - Prompt users via `ctx.ui` (select, confirm, input, notify) +- **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` for complex interactions +- **Custom commands** - Register commands like `/mycommand` via `pi.registerCommand()` +- **Session persistence** - Store state that survives restarts via `pi.appendEntry()` +- **Custom rendering** - Control how tool calls/results and messages appear in TUI + +**Example use cases:** +- Permission gates (confirm before `rm -rf`, `sudo`, etc.) +- Git checkpointing (stash at each turn, restore on branch) +- Path protection (block writes to `.env`, `node_modules/`) +- Custom compaction (summarize conversation your way) +- Conversation summaries (see `summarize.ts` example) +- Interactive tools (questions, wizards, custom dialogs) +- Stateful tools (todo lists, connection pools) +- External integrations (file watchers, webhooks, CI triggers) +- Games while you wait (see `snake.ts` example) + +See [examples/extensions/](../examples/extensions/) for working implementations. + +## Table of Contents + +- [Quick Start](#quick-start) +- [Extension Locations](#extension-locations) +- [Available Imports](#available-imports) +- [Writing an Extension](#writing-an-extension) + - [Extension Styles](#extension-styles) +- [Events](#events) + - [Lifecycle Overview](#lifecycle-overview) + - [Resource Events](#resource-events) + - [Session Events](#session-events) + - [Agent Events](#agent-events) + - [Model Events](#model-events) + - [Tool Events](#tool-events) +- [ExtensionContext](#extensioncontext) +- [ExtensionCommandContext](#extensioncommandcontext) +- [ExtensionAPI Methods](#extensionapi-methods) +- [State Management](#state-management) +- [Custom Tools](#custom-tools) +- [Custom UI](#custom-ui) +- [Error Handling](#error-handling) +- [Mode Behavior](#mode-behavior) +- [Examples Reference](#examples-reference) + +## Quick Start + +Create `~/.pi/agent/extensions/my-extension.ts`: + +```typescript +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +export default function (pi: ExtensionAPI) { + // React to events + pi.on("session_start", async (_event, ctx) => { + ctx.ui.notify("Extension loaded!", "info"); + }); + + pi.on("tool_call", async (event, ctx) => { + if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { + const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?"); + if (!ok) return { block: true, reason: "Blocked by user" }; + } + }); + + // Register a custom tool + pi.registerTool({ + name: "greet", + label: "Greet", + description: "Greet someone by name", + parameters: Type.Object({ + name: Type.String({ description: "Name to greet" }), + }), + async execute(toolCallId, params, signal, onUpdate, ctx) { + return { + content: [{ type: "text", text: `Hello, ${params.name}!` }], + details: {}, + }; + }, + }); + + // Register a command + pi.registerCommand("hello", { + description: "Say hello", + handler: async (args, ctx) => { + ctx.ui.notify(`Hello ${args || "world"}!`, "info"); + }, + }); +} +``` + +Test with `--extension` (or `-e`) flag: + +```bash +pi -e ./my-extension.ts +``` + +## Extension Locations + +> **Security:** Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust. + +Extensions are auto-discovered from: + +| Location | Scope | +| ----------------------------------- | ---------------------------- | +| `~/.pi/agent/extensions/*.ts` | Global (all projects) | +| `~/.pi/agent/extensions/*/index.ts` | Global (subdirectory) | +| `.pi/extensions/*.ts` | Project-local | +| `.pi/extensions/*/index.ts` | Project-local (subdirectory) | + +Additional paths via `settings.json`: + +```json +{ + "packages": [ + "npm:@foo/bar@1.0.0", + "git:github.com/user/repo@v1" + ], + "extensions": [ + "/path/to/local/extension.ts", + "/path/to/local/extension/dir" + ] +} +``` + +To share extensions via npm or git as pi packages, see [packages.md](packages.md). + +## Available Imports + +| Package | Purpose | +| --------------------------------- | ------------------------------------------------------------ | +| `@earendil-works/pi-coding-agent` | Extension types (`ExtensionAPI`, `ExtensionContext`, events) | +| `typebox` | Schema definitions for tool parameters | +| `@earendil-works/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) | +| `@earendil-works/pi-tui` | TUI components for custom rendering | + +npm dependencies work too. Add a `package.json` next to your extension (or in a parent directory), run `npm install`, and imports from `node_modules/` are resolved automatically. + +For distributed pi packages installed with `pi install` (npm or git), runtime deps must be in `dependencies`. Package installation uses production installs (`npm install --omit=dev`) by default, so `devDependencies` are not available at runtime; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. + +Node.js built-ins (`node:fs`, `node:path`, etc.) are also available. + +## Writing an Extension + +An extension exports a default factory function that receives `ExtensionAPI`. The factory can be synchronous or asynchronous: + +```typescript +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + // Subscribe to events + pi.on("event_name", async (event, ctx) => { + // ctx.ui for user interaction + const ok = await ctx.ui.confirm("Title", "Are you sure?"); + ctx.ui.notify("Done!", "info"); + ctx.ui.setStatus("my-ext", "Processing..."); // Footer status + ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor (default) + }); + + // Register tools, commands, shortcuts, flags + pi.registerTool({ ... }); + pi.registerCommand("name", { ... }); + pi.registerShortcut("ctrl+x", { ... }); + pi.registerFlag("my-flag", { ... }); +} +``` + +Extensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation. + +If the factory returns a `Promise`, pi awaits it before continuing startup. That means async initialization completes before `session_start`, before `resources_discover`, and before provider registrations queued via `pi.registerProvider()` are flushed. + +### Async factory functions + +Use an async factory for one-time startup work such as fetching remote configuration or dynamically discovering available models. + +```typescript +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export default async function (pi: ExtensionAPI) { + const response = await fetch("http://localhost:1234/v1/models"); + const payload = (await response.json()) as { + data: Array<{ + id: string; + name?: string; + context_window?: number; + max_tokens?: number; + }>; + }; + + pi.registerProvider("local-openai", { + baseUrl: "http://localhost:1234/v1", + apiKey: "LOCAL_OPENAI_API_KEY", + api: "openai-completions", + models: payload.data.map((model) => ({ + id: model.id, + name: model.name ?? model.id, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: model.context_window ?? 128000, + maxTokens: model.max_tokens ?? 4096, + })), + }); +} +``` + +This pattern makes the fetched models available during normal startup and to `pi --list-models`. + +### Extension Styles + +**Single file** - simplest, for small extensions: + +``` +~/.pi/agent/extensions/ +└── my-extension.ts +``` + +**Directory with index.ts** - for multi-file extensions: + +``` +~/.pi/agent/extensions/ +└── my-extension/ + ├── index.ts # Entry point (exports default function) + ├── tools.ts # Helper module + └── utils.ts # Helper module +``` + +**Package with dependencies** - for extensions that need npm packages: + +``` +~/.pi/agent/extensions/ +└── my-extension/ + ├── package.json # Declares dependencies and entry points + ├── package-lock.json + ├── node_modules/ # After npm install + └── src/ + └── index.ts +``` + +```json +// package.json +{ + "name": "my-extension", + "dependencies": { + "zod": "^3.0.0", + "chalk": "^5.0.0" + }, + "pi": { + "extensions": ["./src/index.ts"] + } +} +``` + +Run `npm install` in the extension directory, then imports from `node_modules/` work automatically. + +## Events + +### Lifecycle Overview + +``` +pi starts + │ + ├─► session_start { reason: "startup" } + └─► resources_discover { reason: "startup" } + │ + ▼ +user sends prompt ─────────────────────────────────────────┐ + │ │ + ├─► (extension commands checked first, bypass if found) │ + ├─► input (can intercept, transform, or handle) │ + ├─► (skill/template expansion if not handled) │ + ├─► before_agent_start (can inject message, modify system prompt) + ├─► agent_start │ + ├─► message_start / message_update / message_end │ + │ │ + │ ┌─── turn (repeats while LLM calls tools) ───┐ │ + │ │ │ │ + │ ├─► turn_start │ │ + │ ├─► context (can modify messages) │ │ + │ ├─► before_provider_request (can inspect or replace payload) + │ ├─► after_provider_response (status + headers, before stream consume) + │ │ │ │ + │ │ LLM responds, may call tools: │ │ + │ │ ├─► tool_execution_start │ │ + │ │ ├─► tool_call (can block) │ │ + │ │ ├─► tool_execution_update │ │ + │ │ ├─► tool_result (can modify) │ │ + │ │ └─► tool_execution_end │ │ + │ │ │ │ + │ └─► turn_end │ │ + │ │ + └─► agent_end │ + │ +user sends another prompt ◄────────────────────────────────┘ + +/new (new session) or /resume (switch session) + ├─► session_before_switch (can cancel) + ├─► session_shutdown + ├─► session_start { reason: "new" | "resume", previousSessionFile? } + └─► resources_discover { reason: "startup" } + +/fork or /clone + ├─► session_before_fork (can cancel) + ├─► session_shutdown + ├─► session_start { reason: "fork", previousSessionFile } + └─► resources_discover { reason: "startup" } + +/compact or auto-compaction + ├─► session_before_compact (can cancel or customize) + └─► session_compact + +/tree navigation + ├─► session_before_tree (can cancel or customize) + └─► session_tree + +/model or Ctrl+P (model selection/cycling) + ├─► thinking_level_select (if model change changes/clamps thinking level) + └─► model_select + +thinking level changes (settings, keybinding, pi.setThinkingLevel()) + └─► thinking_level_select + +exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM) + └─► session_shutdown +``` + +### Resource Events + +#### resources_discover + +Fired after `session_start` so extensions can contribute additional skill, prompt, and theme paths. +The startup path uses `reason: "startup"`. Reload uses `reason: "reload"`. + +```typescript +pi.on("resources_discover", async (event, _ctx) => { + // event.cwd - current working directory + // event.reason - "startup" | "reload" + return { + skillPaths: ["/path/to/skills"], + promptPaths: ["/path/to/prompts"], + themePaths: ["/path/to/themes"], + }; +}); +``` + +### Session Events + +See [Session Format](session-format.md) for session storage internals and the SessionManager API. + +#### session_start + +Fired when a session is started, loaded, or reloaded. + +```typescript +pi.on("session_start", async (event, ctx) => { + // event.reason - "startup" | "reload" | "new" | "resume" | "fork" + // event.previousSessionFile - present for "new", "resume", and "fork" + ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info"); +}); +``` + +#### session_before_switch + +Fired before starting a new session (`/new`) or switching sessions (`/resume`). + +```typescript +pi.on("session_before_switch", async (event, ctx) => { + // event.reason - "new" or "resume" + // event.targetSessionFile - session we're switching to (only for "resume") + + if (event.reason === "new") { + const ok = await ctx.ui.confirm("Clear?", "Delete all messages?"); + if (!ok) return { cancel: true }; + } +}); +``` + +After a successful switch or new-session action, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "new" | "resume"` and `previousSessionFile`. +Do cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`. + +#### session_before_fork + +Fired when forking via `/fork` or cloning via `/clone`. + +```typescript +pi.on("session_before_fork", async (event, ctx) => { + // event.entryId - ID of the selected entry + // event.position - "before" for /fork, "at" for /clone + return { cancel: true }; // Cancel fork/clone + // OR + return { skipConversationRestore: true }; // Reserved for future conversation restore control +}); +``` + +After a successful fork or clone, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "fork"` and `previousSessionFile`. +Do cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`. + +#### session_before_compact / session_compact + +Fired on compaction. See [compaction.md](compaction.md) for details. + +```typescript +pi.on("session_before_compact", async (event, ctx) => { + const { preparation, branchEntries, customInstructions, signal } = event; + + // Cancel: + return { cancel: true }; + + // Custom summary: + return { + compaction: { + summary: "...", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + } + }; +}); + +pi.on("session_compact", async (event, ctx) => { + // event.compactionEntry - the saved compaction + // event.fromExtension - whether extension provided it +}); +``` + +#### session_before_tree / session_tree + +Fired on `/tree` navigation. See [Sessions](sessions.md) for tree navigation concepts. + +```typescript +pi.on("session_before_tree", async (event, ctx) => { + const { preparation, signal } = event; + return { cancel: true }; + // OR provide custom summary: + return { summary: { summary: "...", details: {} } }; +}); + +pi.on("session_tree", async (event, ctx) => { + // event.newLeafId, oldLeafId, summaryEntry, fromExtension +}); +``` + +#### session_shutdown + +Fired before an extension runtime is torn down. + +```typescript +pi.on("session_shutdown", async (event, ctx) => { + // event.reason - "quit" | "reload" | "new" | "resume" | "fork" + // event.targetSessionFile - destination session for session replacement flows + // Cleanup, save state, etc. +}); +``` + +### Agent Events + +#### before_agent_start + +Fired after user submits prompt, before agent loop. Can inject a message and/or modify the system prompt. + +```typescript +pi.on("before_agent_start", async (event, ctx) => { + // event.prompt - user's prompt text + // event.images - attached images (if any) + // event.systemPrompt - current chained system prompt for this handler + // (includes changes from earlier before_agent_start handlers) + // event.systemPromptOptions - structured options used to build the system prompt + // .customPrompt - any custom system prompt (from --system-prompt, SYSTEM.md, or custom templates) + // .selectedTools - tools currently active in the prompt + // .toolSnippets - one-line descriptions for each tool + // .promptGuidelines - custom guideline bullets + // .appendSystemPrompt - text from --append-system-prompt flags + // .cwd - working directory + // .contextFiles - AGENTS.md files and other loaded context files + // .skills - loaded skills + + return { + // Inject a persistent message (stored in session, sent to LLM) + message: { + customType: "my-extension", + content: "Additional context for the LLM", + display: true, + }, + // Replace the system prompt for this turn (chained across extensions) + systemPrompt: event.systemPrompt + "\n\nExtra instructions for this turn...", + }; +}); +``` + +The `systemPromptOptions` field gives extensions access to the same structured data Pi uses to build the system prompt. This lets you inspect what Pi has loaded — custom prompts, guidelines, tool snippets, context files, skills — without re-discovering resources or re-parsing flags. Use it when your extension needs to make deep, informed changes to the system prompt while respecting user-provided configuration. + +Inside `before_agent_start`, `event.systemPrompt` and `ctx.getSystemPrompt()` both reflect the chained system prompt as of the current handler. Later `before_agent_start` handlers can still modify it again. + +#### agent_start / agent_end + +Fired once per user prompt. + +```typescript +pi.on("agent_start", async (_event, ctx) => {}); + +pi.on("agent_end", async (event, ctx) => { + // event.messages - messages from this prompt +}); +``` + +#### turn_start / turn_end + +Fired for each turn (one LLM response + tool calls). + +```typescript +pi.on("turn_start", async (event, ctx) => { + // event.turnIndex, event.timestamp +}); + +pi.on("turn_end", async (event, ctx) => { + // event.turnIndex, event.message, event.toolResults +}); +``` + +#### message_start / message_update / message_end + +Fired for message lifecycle updates. + +- `message_start` and `message_end` fire for user, assistant, and toolResult messages. +- `message_update` fires for assistant streaming updates. +- `message_end` handlers can return `{ message }` to replace the finalized message. The replacement must keep the same `role`. + +```typescript +pi.on("message_start", async (event, ctx) => { + // event.message +}); + +pi.on("message_update", async (event, ctx) => { + // event.message + // event.assistantMessageEvent (token-by-token stream event) +}); + +pi.on("message_end", async (event, ctx) => { + if (event.message.role !== "assistant") return; + + return { + message: { + ...event.message, + usage: { + ...event.message.usage, + cost: { + ...event.message.usage.cost, + total: 0.123, + }, + }, + }, + }; +}); +``` + +#### tool_execution_start / tool_execution_update / tool_execution_end + +Fired for tool execution lifecycle updates. + +In parallel tool mode: +- `tool_execution_start` is emitted in assistant source order during the preflight phase +- `tool_execution_update` events may interleave across tools +- `tool_execution_end` is emitted in tool completion order after each tool is finalized +- final `toolResult` message events are still emitted later in assistant source order + +```typescript +pi.on("tool_execution_start", async (event, ctx) => { + // event.toolCallId, event.toolName, event.args +}); + +pi.on("tool_execution_update", async (event, ctx) => { + // event.toolCallId, event.toolName, event.args, event.partialResult +}); + +pi.on("tool_execution_end", async (event, ctx) => { + // event.toolCallId, event.toolName, event.result, event.isError +}); +``` + +#### context + +Fired before each LLM call. Modify messages non-destructively. See [Session Format](session-format.md) for message types. + +```typescript +pi.on("context", async (event, ctx) => { + // event.messages - deep copy, safe to modify + const filtered = event.messages.filter(m => !shouldPrune(m)); + return { messages: filtered }; +}); +``` + +#### before_provider_request + +Fired after the provider-specific payload is built, right before the request is sent. Handlers run in extension load order. Returning `undefined` keeps the payload unchanged. Returning any other value replaces the payload for later handlers and for the actual request. + +This hook can rewrite provider-level system instructions or remove them entirely. Those payload-level changes are not reflected by `ctx.getSystemPrompt()`, which reports Pi's system prompt string rather than the final serialized provider payload. + +```typescript +pi.on("before_provider_request", (event, ctx) => { + console.log(JSON.stringify(event.payload, null, 2)); + + // Optional: replace payload + // return { ...event.payload, temperature: 0 }; +}); +``` + +This is mainly useful for debugging provider serialization and cache behavior. + +#### after_provider_response + +Fired after an HTTP response is received and before its stream body is consumed. Handlers run in extension load order. + +```typescript +pi.on("after_provider_response", (event, ctx) => { + // event.status - HTTP status code + // event.headers - normalized response headers + if (event.status === 429) { + console.log("rate limited", event.headers["retry-after"]); + } +}); +``` + +Header availability depends on provider and transport. Providers that abstract HTTP responses may not expose headers. + +### Model Events + +#### model_select + +Fired when the model changes via `/model` command, model cycling (`Ctrl+P`), or session restore. + +```typescript +pi.on("model_select", async (event, ctx) => { + // event.model - newly selected model + // event.previousModel - previous model (undefined if first selection) + // event.source - "set" | "cycle" | "restore" + + const prev = event.previousModel + ? `${event.previousModel.provider}/${event.previousModel.id}` + : "none"; + const next = `${event.model.provider}/${event.model.id}`; + + ctx.ui.notify(`Model changed (${event.source}): ${prev} -> ${next}`, "info"); +}); +``` + +Use this to update UI elements (status bars, footers) or perform model-specific initialization when the active model changes. + +#### thinking_level_select + +Fired when the thinking level changes. This is notification-only; handler return values are ignored. + +```typescript +pi.on("thinking_level_select", async (event, ctx) => { + // event.level - newly selected thinking level + // event.previousLevel - previous thinking level + + ctx.ui.setStatus("thinking", `thinking: ${event.level}`); +}); +``` + +Use this to update extension UI when `pi.setThinkingLevel()`, model changes, or built-in thinking-level controls change the active thinking level. + +### Tool Events + +#### tool_call + +Fired after `tool_execution_start`, before the tool executes. **Can block.** Use `isToolCallEventType` to narrow and get typed inputs. + +Before `tool_call` runs, pi waits for previously emitted Agent events to finish draining through `AgentSession`. This means `ctx.sessionManager` is up to date through the current assistant tool-calling message. + +In the default parallel tool execution mode, sibling tool calls from the same assistant message are preflighted sequentially, then executed concurrently. `tool_call` is not guaranteed to see sibling tool results from that same assistant message in `ctx.sessionManager`. + +`event.input` is mutable. Mutate it in place to patch tool arguments before execution. + +Behavior guarantees: +- Mutations to `event.input` affect the actual tool execution +- Later `tool_call` handlers see mutations made by earlier handlers +- No re-validation is performed after your mutation +- Return values from `tool_call` only control blocking via `{ block: true, reason?: string }` + +```typescript +import { isToolCallEventType } from "@earendil-works/pi-coding-agent"; + +pi.on("tool_call", async (event, ctx) => { + // event.toolName - "bash", "read", "write", "edit", etc. + // event.toolCallId + // event.input - tool parameters (mutable) + + // Built-in tools: no type params needed + if (isToolCallEventType("bash", event)) { + // event.input is { command: string; timeout?: number } + event.input.command = `source ~/.profile\n${event.input.command}`; + + if (event.input.command.includes("rm -rf")) { + return { block: true, reason: "Dangerous command" }; + } + } + + if (isToolCallEventType("read", event)) { + // event.input is { path: string; offset?: number; limit?: number } + console.log(`Reading: ${event.input.path}`); + } +}); +``` + +#### Typing custom tool input + +Custom tools should export their input type: + +```typescript +// my-extension.ts +export type MyToolInput = Static; +``` + +Use `isToolCallEventType` with explicit type parameters: + +```typescript +import { isToolCallEventType } from "@earendil-works/pi-coding-agent"; +import type { MyToolInput } from "my-extension"; + +pi.on("tool_call", (event) => { + if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) { + event.input.action; // typed + } +}); +``` + +#### tool_result + +Fired after tool execution finishes and before `tool_execution_end` plus the final tool result message events are emitted. **Can modify result.** + +In parallel tool mode, `tool_result` and `tool_execution_end` may interleave in tool completion order, while final `toolResult` message events are still emitted later in assistant source order. + +`tool_result` handlers chain like middleware: +- Handlers run in extension load order +- Each handler sees the latest result after previous handler changes +- Handlers can return partial patches (`content`, `details`, or `isError`); omitted fields keep their current values + +Use `ctx.signal` for nested async work inside the handler. This lets Esc cancel model calls, `fetch()`, and other abort-aware operations started by the extension. + +```typescript +import { isBashToolResult } from "@earendil-works/pi-coding-agent"; + +pi.on("tool_result", async (event, ctx) => { + // event.toolName, event.toolCallId, event.input + // event.content, event.details, event.isError + + if (isBashToolResult(event)) { + // event.details is typed as BashToolDetails + } + + const response = await fetch("https://example.com/summarize", { + method: "POST", + body: JSON.stringify({ content: event.content }), + signal: ctx.signal, + }); + + // Modify result: + return { content: [...], details: {...}, isError: false }; +}); +``` + +### User Bash Events + +#### user_bash + +Fired when user executes `!` or `!!` commands. **Can intercept.** + +```typescript +import { createLocalBashOperations } from "@earendil-works/pi-coding-agent"; + +pi.on("user_bash", (event, ctx) => { + // event.command - the bash command + // event.excludeFromContext - true if !! prefix + // event.cwd - working directory + + // Option 1: Provide custom operations (e.g., SSH) + return { operations: remoteBashOps }; + + // Option 2: Wrap pi's built-in local bash backend + const local = createLocalBashOperations(); + return { + operations: { + exec(command, cwd, options) { + return local.exec(`source ~/.profile\n${command}`, cwd, options); + } + } + }; + + // Option 3: Full replacement - return result directly + return { result: { output: "...", exitCode: 0, cancelled: false, truncated: false } }; +}); +``` + +### Input Events + +#### input + +Fired when user input is received, after extension commands are checked but before skill and template expansion. The event sees the raw input text, so `/skill:foo` and `/template` are not yet expanded. + +**Processing order:** +1. Extension commands (`/cmd`) checked first - if found, handler runs and input event is skipped +2. `input` event fires - can intercept, transform, or handle +3. If not handled: skill commands (`/skill:name`) expanded to skill content +4. If not handled: prompt templates (`/template`) expanded to template content +5. Agent processing begins (`before_agent_start`, etc.) + +```typescript +pi.on("input", async (event, ctx) => { + // event.text - raw input (before skill/template expansion) + // event.images - attached images, if any + // event.source - "interactive" (typed), "rpc" (API), or "extension" (via sendUserMessage) + + // Transform: rewrite input before expansion + if (event.text.startsWith("?quick ")) + return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` }; + + // Handle: respond without LLM (extension shows its own feedback) + if (event.text === "ping") { + ctx.ui.notify("pong", "info"); + return { action: "handled" }; + } + + // Route by source: skip processing for extension-injected messages + if (event.source === "extension") return { action: "continue" }; + + // Intercept skill commands before expansion + if (event.text.startsWith("/skill:")) { + // Could transform, block, or let pass through + } + + return { action: "continue" }; // Default: pass through to expansion +}); +``` + +**Results:** +- `continue` - pass through unchanged (default if handler returns nothing) +- `transform` - modify text/images, then continue to expansion +- `handled` - skip agent entirely (first handler to return this wins) + +Transforms chain across handlers. See [input-transform.ts](../examples/extensions/input-transform.ts). + +## ExtensionContext + +All handlers receive `ctx: ExtensionContext`. + +### ctx.ui + +UI methods for user interaction. See [Custom UI](#custom-ui) for full details. + +### ctx.hasUI + +`false` in print mode (`-p`) and JSON mode. `true` in interactive and RPC mode. In RPC mode, dialog methods (`select`, `confirm`, `input`, `editor`) work via the extension UI sub-protocol, and fire-and-forget methods (`notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText`) emit requests to the client. Some TUI-specific methods are no-ops or return defaults (see [rpc.md](rpc.md#extension-ui-protocol)). + +### ctx.cwd + +Current working directory. + +### ctx.sessionManager + +Read-only access to session state. See [Session Format](session-format.md) for the full SessionManager API and entry types. + +For `tool_call`, this state is synchronized through the current assistant message before handlers run. In parallel tool execution mode it is still not guaranteed to include sibling tool results from the same assistant message. + +```typescript +ctx.sessionManager.getEntries() // All entries +ctx.sessionManager.getBranch() // Current branch +ctx.sessionManager.getLeafId() // Current leaf entry ID +``` + +### ctx.modelRegistry / ctx.model + +Access to models and API keys. + +### ctx.signal + +The current agent abort signal, or `undefined` when no agent turn is active. + +Use this for abort-aware nested work started by extension handlers, for example: +- `fetch(..., { signal: ctx.signal })` +- model calls that accept `signal` +- file or process helpers that accept `AbortSignal` + +`ctx.signal` is typically defined during active turn events such as `tool_call`, `tool_result`, `message_update`, and `turn_end`. +It is usually `undefined` in idle or non-turn contexts such as session events, extension commands, and shortcuts fired while pi is idle. + +```typescript +pi.on("tool_result", async (event, ctx) => { + const response = await fetch("https://example.com/api", { + method: "POST", + body: JSON.stringify(event), + signal: ctx.signal, + }); + + const data = await response.json(); + return { details: data }; +}); +``` + +### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages() + +Control flow helpers. + +### ctx.shutdown() + +Request a graceful shutdown of pi. + +- **Interactive mode:** Deferred until the agent becomes idle (after processing all queued steering and follow-up messages). +- **RPC mode:** Deferred until the next idle state (after completing the current command response, when waiting for the next command). +- **Print mode:** No-op. The process exits automatically when all prompts are processed. + +Emits `session_shutdown` event to all extensions before exiting. Available in all contexts (event handlers, tools, commands, shortcuts). + +```typescript +pi.on("tool_call", (event, ctx) => { + if (isFatal(event.input)) { + ctx.shutdown(); + } +}); +``` + +### ctx.getContextUsage() + +Returns current context usage for the active model. Uses last assistant usage when available, then estimates tokens for trailing messages. + +```typescript +const usage = ctx.getContextUsage(); +if (usage && usage.tokens > 100_000) { + // ... +} +``` + +### ctx.compact() + +Trigger compaction without awaiting completion. Use `onComplete` and `onError` for follow-up actions. + +```typescript +ctx.compact({ + customInstructions: "Focus on recent changes", + onComplete: (result) => { + ctx.ui.notify("Compaction completed", "info"); + }, + onError: (error) => { + ctx.ui.notify(`Compaction failed: ${error.message}`, "error"); + }, +}); +``` + +### ctx.getSystemPrompt() + +Returns Pi's current system prompt string. + +- During `before_agent_start`, this reflects chained system-prompt changes made so far for the current turn. +- It does not include later `context` message mutations. +- It does not include `before_provider_request` payload rewrites. +- If later-loaded extensions run after yours, they can still change what is ultimately sent. + +```typescript +pi.on("before_agent_start", (event, ctx) => { + const prompt = ctx.getSystemPrompt(); + console.log(`System prompt length: ${prompt.length}`); +}); +``` + +## ExtensionCommandContext + +Command handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with session control methods. These are only available in commands because they can deadlock if called from event handlers. + +### ctx.waitForIdle() + +Wait for the agent to finish streaming: + +```typescript +pi.registerCommand("my-cmd", { + handler: async (args, ctx) => { + await ctx.waitForIdle(); + // Agent is now idle, safe to modify session + }, +}); +``` + +### ctx.newSession(options?) + +Create a new session: + +```typescript +const parentSession = ctx.sessionManager.getSessionFile(); +const kickoff = "Continue in the replacement session"; + +const result = await ctx.newSession({ + parentSession, + setup: async (sm) => { + sm.appendMessage({ + role: "user", + content: [{ type: "text", text: "Context from previous session..." }], + timestamp: Date.now(), + }); + }, + withSession: async (ctx) => { + // Use only the replacement-session ctx here. + await ctx.sendUserMessage(kickoff); + }, +}); + +if (result.cancelled) { + // An extension cancelled the new session +} +``` + +Options: +- `parentSession`: parent session file to record in the new session header +- `setup`: mutate the new session's `SessionManager` before `withSession` runs +- `withSession`: run post-switch work against a fresh replacement-session context. Do not use captured old `pi` / command `ctx`; see [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns). + +### ctx.fork(entryId, options?) + +Fork from a specific entry, creating a new session file: + +```typescript +const result = await ctx.fork("entry-id-123", { + withSession: async (ctx) => { + // Use only the replacement-session ctx here. + ctx.ui.notify("Now in the forked session", "info"); + }, +}); +if (result.cancelled) { + // An extension cancelled the fork +} + +const cloneResult = await ctx.fork("entry-id-456", { position: "at" }); +if (cloneResult.cancelled) { + // An extension cancelled the clone +} +``` + +Options: +- `position`: `"before"` (default) forks before the selected user message, restoring that prompt into the editor +- `position`: `"at"` duplicates the active path through the selected entry without restoring editor text +- `withSession`: run post-switch work against a fresh replacement-session context. Do not use captured old `pi` / command `ctx`; see [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns). + +### ctx.navigateTree(targetId, options?) + +Navigate to a different point in the session tree: + +```typescript +const result = await ctx.navigateTree("entry-id-456", { + summarize: true, + customInstructions: "Focus on error handling changes", + replaceInstructions: false, // true = replace default prompt entirely + label: "review-checkpoint", +}); +``` + +Options: +- `summarize`: Whether to generate a summary of the abandoned branch +- `customInstructions`: Custom instructions for the summarizer +- `replaceInstructions`: If true, `customInstructions` replaces the default prompt instead of being appended +- `label`: Label to attach to the branch summary entry (or target entry if not summarizing) + +### ctx.switchSession(sessionPath, options?) + +Switch to a different session file: + +```typescript +const result = await ctx.switchSession("/path/to/session.jsonl", { + withSession: async (ctx) => { + await ctx.sendUserMessage("Resume work in the replacement session"); + }, +}); +if (result.cancelled) { + // An extension cancelled the switch via session_before_switch +} +``` + +Options: +- `withSession`: run post-switch work against a fresh replacement-session context. Do not use captured old `pi` / command `ctx`; see [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns). + +To discover available sessions, use the static `SessionManager.list()` or `SessionManager.listAll()` methods: + +```typescript +import { SessionManager } from "@earendil-works/pi-coding-agent"; + +pi.registerCommand("switch", { + description: "Switch to another session", + handler: async (args, ctx) => { + const sessions = await SessionManager.list(ctx.cwd); + if (sessions.length === 0) return; + const choice = await ctx.ui.select( + "Pick session:", + sessions.map(s => s.file), + ); + if (choice) { + await ctx.switchSession(choice, { + withSession: async (ctx) => { + ctx.ui.notify("Switched session", "info"); + }, + }); + } + }, +}); +``` + +### Session replacement lifecycle and footguns + +`withSession` receives a fresh `ReplacedSessionContext`, which extends `ExtensionCommandContext` with async `sendMessage()` and `sendUserMessage()` helpers bound to the replacement session. + +Lifecycle and footguns: +- `withSession` runs only after the old session has emitted `session_shutdown`, the old runtime has been torn down, the replacement session has been rebound, and the new extension instance has already received `session_start`. +- The callback still executes in the original closure, not inside the new extension instance. That means your old extension instance may already have run its shutdown cleanup before `withSession` starts. +- Captured old `pi` / old command `ctx` session-bound objects are stale after replacement and will throw if used. Use only the `ctx` passed to `withSession` for session-bound work. +- Previously extracted raw objects are still your responsibility. For example, if you capture `const sm = ctx.sessionManager` before replacement, `sm` is still the old `SessionManager` object. Do not reuse it after replacement. +- Code in `withSession` should assume any state invalidated by your `session_shutdown` handler is already gone. Only capture plain data that survives shutdown cleanly, such as strings, ids, and serialized config. + +Safe pattern: + +```typescript +pi.registerCommand("handoff", { + handler: async (_args, ctx) => { + const kickoff = "Continue from the replacement session"; + await ctx.newSession({ + withSession: async (ctx) => { + await ctx.sendUserMessage(kickoff); + }, + }); + }, +}); +``` + +Unsafe pattern: + +```typescript +pi.registerCommand("handoff", { + handler: async (_args, ctx) => { + const oldSessionManager = ctx.sessionManager; + await ctx.newSession({ + withSession: async (_ctx) => { + // stale old objects: do not do this + oldSessionManager.getSessionFile(); + pi.sendUserMessage("wrong"); + }, + }); + }, +}); +``` + +### ctx.reload() + +Run the same reload flow as `/reload`. + +```typescript +pi.registerCommand("reload-runtime", { + description: "Reload extensions, skills, prompts, and themes", + handler: async (_args, ctx) => { + await ctx.reload(); + return; + }, +}); +``` + +Important behavior: +- `await ctx.reload()` emits `session_shutdown` for the current extension runtime +- It then reloads resources and emits `session_start` with `reason: "reload"` and `resources_discover` with reason `"reload"` +- The currently running command handler still continues in the old call frame +- Code after `await ctx.reload()` still runs from the pre-reload version +- Code after `await ctx.reload()` must not assume old in-memory extension state is still valid +- After the handler returns, future commands/events/tool calls use the new extension version + +For predictable behavior, treat reload as terminal for that handler (`await ctx.reload(); return;`). + +Tools run with `ExtensionContext`, so they cannot call `ctx.reload()` directly. Use a command as the reload entrypoint, then expose a tool that queues that command as a follow-up user message. + +Example tool the LLM can call to trigger reload: + +```typescript +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +export default function (pi: ExtensionAPI) { + pi.registerCommand("reload-runtime", { + description: "Reload extensions, skills, prompts, and themes", + handler: async (_args, ctx) => { + await ctx.reload(); + return; + }, + }); + + pi.registerTool({ + name: "reload_runtime", + label: "Reload Runtime", + description: "Reload extensions, skills, prompts, and themes", + parameters: Type.Object({}), + async execute() { + pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" }); + return { + content: [{ type: "text", text: "Queued /reload-runtime as a follow-up command." }], + }; + }, + }); +} +``` + +## ExtensionAPI Methods + +### pi.on(event, handler) + +Subscribe to events. See [Events](#events) for event types and return values. + +### pi.registerTool(definition) + +Register a custom tool callable by the LLM. See [Custom Tools](#custom-tools) for full details. + +`pi.registerTool()` works both during extension load and after startup. You can call it inside `session_start`, command handlers, or other event handlers. New tools are refreshed immediately in the same session, so they appear in `pi.getAllTools()` and are callable by the LLM without `/reload`. + +Use `pi.setActiveTools()` to enable or disable tools (including dynamically added tools) at runtime. + +Use `promptSnippet` to opt a custom tool into a one-line entry in `Available tools`, and `promptGuidelines` to append tool-specific bullets to the default `Guidelines` section when the tool is active. + +**Important:** `promptGuidelines` bullets are appended flat to the `Guidelines` section with no tool name prefix. Each guideline must name the tool it refers to — avoid "Use this tool when..." because the LLM cannot tell which tool "this" means. Write "Use my_tool when..." instead. + +See [dynamic-tools.ts](../examples/extensions/dynamic-tools.ts) for a full example. + +```typescript +import { Type } from "typebox"; +import { StringEnum } from "@earendil-works/pi-ai"; + +pi.registerTool({ + name: "my_tool", + label: "My Tool", + description: "What this tool does", + promptSnippet: "Summarize or transform text according to action", + promptGuidelines: ["Use my_tool when the user asks to summarize previously generated text."], + parameters: Type.Object({ + action: StringEnum(["list", "add"] as const), + text: Type.Optional(Type.String()), + }), + prepareArguments(args) { + // Optional compatibility shim. Runs before schema validation. + // Return the current schema shape, for example to fold legacy fields + // into the modern parameter object. + return args; + }, + + async execute(toolCallId, params, signal, onUpdate, ctx) { + // Stream progress + onUpdate?.({ content: [{ type: "text", text: "Working..." }] }); + + return { + content: [{ type: "text", text: "Done" }], + details: { result: "..." }, + }; + }, + + // Optional: Custom rendering + renderCall(args, theme, context) { ... }, + renderResult(result, options, theme, context) { ... }, +}); +``` + +### pi.sendMessage(message, options?) + +Inject a custom message into the session. + +```typescript +pi.sendMessage({ + customType: "my-extension", + content: "Message text", + display: true, + details: { ... }, +}, { + triggerTurn: true, + deliverAs: "steer", +}); +``` + +**Options:** +- `deliverAs` - Delivery mode: + - `"steer"` (default) - Queues the message while streaming. Delivered after the current assistant turn finishes executing its tool calls, before the next LLM call. + - `"followUp"` - Waits for agent to finish. Delivered only when agent has no more tool calls. + - `"nextTurn"` - Queued for next user prompt. Does not interrupt or trigger anything. +- `triggerTurn: true` - If agent is idle, trigger an LLM response immediately. Only applies to `"steer"` and `"followUp"` modes (ignored for `"nextTurn"`). + +### pi.sendUserMessage(content, options?) + +Send a user message to the agent. Unlike `sendMessage()` which sends custom messages, this sends an actual user message that appears as if typed by the user. Always triggers a turn. + +```typescript +// Simple text message +pi.sendUserMessage("What is 2+2?"); + +// With content array (text + images) +pi.sendUserMessage([ + { type: "text", text: "Describe this image:" }, + { type: "image", source: { type: "base64", mediaType: "image/png", data: "..." } }, +]); + +// During streaming - must specify delivery mode +pi.sendUserMessage("Focus on error handling", { deliverAs: "steer" }); +pi.sendUserMessage("And then summarize", { deliverAs: "followUp" }); +``` + +**Options:** +- `deliverAs` - Required when agent is streaming: + - `"steer"` - Queues the message for delivery after the current assistant turn finishes executing its tool calls + - `"followUp"` - Waits for agent to finish all tools + +When not streaming, the message is sent immediately and triggers a new turn. When streaming without `deliverAs`, throws an error. + +See [send-user-message.ts](../examples/extensions/send-user-message.ts) for a complete example. + +### pi.appendEntry(customType, data?) + +Persist extension state (does NOT participate in LLM context). + +```typescript +pi.appendEntry("my-state", { count: 42 }); + +// Restore on reload +pi.on("session_start", async (_event, ctx) => { + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type === "custom" && entry.customType === "my-state") { + // Reconstruct from entry.data + } + } +}); +``` + +### pi.setSessionName(name) + +Set the session display name (shown in session selector instead of first message). + +```typescript +pi.setSessionName("Refactor auth module"); +``` + +### pi.getSessionName() + +Get the current session name, if set. + +```typescript +const name = pi.getSessionName(); +if (name) { + console.log(`Session: ${name}`); +} +``` + +### pi.setLabel(entryId, label) + +Set or clear a label on an entry. Labels are user-defined markers for bookmarking and navigation (shown in `/tree` selector). + +```typescript +// Set a label +pi.setLabel(entryId, "checkpoint-before-refactor"); + +// Clear a label +pi.setLabel(entryId, undefined); + +// Read labels via sessionManager +const label = ctx.sessionManager.getLabel(entryId); +``` + +Labels persist in the session and survive restarts. Use them to mark important points (turns, checkpoints) in the conversation tree. + +### pi.registerCommand(name, options) + +Register a command. + +If multiple extensions register the same command name, pi keeps them all and assigns numeric invocation suffixes in load order, for example `/review:1` and `/review:2`. + +```typescript +pi.registerCommand("stats", { + description: "Show session statistics", + handler: async (args, ctx) => { + const count = ctx.sessionManager.getEntries().length; + ctx.ui.notify(`${count} entries`, "info"); + } +}); +``` + +Optional: add argument auto-completion for `/command ...`: + +```typescript +import type { AutocompleteItem } from "@earendil-works/pi-tui"; + +pi.registerCommand("deploy", { + description: "Deploy to an environment", + getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { + const envs = ["dev", "staging", "prod"]; + const items = envs.map((e) => ({ value: e, label: e })); + const filtered = items.filter((i) => i.value.startsWith(prefix)); + return filtered.length > 0 ? filtered : null; + }, + handler: async (args, ctx) => { + ctx.ui.notify(`Deploying: ${args}`, "info"); + }, +}); +``` + +### pi.getCommands() + +Get the slash commands available for invocation via `prompt` in the current session. Includes extension commands, prompt templates, and skill commands. +The list matches the RPC `get_commands` ordering: extensions first, then templates, then skills. + +```typescript +const commands = pi.getCommands(); +const bySource = commands.filter((command) => command.source === "extension"); +const userScoped = commands.filter((command) => command.sourceInfo.scope === "user"); +``` + +Each entry has this shape: + +```typescript +{ + name: string; // Invokable command name without the leading slash. May be suffixed like "review:1" + description?: string; + source: "extension" | "prompt" | "skill"; + sourceInfo: { + path: string; + source: string; + scope: "user" | "project" | "temporary"; + origin: "package" | "top-level"; + baseDir?: string; + }; +} +``` + +Use `sourceInfo` as the canonical provenance field. Do not infer ownership from command names or from ad hoc path parsing. + +Built-in interactive commands (like `/model` and `/settings`) are not included here. They are handled only in interactive +mode and would not execute if sent via `prompt`. + +### pi.registerMessageRenderer(customType, renderer) + +Register a custom TUI renderer for messages with your `customType`. See [Custom UI](#custom-ui). + +### pi.registerShortcut(shortcut, options) + +Register a keyboard shortcut. See [keybindings.md](keybindings.md) for the shortcut format and built-in keybindings. + +```typescript +pi.registerShortcut("ctrl+shift+p", { + description: "Toggle plan mode", + handler: async (ctx) => { + ctx.ui.notify("Toggled!"); + }, +}); +``` + +### pi.registerFlag(name, options) + +Register a CLI flag. + +```typescript +pi.registerFlag("plan", { + description: "Start in plan mode", + type: "boolean", + default: false, +}); + +// Check value +if (pi.getFlag("plan")) { + // Plan mode enabled +} +``` + +### pi.exec(command, args, options?) + +Execute a shell command. + +```typescript +const result = await pi.exec("git", ["status"], { signal, timeout: 5000 }); +// result.stdout, result.stderr, result.code, result.killed +``` + +### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names) + +Manage active tools. This works for both built-in tools and dynamically registered tools. + +```typescript +const active = pi.getActiveTools(); +const all = pi.getAllTools(); +// [{ +// name: "read", +// description: "Read file contents...", +// parameters: ..., +// sourceInfo: { path: "", source: "builtin", scope: "temporary", origin: "top-level" } +// }, ...] +const names = all.map(t => t.name); +const builtinTools = all.filter((t) => t.sourceInfo.source === "builtin"); +const extensionTools = all.filter((t) => t.sourceInfo.source !== "builtin" && t.sourceInfo.source !== "sdk"); +pi.setActiveTools(["read", "bash"]); // Switch to read-only +``` + +`pi.getAllTools()` returns `name`, `description`, `parameters`, and `sourceInfo`. + +Typical `sourceInfo.source` values: +- `builtin` for built-in tools +- `sdk` for tools passed via `createAgentSession({ customTools })` +- extension source metadata for tools registered by extensions + +### pi.setModel(model) + +Set the current model. Returns `false` if no API key is available for the model. See [models.md](models.md) for configuring custom models. + +```typescript +const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5"); +if (model) { + const success = await pi.setModel(model); + if (!success) { + ctx.ui.notify("No API key for this model", "error"); + } +} +``` + +### pi.getThinkingLevel() / pi.setThinkingLevel(level) + +Get or set the thinking level. Level is clamped to model capabilities (non-reasoning models always use "off"). Changes emit `thinking_level_select`. + +```typescript +const current = pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh" +pi.setThinkingLevel("high"); +``` + +### pi.events + +Shared event bus for communication between extensions: + +```typescript +pi.events.on("my:event", (data) => { ... }); +pi.events.emit("my:event", { ... }); +``` + +### pi.registerProvider(name, config) + +Register or override a model provider dynamically. Useful for proxies, custom endpoints, or team-wide model configurations. + +Calls made during the extension factory function are queued and applied once the runner initialises. Calls made after that — for example from a command handler following a user setup flow — take effect immediately without requiring a `/reload`. + +If you need to discover models from a remote endpoint, prefer an async extension factory over deferring the fetch to `session_start`. pi waits for the factory before startup continues, so the registered models are available immediately, including to `pi --list-models`. + +```typescript +// Register a new provider with custom models +pi.registerProvider("my-proxy", { + name: "My Proxy", + baseUrl: "https://proxy.example.com", + apiKey: "PROXY_API_KEY", // env var name or literal + api: "anthropic-messages", + models: [ + { + id: "claude-sonnet-4-20250514", + name: "Claude 4 Sonnet (proxy)", + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 16384 + } + ] +}); + +// Override baseUrl for an existing provider (keeps all models) +pi.registerProvider("anthropic", { + baseUrl: "https://proxy.example.com" +}); + +// Register provider with OAuth support for /login +pi.registerProvider("corporate-ai", { + baseUrl: "https://ai.corp.com", + api: "openai-responses", + models: [...], + oauth: { + name: "Corporate AI (SSO)", + async login(callbacks) { + // Custom OAuth flow + callbacks.onAuth({ url: "https://sso.corp.com/..." }); + const code = await callbacks.onPrompt({ message: "Enter code:" }); + return { refresh: code, access: code, expires: Date.now() + 3600000 }; + }, + async refreshToken(credentials) { + // Refresh logic + return credentials; + }, + getApiKey(credentials) { + return credentials.access; + } + } +}); +``` + +**Config options:** +- `name` - Display name for the provider in UI such as `/login`. +- `baseUrl` - API endpoint URL. Required when defining models. +- `apiKey` - API key or environment variable name. Required when defining models (unless `oauth` provided). +- `api` - API type: `"anthropic-messages"`, `"openai-completions"`, `"openai-responses"`, etc. +- `headers` - Custom headers to include in requests. +- `authHeader` - If true, adds `Authorization: Bearer` header automatically. +- `models` - Array of model definitions. If provided, replaces all existing models for this provider. Model definitions can set `baseUrl` to override the provider endpoint for that model. +- `oauth` - OAuth provider config for `/login` support. When provided, the provider appears in the login menu. +- `streamSimple` - Custom streaming implementation for non-standard APIs. + +See [custom-provider.md](custom-provider.md) for advanced topics: custom streaming APIs, OAuth details, model definition reference. + +### pi.unregisterProvider(name) + +Remove a previously registered provider and its models. Built-in models that were overridden by the provider are restored. Has no effect if the provider was not registered. + +Like `registerProvider`, this takes effect immediately when called after the initial load phase, so a `/reload` is not required. + +```typescript +pi.registerCommand("my-setup-teardown", { + description: "Remove the custom proxy provider", + handler: async (_args, _ctx) => { + pi.unregisterProvider("my-proxy"); + }, +}); +``` + +## State Management + +Extensions with state should store it in tool result `details` for proper branching support: + +```typescript +export default function (pi: ExtensionAPI) { + let items: string[] = []; + + // Reconstruct state from session + pi.on("session_start", async (_event, ctx) => { + items = []; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "toolResult") { + if (entry.message.toolName === "my_tool") { + items = entry.message.details?.items ?? []; + } + } + } + }); + + pi.registerTool({ + name: "my_tool", + // ... + async execute(toolCallId, params, signal, onUpdate, ctx) { + items.push("new item"); + return { + content: [{ type: "text", text: "Added" }], + details: { items: [...items] }, // Store for reconstruction + }; + }, + }); +} +``` + +## Custom Tools + +Register tools the LLM can call via `pi.registerTool()`. Tools appear in the system prompt and can have custom rendering. + +Use `promptSnippet` for a short one-line entry in the `Available tools` section in the default system prompt. If omitted, custom tools are left out of that section. + +Use `promptGuidelines` to add tool-specific bullets to the default system prompt `Guidelines` section. These bullets are included only while the tool is active (for example, after `pi.setActiveTools([...])`). + +**Important:** `promptGuidelines` bullets are appended flat to the `Guidelines` section with no tool name prefix or grouping. Each guideline must name the tool it refers to — avoid "Use this tool when..." because the LLM cannot tell which tool "this" means. Write "Use my_tool when..." instead. + +Note: Some models are idiots and include the @ prefix in tool path arguments. Built-in tools strip a leading @ before resolving paths. If your custom tool accepts a path, normalize a leading @ as well. + +If your custom tool mutates files, use `withFileMutationQueue()` so it participates in the same per-file queue as built-in `edit` and `write`. This matters because tool calls run in parallel by default. Without the queue, two tools can read the same old file contents, compute different updates, and then whichever write lands last overwrites the other. + +Example failure case: your custom tool edits `foo.ts` while built-in `edit` also changes `foo.ts` in the same assistant turn. If your tool does not participate in the queue, both can read the original `foo.ts`, apply separate changes, and one of those changes is lost. + +Pass the real target file path to `withFileMutationQueue()`, not the raw user argument. Resolve it to an absolute path first, relative to `ctx.cwd` or your tool's working directory. For existing files, the helper canonicalizes through `realpath()`, so symlink aliases for the same file share one queue. For new files, it falls back to the resolved absolute path because there is nothing to `realpath()` yet. + +Queue the entire mutation window on that target path. That includes read-modify-write logic, not just the final write. + +```typescript +import { withFileMutationQueue } from "@earendil-works/pi-coding-agent"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const absolutePath = resolve(ctx.cwd, params.path); + + return withFileMutationQueue(absolutePath, async () => { + await mkdir(dirname(absolutePath), { recursive: true }); + const current = await readFile(absolutePath, "utf8"); + const next = current.replace(params.oldText, params.newText); + await writeFile(absolutePath, next, "utf8"); + + return { + content: [{ type: "text", text: `Updated ${params.path}` }], + details: {}, + }; + }); +} +``` + +### Tool Definition + +```typescript +import { Type } from "typebox"; +import { StringEnum } from "@earendil-works/pi-ai"; +import { Text } from "@earendil-works/pi-tui"; + +pi.registerTool({ + name: "my_tool", + label: "My Tool", + description: "What this tool does (shown to LLM)", + promptSnippet: "List or add items in the project todo list", + promptGuidelines: [ + "Use my_tool for todo planning instead of direct file edits when the user asks for a task list." + ], + parameters: Type.Object({ + action: StringEnum(["list", "add"] as const), // Use StringEnum for Google compatibility + text: Type.Optional(Type.String()), + }), + prepareArguments(args) { + if (!args || typeof args !== "object") return args; + const input = args as { action?: string; oldAction?: string }; + if (typeof input.oldAction === "string" && input.action === undefined) { + return { ...input, action: input.oldAction }; + } + return args; + }, + + async execute(toolCallId, params, signal, onUpdate, ctx) { + // Check for cancellation + if (signal?.aborted) { + return { content: [{ type: "text", text: "Cancelled" }] }; + } + + // Stream progress updates + onUpdate?.({ + content: [{ type: "text", text: "Working..." }], + details: { progress: 50 }, + }); + + // Run commands via pi.exec (captured from extension closure) + const result = await pi.exec("some-command", [], { signal }); + + // Return result + return { + content: [{ type: "text", text: "Done" }], // Sent to LLM + details: { data: result }, // For rendering & state + // Optional: stop after this tool batch when every finalized tool result + // in the batch also returns terminate: true. + terminate: true, + }; + }, + + // Optional: Custom rendering + renderCall(args, theme, context) { ... }, + renderResult(result, options, theme, context) { ... }, +}); +``` + +**Signaling errors:** To mark a tool execution as failed (sets `isError: true` on the result and reports it to the LLM), throw an error from `execute`. Returning a value never sets the error flag regardless of what properties you include in the return object. + +**Early termination:** Return `terminate: true` from `execute()` to hint that the automatic follow-up LLM call should be skipped after the current tool batch. This only takes effect when every finalized tool result in that batch is terminating. See [examples/extensions/structured-output.ts](../examples/extensions/structured-output.ts) for a minimal example where the agent ends on a final structured-output tool call. + +```typescript +// Correct: throw to signal an error +async execute(toolCallId, params) { + if (!isValid(params.input)) { + throw new Error(`Invalid input: ${params.input}`); + } + return { content: [{ type: "text", text: "OK" }], details: {} }; +} +``` + +**Important:** Use `StringEnum` from `@earendil-works/pi-ai` for string enums. `Type.Union`/`Type.Literal` doesn't work with Google's API. + +**Argument preparation:** `prepareArguments(args)` is optional. If defined, it runs before schema validation and before `execute()`. Use it to mimic an older accepted input shape when pi resumes an older session whose stored tool call arguments no longer match the current schema. Return the object you want validated against `parameters`. Keep the public schema strict. Do not add deprecated compatibility fields to `parameters` just to keep old resumed sessions working. + +Example: an older session may contain an `edit` tool call with top-level `oldText` and `newText`, while the current schema only accepts `edits: [{ oldText, newText }]`. + +```typescript +pi.registerTool({ + name: "edit", + label: "Edit", + description: "Edit a single file using exact text replacement", + parameters: Type.Object({ + path: Type.String(), + edits: Type.Array( + Type.Object({ + oldText: Type.String(), + newText: Type.String(), + }), + ), + }), + prepareArguments(args) { + if (!args || typeof args !== "object") return args; + + const input = args as { + path?: string; + edits?: Array<{ oldText: string; newText: string }>; + oldText?: unknown; + newText?: unknown; + }; + + if (typeof input.oldText !== "string" || typeof input.newText !== "string") { + return args; + } + + return { + ...input, + edits: [...(input.edits ?? []), { oldText: input.oldText, newText: input.newText }], + }; + }, + async execute(toolCallId, params, signal, onUpdate, ctx) { + // params now matches the current schema + return { + content: [{ type: "text", text: `Applying ${params.edits.length} edit block(s)` }], + details: {}, + }; + }, +}); +``` + +### Overriding Built-in Tools + +Extensions can override built-in tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) by registering a tool with the same name. Interactive mode displays a warning when this happens. + +```bash +# Extension's read tool replaces built-in read +pi -e ./tool-override.ts +``` + +Alternatively, use `--no-builtin-tools` to start without any built-in tools while keeping extension tools enabled: +```bash +# No built-in tools, only extension tools +pi --no-builtin-tools -e ./my-extension.ts +``` + +See [examples/extensions/tool-override.ts](../examples/extensions/tool-override.ts) for a complete example that overrides `read` with logging and access control. + +**Rendering:** Built-in renderer inheritance is resolved per slot. Execution override and rendering override are independent. If your override omits `renderCall`, the built-in `renderCall` is used. If your override omits `renderResult`, the built-in `renderResult` is used. If your override omits both, the built-in renderer is used automatically (syntax highlighting, diffs, etc.). This lets you wrap built-in tools for logging or access control without reimplementing the UI. + +**Prompt metadata:** `promptSnippet` and `promptGuidelines` are not inherited from the built-in tool. If your override should keep those prompt instructions, define them on the override explicitly. + +**Your implementation must match the exact result shape**, including the `details` type. The UI and session logic depend on these shapes for rendering and state tracking. + +Built-in tool implementations: +- [read.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/read.ts) - `ReadToolDetails` +- [bash.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/bash.ts) - `BashToolDetails` +- [edit.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/edit.ts) +- [write.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/write.ts) +- [grep.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/grep.ts) - `GrepToolDetails` +- [find.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/find.ts) - `FindToolDetails` +- [ls.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/ls.ts) - `LsToolDetails` + +### Remote Execution + +Built-in tools support pluggable operations for delegating to remote systems (SSH, containers, etc.): + +```typescript +import { createReadTool, createBashTool, type ReadOperations } from "@earendil-works/pi-coding-agent"; + +// Create tool with custom operations +const remoteRead = createReadTool(cwd, { + operations: { + readFile: (path) => sshExec(remote, `cat ${path}`), + access: (path) => sshExec(remote, `test -r ${path}`).then(() => {}), + } +}); + +// Register, checking flag at execution time +pi.registerTool({ + ...remoteRead, + async execute(id, params, signal, onUpdate, _ctx) { + const ssh = getSshConfig(); + if (ssh) { + const tool = createReadTool(cwd, { operations: createRemoteOps(ssh) }); + return tool.execute(id, params, signal, onUpdate); + } + return localRead.execute(id, params, signal, onUpdate); + }, +}); +``` + +**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations` + +For `user_bash`, extensions can reuse pi's local shell backend via `createLocalBashOperations()` instead of reimplementing local process spawning, shell resolution, and process-tree termination. + +The bash tool also supports a spawn hook to adjust the command, cwd, or env before execution: + +```typescript +import { createBashTool } from "@earendil-works/pi-coding-agent"; + +const bashTool = createBashTool(cwd, { + spawnHook: ({ command, cwd, env }) => ({ + command: `source ~/.profile\n${command}`, + cwd: `/mnt/sandbox${cwd}`, + env: { ...env, CI: "1" }, + }), +}); +``` + +See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag. + +### Output Truncation + +**Tools MUST truncate their output** to avoid overwhelming the LLM context. Large outputs can cause: +- Context overflow errors (prompt too long) +- Compaction failures +- Degraded model performance + +The built-in limit is **50KB** (~10k tokens) and **2000 lines**, whichever is hit first. Use the exported truncation utilities: + +```typescript +import { + truncateHead, // Keep first N lines/bytes (good for file reads, search results) + truncateTail, // Keep last N lines/bytes (good for logs, command output) + truncateLine, // Truncate a single line to maxBytes with ellipsis + formatSize, // Human-readable size (e.g., "50KB", "1.5MB") + DEFAULT_MAX_BYTES, // 50KB + DEFAULT_MAX_LINES, // 2000 +} from "@earendil-works/pi-coding-agent"; + +async execute(toolCallId, params, signal, onUpdate, ctx) { + const output = await runCommand(); + + // Apply truncation + const truncation = truncateHead(output, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + + let result = truncation.content; + + if (truncation.truncated) { + // Write full output to temp file + const tempFile = writeTempFile(output); + + // Inform the LLM where to find complete output + result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`; + result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`; + result += ` Full output saved to: ${tempFile}]`; + } + + return { content: [{ type: "text", text: result }] }; +} +``` + +**Key points:** +- Use `truncateHead` for content where the beginning matters (search results, file reads) +- Use `truncateTail` for content where the end matters (logs, command output) +- Always inform the LLM when output is truncated and where to find the full version +- Document the truncation limits in your tool's description + +See [examples/extensions/truncated-tool.ts](../examples/extensions/truncated-tool.ts) for a complete example wrapping `rg` (ripgrep) with proper truncation. + +### Multiple Tools + +One extension can register multiple tools with shared state: + +```typescript +export default function (pi: ExtensionAPI) { + let connection = null; + + pi.registerTool({ name: "db_connect", ... }); + pi.registerTool({ name: "db_query", ... }); + pi.registerTool({ name: "db_close", ... }); + + pi.on("session_shutdown", async () => { + connection?.close(); + }); +} +``` + +### Custom Rendering + +Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API and [tool-execution.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/modes/interactive/components/tool-execution.ts) for how tool rows are composed. + +By default, tool output is wrapped in a `Box` that handles padding and background. A defined `renderCall` or `renderResult` must return a `Component`. If a slot renderer is not defined, `tool-execution.ts` uses fallback rendering for that slot. + +Set `renderShell: "self"` when the tool should render its own shell instead of using the default `Box`. This is useful for tools that need complete control over framing or background behavior, for example large previews that must stay visually stable after the tool settles. + +```typescript +pi.registerTool({ + name: "my_tool", + label: "My Tool", + description: "Custom shell example", + parameters: Type.Object({}), + renderShell: "self", + async execute() { + return { content: [{ type: "text", text: "ok" }], details: undefined }; + }, + renderCall(args, theme, context) { + return new Text(theme.fg("accent", "my custom shell"), 0, 0); + }, +}); +``` + +`renderCall` and `renderResult` each receive a `context` object with: +- `args` - the current tool call arguments +- `state` - shared row-local state across `renderCall` and `renderResult` +- `lastComponent` - the previously returned component for that slot, if any +- `invalidate()` - request a rerender of this tool row +- `toolCallId`, `cwd`, `executionStarted`, `argsComplete`, `isPartial`, `expanded`, `showImages`, `isError` + +Use `context.state` for cross-slot shared state. Keep slot-local caches on the returned component instance when you want to reuse and mutate the same component across renders. + +#### renderCall + +Renders the tool call or header: + +```typescript +import { Text } from "@earendil-works/pi-tui"; + +renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + let content = theme.fg("toolTitle", theme.bold("my_tool ")); + content += theme.fg("muted", args.action); + if (args.text) { + content += " " + theme.fg("dim", `"${args.text}"`); + } + text.setText(content); + return text; +} +``` + +#### renderResult + +Renders the tool result or output: + +```typescript +renderResult(result, { expanded, isPartial }, theme, context) { + if (isPartial) { + return new Text(theme.fg("warning", "Processing..."), 0, 0); + } + + if (result.details?.error) { + return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0); + } + + let text = theme.fg("success", "✓ Done"); + if (expanded && result.details?.items) { + for (const item of result.details.items) { + text += "\n " + theme.fg("dim", item); + } + } + return new Text(text, 0, 0); +} +``` + +If a slot intentionally has no visible content, return an empty `Component` such as an empty `Container`. + +#### Keybinding Hints + +Use `keyHint()` to display keybinding hints that respect the active keybinding configuration: + +```typescript +import { keyHint } from "@earendil-works/pi-coding-agent"; + +renderResult(result, { expanded }, theme, context) { + let text = theme.fg("success", "✓ Done"); + if (!expanded) { + text += ` (${keyHint("app.tools.expand", "to expand")})`; + } + return new Text(text, 0, 0); +} +``` + +Available functions: +- `keyHint(keybinding, description)` - Formats a configured keybinding id such as `"app.tools.expand"` or `"tui.select.confirm"` +- `keyText(keybinding)` - Returns the raw configured key text for a keybinding id +- `rawKeyHint(key, description)` - Format a raw key string + +Use namespaced keybinding ids: +- Coding-agent ids use the `app.*` namespace, for example `app.tools.expand`, `app.editor.external`, `app.session.rename` +- Shared TUI ids use the `tui.*` namespace, for example `tui.select.confirm`, `tui.select.cancel`, `tui.input.tab` + +For the exhaustive list of keybinding ids and defaults, see [keybindings.md](keybindings.md). `keybindings.json` uses those same namespaced ids. + +Custom editors and `ctx.ui.custom()` components receive `keybindings: KeybindingsManager` as an injected argument. They should use that injected manager directly instead of calling `getKeybindings()` or `setKeybindings()`. + +#### Best Practices + +- Use `Text` with padding `(0, 0)`. The default Box handles padding. +- Use `\n` for multi-line content. +- Handle `isPartial` for streaming progress. +- Support `expanded` for detail on demand. +- Keep default view compact. +- Read `context.args` in `renderResult` instead of copying args into `context.state`. +- Use `context.state` only for data that must be shared across call and result slots. +- Reuse `context.lastComponent` when the same component instance can be updated in place. +- Use `renderShell: "self"` only when the default boxed shell gets in the way. In self-shell mode the tool is responsible for its own framing, padding, and background. + +#### Fallback + +If a slot renderer is not defined or throws: +- `renderCall`: Shows the tool name +- `renderResult`: Shows raw text from `content` + +## Custom UI + +Extensions can interact with users via `ctx.ui` methods and customize how messages/tools render. + +**For custom components, see [tui.md](tui.md)** which has copy-paste patterns for: +- Selection dialogs (SelectList) +- Async operations with cancel (BorderedLoader) +- Settings toggles (SettingsList) +- Status indicators (setStatus) +- Working message, visibility, and indicator during streaming (`setWorkingMessage`, `setWorkingVisible`, `setWorkingIndicator`) +- Widgets above/below editor (setWidget) +- Autocomplete providers layered on top of built-in slash/path completion (addAutocompleteProvider) +- Custom footers (setFooter) + +### Dialogs + +```typescript +// Select from options +const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]); + +// Confirm dialog +const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); + +// Text input +const name = await ctx.ui.input("Name:", "placeholder"); + +// Multi-line editor +const text = await ctx.ui.editor("Edit:", "prefilled text"); + +// Notification (non-blocking) +ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" +``` + +#### Timed Dialogs with Countdown + +Dialogs support a `timeout` option that auto-dismisses with a live countdown display: + +```typescript +// Dialog shows "Title (5s)" → "Title (4s)" → ... → auto-dismisses at 0 +const confirmed = await ctx.ui.confirm( + "Timed Confirmation", + "This dialog will auto-cancel in 5 seconds. Confirm?", + { timeout: 5000 } +); + +if (confirmed) { + // User confirmed +} else { + // User cancelled or timed out +} +``` + +**Return values on timeout:** +- `select()` returns `undefined` +- `confirm()` returns `false` +- `input()` returns `undefined` + +#### Manual Dismissal with AbortSignal + +For more control (e.g., to distinguish timeout from user cancel), use `AbortSignal`: + +```typescript +const controller = new AbortController(); +const timeoutId = setTimeout(() => controller.abort(), 5000); + +const confirmed = await ctx.ui.confirm( + "Timed Confirmation", + "This dialog will auto-cancel in 5 seconds. Confirm?", + { signal: controller.signal } +); + +clearTimeout(timeoutId); + +if (confirmed) { + // User confirmed +} else if (controller.signal.aborted) { + // Dialog timed out +} else { + // User cancelled (pressed Escape or selected "No") +} +``` + +See [examples/extensions/timed-confirm.ts](../examples/extensions/timed-confirm.ts) for complete examples. + +### Widgets, Status, and Footer + +```typescript +// Status in footer (persistent until cleared) +ctx.ui.setStatus("my-ext", "Processing..."); +ctx.ui.setStatus("my-ext", undefined); // Clear + +// Working loader (shown during streaming) +ctx.ui.setWorkingMessage("Thinking deeply..."); +ctx.ui.setWorkingMessage(); // Restore default +ctx.ui.setWorkingVisible(false); // Hide the built-in working loader row entirely +ctx.ui.setWorkingVisible(true); // Show the built-in working loader row + +// Working indicator (shown during streaming) +ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] }); // Static dot +ctx.ui.setWorkingIndicator({ + frames: [ + ctx.ui.theme.fg("dim", "·"), + ctx.ui.theme.fg("muted", "•"), + ctx.ui.theme.fg("accent", "●"), + ctx.ui.theme.fg("muted", "•"), + ], + intervalMs: 120, +}); +ctx.ui.setWorkingIndicator({ frames: [] }); // Hide indicator +ctx.ui.setWorkingIndicator(); // Restore default spinner + +// Widget above editor (default) +ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]); +// Widget below editor +ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" }); +ctx.ui.setWidget("my-widget", (tui, theme) => new Text(theme.fg("accent", "Custom"), 0, 0)); +ctx.ui.setWidget("my-widget", undefined); // Clear + +// Custom footer (replaces built-in footer entirely) +ctx.ui.setFooter((tui, theme) => ({ + render(width) { return [theme.fg("dim", "Custom footer")]; }, + invalidate() {}, +})); +ctx.ui.setFooter(undefined); // Restore built-in footer + +// Terminal title +ctx.ui.setTitle("pi - my-project"); + +// Editor text +ctx.ui.setEditorText("Prefill text"); +const current = ctx.ui.getEditorText(); + +// Paste into editor (triggers paste handling, including collapse for large content) +ctx.ui.pasteToEditor("pasted content"); + +// Stack custom autocomplete behavior on top of the built-in provider +ctx.ui.addAutocompleteProvider((current) => ({ + async getSuggestions(lines, line, col, options) { + const beforeCursor = (lines[line] ?? "").slice(0, col); + const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/); + if (!match) { + return current.getSuggestions(lines, line, col, options); + } + + return { + prefix: `#${match[1] ?? ""}`, + items: [{ value: "#2983", label: "#2983", description: "Extension API for autocomplete" }], + }; + }, + applyCompletion(lines, line, col, item, prefix) { + return current.applyCompletion(lines, line, col, item, prefix); + }, + shouldTriggerFileCompletion(lines, line, col) { + return current.shouldTriggerFileCompletion?.(lines, line, col) ?? true; + }, +})); + +// Tool output expansion +const wasExpanded = ctx.ui.getToolsExpanded(); +ctx.ui.setToolsExpanded(true); +ctx.ui.setToolsExpanded(wasExpanded); + +// Custom editor (vim mode, emacs mode, etc.) +ctx.ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(tui, theme, keybindings)); +const currentEditor = ctx.ui.getEditorComponent(); +ctx.ui.setEditorComponent((tui, theme, keybindings) => + new WrappedEditor(tui, theme, keybindings, currentEditor?.(tui, theme, keybindings)) +); +ctx.ui.setEditorComponent(undefined); // Restore default editor + +// Theme management (see themes.md for creating themes) +const themes = ctx.ui.getAllThemes(); // [{ name: "dark", path: "/..." | undefined }, ...] +const lightTheme = ctx.ui.getTheme("light"); // Load without switching +const result = ctx.ui.setTheme("light"); // Switch by name +if (!result.success) { + ctx.ui.notify(`Failed: ${result.error}`, "error"); +} +ctx.ui.setTheme(lightTheme!); // Or switch by Theme object +ctx.ui.theme.fg("accent", "styled text"); // Access current theme +``` + +Custom working-indicator frames are rendered verbatim. If you want colors, add them to the frame strings yourself, for example with `ctx.ui.theme.fg(...)`. + +### Autocomplete Providers + +Use `ctx.ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider. + +Typical pattern: + +- inspect the text before the cursor +- return your own suggestions when your extension-specific syntax matches +- otherwise delegate to `current.getSuggestions(...)` +- delegate `applyCompletion(...)` unless you need custom insertion behavior + +```typescript +pi.on("session_start", (_event, ctx) => { + ctx.ui.addAutocompleteProvider((current) => ({ + async getSuggestions(lines, cursorLine, cursorCol, options) { + const line = lines[cursorLine] ?? ""; + const beforeCursor = line.slice(0, cursorCol); + const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/); + if (!match) { + return current.getSuggestions(lines, cursorLine, cursorCol, options); + } + + return { + prefix: `#${match[1] ?? ""}`, + items: [ + { value: "#2983", label: "#2983", description: "Extension API for registering custom @ autocomplete providers" }, + { value: "#2753", label: "#2753", description: "Reload stale resource settings" }, + ], + }; + }, + + applyCompletion(lines, cursorLine, cursorCol, item, prefix) { + return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + }, + + shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { + return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true; + }, + })); +}); +``` + +See [github-issue-autocomplete.ts](../examples/extensions/github-issue-autocomplete.ts) for a complete example that preloads the latest open GitHub issues with `gh issue list` and filters them locally for fast `#...` completion. It requires GitHub CLI (`gh`) and a GitHub repository checkout. + +### Custom Components + +For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with your component until `done()` is called: + +```typescript +import { Text, Component } from "@earendil-works/pi-tui"; + +const result = await ctx.ui.custom((tui, theme, keybindings, done) => { + const text = new Text("Press Enter to confirm, Escape to cancel", 1, 1); + + text.onKey = (key) => { + if (key === "return") done(true); + if (key === "escape") done(false); + return true; + }; + + return text; +}); + +if (result) { + // User pressed Enter +} +``` + +The callback receives: +- `tui` - TUI instance (for screen dimensions, focus management) +- `theme` - Current theme for styling +- `keybindings` - App keybinding manager (for checking shortcuts) +- `done(value)` - Call to close component and return value + +See [tui.md](tui.md) for the full component API. + +#### Overlay Mode (Experimental) + +Pass `{ overlay: true }` to render the component as a floating modal on top of existing content, without clearing the screen: + +```typescript +const result = await ctx.ui.custom( + (tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }), + { overlay: true } +); +``` + +For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control visibility programmatically: + +```typescript +const result = await ctx.ui.custom( + (tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }), + { + overlay: true, + overlayOptions: { anchor: "top-right", width: "50%", margin: 2 }, + onHandle: (handle) => { /* handle.setHidden(true/false) */ } + } +); +``` + +See [tui.md](tui.md) for the full `OverlayOptions` API and [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for examples. + +### Custom Editor + +Replace the main input editor with a custom implementation (vim mode, emacs mode, etc.): + +```typescript +import { CustomEditor, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { matchesKey } from "@earendil-works/pi-tui"; + +class VimEditor extends CustomEditor { + private mode: "normal" | "insert" = "insert"; + + handleInput(data: string): void { + if (matchesKey(data, "escape") && this.mode === "insert") { + this.mode = "normal"; + return; + } + if (this.mode === "normal" && data === "i") { + this.mode = "insert"; + return; + } + super.handleInput(data); // App keybindings + text editing + } +} + +export default function (pi: ExtensionAPI) { + pi.on("session_start", (_event, ctx) => { + ctx.ui.setEditorComponent((_tui, theme, keybindings) => + new VimEditor(theme, keybindings) + ); + }); +} +``` + +**Key points:** +- Extend `CustomEditor` (not base `Editor`) to get app keybindings (escape to abort, ctrl+d, model switching) +- Call `super.handleInput(data)` for keys you don't handle +- Factory receives `theme` and `keybindings` from the app +- Use `ctx.ui.getEditorComponent()` before `setEditorComponent()` to wrap the previously configured custom editor +- Pass `undefined` to restore default: `ctx.ui.setEditorComponent(undefined)` + +To compose with another extension that already replaced the editor, capture the previous factory before setting yours: + +```typescript +const previous = ctx.ui.getEditorComponent(); +ctx.ui.setEditorComponent((tui, theme, keybindings) => + new MyEditor(tui, theme, keybindings, { base: previous?.(tui, theme, keybindings) }) +); +``` + +See [tui.md](tui.md) Pattern 7 for a complete example with mode indicator. + +### Message Rendering + +Register a custom renderer for messages with your `customType`: + +```typescript +import { Text } from "@earendil-works/pi-tui"; + +pi.registerMessageRenderer("my-extension", (message, options, theme) => { + const { expanded } = options; + let text = theme.fg("accent", `[${message.customType}] `); + text += message.content; + + if (expanded && message.details) { + text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2)); + } + + return new Text(text, 0, 0); +}); +``` + +Messages are sent via `pi.sendMessage()`: + +```typescript +pi.sendMessage({ + customType: "my-extension", // Matches registerMessageRenderer + content: "Status update", + display: true, // Show in TUI + details: { ... }, // Available in renderer +}); +``` + +### Theme Colors + +All render functions receive a `theme` object. See [themes.md](themes.md) for creating custom themes and the full color palette. + +```typescript +// Foreground colors +theme.fg("toolTitle", text) // Tool names +theme.fg("accent", text) // Highlights +theme.fg("success", text) // Success (green) +theme.fg("error", text) // Errors (red) +theme.fg("warning", text) // Warnings (yellow) +theme.fg("muted", text) // Secondary text +theme.fg("dim", text) // Tertiary text + +// Text styles +theme.bold(text) +theme.italic(text) +theme.strikethrough(text) +``` + +For syntax highlighting in custom tool renderers: + +```typescript +import { highlightCode, getLanguageFromPath } from "@earendil-works/pi-coding-agent"; + +// Highlight code with explicit language +const highlighted = highlightCode("const x = 1;", "typescript", theme); + +// Auto-detect language from file path +const lang = getLanguageFromPath("/path/to/file.rs"); // "rust" +const highlighted = highlightCode(code, lang, theme); +``` + +## Error Handling + +- Extension errors are logged, agent continues +- `tool_call` errors block the tool (fail-safe) +- Tool `execute` errors must be signaled by throwing; the thrown error is caught, reported to the LLM with `isError: true`, and execution continues + +## Mode Behavior + +| Mode | UI Methods | Notes | +| -------------------- | ------------- | ---------------------------------------------- | +| Interactive | Full TUI | Normal operation | +| RPC (`--mode rpc`) | JSON protocol | Host handles UI, see [rpc.md](rpc.md) | +| JSON (`--mode json`) | No-op | Event stream to stdout, see [json.md](json.md) | +| Print (`-p`) | No-op | Extensions run but can't prompt | + +In non-interactive modes, check `ctx.hasUI` before using UI methods. + +## Examples Reference + +All examples in [examples/extensions/](~/Clones/earendil-works/pi/packages/pi-coding-agent/examples/extensions/). + +| Example | Description | Key APIs | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| **Tools** | | | +| `hello.ts` | Minimal tool registration | `registerTool` | +| `question.ts` | Tool with user interaction | `registerTool`, `ui.select` | +| `questionnaire.ts` | Multi-step wizard tool | `registerTool`, `ui.custom` | +| `todo.ts` | Stateful tool with persistence | `registerTool`, `appendEntry`, `renderResult`, session events | +| `dynamic-tools.ts` | Register tools after startup and during commands | `registerTool`, `session_start`, `registerCommand` | +| `structured-output.ts` | Final structured-output tool with `terminate: true` | `registerTool`, terminating tool results | +| `truncated-tool.ts` | Output truncation example | `registerTool`, `truncateHead` | +| `tool-override.ts` | Override built-in read tool | `registerTool` (same name as built-in) | +| **Commands** | | | +| `pirate.ts` | Modify system prompt per-turn | `registerCommand`, `before_agent_start` | +| `summarize.ts` | Conversation summary command | `registerCommand`, `ui.custom` | +| `handoff.ts` | Cross-provider model handoff | `registerCommand`, `ui.editor`, `ui.custom` | +| `qna.ts` | Q&A with custom UI | `registerCommand`, `ui.custom`, `setEditorText` | +| `send-user-message.ts` | Inject user messages | `registerCommand`, `sendUserMessage` | +| `reload-runtime.ts` | Reload command and LLM tool handoff | `registerCommand`, `ctx.reload()`, `sendUserMessage` | +| `shutdown-command.ts` | Graceful shutdown command | `registerCommand`, `shutdown()` | +| **Events & Gates** | | | +| `permission-gate.ts` | Block dangerous commands | `on("tool_call")`, `ui.confirm` | +| `protected-paths.ts` | Block writes to specific paths | `on("tool_call")` | +| `confirm-destructive.ts` | Confirm session changes | `on("session_before_switch")`, `on("session_before_fork")` | +| `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` | +| `input-transform.ts` | Transform user input | `on("input")` | +| `model-status.ts` | React to model changes | `on("model_select")`, `setStatus` | +| `provider-payload.ts` | Inspect payloads and provider response headers | `on("before_provider_request")`, `on("after_provider_response")` | +| `system-prompt-header.ts` | Display system prompt info | `on("agent_start")`, `getSystemPrompt` | +| `claude-rules.ts` | Load rules from files | `on("session_start")`, `on("before_agent_start")` | +| `prompt-customizer.ts` | Add context-aware tool guidance using `systemPromptOptions` | `on("before_agent_start")`, `BuildSystemPromptOptions` | +| `file-trigger.ts` | File watcher triggers messages | `sendMessage` | +| **Compaction & Sessions** | | | +| `custom-compaction.ts` | Custom compaction summary | `on("session_before_compact")` | +| `trigger-compact.ts` | Trigger compaction manually | `compact()` | +| `git-checkpoint.ts` | Git stash on turns | `on("turn_start")`, `on("session_before_fork")`, `exec` | +| `auto-commit-on-exit.ts` | Commit on shutdown | `on("session_shutdown")`, `exec` | +| **UI Components** | | | +| `status-line.ts` | Footer status indicator | `setStatus`, session events | +| `working-indicator.ts` | Customize the streaming working indicator | `setWorkingIndicator`, `registerCommand` | +| `github-issue-autocomplete.ts` | Add `#1234` issue completions on top of built-in autocomplete by preloading recent open issues from `gh issue list` | `addAutocompleteProvider`, `on("session_start")`, `exec` | +| `custom-footer.ts` | Replace footer entirely | `registerCommand`, `setFooter` | +| `custom-header.ts` | Replace startup header | `on("session_start")`, `setHeader` | +| `modal-editor.ts` | Vim-style modal editor | `setEditorComponent`, `CustomEditor` | +| `rainbow-editor.ts` | Custom editor styling | `setEditorComponent` | +| `widget-placement.ts` | Widget above/below editor | `setWidget` | +| `overlay-test.ts` | Overlay components | `ui.custom` with overlay options | +| `overlay-qa-tests.ts` | Comprehensive overlay tests | `ui.custom`, all overlay options | +| `notify.ts` | Simple notifications | `ui.notify` | +| `timed-confirm.ts` | Dialogs with timeout | `ui.confirm` with timeout/signal | +| `mac-system-theme.ts` | Auto-switch theme | `setTheme`, `exec` | +| **Complex Extensions** | | | +| `plan-mode/` | Full plan mode implementation | All event types, `registerCommand`, `registerShortcut`, `registerFlag`, `setStatus`, `setWidget`, `sendMessage`, `setActiveTools` | +| `preset.ts` | Saveable presets (model, tools, thinking) | `registerCommand`, `registerShortcut`, `registerFlag`, `setModel`, `setActiveTools`, `setThinkingLevel`, `appendEntry` | +| `tools.ts` | Toggle tools on/off UI | `registerCommand`, `setActiveTools`, `SettingsList`, session events | +| **Remote & Sandbox** | | | +| `ssh.ts` | SSH remote execution | `registerFlag`, `on("user_bash")`, `on("before_agent_start")`, tool operations | +| `interactive-shell.ts` | Persistent shell session | `on("user_bash")` | +| `sandbox/` | Sandboxed tool execution | Tool operations | +| `subagent/` | Spawn sub-agents | `registerTool`, `exec` | +| **Games** | | | +| `snake.ts` | Snake game | `registerCommand`, `ui.custom`, keyboard handling | +| `space-invaders.ts` | Space Invaders game | `registerCommand`, `ui.custom` | +| `doom-overlay/` | Doom in overlay | `ui.custom` with overlay | +| **Providers** | | | +| `custom-provider-anthropic/` | Custom Anthropic proxy | `registerProvider` | +| `custom-provider-gitlab-duo/` | GitLab Duo integration | `registerProvider` with OAuth | +| **Messages & Communication** | | | +| `message-renderer.ts` | Custom message rendering | `registerMessageRenderer`, `sendMessage` | +| `event-bus.ts` | Inter-extension events | `pi.events` | +| **Session Metadata** | | | +| `session-name.ts` | Name sessions for selector | `setSessionName`, `getSessionName` | +| `bookmark.ts` | Bookmark entries for /tree | `setLabel` | +| **Misc** | | | +| `inline-bash.ts` | Inline bash in tool calls | `on("tool_call")` | +| `bash-spawn-hook.ts` | Adjust bash command, cwd, and env before execution | `createBashTool`, `spawnHook` | +| `with-deps/` | Extension with npm dependencies | Package structure with `package.json` | diff --git a/memory/PLAN.md b/memory/PLAN.md index 815473fa..3bbba0af 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -20,17 +20,17 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta ### Active -1. `graph-data-plane` — M4. SQLite-backed graph persistence; intent-plane nodes/edges; graph clock; change log; coherence-state homes. +1. `pi-ui-extension-patterns` — Continue FE-744 for the POC-critical offer-first custom UI loop: transcript-native structured offer → input-replacing custom response UI → persisted structured response → elicitation-exchange projection. ### Next -1. `agent-graph-integration` — M5. Graph tools and observer extraction through pi extension seams; all writes via the shared command layer. +1. `graph-data-plane` — M4 remains structurally next after the offer-first UI seam is proven; do not return to it until FE-744 has a credible elicitation input loop for POC sessions. +2. `agent-graph-integration` — M5. Graph tools and observer extraction through pi extension seams; all writes via the shared command layer. ### Parallel / Low-conflict - `brief-library-curation` — Author and review briefs #4–#7 plus the adversarial second tier; can proceed independently once `walking-skeleton` exists. Briefs are text, no code dependency. - `fixture-strategy-evolution` — Iterate `fixture-strategy.md` (property invariants, brief expectations) as fixtures are captured. Doc-only. -- `pi-ui-extension-patterns` — Prove the Pi extension seams Brunch needs for lens/review-set UX: custom slash commands, styled persistent chrome (color/glyphs), modal/popover overlays, radio/checkbox/select prompts, clickable/navigable action buttons, picker/list-selection modals, and ambient establishment-offer rendering that stays orientation-first rather than becoming a default lens menu. Spike-shaped probe whose output is a feasibility matrix + minimum-viable wrappers that downstream frontiers (M5 lenses/review-sets, M6 authority gates, M7 turn-boundary delivery) can build on. Can run in parallel with `graph-data-plane` and ahead of `agent-graph-integration`. ### Horizon @@ -218,16 +218,18 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta ### pi-ui-extension-patterns - **Name:** Prove Pi extension patterns for Brunch UI affordances -- **Linear:** unassigned +- **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) +- **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** not-started -- **Objective:** Demonstrate that Pi's extension seams can host the UI affordances Brunch needs for elicitation-lens and review-set flows without forking Pi or building a parallel rendering substrate. Catalog and prototype: custom slash commands routed through Brunch handlers; persistent chrome with TUI styling/color/glyphs beyond the current minimal status line; modal/popover overlays for proposal review; radio/checkbox/select prompts for multi-choice answers and user-invoked orientation/selection affordances; clickable/navigable action buttons for accept/request-changes/reject affordances; picker/list-selection modals for spec switching and entity selection; ambient rendering of the latest `brunch.establishment_offer`. The output is a feasibility matrix mapping each affordance to (a) the Pi seam(s) used, (b) Brunch-owned wrapper code required, (c) controllability cost for the agent-as-user driver, and (d) residual risks — plus minimum-viable wrappers that later frontiers can call directly. -- **Acceptance:** A short design memo (`docs/architecture/pi-ui-extension-patterns.md` or section in `pi-seam-extensions.md`) catalogs the affordance matrix with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`); the matrix distinguishes ambient establishment-offer rendering from any user-invoked orientation view and records that Brunch is not building a default exhaustive lens menu; a runnable demo wires at least one representative of each viable category through Brunch's TUI host (custom slash command, styled chrome element, modal/popover, multi-choice prompt, action button, picker modal, establishment-offer chrome rendering); the agent-as-user driver can controllably exercise the multi-choice and action-button affordances (informs the controllability/cost answer in `D27-L` and reviewer-flow oracle design); the matrix explicitly records which affordances are unviable so downstream UX design does not assume them; SPEC.md and PLAN.md links to the memo are added where M5/M6/M7 verification depends on a charted affordance. -- **Verification:** Inner — verify gate plus unit tests for any extension wrappers added. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection). Outer — manual TUI walkthrough validating visual quality and interaction feel; comparative walkthrough between scripted-driver and manual paths to record controllability cost. -- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Multi-choice affordances must integrate with the existing capture-aware offer envelope (`pi-seam-extensions.md §4`) and the structured elicitation-entry shape. Slash commands and action buttons must route writes through the `CommandExecutor`. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. +- **Status:** in-progress (command-containment, dynamic chrome, workspace-switcher startup flow, in-session switch command, pty startup oracle, and evidence-memo reconciliation have landed; current missing seam is the offer-first custom UI loop) +- **Objective:** Demonstrate the Pi extension seams Brunch needs before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; and, now active, an offer-first interaction loop where a system/assistant-originated structured custom entry acts as the assistant turn, renders as transcript-visible state, replaces the default input surface with single-choice / multi-choice / optional-freeform custom UI, and persists the user's structured response as session truth. +- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, branch policy, session-boundary binding, and `/brunch-workspace`; the workspace switcher supports explicit continue/open-session/new-session/new-spec/cancel decisions without UI-owned session mutation; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is an offer-first custom UI proof: a transcript-native unresolved offer can replace ambient free input, collect single-choice / multi-choice / optional-freeform answers, persist a linked structured response entry, project as an elicitation exchange, and expose an RPC/fixture-controllable semantic response path even though TUI `ctx.ui.custom()` itself is not RPC-controllable. +- **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. +- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit workspace-switch decision. Workspace switcher UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. Multi-choice affordances must integrate with the existing capture-aware offer envelope (`pi-seam-extensions.md §4`) and the structured elicitation-entry shape. Slash commands and action buttons must route writes through the `CommandExecutor`. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives, and RPC fixtures should assert only chrome events that Pi actually emits (`setStatus`, string-array `setWidget`, `setTitle`, notifications). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. -- **Traceability:** R4, R14, R16, R20, R21 / D2-L, D11-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L / I18-L, I19-L / A10-L, A14-L, A17-L -- **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md); new memo to be created during the spike. +- **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L / A10-L, A14-L, A17-L, A18-L +- **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). +- **Current execution pointer:** Scope the offer-first custom UI loop. Use Pi's `question.ts` / `questionnaire.ts` examples and TUI editor-replacement docs as the implementation reference; prove transcript-native offer display, input replacement, response persistence, elicitation-exchange projection, and RPC/fixture semantic controllability before returning to `graph-data-plane`. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index be240a57..b39e49d7 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -71,10 +71,10 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Elicitation product shape -16. Brunch must keep sessions elicitation-first: at idle, the user is responding to a system/assistant-originated elicitation prompt rather than initiating ambient free chat. -17. Brunch must support action, radio (single-select), checkbox (multi-select), and freeform-plus-choice response surfaces as optional typed transcript entries, and must be able to project elicitation exchanges from Pi JSONL for observer extraction. +16. Brunch must keep sessions elicitation-first and offer-first: at idle, the user is responding to a system/assistant-originated elicitation prompt or structured offer rather than initiating ambient free chat. +17. Brunch must support action, radio (single-select), checkbox (multi-select), and freeform-plus-choice response surfaces as typed transcript-backed interactions; in TUI mode a pending structured offer may replace the default input surface with custom UI, and other modes must answer the same semantic offer through product handlers or supported dialog fallbacks. Brunch must be able to project elicitation exchanges from Pi JSONL for observer extraction. 18. Brunch must support `#`-mentions of graph entities anchored to stable IDs, with session-scoped staleness tracking that produces discretionary re-read hints during `prepareNextTurn`. -19. Brunch must enforce a workspace state hierarchy `cwd → spec → session`, where the active spec is selected before any agent loop runs, persists across `/new`, and binds each session to exactly one spec. +19. Brunch must enforce a workspace state hierarchy `cwd → spec → session`, where the active spec and session are selected or created through Brunch-owned workspace flow before any agent loop runs, spec selection persists across `/new`, and each session binds to exactly one spec. 20. Brunch must support multiple elicitation lenses within the `elicitor` agent-mode, with the agent owning lens selection and offer through transcript-native establishment offers; lens metadata is carried on elicitor-emitted custom entries for downstream routing. 21. Brunch must distinguish *extractive* lenses (single-exchange, observer-extracted) from *generative* lenses (batch-proposal, captured at proposal time as structured entity-draft payloads, reviewer-analyzed post-acceptance). 22. Brunch must establish a minimum grounding bundle (domain, protagonist, pain/pull, and constraint anchors) before generative lenses produce non-speculative output; lenses remain always-available with epistemic-status signaling honestly reflecting grounding density. @@ -100,13 +100,14 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A7-L | `framing_as` as an orthogonal modality on existing node kinds is sufficient for product-intent ontology (problem, persona, JTBD, etc.) and does not need to become first-class node kinds in the POC. | medium | open | D7-L | Fixture runs across briefs #1–#7: if a framing repeatedly demands unique relation policy, promote per the seam-extensions Open Question #8. | | A8-L | One reconciliation-need substrate, sharing the same global LSN as the change log, can absorb impasses, conflicts, gaps, and process debt without needing finer kind subtypes in the POC. | medium | open | D8-L | M8 + adversarial fixtures ("contradictory requirements") exercise the substrate; subtype split deferred per Open Question #10. | | A9-L | A session-scoped mention ledger of (`entity_id`, `snapshotted_lsn`) is the right granularity for staleness hints; transcript-scoped or graph-scoped ledgers are not needed for the POC. | low | open | I7-L | M7 — turn-boundary reconciliation slice; observed via fixture runs that stress re-read decisions. | -| A10-L | A persistent TUI chrome region showing cwd / spec / phase / chat-mode can be added on top of `pi-tui`'s root layout without modifying pi. | medium | open | D2-L | M0 — walking skeleton attempts to mount the chrome; escalates to a pi upstream issue only if blocked. | +| A10-L | A persistent TUI chrome region showing cwd / spec / phase / chat-mode can be added on top of `pi-tui`'s root layout without modifying pi. | high | validated | D2-L, D35-L | M0 mounted initial chrome through the widget seam; `pi-ui-extension-patterns` Card 2 proved header/footer/status/widget dynamic chrome through a Brunch wrapper plus raw TUI transcript evidence. | | A11-L | Pi's `prepareNextTurn` plus custom-message delivery are sufficient to express side-task result delivery without inventing a second event plane or forking pi. | medium | open | D15-L | M5 + M7: side-task registry wiring and next-turn delivery proof. | | A13-L | A durable observer-job queue keyed by session id and elicitation-exchange entry range can recover async extraction after process interruption without reintroducing canonical chat/turn tables; whether this shares storage with a generalized work-item/reconciliation table can be deferred. | medium | open | D18-L, I14-L | M5: observer extraction tests exercise restart/idempotence once graph writes exist. | | A14-L | LLM elicitor agents can reliably produce graph-structurally-legal intent-graph proposals (well-formed entity drafts and semantic edges that pass `CommandExecutor` structural validation) for generative lenses. | medium | open | D27-L | Fixture replay across briefs that exercise `propose-scenarios-with-tradeoffs`-shaped lenses; dry-run `CommandExecutor` validation at proposal time before user review. Fallback (constrained generation, retry-with-feedback, or NL-parse-at-accept) preserves the user-facing review-cycle if reliability is insufficient. | | A15-L | Establishment hints as transcript-native custom entries (`brunch.establishment_offer`) provide sufficient inspectability, fixture-ability, and ambient-affordance source without a separate establishment-needs graph substrate; whether such a substrate ever shares storage with reconciliation needs can be deferred. | medium | open | D25-L, D30-L | M5+: fixture inspection confirms lens offers are reconstructable from transcript; chrome region renders ambient affordances from the latest such entry. | | A16-L | Reviewer triggering policy (always-on vs lens-keyed) and reviewer scope (batch + how-far-neighborhood) can be deferred to per-lens decisions without architectural commitment now. | low | open | D29-L | M5+: empirical — observer/reviewer integration reveals which policy avoids unacceptable next-turn latency without losing relevant findings. | | A17-L | A user-level temperamental preference for extractive vs generative lenses meaningfully affects adoption and eventually warrants expression as a user-level setting. | low | open | D25-L, D26-L | Deferred; surfaces from outer-loop walkthroughs and adversarial fixtures once both lens families exist in product. | +| A18-L | Hiding unsupported Pi built-ins from autocomplete plus blocking dangerous session effects is sufficient for the POC product shell even though exact interactive built-ins remain callable until Pi exposes command policy. | medium | open | D2-L, D24-L, D34-L, D35-L | `pi-ui-extension-patterns` product-shell review after command-containment and dynamic Brunch chrome evidence; strict suppression requires a Pi upstream/API change if residual exposure is unacceptable. | ### Active Decisions @@ -114,6 +115,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. +- **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. +- **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setStatus`, `setWidget`, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id; reload and session replacement reconstruct chrome by rerunning extension setup with a fresh Brunch snapshot. RPC clients should rely on status/widget/title events because header/footer/working-indicator are TUI-only in current Pi RPC mode. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs or rendering placeholder session state such as `unbound` after a session is activated. #### Data model & vocabulary @@ -150,9 +153,10 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Interaction & UI shape - **D11-L — Workspace state hierarchy `cwd → spec → session`, with spec selection gated before any agent loop.** Spec selection is durable across `/new` and persisted in `.brunch/state.json`. Each Pi session is bound to exactly one spec by a `brunch.session_binding` custom entry at session start; switching specs selects or creates another session rather than mutating the spec of the current session. Depends on: A10-L. Supersedes: —. -- **D21-L — Workspace session coordination is the spec/session boot seam.** Brunch owns a narrow `WorkspaceSessionCoordinator` for boot, spec selection, selected-session reopening, and `/new` session creation. It is the only product module allowed to create or open Pi sessions for Brunch user flows and the only module allowed to write `brunch.session_binding`; callers receive `ready | select_spec | needs_human` workspace-session state and never mutate a session's bound spec. The coordinator hides `SessionManager.create/open/continueRecent(cwd, ".brunch/sessions/")`, internal session-start binding for pi-created replacement sessions, `.brunch/state.json` current-spec and current-session-file acceleration, binding validation, and chrome-state derivation. Because pi defers appending session JSONL until an assistant message exists, the coordinator flushes Brunch's binding when it is created, refreshes it at `before_agent_start`, and performs the final pre-assistant flush from Brunch's internal assistant `message_start` hook after pi has persisted the user message but before assistant persistence; each flush reloads the session file so pi's next assistant append does not duplicate the already-written prefix. Depends on: D6-L, D11-L. Supersedes: the loose `SpecRegistry` + caller-orchestrated session-binding mental model. -- **D22-L — M0 TUI chrome rides pi's extension UI widget seam.** Brunch's initial persistent chrome is mounted by an internal Brunch extension using pi's public `ExtensionUIContext.setWidget(..., { placement: "aboveEditor" })`, while spec selection remains a Brunch-owned boot gate before `InteractiveMode.run()`. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for M0 chrome. Depends on: A10-L, D2-L, D21-L. Supersedes: private-header/monkeypatch approaches for M0 chrome. +- **D21-L — Workspace session coordination is the spec/session boot seam.** Brunch owns a narrow `WorkspaceSessionCoordinator` for boot, spec inventory, spec/session selection, selected-session reopening, and `/new` session creation. It is the only product module allowed to create or open Pi sessions for Brunch user flows and the only module allowed to write `brunch.session_binding`; callers inspect workspace inventory and activate a product decision rather than mutating a session's bound spec directly. The coordinator hides `SessionManager.create/open/continueRecent(cwd, ".brunch/sessions/")`, internal session-start binding for pi-created replacement sessions, `.brunch/state.json` current-spec and current-session-file acceleration, binding validation, and chrome-state derivation. Because pi defers appending session JSONL until an assistant message exists, the coordinator flushes Brunch's binding when it is created, refreshes it at `before_agent_start`, and performs the final pre-assistant flush from Brunch's internal assistant `message_start` hook after pi has persisted the user message but before assistant persistence; each flush reloads the session file so pi's next assistant append does not duplicate the already-written prefix. Depends on: D6-L, D11-L. Supersedes: the loose `SpecRegistry` + caller-orchestrated session-binding mental model, and treating `.brunch/state.json` as an implicit instruction to resume without user-visible Brunch flow. +- **D22-L — TUI boot is Brunch-owned before Pi interactive runtime begins.** Brunch's TUI mode may use `@earendil-works/pi-tui` directly for a pre-Pi startup gate that selects or creates the active spec/session before `InteractiveMode.run()`. After activation, persistent chrome is mounted by an internal Brunch extension through Pi's public UI seams. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for product boot/chrome. Depends on: A10-L, D2-L, D21-L, D36-L. Supersedes: private-header/monkeypatch approaches for M0 chrome and raw readline-only spec selection as the durable TUI product flow. - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces may be represented by Brunch custom entries when needed, but there is no DB-owned prompt/response entity; at idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L. Supersedes: —. +- **D37-L — Offer-first custom UI is a transcript-driven input surface, not a side dialog.** A structured system/assistant offer may act as the assistant turn by being persisted as a Brunch custom entry, rendered in transcript history, and mounted as the active response surface while unresolved. In TUI mode, the response surface may replace the default Pi editor with Brunch custom UI supporting single-choice, multi-choice, and optional freeform input, following Pi's `question`/`questionnaire` custom-UI patterns. The user's answer is persisted as a linked structured response entry and projected as the response side of the elicitation exchange. RPC/web paths answer the same semantic pending offer through product handlers or supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L. Supersedes: treating structured prompt UI as optional polish or as an ephemeral dialog result detached from transcript truth. - **D13-L — Capture-aware elicitation exchange projection.** Observer extraction consumes derived elicitation exchanges: a prompt-side span (all system/assistant/tool-side entries since the previous user response, including any structured/internal prompt content) plus a response-side span (user text and/or structured action entries). Role/span alternation is the default projection in Brunch-supported linear sessions; typed markers are added only where structure/actions need deterministic replay. Depends on: D12-L, D24-L. Supersedes: —. - **D14-L — `#`-mentions are ID-anchored, with a session-scoped mention ledger.** Autocomplete may resolve by title but insertion always rewrites to ID-anchored. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: —. - **D25-L — Elicitation strategies are *lenses* within the `elicitor` agent-mode, not separate agent-modes.** Lens is metadata on elicitor-emitted custom transcript entries (`brunch.elicitor_intent_hint`, `brunch.establishment_offer`, `brunch.review_set_proposal`, etc.); agent-modes (`elicitor`, `observer`, `reviewer`, `reconciler`) remain orthogonal. The known starter lens set is `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, and `project-requirements-from-upstream`; the catalogue is expected to grow. Observer-job and reviewer-job routing filters on lens. Depends on: D12-L, D17-L, D23-L. Supersedes: collapsing strategy and agent-mode into one vocabulary axis. @@ -160,6 +164,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D30-L — Grounding is a precondition gate for generative-lens output, with epistemic-status signaling honestly tracking grounding density; lenses themselves are always available.** A minimum grounding bundle — *domain anchor*, *protagonist anchor*, *pain/pull anchor*, *constraint anchor* — must be established before generative lenses produce non-speculative output. Generative-lens proposals declare `epistemic_status` (`inferred | assumed | asserted | observed`) consistent with grounding density at proposal time, and proposal/offer payloads carry explicit grounding-bundle coverage for those four anchors so UI copy, fixture assertions, and reviewer/debug tooling can justify that status rather than infer it from free text. UI renderings reflect this status so low-status proposals *feel* speculative (visible hedging, lower visual weight, explicit "speculative — based on N anchors so far" footers). The lens is never refused: the agent always produces *some form* of what was asked for, but its output resolution and epistemic load honestly reflect what grounding supports. Rendering mode scales with density: empty/thin → framing proposals (Shape Up pitches); moderate → scenario sketches; rich → completion proposals; mature → refactor proposals. Depends on: D26-L. Supersedes: gating-by-refusal as a UX move. - **D32-L — Establishment offers are orientation artifacts, not a default next-action menu.** `brunch.establishment_offer` records the agent's current offer tree and recommended next move as durable transcript state. Ambient chrome or web affordances may render the latest offer, and Brunch may expose a user-invoked orientation view summarizing what is established vs open, but Brunch does not surface an exhaustive lens/offer chooser by default; the agent still owns next-move selection unless the user explicitly asks to inspect alternatives. Depends on: D25-L, D30-L, A15-L. Supersedes: UI interpretations that turn establishment offers into a persistent strategy menu. - **D31-L — A four-axis meta-rubric is a soft heuristic for fan-out comparison rubrics across all three flows; not architecturally enforced.** When generating comparison rubrics for fan-out alternatives across candidate-spec, technical-design, and verification-design flows, the elicitor attempts to express each axis in terms of (*legibility / cost-of-knowing*, *failure modes*, *coverage / range*, *commitment*). Project-specific axes are allowed alongside; the meta-frame is dropped when it doesn't fit. The hypothesis (uniform comparison UI across all three flows) is testable via fixture comparison; promote to schema/UI only if it holds up. Depends on: D25-L, D26-L. Supersedes: a hardcoded per-flow rubric. +- **D36-L — Workspace switching is a reusable decision UI with coordinator activation adapters.** Brunch owns a pure workspace-switcher surface that renders workspace inventory and returns a product decision (`continue selected session`, `open session`, `new session for spec`, `new spec`, `cancel/quit`) without opening Pi sessions or mutating `.brunch/state.json` itself. The `WorkspaceSessionCoordinator` activates that decision and owns all persistence/session-binding effects. The same decision UI should be usable by a pre-Pi TUI startup adapter and later by an in-Pi command/modal adapter; adapters differ only in terminal lifecycle and Pi session-replacement mechanics, not in product semantics. Depends on: D11-L, D21-L, D24-L, D33-L. Supersedes: implicit resume of `.brunch/state.json` on TUI launch, Pi `/resume`/`/new` as Brunch's product session chooser, and one-off startup-only picker implementations. ### Critical Invariants @@ -183,9 +188,11 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I16-L | Reviewer-attributed writes target only the `reconciliation_need` substrate; no reviewer-attributed `CommandExecutor` call writes graph entities, edges, change-log entries directly, or any other record class. | planned (M5+ architectural test on reviewer command writers; reviewer-attributed command-result audit) | D29-L; I2-L, I11-L | | I17-L | Every generative-lens proposal entry (`brunch.review_set_proposal`) declares an `epistemic_status` (`inferred | assumed | asserted | observed`) and explicit grounding-bundle coverage for the four grounding anchors, with the status consistent with that coverage at proposal time; UI renderings honor this status as a presentation contract. | planned (M5+ proposal-entry schema test; fixture asserts status under thin and rich grounding) | D30-L; A14-L | | I18-L | Every elicitor-emitted prompt or proposal custom entry (`brunch.elicitor_intent_hint`, `brunch.establishment_offer`, `brunch.review_set_proposal`) carries a `lens` field; observer-job and reviewer-job routing filters on this field. | planned (M5+ observer/reviewer routing tests; transcript-shape contract test) | D25-L, D26-L, D29-L | -| I19-L | Brunch-controlled flows do not create or navigate Pi session branches, and Brunch transcript readers fail fast on non-linear JSONL rather than flattening, migrating, or branch-selecting. | partially covered (M3 transcript loader requires exactly one Pi session header, rejects malformed non-header entry shapes, and rejects non-linear child graphs, `parentSession`, and `branch_summary`; product-facing exchange projection helper preserves the non-linear error discriminant and is used by RPC and fixture replay assertions; `session.elicitationExchanges` returns a product-shaped error for non-linear selected sessions over stdio and WebSocket JSON-RPC; Brunch TUI extension cancels `session_before_tree` and `session_before_fork`) | D24-L, D6-L, D11-L, D13-L | +| I19-L | Brunch-controlled flows do not create or navigate Pi session branches, and Brunch transcript readers fail fast on non-linear JSONL rather than flattening, migrating, or branch-selecting. | partially covered (M3 transcript loader requires exactly one Pi session header, rejects malformed non-header entry shapes, and rejects non-linear child graphs, `parentSession`, and `branch_summary`; product-facing exchange projection helper preserves the non-linear error discriminant and is used by RPC and fixture replay assertions; `session.elicitationExchanges` returns a product-shaped error for non-linear selected sessions over stdio and WebSocket JSON-RPC; Brunch TUI extension cancels `session_before_tree` and `session_before_fork`; Pi command-containment source/RPC evidence shows `session_before_fork` can also cancel clone/fork effects but exact interactive built-ins still need product-shell policy if visibility must be strict; dynamic chrome remains projection-only and does not add branch or mutation authority) | D24-L, D6-L, D11-L, D13-L, D34-L, D35-L | | I20-L | Every user-reviewable generative-lens proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | +| I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a workspace-switch decision; creating a new session lands in a binding-only session for the selected spec, while resuming a prior transcript is opt-in. | covered (FE-744 startup-switcher coordinator tests plus `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | +| I23-L | Every unresolved structured offer that owns the input surface has exactly one terminal response entry (`answered`, `skipped`, or `cancelled`) linked to the offer id before the next agent turn consumes it; response capture is persisted in Pi JSONL and projected as the user-response side of the elicitation exchange rather than held only in UI state. | planned (FE-744 offer-first custom UI tests + RPC/fixture response-path contract) | D12-L, D13-L, D17-L, D37-L | ## Future Direction Register @@ -239,8 +246,9 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Session binding** | The first Brunch custom entry in a session that binds the Pi session id to exactly one spec id and schema version. Makes JSONL self-describing; registry/index state is an acceleration, not the canonical binding. | | **Client attachment** | An ephemeral TUI instance, browser tab, stdio stream, or WebSocket connection attached to one or more Brunch product resources for viewing or driving. Client attachment state may guide subscriptions and UI routing, but it is not durable spec/session truth. | | **Workspace session coordinator** | The Brunch boot seam that returns `ready | select_spec | needs_human` workspace-session state for a cwd/mode, owns spec selection, selected-session reopening, and `/new`, creates/opens Pi sessions through `SessionManager`, writes `brunch.session_binding`, persists current spec/session acceleration in `.brunch/state.json`, and derives chrome state for callers. | -| **Workspace state hierarchy** | `cwd → spec → session`. Each level scopes the one below it; spec is selected before any agent loop runs and persists across `/new`. | -| **Workspace default state** | Lightweight `.brunch/state.json` acceleration for reopening the last selected spec/session in a cwd. It is a launch/default convenience, not the canonical binding of a session and not a multi-client concurrency authority. | +| **Workspace state hierarchy** | `cwd → spec → session`. Each level scopes the one below it; active spec/session activation is Brunch-owned before any agent loop runs, and spec selection persists across `/new`. | +| **Workspace default state** | Lightweight `.brunch/state.json` acceleration for reopening the last selected spec/session in a cwd. It is a launch/default convenience, not the canonical binding of a session, not an instruction to resume without product flow, and not a multi-client concurrency authority. | +| **Workspace switcher** | Brunch-owned decision UI over workspace inventory. It lets the user continue/open a session, create a new session for a selected spec, create a new spec, or cancel/quit. The switcher returns a decision; the `WorkspaceSessionCoordinator` activates it and owns all Pi session and binding effects. | | **Intent graph** | The canonical specification-meaning plane. Authority over what the system is for. | | **Oracle graph** | Verification-strategy plane accountable to intent. Houses Checks, Validation Methods, Evidence, Obligations. | | **Design graph** | Modules, interfaces, seams, and adapters accountable to intent. Stubbed in POC. | @@ -259,7 +267,9 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Elicitation prompt** | System- or assistant-originated transcript span that prompts/directs the user's next response. At idle, a Brunch-supported linear session ends with an unresolved elicitation prompt. | | **User response** | User-originated text and/or structured action selection responding to the current elicitation prompt. There is no ambient chat input in the POC model. | | **Elicitation exchange** | A derived projection over Brunch-supported linear Pi JSONL: prompt-side span (system/assistant/tool-side entries since the prior user response) plus response-side span (the user's text and/or structured action entries). This is the observer's default extraction unit. | -| **Structured elicitation entry** | Optional Brunch custom transcript entry used when an elicitation prompt or response carries actions, choices, or other deterministic UI structure. Plain generative prompts can remain ordinary Pi messages. | +| **Structured elicitation entry** | Optional Brunch custom transcript entry used when an elicitation prompt/offer or response carries actions, choices, or other deterministic UI structure. Plain generative prompts can remain ordinary Pi messages. | +| **Structured offer** | A system/assistant-originated Brunch custom entry that acts as the current elicitation prompt and owns the response surface until answered, skipped, or cancelled. In TUI it may replace the default editor with custom UI; in RPC/web it is answered through product handlers over the same semantic payload. | +| **Offer response** | A linked Brunch custom entry recording the user's structured answer to a structured offer, including selected option ids and optional freeform text. It is transcript truth, not an ephemeral UI return value. | | **Observer job** | Durable async work item keyed by session id and elicitation-exchange entry-range ids. It analyzes an exchange for graph mutations or low-confidence suggestions, and survives process restart. | | **Lens switch** | A durable `brunch.lens_switch` transcript entry recording that the active agent/session changed lenses. The switch event is distinct from the lens concept itself. | | **Side task** | A scoped sub-agent invocation whose result returns through the shared command layer. | @@ -342,14 +352,14 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | --- | --- | --- | --- | | Inner | Type-aware lint, type checks, fast unit tests | Local module correctness, typed command/result shapes (including `acceptReviewSet` and reviewer-writable record-class types), projection helper behavior (including `supersedes`-chain filtering). | D12-L, D13-L, D20-L, D21-L, D27-L, D28-L, D29-L. | | Inner | Schema/shape validation at boundaries | JSON-RPC payloads, command results, structured elicitation entries, fixture metadata, graph exports, `brunch.review_set_proposal` / `brunch.establishment_offer` / `brunch.elicitor_intent_hint` custom-entry payloads (lens presence, `epistemic_status`, grounding coverage, entity-draft shape). | R8, R10, R11, R17, R20, R21, R23; I3-L, I10-L, I11-L, I17-L, I18-L. | -| Middle | **Runbook oracles**: prose manual actions plus executable postcondition checkers | Interactive seams leave correct durable state. Early M0 checkers may inspect stores only; once handlers exist, prefer projection-including checks. Extends to in-flight reviewer-signal chrome behavior and ambient-affordance rendering from latest establishment-offer entry. | D11-L, D21-L, D25-L, D29-L; I8-L, I13-L; A10-L. | +| Middle | **Runbook oracles**: prose manual actions plus executable postcondition checkers | Interactive seams leave correct durable state. Early M0 checkers may inspect stores only; once handlers exist, prefer projection-including checks. Extends to workspace-switcher startup behavior, in-flight reviewer-signal chrome behavior, and ambient-affordance rendering from latest establishment-offer entry. | D11-L, D21-L, D22-L, D25-L, D29-L, D36-L; I8-L, I13-L, I22-L; A10-L. | | Middle | Round-trip tests | JSONL reload, linear transcript validation, elicitation exchange projection, compaction, graph export/import, command result serialization, `supersedes`-chain reconstruction across regeneration. | D6-L, D13-L, D24-L, D28-L; I3-L, I8-L, I10-L, I19-L. | | Middle | Property-based / model-based tests | LSN monotonicity, change-log replay, reconciliation-need invariants, mention staleness, interest-set recomputation, side-task delivery ordering, **batch-acceptance atomicity (one LSN / one change-log entry, partial-batch impossible even under mid-batch validation failure)**, **`supersedes`-chain acyclicity and unique-leaf-per-thread**, **lens-routing correctness (generated elicitor entries route to the right consumer)**, **reviewer-finding turn-boundary delivery ordering**. | A4-L, A8-L, A9-L, A11-L; I1-L, I4-L, I5-L, I6-L, I9-L, I12-L, I15-L, I16-L, I18-L. | | Middle | Contract tests | Named RPC method families and transport adapters share handler semantics; subscriptions deliver initial snapshot plus ordered updates; `CommandExecutor` hides policy/transaction details; `acceptReviewSet` returns expected structured discriminants; only prevalidated proposals become reviewable review sets. | D5-L, D19-L, D20-L, D27-L; R11, R12. | -| Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; reviewer-attributed writes target only `reconciliation_need`. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L; I2-L, I10-L, I11-L, I16-L, I19-L. | +| Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; workspace-switcher UI returns decisions rather than opening/mutating sessions; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; reviewer-attributed writes target only `reconciliation_need`. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L. | | Middle | **Differential testing** | Dry-run validation at proposal time matches real-run validation at acceptance time (no drift between modes); free-form-generation vs constrained-generation legality rates (informs whether fallback path is needed per A14-L). | D27-L; A14-L. | | Middle | Fixture replay and property assertions | Brief-driven sessions still produce structurally valid transcript/graph/coherence artifacts despite model drift. For generative lenses: **structural-legality rate of LLM proposals tracked per-run in fixture metadata as POC-phase fitness, not a merge gate**; first-attempt vs retry-with-feedback rates surfaced for human review. | A5-L, A6-L, A7-L, A14-L; I7-L; R20, R21, R22, R23. | -| Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, spec selector, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | +| Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, workspace switcher, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | | Outer | Adversarial / generative fixture probes | Elicitation quality, human-gated `needs_human`, contradictory requirements, cross-session updates, long-horizon compaction, **reviewer-finding precision via small targeted set of briefs designed to produce *known* coherence problems** (POC-scope: 1–2 known-bad scenarios per relevant invariant, not exhaustive coverage). | A5-L, A8-L, A9-L, A11-L, A14-L; I4-L, I6-L, I12-L, I13-L, I16-L. | ### Runbook Oracle Design @@ -361,7 +371,7 @@ A **runbook oracle** is the preferred bridge for seams that require human intera Runbook postconditions should be boring and product-shaped: paths exist, JSON fields match, JSONL entries are present and unique, projections reconstruct the same state, command results carry expected discriminants. Store-only checks are acceptable before projection handlers exist; projection-including checks become the default once `workspace.*`, `session.*`, `graph.*`, or `coherence.*` handlers exist. -The first required runbook is M0: after manual TUI interaction, a checker proves `.brunch/` creation, `.brunch/state.json` current spec acceleration, Pi session JSONL files, exactly one `brunch.session_binding` per session, same-spec `/new`, and workspace/session reconstruction when available. +The first required runbook is M0: after manual TUI interaction, a checker proves `.brunch/` creation, `.brunch/state.json` current spec acceleration, Pi session JSONL files, exactly one `brunch.session_binding` per session, same-spec `/new`, and workspace/session reconstruction when available. FE-744 extends this with a startup-switcher runbook: launch Brunch against a workspace with an existing selected transcript, assert the pre-Pi switcher appears before transcript rendering, choose new-session vs resume paths explicitly, and pair the visual capture with store/projection checks for activated spec/session state. ### Invariant Oracle Coverage @@ -387,6 +397,9 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | I18-L | M5+ inner-loop schema validation on elicitor-emitted custom entries (must declare `lens`); paired with middle-loop property test that generated entries route to the correct observer/reviewer consumer. | | I19-L | Brunch extension/runtime guard tests for `/tree`/`/fork`/`/clone` blocking plus transcript-reader non-linearity rejection tests. | | I20-L | M5+ proposal-validation contract and differential tests proving only dry-run-valid proposals become reviewable review sets. | +| I21-L | M3 RPC/WebSocket explicit-session projection tests; future write-lease tests when browser writes land. | +| I22-L | FE-744 coordinator inventory/activation tests plus pty/ANSI-stripped TUI runbook assertions: no stale transcript before explicit resume, new-session path yields binding-only JSONL, resume path renders the chosen transcript, chrome includes activated session id. | +| I23-L | FE-744 offer-first custom UI tests: pending offer mounts an input-replacing TUI response surface, single/multi/freeform answers persist as linked custom entries, RPC/fixture path submits the same semantic response, and elicitation-exchange projection pairs offer prompt side with response side. | ### Design Notes @@ -399,7 +412,7 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | Blind spot | Reason | Mitigation | Revisit trigger | | --- | --- | --- | --- | -| Full TUI automation | Cost exceeds value before the product state seams are proven. | Manual checklist plus artifact/query runbook oracle. | Manual TUI steps become frequent/flaky or block CI confidence. | +| Full TUI automation | Cost exceeds value before the product state seams are proven, but startup-switcher regressions need a stronger visual signal than store-only checks. | Manual checklist plus artifact/query runbook oracle; for FE-744 startup, add pty/ANSI-stripped capture assertions for the pre-Pi decision surface and absence of stale transcript before explicit resume. | Manual TUI steps become frequent/flaky or block CI confidence. | | LLM elicitation quality and interaction flow | No stable deterministic ground truth for “good interview” early in the POC, and M1 scripted exchanges intentionally encode only a thin current exchange model. | Brief library, human-reviewed golden captures, adversarial probes, expected structural coverage, and later review of knowledge flow through real elicitation loops. | Repeated fixture failures where structure passes but elicitation is judged poor, or M2/M3 reveals that prompt/response markers, offer envelopes, or knowledge-flow assumptions need sharper transcript semantics. | | Subscription reconnect/resume | POC can prove snapshot + live update without hardening network recovery yet. | Contract tests for initial snapshot and ordered update sequence. | Web/RPC clients need robust reconnect semantics or long-running fixture runs expose drift. | | Performance and scale | Local POC graph/session sizes are small; premature budgets may distort design. | Keep exports/checkers text-native and simple; add budgets when slow tests appear. | `npm run verify` or fixture runs exceed acceptable local iteration time. | diff --git a/package-lock.json b/package-lock.json index 777c4cf7..1b1ea4db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@earendil-works/pi-coding-agent": "^0.75.3", + "@earendil-works/pi-tui": "^0.75.4", "@tanstack/react-query": "^5.100.11", "@tanstack/react-router": "^1.170.6", "react": "^19.2.6", @@ -1059,19 +1060,19 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.75.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.3.tgz", - "integrity": "sha512-UbhtCsae+b3Y8/ZxtBPhiOrkD66gOHvJbfvLZwhBBsNtQuvUkZY5t9MQwmb8QcDYkFRnXHaq3FcEy1hjRSfj6w==", + "version": "0.75.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.4.tgz", + "integrity": "sha512-PDhKU7u6fmEcvHUFHzrRwGc/Ytokj/hO+X4RPf+MWKEGpvg3B1vHv88Ee+Dy33004tYkQF5YeXV4btJZcp5x1g==", "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "marked": "^15.0.12" + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" }, "engines": { "node": ">=22.19.0" }, "optionalDependencies": { - "koffi": "^2.9.0" + "koffi": "2.16.2" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/package.json b/package.json index 0a30eb52..0d549849 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "files": [ "dist", "dist-web", - "bin" + "bin", + "assets" ], "scripts": { "dev": "tsx src/brunch.ts", @@ -30,6 +31,7 @@ }, "dependencies": { "@earendil-works/pi-coding-agent": "^0.75.3", + "@earendil-works/pi-tui": "^0.75.4", "@tanstack/react-query": "^5.100.11", "@tanstack/react-router": "^1.170.6", "react": "^19.2.6", diff --git a/runbooks/verify-m1.sh b/runbooks/verify-m1.sh index 71e2f279..23f6aa32 100755 --- a/runbooks/verify-m1.sh +++ b/runbooks/verify-m1.sh @@ -100,14 +100,14 @@ import { createWorkspaceSessionCoordinator } from "./src/workspace-session-coord const cwd = process.env.TMP_WORKSPACE const coordinator = createWorkspaceSessionCoordinator({ cwd }) -const workspace = await coordinator.startOrCreate({ specTitle: "M1 runbook smoke" }) +const workspace = await coordinator.createSetupSession({ specTitle: "M1 runbook smoke" }) workspace.session.manager.appendCustomMessageEntry( "brunch.elicitation_prompt", "Runbook prompt: confirm the M1 mode shell is product-shaped.", true, ) workspace.session.manager.appendMessage({ role: "user", content: "Runbook response" }) -await coordinator.bindCurrentSpecToSession(workspace.session.manager) +await coordinator.bindCurrentSpecToReplacementSession(workspace.session.manager) NODE run_check "Print-mode smoke output" \ diff --git a/runbooks/verify-startup-no-resume.sh b/runbooks/verify-startup-no-resume.sh new file mode 100755 index 00000000..0f86bf07 --- /dev/null +++ b/runbooks/verify-startup-no-resume.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Proves FE-744/I22 at the terminal boundary: Brunch TUI startup shows the +# workspace switcher before any prior transcript is rendered. This runbook uses +# a real pty via `script`; it is intended as a manual/middle-loop oracle rather +# than part of the default verify gate. + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK_DIR="${WORK_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/brunch-startup-oracle.XXXXXX")}" +CAPTURE_RAW="$WORK_DIR/startup.raw" +CAPTURE_STRIPPED="$WORK_DIR/startup.stripped" +STALE_TEXT="BRUNCH_STALE_TRANSCRIPT_SENTINEL_$(date +%s)_$$" + +cd "$ROOT_DIR" +npm run build >/dev/null + +STALE_TEXT="$STALE_TEXT" WORK_DIR="$WORK_DIR" node --input-type=module <<'NODE' +import { createWorkspaceSessionCoordinator } from './dist/workspace-session-coordinator.js' + +const cwd = process.env.WORK_DIR +const staleText = process.env.STALE_TEXT +const coordinator = createWorkspaceSessionCoordinator({ cwd }) +const workspace = await coordinator.createSetupSession({ + specTitle: 'Startup Oracle Spec', +}) +workspace.session.manager.appendMessage({ + role: 'assistant', + content: staleText, +}) +console.log(`Seeded stale transcript: ${workspace.session.file}`) +NODE + +BRUNCH_CMD="cd '$WORK_DIR' && PI_OFFLINE=1 node '$ROOT_DIR/dist/brunch.js' --mode tui" + +set +e +if script --version >/dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' 3 script -q -f -c "$BRUNCH_CMD" "$CAPTURE_RAW" +else + perl -e 'alarm shift; exec @ARGV' 3 script -q -F "$CAPTURE_RAW" /bin/sh -lc "$BRUNCH_CMD" +fi +set -e + +perl -CS -pe 's/\e\[[0-?]*[ -\/]*[@-~]//g; s/\e\][^\a]*(\a|\e\\)//g; s/\eP.*?(\a|\e\\)//g; s/\r/\n/g' \ + "$CAPTURE_RAW" > "$CAPTURE_STRIPPED" + +if grep -Fq "$STALE_TEXT" "$CAPTURE_STRIPPED"; then + echo "FAILED: startup rendered stale transcript text before explicit activation" >&2 + echo "Capture: $CAPTURE_STRIPPED" >&2 + exit 1 +fi + +if ! grep -Eq "Brunch workspace|Choose how to start this session|New spec" "$CAPTURE_STRIPPED"; then + echo "FAILED: startup capture did not show a stable workspace-switcher marker" >&2 + echo "Capture: $CAPTURE_STRIPPED" >&2 + exit 1 +fi + +cat < { it("gates spec selection through the coordinator before launching interactive mode", async () => { @@ -44,18 +62,228 @@ describe("Brunch TUI boot", () => { } }) - it("passes coordinator chrome state to the persistent chrome widget", async () => { - const lines = formatChromeWidgetLines({ + it("runs inspect, preflight, and activation before launching interactive mode", async () => { + const events: string[] = [] + const workspace = readyWorkspace("/tmp/project", "session-ready") + + await runBrunchTui({ + cwd: "/tmp/project", + coordinator: { + inspectWorkspace: async () => { + events.push("inspect") + return { + cwd: "/tmp/project", + currentSpec: workspace.spec, + currentSessionFile: workspace.session.file, + needsNewSpec: false, + specs: [], + unavailableSessions: [], + } + }, + activateWorkspace: async (decision) => { + events.push(`activate:${decision.action}`) + return workspace + }, + bindCurrentSpecToReplacementSession: async () => workspace, + }, + runWorkspaceSwitchPreflight: async () => { + events.push("preflight") + return { + action: "continue", + specId: workspace.spec.id, + sessionFile: workspace.session.file, + } + }, + launchInteractive: async ({ workspace: launched }) => { + events.push(`launch:${launched.session.id}`) + }, + }) + + expect(events).toEqual([ + "inspect", + "preflight", + "activate:continue", + "launch:session-ready", + ]) + }) + + it("does not launch interactive mode when startup preflight is cancelled", async () => { + const events: string[] = [] + const workspace = readyWorkspace("/tmp/project", "session-ready") + + await runBrunchTui({ + cwd: "/tmp/project", + coordinator: { + inspectWorkspace: async () => { + events.push("inspect") + return { + cwd: "/tmp/project", + currentSpec: workspace.spec, + currentSessionFile: workspace.session.file, + needsNewSpec: false, + specs: [], + unavailableSessions: [], + } + }, + activateWorkspace: async () => { + events.push("activate") + return { + status: "cancelled", + cwd: "/tmp/project", + chrome: workspace.chrome, + } + }, + bindCurrentSpecToReplacementSession: async () => workspace, + }, + runWorkspaceSwitchPreflight: async () => { + events.push("preflight") + return { action: "cancel" } + }, + launchInteractive: async () => { + events.push("launch") + }, + }) + + expect(events).toEqual(["inspect", "preflight", "activate"]) + }) + + it("chooses a new binding-only session instead of implicitly resuming stale transcript", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-tui-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + const first = await coordinator.createSetupSession({ + specTitle: "Spec One", + }) + first.session.manager.appendMessage({ + role: "user", + content: "stale transcript", + }) + const firstContent = await readFile(first.session.file, "utf8") + let launchedSessionFile: string | undefined + + await runBrunchTui({ + cwd, + coordinator, + runWorkspaceSwitchPreflight: async () => ({ + action: "newSession", + specId: first.spec.id, + }), + launchInteractive: async ({ workspace }) => { + launchedSessionFile = workspace.session.file + }, + }) + + expect(launchedSessionFile).toBeDefined() + expect(launchedSessionFile).not.toBe(first.session.file) + await expect(readFile(first.session.file, "utf8")).resolves.toBe( + firstContent, + ) + expect(await readFile(launchedSessionFile!, "utf8")).not.toContain( + "stale transcript", + ) + }) + + it("passes activated session state into chrome instead of fabricating unbound", async () => { + const state = chromeStateForWorkspace( + readyWorkspace("/tmp/project", "session-real"), + ) + + expect(formatBrunchChromeHeaderLines(state).join("\n")).toContain( + "session-real", + ) + }) + + it("formats Brunch chrome from one product-state snapshot", async () => { + const state = { + cwd: "/tmp/project", + spec: { id: "spec-1", title: "Spec One" }, + session: { id: "session-1", label: "Interview #1" }, + phase: "elicitation" as const, + stage: "observer-review" as const, + chatMode: "responding-to-elicitation" as const, + activeLens: "problem-framing", + coherenceVerdict: "needs_review" as const, + observerStatus: "running" as const, + reviewerStatus: "queued" as const, + reconcilerStatus: "idle" as const, + reconciliationNeedCount: 3, + latestEstablishmentOfferSummary: + "Recommended lens: problem-framing; missing constraints.", + } + + expect(formatBrunchChromeHeaderLines(state).join("\n")).toContain( + "Spec One", + ) + expect(formatChromeWidgetLines(state).join("\n")).toContain( + "lens: problem-framing", + ) + expect(formatBrunchStatus(state)).toBe( + "Brunch · elicitation · needs_review · needs 3", + ) + expect(formatChromeWidgetLines(state).join("\n")).toContain( + "offer: Recommended lens: problem-framing; missing constraints.", + ) + }) + + it("renders Brunch chrome through one wrapper over Pi UI calls", async () => { + const calls: FakeUiCall[] = [] + const ui: FakeExtensionUi = { + setHeader: (...args: unknown[]) => + calls.push({ method: "setHeader", args }), + setFooter: (...args: unknown[]) => + calls.push({ method: "setFooter", args }), + setStatus: (...args: unknown[]) => + calls.push({ method: "setStatus", args }), + setWidget: (...args: unknown[]) => + calls.push({ method: "setWidget", args }), + setWorkingIndicator: (_options) => {}, + setTitle: (...args: unknown[]) => + calls.push({ method: "setTitle", args }), + notify: (_message: string, _type?: "info" | "warning" | "error") => {}, + } + + renderBrunchChrome(ui, { cwd: "/tmp/project", spec: { id: "spec-1", title: "Spec One" }, + session: { id: "session-1" }, phase: "elicitation", + stage: "idle", chatMode: "responding-to-elicitation", + activeLens: null, + coherenceVerdict: "coherent", + observerStatus: "idle", + reviewerStatus: "idle", + reconcilerStatus: "idle", + reconciliationNeedCount: 0, + latestEstablishmentOfferSummary: null, }) - expect(lines.join("\n")).toContain("cwd: /tmp/project") - expect(lines.join("\n")).toContain("spec: Spec One") - expect(lines.join("\n")).toContain("phase: elicitation") - expect(lines.join("\n")).toContain("chat: responding-to-elicitation") + expect(calls.map((call) => call.method)).toEqual([ + "setHeader", + "setFooter", + "setStatus", + "setWidget", + "setTitle", + ]) + expect(calls.find((call) => call.method === "setFooter")?.args).toEqual([ + undefined, + ]) + expect(calls.find((call) => call.method === "setStatus")?.args).toEqual([ + "brunch.chrome", + "Brunch · elicitation · coherent · needs 0", + ]) + expect(calls.find((call) => call.method === "setWidget")?.args).toEqual([ + "brunch.chrome", + [ + "cwd: /tmp/project", + "chat mode: responding-to-elicitation stage: idle", + "lens: none", + "workers: observer idle · reviewer idle · reconciler idle", + ], + { placement: "aboveEditor" }, + ]) + expect(calls.find((call) => call.method === "setTitle")?.args).toEqual([ + "brunch — Spec One", + ]) }) it("binds replacement sessions through internal session boundary events", async () => { @@ -63,13 +291,18 @@ describe("Brunch TUI boot", () => { const manager = SessionManager.create(cwd, join(cwd, ".brunch", "sessions")) const boundSessionIds: string[] = [] const widgets = new Map() + const titles: string[] = [] const ui: FakeExtensionUi = { + setHeader: (_factory) => {}, + setFooter: (_factory) => {}, + setStatus: (_key, _text) => {}, setWidget: (key: string, content: unknown) => { if (isStringArray(content)) { widgets.set(key, content) } }, - setTitle: (_title: string) => {}, + setWorkingIndicator: (_options) => {}, + setTitle: (title: string) => titles.push(title), notify: (_message: string, _type?: "info" | "warning" | "error") => {}, } const ctx: FakeExtensionContext = { sessionManager: manager, ui } @@ -86,16 +319,12 @@ describe("Brunch TUI boot", () => { ctx: FakeExtensionContext, ) => Promise) | undefined - createBrunchChromeExtension( - { - cwd, - spec: { id: "spec-1", title: "Spec One" }, - phase: "elicitation", - chatMode: "responding-to-elicitation", - }, + createBrunchPiExtensionShell( + chromeStateForWorkspace(readyWorkspace(cwd, manager.getSessionId())), (sessionManager) => { boundSessionIds.push(sessionManager.getSessionId()) }, + { coordinator: noOpWorkspaceCoordinator(cwd) }, )({ on: (event: string, handler: typeof sessionStart) => { if (event === "session_start") { @@ -108,6 +337,7 @@ describe("Brunch TUI boot", () => { messageStart = handler } }, + registerCommand: (_name: string, _options: unknown) => {}, } as never) await sessionStart?.({}, ctx) @@ -126,7 +356,135 @@ describe("Brunch TUI boot", () => { manager.getSessionId(), manager.getSessionId(), ]) - expect(widgets.get("brunch.chrome")?.join("\n")).toContain("Spec One") + expect(widgets.get("brunch.chrome")?.join("\n")).toContain( + "chat mode: responding-to-elicitation", + ) + expect(titles).toEqual(["brunch — Spec One"]) + }) + + it("registers a Brunch-owned workspace switch command", async () => { + const commands = + new Map>() + + createBrunchPiExtensionShell( + chromeStateForWorkspace(readyWorkspace("/tmp/project", "session-1")), + undefined, + { + coordinator: { + inspectWorkspace: async () => emptyInventory("/tmp/project"), + activateWorkspace: async () => + readyWorkspace("/tmp/project", "session-1"), + }, + }, + )({ + on: (_event: string, _handler: unknown) => {}, + registerCommand: (name, options) => commands.set(name, options), + } as never) + + expect(commands.get(BRUNCH_WORKSPACE_COMMAND)?.description).toBe( + "Switch Brunch spec/session workspace", + ) + }) + + it("runs the in-session workspace switch through coordinator activation and replacement context", async () => { + const events: string[] = [] + const customOptions: unknown[] = [] + const target = readyWorkspace("/tmp/project", "session-target") + const replacementUi = fakeUi((method) => + events.push(`replacement:${method}`), + ) + const ctx = fakeCommandContext({ + currentSessionFile: "/sessions/session-old.jsonl", + decision: { + action: "openSession", + specId: target.spec.id, + sessionFile: target.session.file, + }, + onCustomOptions: (options) => customOptions.push(options), + onEvent: (event) => events.push(event), + replacementUi, + }) + + await runBrunchWorkspaceCommand(ctx, { + inspectWorkspace: async () => { + events.push("inspect") + return inventoryWithWorkspace(target) + }, + activateWorkspace: async (decision) => { + events.push(`activate:${decision.action}`) + return target + }, + }) + + expect(events).toEqual([ + "waitForIdle", + "inspect", + "custom", + "activate:openSession", + `switch:${target.session.file}`, + "replacement:setHeader", + "replacement:setFooter", + "replacement:setStatus", + "replacement:setWidget", + "replacement:setTitle", + "replacement:notify", + ]) + expect(customOptions).toEqual([]) + }) + + it("leaves the current session untouched when workspace switch is cancelled", async () => { + const events: string[] = [] + const ctx = fakeCommandContext({ + currentSessionFile: "/sessions/session-old.jsonl", + decision: { action: "cancel" }, + onEvent: (event) => events.push(event), + }) + + await runBrunchWorkspaceCommand(ctx, { + inspectWorkspace: async () => emptyInventory("/tmp/project"), + activateWorkspace: async () => ({ + status: "cancelled", + cwd: "/tmp/project", + chrome: { + cwd: "/tmp/project", + spec: null, + phase: "select_spec", + chatMode: "select-spec", + }, + }), + }) + + expect(events).toEqual(["waitForIdle", "custom", "notify:info"]) + }) + + it("reports needs-human workspace switch decisions without switching sessions", async () => { + const events: string[] = [] + const ctx = fakeCommandContext({ + currentSessionFile: "/sessions/session-old.jsonl", + decision: { + action: "openSession", + specId: "missing", + sessionFile: "/sessions/missing.jsonl", + }, + onEvent: (event) => events.push(event), + }) + + await runBrunchWorkspaceCommand(ctx, { + inspectWorkspace: async () => emptyInventory("/tmp/project"), + activateWorkspace: async () => ({ + status: "needs_human", + cwd: "/tmp/project", + reason: "Selected session is not available.", + chrome: { + cwd: "/tmp/project", + spec: null, + phase: "select_spec", + chatMode: "select-spec", + }, + }), + }) + + expect(events).toEqual(["waitForIdle", "custom", "notify:warning"]) }) it("cancels Pi branch-flow hooks with a stable user-facing reason", async () => { @@ -139,7 +497,11 @@ describe("Brunch TUI boot", () => { const ctx: FakeExtensionContext = { sessionManager: manager, ui: { + setHeader: (_factory) => {}, + setFooter: (_factory) => {}, + setStatus: (_key, _text) => {}, setWidget: (_key: string, _content: unknown) => {}, + setWorkingIndicator: (_options) => {}, setTitle: (_title: string) => {}, notify: (message, type) => notifications.push({ message, type }), }, @@ -149,18 +511,18 @@ describe("Brunch TUI boot", () => { ctx: FakeExtensionContext, ) => unknown>() - createBrunchChromeExtension({ - cwd, - spec: { id: "spec-1", title: "Spec One" }, - phase: "elicitation", - chatMode: "responding-to-elicitation", - })({ + createBrunchPiExtensionShell( + chromeStateForWorkspace(readyWorkspace(cwd, manager.getSessionId())), + undefined, + { coordinator: noOpWorkspaceCoordinator(cwd) }, + )({ on: ( event: string, handler: (event: unknown, ctx: FakeExtensionContext) => unknown, ) => { handlers.set(event, handler) }, + registerCommand: (_name: string, _options: unknown) => {}, } as never) await expect( @@ -193,23 +555,163 @@ describe("Brunch TUI boot", () => { ]) }) - it("keeps session creation and binding out of the TUI boot adapter", async () => { - const source = await readFile( - new URL("./brunch-tui.ts", import.meta.url), - "utf8", - ) + it("suppresses generic Pi startup resources for the Brunch shell", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-tui-")) + const settingsManager = createBrunchSettingsManager(cwd, cwd) + const extension = () => {} + const resourceOptions = brunchResourceLoaderOptions([extension]) + const env: { PI_OFFLINE?: string } = {} - expect(source).not.toContain("SessionManager.create") - expect(source).not.toContain("appendCustomEntry") - expect(source).not.toContain("brunch.session_binding") + applyBrunchOfflineDefault(env) + + expect(settingsManager.getQuietStartup()).toBe(true) + expect(resourceOptions).toEqual({ + noContextFiles: true, + noExtensions: true, + noPromptTemplates: true, + noSkills: true, + noThemes: true, + extensionFactories: [extension], + }) + expect(env.PI_OFFLINE).toBe("1") }) }) +function readyWorkspace( + cwd: string, + sessionId: string, +): WorkspaceSessionReadyState { + const spec = { id: "spec-1", title: "Spec One" } + return { + status: "ready", + cwd, + spec, + session: { + id: sessionId, + file: `/sessions/${sessionId}.jsonl`, + manager: {} as WorkspaceSessionReadyState["session"]["manager"], + }, + chrome: { + cwd, + spec, + phase: "elicitation", + chatMode: "responding-to-elicitation", + }, + } +} + +function emptyInventory(cwd: string): WorkspaceLaunchInventory { + return { + cwd, + currentSpec: null, + currentSessionFile: null, + needsNewSpec: true, + specs: [], + unavailableSessions: [], + } +} + +function inventoryWithWorkspace( + workspace: WorkspaceSessionReadyState, +): WorkspaceLaunchInventory { + return { + cwd: workspace.cwd, + currentSpec: workspace.spec, + currentSessionFile: workspace.session.file, + needsNewSpec: false, + specs: [ + { + spec: workspace.spec, + sessions: [ + { + id: workspace.session.id, + file: workspace.session.file, + specId: workspace.spec.id, + specTitle: workspace.spec.title, + available: true, + }, + ], + }, + ], + unavailableSessions: [], + } +} + +function noOpWorkspaceCoordinator(cwd: string) { + return { + inspectWorkspace: async () => emptyInventory(cwd), + activateWorkspace: async () => readyWorkspace(cwd, "session-1"), + } +} + +function fakeCommandContext(options: { + currentSessionFile: string + decision: Awaited> + onCustomOptions?: (customOptions: unknown) => void + onEvent: (event: string) => void + replacementUi?: FakeExtensionUi +}): ExtensionCommandContext { + const ui = fakeUi((method, type) => { + if (method === "notify") { + options.onEvent(`notify:${type}`) + } + }) + const ctx = { + cwd: "/tmp/project", + sessionManager: { + getSessionFile: () => options.currentSessionFile, + }, + ui: { + ...ui, + custom: async (_component: unknown, customOptions?: unknown) => { + options.onEvent("custom") + if (customOptions !== undefined) { + options.onCustomOptions?.(customOptions) + } + return options.decision + }, + }, + waitForIdle: async () => options.onEvent("waitForIdle"), + switchSession: async ( + sessionPath: string, + switchOptions?: Parameters[1], + ) => { + options.onEvent(`switch:${sessionPath}`) + await switchOptions?.withSession?.({ + ...ctx, + ui: options.replacementUi ?? ui, + sessionManager: { getSessionFile: () => sessionPath }, + } as ExtensionCommandContext) + return { cancelled: false } + }, + } + return ctx as unknown as ExtensionCommandContext +} + +function fakeUi( + onCall: (method: string, notifyType?: "info" | "warning" | "error") => void, +): FakeExtensionUi { + return { + setHeader: (_factory) => onCall("setHeader"), + setFooter: (_factory) => onCall("setFooter"), + setStatus: (_key, _text) => onCall("setStatus"), + setWidget: (_key, _content, _options) => onCall("setWidget"), + setWorkingIndicator: (_options) => onCall("setWorkingIndicator"), + setTitle: (_title) => onCall("setTitle"), + notify: (_message, type) => onCall("notify", type), + } +} + +interface FakeUiCall { + method: string + args: unknown[] +} + type FakeExtensionContext = Pick & { ui: FakeExtensionUi } -type FakeExtensionUi = Pick +type FakeExtensionUi = Pick function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string") diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index b818ea81..6200e2b9 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -1,4 +1,3 @@ -import { createInterface } from "node:readline/promises" import process from "node:process" import { @@ -7,33 +6,55 @@ import { createAgentSessionServices, getAgentDir, InteractiveMode, - SessionManager, + SettingsManager, type CreateAgentSessionRuntimeFactory, type ExtensionFactory, } from "@earendil-works/pi-coding-agent" import { createWorkspaceSessionCoordinator, - type WorkspaceSessionChromeState, - type WorkspaceSessionCoordinator, + type WorkspaceLaunchInventory, + type WorkspaceSessionBoundaryCoordinator, type WorkspaceSessionReadyState, + type WorkspaceSwitchCoordinator, + type WorkspaceSwitchDecision, } from "./workspace-session-coordinator.js" +import { + chromeStateForWorkspace, + createBrunchPiExtensionShell, +} from "./pi-extensions/brunch/index.js" +import { runWorkspaceSwitchPreflight } from "./workspace-switcher.js" +export { + BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, + chromeStateForWorkspace, + createBrunchPiExtensionShell, + formatBrunchChromeHeaderLines, + formatChromeWidgetLines, + renderBrunchChrome, + type BrunchChromeCoherenceVerdict, + type BrunchChromeStage, + type BrunchChromeState, + type BrunchChromeWorkerStatus, +} from "./pi-extensions/brunch/index.js" +export { runWorkspaceSwitchPreflight } from "./workspace-switcher.js" + +export type BrunchTuiCoordinator = WorkspaceSwitchCoordinator & WorkspaceSessionBoundaryCoordinator export interface BrunchTuiLaunchContext { workspace: WorkspaceSessionReadyState - coordinator: WorkspaceSessionCoordinator + coordinator: BrunchTuiCoordinator } export interface BrunchTuiOptions { cwd?: string - coordinator?: WorkspaceSessionCoordinator + coordinator?: BrunchTuiCoordinator selectSpecTitle?: () => Promise + runWorkspaceSwitchPreflight?: ( + inventory: WorkspaceLaunchInventory, + ) => Promise launchInteractive?: (context: BrunchTuiLaunchContext) => Promise } -export const BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE = - "Brunch does not support Pi session branches in this POC. Use /new to continue within the selected spec." - export async function runBrunchTui( options: BrunchTuiOptions = {}, ): Promise { @@ -41,15 +62,13 @@ export async function runBrunchTui( const coordinator = options.coordinator ?? createWorkspaceSessionCoordinator({ cwd }) - let workspaceState = await coordinator.openExisting() - if (workspaceState.status === "select_spec") { - const title = await (options.selectSpecTitle ?? promptForSpecTitle)() - if (!title) { - return - } - workspaceState = await coordinator.startOrCreate({ specTitle: title }) - } + const inventory = await coordinator.inspectWorkspace() + const decision = await chooseWorkspaceSwitchDecision(inventory, options) + const workspaceState = await coordinator.activateWorkspace(decision) + if (workspaceState.status === "cancelled") { + return + } if (workspaceState.status === "needs_human") { throw new Error(workspaceState.reason) } @@ -60,56 +79,18 @@ export async function runBrunchTui( }) } -export function formatChromeWidgetLines( - chrome: WorkspaceSessionChromeState, -): string[] { - const spec = chrome.spec ? chrome.spec.title : "" - return [ - `brunch cwd: ${chrome.cwd}`, - ` spec: ${spec} phase: ${chrome.phase} chat: ${chrome.chatMode}`, - ] -} - -export function createBrunchChromeExtension( - chrome: WorkspaceSessionChromeState, - onSessionBoundary?: (sessionManager: SessionManager) => Promise | void, -): ExtensionFactory { - return (pi) => { - pi.on("session_start", async (_event, ctx) => { - await onSessionBoundary?.(ctx.sessionManager as SessionManager) - ctx.ui.setWidget("brunch.chrome", formatChromeWidgetLines(chrome), { - placement: "aboveEditor", - }) - ctx.ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`) - }) - pi.on("before_agent_start", async (_event, ctx) => { - await onSessionBoundary?.(ctx.sessionManager as SessionManager) - }) - pi.on("message_start", async (event, ctx) => { - if (event.message.role === "assistant") { - await onSessionBoundary?.(ctx.sessionManager as SessionManager) - } - }) - pi.on("session_before_tree", (_event, ctx) => { - ctx.ui.notify(BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, "warning") - return { cancel: true } - }) - pi.on("session_before_fork", (_event, ctx) => { - ctx.ui.notify(BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, "warning") - return { cancel: true } - }) +async function chooseWorkspaceSwitchDecision( + inventory: WorkspaceLaunchInventory, + options: BrunchTuiOptions, +): Promise { + if (options.runWorkspaceSwitchPreflight) { + return options.runWorkspaceSwitchPreflight(inventory) } -} - -async function promptForSpecTitle(): Promise { - const rl = createInterface({ input: process.stdin, output: process.stdout }) - try { - const answer = await rl.question("Create/select Brunch spec title: ") - const title = answer.trim() - return title.length > 0 ? title : undefined - } finally { - rl.close() + if (options.selectSpecTitle && inventory.needsNewSpec) { + const title = await options.selectSpecTitle() + return title ? { action: "newSpec", title } : { action: "cancel" } } + return runWorkspaceSwitchPreflight(inventory) } async function launchPiInteractive({ @@ -122,19 +103,22 @@ async function launchPiInteractive({ agentDir: runtimeAgentDir, sessionManager, }) => { + const settingsManager = createBrunchSettingsManager(cwd, runtimeAgentDir) const services = await createAgentSessionServices({ cwd, agentDir: runtimeAgentDir, - resourceLoaderOptions: { - extensionFactories: [ - createBrunchChromeExtension( - workspace.chrome, - async (sessionManager) => { - await coordinator.bindCurrentSpecToSession(sessionManager) - }, - ), - ], - }, + settingsManager, + resourceLoaderOptions: brunchResourceLoaderOptions([ + createBrunchPiExtensionShell( + chromeStateForWorkspace(workspace), + async (sessionManager) => { + await coordinator.bindCurrentSpecToReplacementSession( + sessionManager, + ) + }, + { coordinator }, + ), + ]), }) const created = await createAgentSessionFromServices({ services, @@ -153,5 +137,34 @@ async function launchPiInteractive({ sessionManager: workspace.session.manager, }) + applyBrunchOfflineDefault() await new InteractiveMode(runtime).run() } + +export function brunchResourceLoaderOptions( + extensionFactories: ExtensionFactory[], +) { + return { + noContextFiles: true, + noExtensions: true, + noPromptTemplates: true, + noSkills: true, + noThemes: true, + extensionFactories, + } +} + +export function applyBrunchOfflineDefault( + env: { PI_OFFLINE?: string } = process.env, +): void { + env.PI_OFFLINE ??= "1" +} + +export function createBrunchSettingsManager( + cwd: string, + agentDir: string, +): SettingsManager { + const settingsManager = SettingsManager.create(cwd, agentDir) + settingsManager.getQuietStartup = () => true + return settingsManager +} diff --git a/src/brunch.test.ts b/src/brunch.test.ts index 4cd19672..a31f79c1 100644 --- a/src/brunch.test.ts +++ b/src/brunch.test.ts @@ -16,7 +16,7 @@ import { function coordinator(sessionFile?: string): WorkspaceSessionCoordinator { return { - async openExisting() { + async openDefaultWorkspace() { return { ...(sessionFile ? { @@ -46,16 +46,16 @@ function coordinator(sessionFile?: string): WorkspaceSessionCoordinator { cwd: "/tmp/brunch-project", } }, - async startOrCreate() { + async createSetupSession() { throw new Error("print must not create a session") }, - async createNewSessionForCurrentSpec() { + async createSetupSessionForCurrentSpec() { throw new Error("not used") }, - async bindCurrentSpecToSession() { + async bindCurrentSpecToReplacementSession() { throw new Error("not used") }, - async deriveChromeState() { + async deriveDefaultChromeState() { throw new Error("not used") }, } @@ -167,7 +167,7 @@ describe("Brunch CLI dispatch", () => { it("exposes matching print and RPC workspace snapshots from a real coordinator store", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-parity-")) - await createWorkspaceSessionCoordinator({ cwd }).startOrCreate({ + await createWorkspaceSessionCoordinator({ cwd }).createSetupSession({ specTitle: "Parity spec", }) let printOutput = "" diff --git a/src/brunch.ts b/src/brunch.ts index c858e834..889c2722 100644 --- a/src/brunch.ts +++ b/src/brunch.ts @@ -11,12 +11,13 @@ import { createRpcHandlers, runJsonRpcLineServer } from "./rpc.js" import { startWebHost } from "./web-host.js" import { createWorkspaceSessionCoordinator, + type DefaultWorkspaceCoordinator, type WorkspaceSessionCoordinator, } from "./workspace-session-coordinator.js" export interface WebHostRunnerOptions { cwd: string - coordinator: WorkspaceSessionCoordinator + coordinator: DefaultWorkspaceCoordinator } export interface BrunchCliOptions { @@ -38,7 +39,7 @@ export async function runBrunchCli( options.coordinator ?? createWorkspaceSessionCoordinator({ cwd }) if (mode === "print") { - const state = await coordinator.openExisting() + const state = await coordinator.openDefaultWorkspace() const snapshot = workspaceSnapshotFromState(state) writeStdout(options.stdout, renderWorkspaceSnapshot(snapshot)) return 0 diff --git a/src/fixture-capture.test.ts b/src/fixture-capture.test.ts index b0e37b2f..042ce63b 100644 --- a/src/fixture-capture.test.ts +++ b/src/fixture-capture.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { describe, expect, it } from "vitest" -import type { WorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" +import type { DefaultWorkspaceCoordinator } from "./workspace-session-coordinator.js" import { createWorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" import { loadLinearElicitationExchangeProjection } from "./elicitation-exchange.js" import { @@ -16,7 +16,7 @@ describe("fixture capture", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-fixture-real-")) const workspace = await createWorkspaceSessionCoordinator({ cwd, - }).startOrCreate({ + }).createSetupSession({ specTitle: "Fixture spec", }) workspace.session.manager.appendMessage({ @@ -65,7 +65,7 @@ describe("fixture capture", () => { ) const workspace = await createWorkspaceSessionCoordinator({ cwd, - }).startOrCreate({ + }).createSetupSession({ specTitle: "Fixture spec", }) workspace.session.manager.appendMessage({ @@ -95,7 +95,7 @@ describe("fixture capture", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-fixture-")) const workspace = await createWorkspaceSessionCoordinator({ cwd, - }).startOrCreate({ + }).createSetupSession({ specTitle: "Fixture spec", }) workspace.session.manager.appendMessage({ @@ -104,22 +104,10 @@ describe("fixture capture", () => { }) workspace.session.manager.appendMessage({ role: "user", content: "Answer" }) - const coordinator: WorkspaceSessionCoordinator = { - async openExisting() { + const coordinator: DefaultWorkspaceCoordinator = { + async openDefaultWorkspace() { return workspace }, - async startOrCreate() { - return workspace - }, - async createNewSessionForCurrentSpec() { - return workspace - }, - async bindCurrentSpecToSession() { - return workspace - }, - async deriveChromeState() { - return workspace.chrome - }, } const result = await captureFixtureRun({ diff --git a/src/fixture-capture.ts b/src/fixture-capture.ts index 154e4499..9e1c8db3 100644 --- a/src/fixture-capture.ts +++ b/src/fixture-capture.ts @@ -4,13 +4,15 @@ import { PassThrough } from "node:stream" import { fileURLToPath } from "node:url" import { loadBriefLibrary, type FixtureBrief } from "./brief-library.js" -import { runBrunchCli } from "./brunch.js" +import { createRpcHandlers, runJsonRpcLineServer } from "./rpc.js" import type { ElicitationExchangeProjection } from "./elicitation-exchange.js" import type { WorkspaceSnapshot } from "./print-snapshot.js" import type { JsonRpcResponse } from "./json-rpc-protocol.js" import { createWorkspaceSessionCoordinator, - type WorkspaceSessionCoordinator, + type DefaultWorkspaceCoordinator, + type WorkspaceSessionBoundaryCoordinator, + type WorkspaceSetupCoordinator, } from "./workspace-session-coordinator.js" export interface FixtureCaptureOptions { @@ -18,7 +20,7 @@ export interface FixtureCaptureOptions { briefId: string runId: string timestamp?: string - coordinator?: WorkspaceSessionCoordinator + coordinator?: DefaultWorkspaceCoordinator } export interface FixtureCaptureResult { @@ -117,7 +119,9 @@ export async function captureDeterministicBriefRuns( content: brief.scriptedUserNotes.join("\n"), timestamp: Date.parse(options.timestamp ?? new Date().toISOString()), }) - await coordinator.bindCurrentSpecToSession(workspace.session.manager) + await coordinator.bindCurrentSpecToReplacementSession( + workspace.session.manager, + ) results.push( await captureFixtureRun({ @@ -133,10 +137,10 @@ export async function captureDeterministicBriefRuns( } async function openScriptedBriefSession( - coordinator: WorkspaceSessionCoordinator, + coordinator: WorkspaceSetupCoordinator & WorkspaceSessionBoundaryCoordinator, brief: FixtureBrief, ) { - return coordinator.startOrCreate({ + return coordinator.createSetupSession({ specTitle: brief.title, createNewSpec: true, }) @@ -152,12 +156,15 @@ async function callRpc( stdout.on("data", (chunk) => chunks.push(String(chunk))) stdin.end(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method })}\n`) - await runBrunchCli({ - argv: ["--mode=rpc"], - cwd: options.cwd, - ...(options.coordinator ? { coordinator: options.coordinator } : {}), - stdin, - stdout, + await runJsonRpcLineServer({ + input: stdin, + output: stdout, + handlers: createRpcHandlers({ + coordinator: + options.coordinator ?? + createWorkspaceSessionCoordinator({ cwd: options.cwd }), + cwd: options.cwd, + }), }) const response = JSON.parse(chunks.join("")) as JsonRpcResponse diff --git a/src/pi-extensions/brunch/branch-policy.ts b/src/pi-extensions/brunch/branch-policy.ts new file mode 100644 index 00000000..e2eaff13 --- /dev/null +++ b/src/pi-extensions/brunch/branch-policy.ts @@ -0,0 +1,15 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" + +export const BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE = + "Brunch does not support Pi session branches in this POC. Use /new to continue within the selected spec." + +export function registerBrunchBranchPolicyHandlers(pi: ExtensionAPI): void { + pi.on("session_before_tree", (_event, ctx) => { + ctx.ui.notify(BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, "warning") + return { cancel: true } + }) + pi.on("session_before_fork", (_event, ctx) => { + ctx.ui.notify(BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, "warning") + return { cancel: true } + }) +} diff --git a/src/pi-extensions/brunch/chrome.ts b/src/pi-extensions/brunch/chrome.ts new file mode 100644 index 00000000..8d942455 --- /dev/null +++ b/src/pi-extensions/brunch/chrome.ts @@ -0,0 +1,97 @@ +import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent" + +import type { + WorkspaceSessionChromeState, + WorkspaceSessionReadyState, +} from "../../workspace-session-coordinator.js" + +export type BrunchChromeStage = "idle" | "streaming" | "observer-review" +export type BrunchChromeWorkerStatus = "idle" | "queued" | "running" | "blocked" +export type BrunchChromeCoherenceVerdict = "unknown" | "coherent" | "needs_review" | "incoherent" + +export interface BrunchChromeState extends WorkspaceSessionChromeState { + session: { + id: string + label?: string + } + stage: BrunchChromeStage + activeLens: string | null + coherenceVerdict: BrunchChromeCoherenceVerdict + observerStatus: BrunchChromeWorkerStatus + reviewerStatus: BrunchChromeWorkerStatus + reconcilerStatus: BrunchChromeWorkerStatus + reconciliationNeedCount: number + latestEstablishmentOfferSummary: string | null +} + +export type BrunchChromeUi = Pick + +export function formatBrunchChromeHeaderLines( + chrome: BrunchChromeState, +): string[] { + return [ + "brunch specification workspace", + `${formatSpec(chrome)} · ${formatSession(chrome)}`, + ] +} + +export function formatBrunchStatus(chrome: BrunchChromeState): string { + return `Brunch · ${chrome.phase} · ${chrome.coherenceVerdict} · needs ${chrome.reconciliationNeedCount}` +} + +export function formatChromeWidgetLines(chrome: BrunchChromeState): string[] { + const lines = [ + `cwd: ${chrome.cwd}`, + `chat mode: ${chrome.chatMode} stage: ${chrome.stage}`, + `lens: ${chrome.activeLens ?? "none"}`, + `workers: observer ${chrome.observerStatus} · reviewer ${chrome.reviewerStatus} · reconciler ${chrome.reconcilerStatus}`, + ] + if (chrome.latestEstablishmentOfferSummary) { + lines.push(`offer: ${chrome.latestEstablishmentOfferSummary}`) + } + return lines +} + +export function chromeStateForWorkspace( + workspace: WorkspaceSessionReadyState, +): BrunchChromeState { + return { + ...workspace.chrome, + session: { + id: workspace.session.id, + label: workspace.session.id, + }, + stage: "idle", + activeLens: null, + coherenceVerdict: "unknown", + observerStatus: "idle", + reviewerStatus: "idle", + reconcilerStatus: "idle", + reconciliationNeedCount: 0, + latestEstablishmentOfferSummary: null, + } +} + +export function renderBrunchChrome( + ui: BrunchChromeUi, + chrome: BrunchChromeState, +): void { + ui.setHeader(() => ({ + render: () => formatBrunchChromeHeaderLines(chrome), + invalidate: () => {}, + })) + ui.setFooter(undefined) + ui.setStatus("brunch.chrome", formatBrunchStatus(chrome)) + ui.setWidget("brunch.chrome", formatChromeWidgetLines(chrome), { + placement: "aboveEditor", + }) + ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`) +} + +function formatSpec(chrome: BrunchChromeState): string { + return chrome.spec?.title ?? "no spec selected" +} + +function formatSession(chrome: BrunchChromeState): string { + return chrome.session.label ?? chrome.session.id +} diff --git a/src/pi-extensions/brunch/index.ts b/src/pi-extensions/brunch/index.ts new file mode 100644 index 00000000..291310ae --- /dev/null +++ b/src/pi-extensions/brunch/index.ts @@ -0,0 +1,60 @@ +import { + SessionManager, + type ExtensionFactory, +} from "@earendil-works/pi-coding-agent" + +import { registerBrunchBranchPolicyHandlers } from "./branch-policy.js" +import { renderBrunchChrome, type BrunchChromeState } from "./chrome.js" +import { + bindBrunchSessionBoundary, + registerBrunchSessionBoundaryRefreshHandlers, + type BrunchSessionBoundaryHandler, +} from "./session-boundary.js" +import { + registerBrunchWorkspaceCommand, + type BrunchWorkspaceCommandOptions, +} from "./workspace-command.js" + +export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./branch-policy.js" +export { + chromeStateForWorkspace, + formatBrunchChromeHeaderLines, + formatBrunchStatus, + formatChromeWidgetLines, + renderBrunchChrome, + type BrunchChromeCoherenceVerdict, + type BrunchChromeStage, + type BrunchChromeState, + type BrunchChromeUi, + type BrunchChromeWorkerStatus, +} from "./chrome.js" +export { + bindBrunchSessionBoundary, + registerBrunchSessionBoundaryRefreshHandlers, + type BrunchSessionBoundaryHandler, +} from "./session-boundary.js" +export { + BRUNCH_WORKSPACE_COMMAND, + registerBrunchWorkspaceCommand, + runBrunchWorkspaceCommand, + type BrunchWorkspaceCommandOptions, +} from "./workspace-command.js" + +export function createBrunchPiExtensionShell( + chrome: BrunchChromeState, + onSessionBoundary: BrunchSessionBoundaryHandler | undefined, + options: BrunchWorkspaceCommandOptions, +): ExtensionFactory { + return (pi) => { + pi.on("session_start", async (_event, ctx) => { + await bindBrunchSessionBoundary( + ctx.sessionManager as SessionManager, + onSessionBoundary, + ) + renderBrunchChrome(ctx.ui, chrome) + }) + registerBrunchSessionBoundaryRefreshHandlers(pi, onSessionBoundary) + registerBrunchBranchPolicyHandlers(pi) + registerBrunchWorkspaceCommand(pi, options) + } +} diff --git a/src/pi-extensions/brunch/session-boundary.ts b/src/pi-extensions/brunch/session-boundary.ts new file mode 100644 index 00000000..4e49f8c1 --- /dev/null +++ b/src/pi-extensions/brunch/session-boundary.ts @@ -0,0 +1,35 @@ +import { + SessionManager, + type ExtensionAPI, +} from "@earendil-works/pi-coding-agent" + +export type BrunchSessionBoundaryHandler = ( + sessionManager: SessionManager, +) => Promise | void + +export async function bindBrunchSessionBoundary( + sessionManager: SessionManager, + onSessionBoundary?: BrunchSessionBoundaryHandler, +): Promise { + await onSessionBoundary?.(sessionManager) +} + +export function registerBrunchSessionBoundaryRefreshHandlers( + pi: ExtensionAPI, + onSessionBoundary?: BrunchSessionBoundaryHandler, +): void { + pi.on("before_agent_start", async (_event, ctx) => { + await bindBrunchSessionBoundary( + ctx.sessionManager as SessionManager, + onSessionBoundary, + ) + }) + pi.on("message_start", async (event, ctx) => { + if (event.message.role === "assistant") { + await bindBrunchSessionBoundary( + ctx.sessionManager as SessionManager, + onSessionBoundary, + ) + } + }) +} diff --git a/src/pi-extensions/brunch/workspace-command.ts b/src/pi-extensions/brunch/workspace-command.ts new file mode 100644 index 00000000..4b610ecf --- /dev/null +++ b/src/pi-extensions/brunch/workspace-command.ts @@ -0,0 +1,84 @@ +import type { + ExtensionAPI, + ExtensionCommandContext, +} from "@earendil-works/pi-coding-agent" + +import { + type WorkspaceSessionReadyState, + type WorkspaceSwitchCoordinator, + type WorkspaceSwitchDecision, +} from "../../workspace-session-coordinator.js" +import { createWorkspaceSwitchComponent } from "../../workspace-switcher/index.js" +import { chromeStateForWorkspace, renderBrunchChrome } from "./chrome.js" + +export const BRUNCH_WORKSPACE_COMMAND = "brunch-workspace" + +export interface BrunchWorkspaceCommandOptions { + coordinator: WorkspaceSwitchCoordinator +} + +export function registerBrunchWorkspaceCommand( + pi: ExtensionAPI, + { coordinator }: BrunchWorkspaceCommandOptions, +): void { + pi.registerCommand(BRUNCH_WORKSPACE_COMMAND, { + description: "Switch Brunch spec/session workspace", + handler: async (_args, ctx) => { + await runBrunchWorkspaceCommand(ctx, coordinator) + }, + }) +} + +export async function runBrunchWorkspaceCommand( + ctx: ExtensionCommandContext, + coordinator: WorkspaceSwitchCoordinator, +): Promise { + await ctx.waitForIdle() + const inventory = await coordinator.inspectWorkspace() + const decision = await ctx.ui.custom( + (_tui, _theme, _keybindings, done) => + createWorkspaceSwitchComponent({ inventory, onDecision: done }), + ) + const activated = await coordinator.activateWorkspace(decision) + + if (activated.status === "cancelled") { + ctx.ui.notify("Workspace switch cancelled.", "info") + return + } + if (activated.status === "needs_human") { + ctx.ui.notify(activated.reason, "warning") + return + } + + await switchToActivatedWorkspace(ctx, activated) +} + +async function switchToActivatedWorkspace( + ctx: ExtensionCommandContext, + activated: WorkspaceSessionReadyState, +): Promise { + const targetFile = activated.session.file + if (ctx.sessionManager.getSessionFile() === targetFile) { + renderBrunchChrome(ctx.ui, chromeStateForWorkspace(activated)) + ctx.ui.notify("Already using the selected Brunch workspace.", "info") + return + } + + const targetSessionId = activated.session.id + const targetSpecTitle = activated.spec.title + const targetChrome = chromeStateForWorkspace(activated) + + const result = await ctx.switchSession(targetFile, { + withSession: async (replacementCtx) => { + renderBrunchChrome(replacementCtx.ui, targetChrome) + replacementCtx.ui.notify( + `Switched Brunch workspace to ${targetSpecTitle} (${targetSessionId}).`, + "info", + ) + }, + }) + + if (result.cancelled) { + ctx.ui.notify("Workspace switch was cancelled by Pi.", "warning") + } +} diff --git a/src/rpc.test.ts b/src/rpc.test.ts index a59cc4a7..aed98176 100644 --- a/src/rpc.test.ts +++ b/src/rpc.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { PassThrough } from "node:stream" @@ -10,7 +10,7 @@ import { createRpcHandlers, runJsonRpcLineServer } from "./rpc.js" import { createSessionBindingData } from "./session-binding.js" import { createWorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" import type { - WorkspaceSessionCoordinator, + DefaultWorkspaceCoordinator, WorkspaceSessionState, } from "./workspace-session-coordinator.js" @@ -18,23 +18,11 @@ function coordinator( state: WorkspaceSessionState = readyState( "/tmp/brunch-project/.brunch/sessions/session-1.jsonl", ), -): WorkspaceSessionCoordinator { +): DefaultWorkspaceCoordinator { return { - async openExisting() { + async openDefaultWorkspace() { return state }, - async startOrCreate() { - throw new Error("not used") - }, - async createNewSessionForCurrentSpec() { - throw new Error("not used") - }, - async bindCurrentSpecToSession() { - throw new Error("not used") - }, - async deriveChromeState() { - throw new Error("not used") - }, } } @@ -201,7 +189,7 @@ describe("JSON-RPC handlers", () => { it("serves session elicitation exchanges by durable session id without opening the selected workspace session", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-explicit-session-")) const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) - const first = await coordinatorInstance.startOrCreate({ + const first = await coordinatorInstance.createSetupSession({ specTitle: "Explicit spec", }) first.session.manager.appendMessage({ @@ -212,14 +200,14 @@ describe("JSON-RPC handlers", () => { role: "user", content: "First answer", }) - const second = await coordinatorInstance.createNewSessionForCurrentSpec() + const second = await coordinatorInstance.createSetupSessionForCurrentSpec() if (second.status !== "ready") { throw new Error("expected a ready second session") } const handlers = createRpcHandlers({ coordinator: { ...coordinatorInstance, - async openExisting() { + async openDefaultWorkspace() { throw new Error("explicit reads must not open selected session") }, }, @@ -246,7 +234,7 @@ describe("JSON-RPC handlers", () => { it("serves transcript display rows by durable session id without opening the selected workspace session", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-display-")) const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) - const workspace = await coordinatorInstance.startOrCreate({ + const workspace = await coordinatorInstance.createSetupSession({ specTitle: "Display spec", }) workspace.session.manager.appendMessage({ @@ -260,7 +248,7 @@ describe("JSON-RPC handlers", () => { const handlers = createRpcHandlers({ coordinator: { ...coordinatorInstance, - async openExisting() { + async openDefaultWorkspace() { throw new Error("explicit reads must not open selected session") }, }, @@ -286,17 +274,10 @@ describe("JSON-RPC handlers", () => { }) }) - it("does not parse durable session bindings inside the RPC handler module", async () => { - const source = await readFile(new URL("./rpc.ts", import.meta.url), "utf8") - - expect(source).not.toContain("brunch.session_binding") - expect(source).not.toContain("customType") - }) - it("validates explicit session projection against a requested spec id", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-explicit-spec-")) const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) - const workspace = await coordinatorInstance.startOrCreate({ + const workspace = await coordinatorInstance.createSetupSession({ specTitle: "Explicit spec", }) const handlers = createRpcHandlers({ @@ -435,7 +416,7 @@ describe("JSON-RPC handlers", () => { it("returns a product-shaped error for unknown explicit sessions", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-missing-session-")) const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) - await coordinatorInstance.startOrCreate({ specTitle: "Explicit spec" }) + await coordinatorInstance.createSetupSession({ specTitle: "Explicit spec" }) const handlers = createRpcHandlers({ coordinator: coordinatorInstance, cwd, @@ -461,7 +442,7 @@ describe("JSON-RPC handlers", () => { it("returns a product-shaped error for non-linear explicit sessions", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-explicit-branch-")) const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) - const workspace = await coordinatorInstance.startOrCreate({ + const workspace = await coordinatorInstance.createSetupSession({ specTitle: "Explicit branch spec", }) const manager = SessionManager.open(workspace.session.file) diff --git a/src/rpc.ts b/src/rpc.ts index 03c69059..dc74ed45 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -25,14 +25,17 @@ import { type ExplicitSessionProjectionParams, type SessionProjectionTarget, } from "./session-projection-reader.js" -import type { WorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" +import type { + DefaultWorkspaceCoordinator, + WorkspaceSessionState, +} from "./workspace-session-coordinator.js" export interface RpcHandlers { handle(request: unknown): Promise } export function createRpcHandlers(options: { - coordinator: WorkspaceSessionCoordinator + coordinator: DefaultWorkspaceCoordinator cwd: string }): RpcHandlers { return { @@ -47,7 +50,7 @@ export function createRpcHandlers(options: { if (request.params !== undefined) { return createJsonRpcFailure(requestId, -32602, "Invalid params") } - const state = await options.coordinator.openExisting() + const state = await options.coordinator.openDefaultWorkspace() return createJsonRpcSuccess( requestId, workspaceSnapshotFromState(state), @@ -81,7 +84,7 @@ async function handleSessionProjection( requestId: JsonRpcId, rawParams: unknown, options: { - coordinator: WorkspaceSessionCoordinator + coordinator: DefaultWorkspaceCoordinator cwd: string }, loadProjection: (envelope: BrunchSessionEnvelope) => T, @@ -93,7 +96,9 @@ async function handleSessionProjection( const target = params.value ? await resolveExplicitSessionProjectionTarget(options.cwd, params.value) - : await selectedSessionFile(await options.coordinator.openExisting()) + : await selectedSessionFile( + await options.coordinator.openDefaultWorkspace(), + ) if (!target.ok) { return createJsonRpcFailure(requestId, target.code, target.message) } @@ -146,7 +151,7 @@ function parseSessionProjectionParams( } async function selectedSessionFile( - state: Awaited>, + state: WorkspaceSessionState, ): Promise { if (state.status !== "ready") { return { ok: false, code: -32001, message: "No selected Brunch session" } diff --git a/src/web-host.test.ts b/src/web-host.test.ts index a9b8f32b..cbf6e24b 100644 --- a/src/web-host.test.ts +++ b/src/web-host.test.ts @@ -9,7 +9,7 @@ import { SessionManager } from "@earendil-works/pi-coding-agent" import { createWorkspaceSessionCoordinator, - type WorkspaceSessionCoordinator, + type DefaultWorkspaceCoordinator, } from "./workspace-session-coordinator.js" import { startWebHost } from "./web-host.js" @@ -170,7 +170,7 @@ describe("web host", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-web-rpc-")) const workspace = await createWorkspaceSessionCoordinator({ cwd, - }).startOrCreate({ + }).createSetupSession({ specTitle: "Web spec", }) workspace.session.manager.appendMessage({ @@ -216,7 +216,7 @@ describe("web host", () => { it("serves explicit session projection over WebSocket", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-web-rpc-explicit-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const first = await coordinator.startOrCreate({ + const first = await coordinator.createSetupSession({ specTitle: "Explicit web spec", }) first.session.manager.appendMessage({ @@ -232,7 +232,7 @@ describe("web host", () => { role: "user", content: "First answer", }) - await coordinator.createNewSessionForCurrentSpec() + await coordinator.createSetupSessionForCurrentSpec() const host = await startWebHost({ cwd, port: 0, @@ -280,7 +280,7 @@ describe("web host", () => { it("multiplexes two JSON-RPC requests over one WebSocket", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-web-rpc-multiplex-")) - await createWorkspaceSessionCoordinator({ cwd }).startOrCreate({ + await createWorkspaceSessionCoordinator({ cwd }).createSetupSession({ specTitle: "Multiplex spec", }) const host = await startWebHost({ @@ -308,7 +308,7 @@ describe("web host", () => { it("returns a parse error for malformed WebSocket JSON without killing the host", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-web-rpc-malformed-")) - await createWorkspaceSessionCoordinator({ cwd }).startOrCreate({ + await createWorkspaceSessionCoordinator({ cwd }).createSetupSession({ specTitle: "Malformed spec", }) const host = await startWebHost({ @@ -378,7 +378,7 @@ describe("web host", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-web-rpc-branch-")) const workspace = await createWorkspaceSessionCoordinator({ cwd, - }).startOrCreate({ + }).createSetupSession({ specTitle: "Branch spec", }) const manager = SessionManager.open(workspace.session.file) @@ -491,22 +491,10 @@ function openWebSocket(url: string): Promise { }) } -function throwingCoordinator(): WorkspaceSessionCoordinator { +function throwingCoordinator(): DefaultWorkspaceCoordinator { return { - async openExisting() { + async openDefaultWorkspace() { throw new Error("boom") }, - async startOrCreate() { - throw new Error("not used") - }, - async createNewSessionForCurrentSpec() { - throw new Error("not used") - }, - async bindCurrentSpecToSession() { - throw new Error("not used") - }, - async deriveChromeState() { - throw new Error("not used") - }, } } diff --git a/src/web-host.ts b/src/web-host.ts index 8442d57f..1f4ea9fd 100644 --- a/src/web-host.ts +++ b/src/web-host.ts @@ -5,13 +5,13 @@ import { fileURLToPath } from "node:url" import { createRpcHandlers } from "./rpc.js" import { attachWebRpcTransport } from "./web-rpc-transport.js" -import type { WorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" +import type { DefaultWorkspaceCoordinator } from "./workspace-session-coordinator.js" export interface WebHostOptions { cwd: string port?: number hostname?: string - coordinator?: WorkspaceSessionCoordinator + coordinator?: DefaultWorkspaceCoordinator webAssetRoot?: string } diff --git a/src/workspace-session-coordinator.test.ts b/src/workspace-session-coordinator.test.ts index 2df1b026..63fbafcf 100644 --- a/src/workspace-session-coordinator.test.ts +++ b/src/workspace-session-coordinator.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" @@ -23,7 +23,7 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const result = await coordinator.startOrCreate({ + const result = await coordinator.createSetupSession({ specTitle: "Scratch spec", }) @@ -52,8 +52,10 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const first = await coordinator.startOrCreate({ specTitle: "Scratch spec" }) - const second = await coordinator.createNewSessionForCurrentSpec() + const first = await coordinator.createSetupSession({ + specTitle: "Scratch spec", + }) + const second = await coordinator.createSetupSessionForCurrentSpec() expect(second.status).toBe("ready") if (second.status !== "ready") { @@ -108,7 +110,7 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const result = await coordinator.startOrCreate({ + const result = await coordinator.createSetupSession({ specTitle: "Scratch spec", }) const reloaded = SessionManager.open(result.session.file, undefined, cwd) @@ -131,7 +133,7 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const result = await coordinator.startOrCreate({ + const result = await coordinator.createSetupSession({ specTitle: "Scratch spec", }) const reloaded = SessionManager.open(result.session.file, undefined, cwd) @@ -154,7 +156,7 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const result = await coordinator.startOrCreate({ + const result = await coordinator.createSetupSession({ specTitle: "Scratch spec", }) result.session.manager.appendMessage({ @@ -182,14 +184,18 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const result = await coordinator.startOrCreate({ + const result = await coordinator.createSetupSession({ specTitle: "Scratch spec", }) result.session.manager.appendModelChange("test-provider", "test-model") result.session.manager.appendThinkingLevelChange("high") - await coordinator.bindCurrentSpecToSession(result.session.manager) + await coordinator.bindCurrentSpecToReplacementSession( + result.session.manager, + ) result.session.manager.appendMessage({ role: "user", content: "hello" }) - await coordinator.bindCurrentSpecToSession(result.session.manager) + await coordinator.bindCurrentSpecToReplacementSession( + result.session.manager, + ) result.session.manager.appendMessage({ role: "assistant", content: "hi" }) const content = await readFile(result.session.file, "utf8") @@ -212,7 +218,7 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const result = await coordinator.startOrCreate({ + const result = await coordinator.createSetupSession({ specTitle: "Scratch spec", }) result.session.manager.appendMessage({ @@ -236,9 +242,11 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const first = await coordinator.startOrCreate({ specTitle: "Scratch spec" }) + const first = await coordinator.createSetupSession({ + specTitle: "Scratch spec", + }) const replacementFile = first.session.manager.newSession() - await coordinator.bindCurrentSpecToSession(first.session.manager) + await coordinator.bindCurrentSpecToReplacementSession(first.session.manager) expect(replacementFile).toBeDefined() const oracle = await verifyWorkspaceSessionStores({ @@ -260,12 +268,287 @@ describe("WorkspaceSessionCoordinator", () => { ) }) + it("inspects current defaults, bound specs, and sessions without activation writes", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + + const first = await coordinator.createSetupSession({ specTitle: "Alpha" }) + first.session.manager.appendMessage({ role: "user", content: "first" }) + const second = await coordinator.createSetupSession({ + specTitle: "Beta", + createNewSpec: true, + }) + const beforeState = await readFile( + join(cwd, ".brunch", "state.json"), + "utf8", + ) + const beforeFirst = await readFile(first.session.file, "utf8") + const beforeSecond = await readFile(second.session.file, "utf8") + + const inventory = await coordinator.inspectWorkspace() + + expect(inventory.cwd).toBe(cwd) + expect(inventory.needsNewSpec).toBe(false) + expect(inventory.currentSpec).toEqual(second.spec) + expect(inventory.currentSessionFile).toBe(second.session.file) + expect(inventory.specs.map(({ spec }) => spec.title)).toEqual([ + "Alpha", + "Beta", + ]) + expect(inventory.specs[0]?.sessions).toEqual([ + expect.objectContaining({ + id: first.session.id, + file: first.session.file, + specId: first.spec.id, + specTitle: "Alpha", + available: true, + }), + ]) + expect(inventory.specs[1]?.sessions).toEqual([ + expect.objectContaining({ + id: second.session.id, + file: second.session.file, + specId: second.spec.id, + specTitle: "Beta", + available: true, + }), + ]) + expect(inventory.unavailableSessions).toEqual([]) + await expect( + readFile(join(cwd, ".brunch", "state.json"), "utf8"), + ).resolves.toBe(beforeState) + await expect(readFile(first.session.file, "utf8")).resolves.toBe( + beforeFirst, + ) + await expect(readFile(second.session.file, "utf8")).resolves.toBe( + beforeSecond, + ) + }) + + it("inspects an empty workspace without creating session files", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + + const inventory = await coordinator.inspectWorkspace() + + expect(inventory).toMatchObject({ + cwd, + currentSpec: null, + currentSessionFile: null, + needsNewSpec: true, + specs: [], + unavailableSessions: [], + }) + await expect( + readFile(join(cwd, ".brunch", "sessions", "missing.jsonl"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }) + }) + + it("marks unbound or incompatible sessions unavailable during inventory", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + const ready = await coordinator.createSetupSession({ specTitle: "Alpha" }) + const unboundFile = join(cwd, ".brunch", "sessions", "unbound.jsonl") + const mismatchedFile = join(cwd, ".brunch", "sessions", "mismatched.jsonl") + await writeFile( + unboundFile, + `${JSON.stringify({ type: "session", id: "unbound-session", cwd })}\n`, + "utf8", + ) + await writeFile( + mismatchedFile, + `${JSON.stringify({ type: "session", id: "header-session", cwd })}\n${JSON.stringify( + { + type: "custom", + customType: SESSION_BINDING_TYPE, + data: { + schemaVersion: 1, + sessionId: "other-session", + specId: ready.spec.id, + specTitle: ready.spec.title, + }, + }, + )}\n`, + "utf8", + ) + const beforeUnbound = await readFile(unboundFile, "utf8") + const beforeMismatched = await readFile(mismatchedFile, "utf8") + + const inventory = await coordinator.inspectWorkspace() + + expect(inventory.specs).toHaveLength(1) + expect(inventory.specs[0]?.sessions).toHaveLength(1) + expect(inventory.unavailableSessions).toEqual([ + expect.objectContaining({ + file: mismatchedFile, + reason: "incompatible_binding", + }), + expect.objectContaining({ file: unboundFile, reason: "missing_binding" }), + ]) + await expect(readFile(unboundFile, "utf8")).resolves.toBe(beforeUnbound) + await expect(readFile(mismatchedFile, "utf8")).resolves.toBe( + beforeMismatched, + ) + }) + + it("activates explicit open and continue decisions as the current workspace", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + const first = await coordinator.createSetupSession({ specTitle: "Alpha" }) + const second = await coordinator.createSetupSession({ + specTitle: "Beta", + createNewSpec: true, + }) + + const opened = await coordinator.activateWorkspace({ + action: "openSession", + specId: first.spec.id, + sessionFile: first.session.file, + }) + + expect(opened.status).toBe("ready") + if (opened.status !== "ready") { + return + } + expect(opened.spec).toEqual(first.spec) + expect(opened.session.id).toBe(first.session.id) + expect(opened.session.file).toBe(first.session.file) + expect(opened.chrome.spec).toEqual(first.spec) + + const continued = await coordinator.activateWorkspace({ + action: "continue", + specId: second.spec.id, + sessionFile: second.session.file, + }) + + expect(continued.status).toBe("ready") + if (continued.status !== "ready") { + return + } + expect(continued.spec).toEqual(second.spec) + expect(continued.session.id).toBe(second.session.id) + expect( + JSON.parse(await readFile(join(cwd, ".brunch", "state.json"), "utf8")), + ).toMatchObject({ + currentSpec: second.spec, + currentSessionFile: second.session.file, + }) + }) + + it("activates a new session decision as a binding-only session for the selected spec", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + const first = await coordinator.createSetupSession({ specTitle: "Alpha" }) + first.session.manager.appendMessage({ + role: "user", + content: "preserve me", + }) + const beforeFirst = await readFile(first.session.file, "utf8") + + const created = await coordinator.activateWorkspace({ + action: "newSession", + specId: first.spec.id, + }) + + expect(created.status).toBe("ready") + if (created.status !== "ready") { + return + } + expect(created.spec).toEqual(first.spec) + expect(created.session.id).not.toBe(first.session.id) + await expect(readFile(first.session.file, "utf8")).resolves.toBe( + beforeFirst, + ) + const createdContent = await readFile(created.session.file, "utf8") + expect(createdContent).toContain(SESSION_BINDING_TYPE) + expect(createdContent).not.toContain("preserve me") + const oracle = await verifyWorkspaceSessionStores({ + cwd, + expectedSessionCount: 2, + }) + expect(oracle.ok).toBe(true) + }) + + it("activates a new spec decision by creating a bound current session", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + + const created = await coordinator.activateWorkspace({ + action: "newSpec", + title: "Gamma", + }) + + expect(created.status).toBe("ready") + if (created.status !== "ready") { + return + } + expect(created.spec.title).toBe("Gamma") + expect(created.session.id).toMatch(/[\da-f-]+/iu) + const oracle = await verifyWorkspaceSessionStores({ + cwd, + expectedSessionCount: 1, + }) + expect(oracle.ok).toBe(true) + }) + + it("activates cancel without mutating workspace state or session files", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + const ready = await coordinator.createSetupSession({ specTitle: "Alpha" }) + const beforeState = await readFile( + join(cwd, ".brunch", "state.json"), + "utf8", + ) + const beforeSession = await readFile(ready.session.file, "utf8") + + const result = await coordinator.activateWorkspace({ action: "cancel" }) + + expect(result.status).toBe("cancelled") + await expect( + readFile(join(cwd, ".brunch", "state.json"), "utf8"), + ).resolves.toBe(beforeState) + await expect(readFile(ready.session.file, "utf8")).resolves.toBe( + beforeSession, + ) + }) + + it("refuses to activate mismatched or unavailable sessions", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + const ready = await coordinator.createSetupSession({ specTitle: "Alpha" }) + const unavailableFile = join( + cwd, + ".brunch", + "sessions", + "unavailable.jsonl", + ) + await writeFile( + unavailableFile, + `${JSON.stringify({ type: "session", id: "unavailable-session", cwd })}\n`, + "utf8", + ) + + const unavailable = await coordinator.activateWorkspace({ + action: "openSession", + specId: ready.spec.id, + sessionFile: unavailableFile, + }) + const mismatched = await coordinator.activateWorkspace({ + action: "openSession", + specId: "spec-missing", + sessionFile: ready.session.file, + }) + + expect(unavailable.status).toBe("needs_human") + expect(mismatched.status).toBe("needs_human") + }) + it("asks for spec selection when no current spec exists and creation is not allowed", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) await mkdir(join(cwd, ".brunch"), { recursive: true }) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const result = await coordinator.openExisting() + const result = await coordinator.openDefaultWorkspace() expect(result.status).toBe("select_spec") expect(result.chrome.cwd).toBe(cwd) diff --git a/src/workspace-session-coordinator.ts b/src/workspace-session-coordinator.ts index b2a63588..ed848068 100644 --- a/src/workspace-session-coordinator.ts +++ b/src/workspace-session-coordinator.ts @@ -62,21 +62,111 @@ export interface WorkspaceSessionNeedsHumanState { chrome: WorkspaceSessionChromeState } +export interface WorkspaceSessionCancelledState { + status: "cancelled" + cwd: string + chrome: WorkspaceSessionChromeState +} + export type WorkspaceSessionState = WorkspaceSessionReadyState | WorkspaceSessionSelectSpecState | WorkspaceSessionNeedsHumanState -export interface WorkspaceSessionCoordinator { - openExisting(): Promise - startOrCreate(options?: { +export interface WorkspaceContinueDecision { + action: "continue" + specId: string + sessionFile: string +} + +export interface WorkspaceOpenSessionDecision { + action: "openSession" + specId: string + sessionFile: string +} + +export interface WorkspaceNewSessionDecision { + action: "newSession" + specId: string +} + +export interface WorkspaceNewSpecDecision { + action: "newSpec" + title: string +} + +export interface WorkspaceCancelDecision { + action: "cancel" +} + +export type WorkspaceSwitchDecision = WorkspaceContinueDecision | WorkspaceOpenSessionDecision | WorkspaceNewSessionDecision | WorkspaceNewSpecDecision | WorkspaceCancelDecision + +export type WorkspaceActivationState = WorkspaceSessionReadyState | WorkspaceSessionNeedsHumanState | WorkspaceSessionCancelledState + +export interface WorkspaceLaunchSession { + id: string + file: string + specId: string + specTitle: string + name?: string + available: true +} + +export interface WorkspaceLaunchSpec { + spec: WorkspaceSpecState + sessions: WorkspaceLaunchSession[] +} + +export type WorkspaceUnavailableSessionReason = "missing_header" | "missing_binding" | "incompatible_binding" + +export interface WorkspaceUnavailableSession { + file: string + reason: WorkspaceUnavailableSessionReason + available: false +} + +export interface WorkspaceLaunchInventory { + cwd: string + currentSpec: WorkspaceSpecState | null + currentSessionFile: string | null + needsNewSpec: boolean + specs: WorkspaceLaunchSpec[] + unavailableSessions: WorkspaceUnavailableSession[] +} + +export interface WorkspaceSwitchCoordinator { + inspectWorkspace(): Promise + activateWorkspace( + decision: WorkspaceSwitchDecision, + ): Promise +} + +export interface DefaultWorkspaceCoordinator { + openDefaultWorkspace(): Promise +} + +export interface WorkspaceSetupCoordinator { + createSetupSession(options?: { specTitle?: string createNewSpec?: boolean }): Promise - createNewSessionForCurrentSpec(): Promise - bindCurrentSpecToSession( + createSetupSessionForCurrentSpec(): Promise +} + +export interface WorkspaceSessionBoundaryCoordinator { + bindCurrentSpecToReplacementSession( manager: SessionManager, ): Promise - deriveChromeState(): Promise } +export interface WorkspaceDefaultChromeCoordinator { + deriveDefaultChromeState(): Promise +} + +export interface WorkspaceSessionCoordinator + extends WorkspaceSwitchCoordinator, + DefaultWorkspaceCoordinator, + WorkspaceSetupCoordinator, + WorkspaceSessionBoundaryCoordinator, + WorkspaceDefaultChromeCoordinator {} + export function createWorkspaceSessionCoordinator(options?: { cwd?: string }): WorkspaceSessionCoordinator { @@ -91,7 +181,70 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { this.#cwd = cwd } - async openExisting(): Promise { + async inspectWorkspace(): Promise { + return inspectWorkspaceInventory(this.#cwd) + } + + async activateWorkspace( + decision: WorkspaceSwitchDecision, + ): Promise { + if (decision.action === "cancel") { + const state = await readWorkspaceState(this.#cwd) + return { + status: "cancelled", + cwd: this.#cwd, + chrome: chromeState(this.#cwd, state?.currentSpec ?? null), + } + } + + if (decision.action === "newSpec") { + return this.createSetupSession({ + specTitle: decision.title, + createNewSpec: true, + }) + } + + const inventory = await inspectWorkspaceInventory(this.#cwd) + const spec = inventory.specs.find( + (candidate) => candidate.spec.id === decision.specId, + ) + + if (!spec) { + return needsHumanState( + this.#cwd, + inventory.currentSpec, + "Selected spec is not available in this workspace.", + ) + } + + if (decision.action === "newSession") { + const session = await createBoundSession(this.#cwd, spec.spec) + await writeCurrentWorkspaceState(this.#cwd, spec.spec, session.file) + return readyState(this.#cwd, spec.spec, session) + } + + const session = spec.sessions.find( + (candidate) => candidate.file === decision.sessionFile, + ) + if (!session) { + return needsHumanState( + this.#cwd, + inventory.currentSpec, + "Selected session is not available for the selected spec.", + ) + } + + const manager = SessionManager.open( + session.file, + sessionDir(this.#cwd), + this.#cwd, + ) + const opened = bindSessionToSpec(manager, spec.spec) + await writeCurrentWorkspaceState(this.#cwd, spec.spec, opened.file) + return readyState(this.#cwd, spec.spec, opened) + } + + async openDefaultWorkspace(): Promise { const state = await readWorkspaceState(this.#cwd) if (!state) { return { @@ -110,7 +263,7 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { return readyState(this.#cwd, state.currentSpec, session) } - async startOrCreate(options?: { + async createSetupSession(options?: { specTitle?: string createNewSpec?: boolean }): Promise { @@ -125,7 +278,7 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { return readyState(this.#cwd, spec, session) } - async createNewSessionForCurrentSpec(): Promise { + async createSetupSessionForCurrentSpec(): Promise { const state = await readWorkspaceState(this.#cwd) if (!state) { return { @@ -141,7 +294,7 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { return readyState(this.#cwd, state.currentSpec, session) } - async bindCurrentSpecToSession( + async bindCurrentSpecToReplacementSession( manager: SessionManager, ): Promise { const state = await readWorkspaceState(this.#cwd) @@ -154,7 +307,7 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { return readyState(this.#cwd, state.currentSpec, session) } - async deriveChromeState(): Promise { + async deriveDefaultChromeState(): Promise { const state = await readWorkspaceState(this.#cwd) return chromeState(this.#cwd, state?.currentSpec ?? null) } @@ -280,6 +433,96 @@ async function readWorkspaceState( } } +async function inspectWorkspaceInventory( + cwd: string, +): Promise { + const state = await readWorkspaceState(cwd) + const files = await listSessionFiles(cwd) + const specsById = new Map() + const unavailableSessions: WorkspaceUnavailableSession[] = [] + + if (state) { + specsById.set(state.currentSpec.id, { + spec: state.currentSpec, + sessions: [], + }) + } + + for (const file of files) { + const session = await inspectSessionFile(file) + if (session.available) { + const spec = getOrCreateLaunchSpec(specsById, { + id: session.specId, + title: session.specTitle, + }) + spec.sessions.push(session) + } else { + unavailableSessions.push(session) + } + } + + const specs = [...specsById.values()] + .map((spec) => ({ + ...spec, + sessions: spec.sessions.sort((left, right) => + left.file.localeCompare(right.file), + ), + })) + .sort((left, right) => left.spec.title.localeCompare(right.spec.title)) + + return { + cwd, + currentSpec: state?.currentSpec ?? null, + currentSessionFile: state?.currentSessionFile ?? null, + needsNewSpec: specs.length === 0, + specs, + unavailableSessions: unavailableSessions.sort((left, right) => + left.file.localeCompare(right.file), + ), + } +} + +type InspectedSessionFile = WorkspaceLaunchSession | WorkspaceUnavailableSession + +async function inspectSessionFile(file: string): Promise { + const entries = await readJsonl(file) + const header = entries.find(isSessionHeader) + if (!header) { + return { file, reason: "missing_header", available: false } + } + + const bindings = entries.filter(isSessionBindingEntry) + if (bindings.length === 0) { + return { file, reason: "missing_binding", available: false } + } + + const binding = bindings[0]! + if (bindings.length !== 1 || binding.data.sessionId !== header.id) { + return { file, reason: "incompatible_binding", available: false } + } + + return { + id: header.id, + file, + specId: binding.data.specId, + specTitle: binding.data.specTitle, + available: true, + } +} + +function getOrCreateLaunchSpec( + specsById: Map, + spec: WorkspaceSpecState, +): WorkspaceLaunchSpec { + const existing = specsById.get(spec.id) + if (existing) { + return existing + } + const created = { spec, sessions: [] } + specsById.set(spec.id, created) + return created +} + async function writeWorkspaceState( cwd: string, state: WorkspaceStateFile, @@ -314,6 +557,19 @@ function readyState( } } +function needsHumanState( + cwd: string, + spec: WorkspaceSpecState | null, + reason: string, +): WorkspaceSessionNeedsHumanState { + return { + status: "needs_human", + cwd, + reason, + chrome: chromeState(cwd, spec), + } +} + function chromeState( cwd: string, spec: WorkspaceSpecState | null, diff --git a/src/workspace-switcher.test.ts b/src/workspace-switcher.test.ts new file mode 100644 index 00000000..309ed431 --- /dev/null +++ b/src/workspace-switcher.test.ts @@ -0,0 +1,158 @@ +import { readFile } from "node:fs/promises" + +import { visibleWidth } from "@earendil-works/pi-tui" + +import { describe, expect, it } from "vitest" + +import { + buildWorkspaceSwitchOptions, + createWorkspaceSwitchComponent, +} from "./workspace-switcher.js" +import type { WorkspaceLaunchInventory } from "./workspace-session-coordinator.js" + +describe("workspace switcher", () => { + it("builds explicit resume, new-session, open-session, create-spec, and cancel options", () => { + const options = buildWorkspaceSwitchOptions(inventory()) + + expect(options.map((option) => option.kind)).toEqual([ + "continue", + "newSession", + "openSession", + "newSession", + "openSession", + "newSpec", + "cancel", + ]) + expect(options[0]).toMatchObject({ + label: "Continue Alpha", + decision: { + action: "continue", + specId: "spec-alpha", + sessionFile: "/sessions/alpha-current.jsonl", + }, + }) + expect(options.at(-2)).toMatchObject({ + label: "Create spec", + }) + expect(options.at(-2)).not.toHaveProperty("decision") + expect(options.at(-1)).toMatchObject({ + label: "Cancel", + decision: { action: "cancel" }, + }) + }) + + it("selects current resume and existing sessions as typed decisions", () => { + const decisions: unknown[] = [] + const component = createWorkspaceSwitchComponent({ + inventory: inventory(), + onDecision: (decision) => decisions.push(decision), + }) + + component.handleInput("\r") + component.handleInput("\x1B[B") + component.handleInput("\x1B[B") + component.handleInput("\r") + + expect(decisions).toEqual([ + { + action: "continue", + specId: "spec-alpha", + sessionFile: "/sessions/alpha-current.jsonl", + }, + { + action: "openSession", + specId: "spec-alpha", + sessionFile: "/sessions/alpha-older.jsonl", + }, + ]) + }) + + it("returns new-spec decisions from title entry and cancel on escape", () => { + const decisions: unknown[] = [] + const component = createWorkspaceSwitchComponent({ + inventory: inventory(), + onDecision: (decision) => decisions.push(decision), + }) + + for (let index = 0; index < 5; index += 1) { + component.handleInput("\x1B[B") + } + component.handleInput("\r") + for (const char of "Gamma") { + component.handleInput(char) + } + component.handleInput("\r") + const cancelComponent = createWorkspaceSwitchComponent({ + inventory: inventory(), + onDecision: (decision) => decisions.push(decision), + }) + cancelComponent.handleInput("\x1B") + + expect(decisions).toEqual([ + { action: "newSpec", title: "Gamma" }, + { action: "cancel" }, + ]) + }) + + it("keeps rendered lines within the requested width", () => { + const component = createWorkspaceSwitchComponent({ + inventory: inventory(), + onDecision: () => {}, + }) + + expect(component.render(24).every((line) => visibleWidth(line) <= 24)).toBe( + true, + ) + }) + + it("declares pi-tui as a direct dependency", async () => { + const manifest = JSON.parse( + await readFile(new URL("../package.json", import.meta.url), "utf8"), + ) as { dependencies?: Record } + + expect(manifest.dependencies).toHaveProperty("@earendil-works/pi-tui") + }) +}) + +function inventory(): WorkspaceLaunchInventory { + return { + cwd: "/project", + currentSpec: { id: "spec-alpha", title: "Alpha" }, + currentSessionFile: "/sessions/alpha-current.jsonl", + needsNewSpec: false, + specs: [ + { + spec: { id: "spec-alpha", title: "Alpha" }, + sessions: [ + { + id: "session-alpha-current", + file: "/sessions/alpha-current.jsonl", + specId: "spec-alpha", + specTitle: "Alpha", + available: true, + }, + { + id: "session-alpha-older", + file: "/sessions/alpha-older.jsonl", + specId: "spec-alpha", + specTitle: "Alpha", + available: true, + }, + ], + }, + { + spec: { id: "spec-beta", title: "Beta" }, + sessions: [ + { + id: "session-beta", + file: "/sessions/beta.jsonl", + specId: "spec-beta", + specTitle: "Beta", + available: true, + }, + ], + }, + ], + unavailableSessions: [], + } +} diff --git a/src/workspace-switcher.ts b/src/workspace-switcher.ts new file mode 100644 index 00000000..05c44801 --- /dev/null +++ b/src/workspace-switcher.ts @@ -0,0 +1,7 @@ +export { + buildWorkspaceSwitchOptions, + createWorkspaceSwitchComponent, + runWorkspaceSwitchPreflight, + type WorkspaceSwitchComponentOptions, + type WorkspaceSwitchOption, +} from "./workspace-switcher/index.js" diff --git a/src/workspace-switcher/component.ts b/src/workspace-switcher/component.ts new file mode 100644 index 00000000..5fdb7d48 --- /dev/null +++ b/src/workspace-switcher/component.ts @@ -0,0 +1,126 @@ +import { + Key, + matchesKey, + truncateToWidth, + type Component, +} from "@earendil-works/pi-tui" + +import type { + WorkspaceLaunchInventory, + WorkspaceSwitchDecision, +} from "../workspace-session-coordinator.js" +import { + buildWorkspaceSwitchOptions, + type WorkspaceSwitchOption, +} from "./model.js" + +export interface WorkspaceSwitchComponentOptions { + inventory: WorkspaceLaunchInventory + onDecision: (decision: WorkspaceSwitchDecision) => void +} + +export function createWorkspaceSwitchComponent( + options: WorkspaceSwitchComponentOptions, +): Component { + return new WorkspaceSwitchComponent(options) +} + +class WorkspaceSwitchComponent implements Component { + #options: WorkspaceSwitchOption[] + #onDecision: (decision: WorkspaceSwitchDecision) => void + #selectedIndex = 0 + #mode: "select" | "newSpecTitle" = "select" + #title = "" + + constructor(options: WorkspaceSwitchComponentOptions) { + this.#options = buildWorkspaceSwitchOptions(options.inventory) + this.#onDecision = options.onDecision + } + + handleInput(data: string): void { + if (this.#mode === "newSpecTitle") { + this.#handleTitleInput(data) + return + } + + if (matchesKey(data, Key.up)) { + this.#selectedIndex = Math.max(0, this.#selectedIndex - 1) + return + } + if (matchesKey(data, Key.down)) { + this.#selectedIndex = Math.min( + this.#options.length - 1, + this.#selectedIndex + 1, + ) + return + } + if (matchesKey(data, Key.escape)) { + this.#onDecision({ action: "cancel" }) + return + } + if (matchesKey(data, Key.enter)) { + this.#selectCurrentOption() + } + } + + render(width: number): string[] { + const lines = ["Brunch workspace", "Choose how to start this session:", ""] + + if (this.#mode === "newSpecTitle") { + lines.push("New spec title:", `> ${this.#title}`) + lines.push("enter create • esc cancel") + return lines.map((line) => truncateToWidth(line, width)) + } + + for (const [index, option] of this.#options.entries()) { + const prefix = index === this.#selectedIndex ? "› " : " " + lines.push(`${prefix}${option.label}`) + lines.push(` ${option.description}`) + } + lines.push("", "↑↓ navigate • enter select • esc cancel") + return lines.map((line) => truncateToWidth(line, width)) + } + + invalidate(): void {} + + #selectCurrentOption(): void { + const option = this.#options[this.#selectedIndex] + if (!option) { + return + } + if (option.kind === "newSpec") { + this.#mode = "newSpecTitle" + this.#title = "" + return + } + if (option.decision) { + this.#onDecision(option.decision) + } + } + + #handleTitleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.#mode = "select" + this.#title = "" + return + } + if (matchesKey(data, Key.backspace)) { + this.#title = this.#title.slice(0, -1) + return + } + if (matchesKey(data, Key.enter)) { + const title = this.#title.trim() + if (title.length > 0) { + this.#onDecision({ action: "newSpec", title }) + } + return + } + if (isPrintableInput(data)) { + this.#title += data + } + } +} + +function isPrintableInput(data: string): boolean { + return data.length === 1 && data >= " " && data !== "\u007f" +} diff --git a/src/workspace-switcher/index.ts b/src/workspace-switcher/index.ts new file mode 100644 index 00000000..241e8e6e --- /dev/null +++ b/src/workspace-switcher/index.ts @@ -0,0 +1,9 @@ +export { + createWorkspaceSwitchComponent, + type WorkspaceSwitchComponentOptions, +} from "./component.js" +export { + buildWorkspaceSwitchOptions, + type WorkspaceSwitchOption, +} from "./model.js" +export { runWorkspaceSwitchPreflight } from "./preflight.js" diff --git a/src/workspace-switcher/model.ts b/src/workspace-switcher/model.ts new file mode 100644 index 00000000..d7dc7a31 --- /dev/null +++ b/src/workspace-switcher/model.ts @@ -0,0 +1,104 @@ +import type { + WorkspaceLaunchInventory, + WorkspaceLaunchSession, + WorkspaceSwitchDecision, +} from "../workspace-session-coordinator.js" + +export interface WorkspaceSwitchOption { + id: string + label: string + description: string + kind: "continue" | "openSession" | "newSession" | "newSpec" | "cancel" + decision?: WorkspaceSwitchDecision +} + +export function buildWorkspaceSwitchOptions( + inventory: WorkspaceLaunchInventory, +): WorkspaceSwitchOption[] { + const options: WorkspaceSwitchOption[] = [] + const currentSession = findCurrentSession(inventory) + + if (currentSession && inventory.currentSpec) { + options.push({ + id: `continue:${currentSession.file}`, + label: `Continue ${inventory.currentSpec.title}`, + description: sessionDescription( + currentSession, + "Resume selected session", + ), + kind: "continue", + decision: { + action: "continue", + specId: inventory.currentSpec.id, + sessionFile: currentSession.file, + }, + }) + } + + for (const { spec, sessions } of inventory.specs) { + options.push({ + id: `new-session:${spec.id}`, + label: `Start new session in ${spec.title}`, + description: "Create a binding-only session before Pi starts", + kind: "newSession", + decision: { action: "newSession", specId: spec.id }, + }) + + for (const session of sessions) { + if (session.file === currentSession?.file) { + continue + } + options.push({ + id: `open:${session.file}`, + label: `Open ${spec.title}`, + description: sessionDescription(session, "Open existing session"), + kind: "openSession", + decision: { + action: "openSession", + specId: spec.id, + sessionFile: session.file, + }, + }) + } + } + + options.push({ + id: "new-spec", + label: "Create spec", + description: "Name a new specification workspace", + kind: "newSpec", + }) + options.push({ + id: "cancel", + label: "Cancel", + description: "Exit without opening a Brunch session", + kind: "cancel", + decision: { action: "cancel" }, + }) + + return options +} + +function findCurrentSession( + inventory: WorkspaceLaunchInventory, +): WorkspaceLaunchSession | undefined { + if (!inventory.currentSessionFile) { + return undefined + } + for (const spec of inventory.specs) { + const session = spec.sessions.find( + (candidate) => candidate.file === inventory.currentSessionFile, + ) + if (session) { + return session + } + } + return undefined +} + +function sessionDescription( + session: WorkspaceLaunchSession, + prefix: string, +): string { + return `${prefix} · ${session.id}` +} diff --git a/src/workspace-switcher/preflight.ts b/src/workspace-switcher/preflight.ts new file mode 100644 index 00000000..919cf84c --- /dev/null +++ b/src/workspace-switcher/preflight.ts @@ -0,0 +1,29 @@ +import { ProcessTerminal, TUI } from "@earendil-works/pi-tui" + +import type { + WorkspaceLaunchInventory, + WorkspaceSwitchDecision, +} from "../workspace-session-coordinator.js" +import { createWorkspaceSwitchComponent } from "./component.js" + +export async function runWorkspaceSwitchPreflight( + inventory: WorkspaceLaunchInventory, +): Promise { + const terminal = new ProcessTerminal() + const tui = new TUI(terminal) + + return await new Promise((resolve) => { + const finish = (decision: WorkspaceSwitchDecision) => { + tui.stop() + resolve(decision) + } + const component = createWorkspaceSwitchComponent({ + inventory, + onDecision: finish, + }) + tui.addChild(component) + tui.setFocus(component) + terminal.clearScreen() + tui.start() + }) +}