From bbbde6544ce0780f722c0b61cc1c8c9e7b41fb8c Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Mon, 5 Oct 2026 20:11:04 -0400 Subject: [PATCH 01/11] docs: update Phase 5 spec and implementation plan for permission panel UX refinements --- ...-03-phase-05-pi-dcp-permission-panel-ux.md | 477 +++++++++--------- ...10-03-pi-dcp-improvement-roadmap-design.md | 76 ++- 2 files changed, 308 insertions(+), 245 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md b/docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md index 3a8165c..596f757 100644 --- a/docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md +++ b/docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md @@ -2,9 +2,9 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add per-call compression approval and a Pi-native status/action panel while retaining every existing command and safe headless behavior. +**Goal:** Add per-call compression approval and one Pi-native status/action panel while retaining every existing command and safe headless behavior. -**Architecture:** Extend permission and persistence first, then make tool authorization consume that state. Build the panel from a pure view model and action executor; a thin Pi TUI component handles only rendering and keyboard selection. Existing command functions remain the single mutation path for manual mode, sweep, and block activation. +**Architecture:** Authorize ask calls inside the registered execute function before compression timing or mutation. Build the panel from a pure view model and command-backed action executor sharing the existing command policy guard. A thin Pi TUI component handles rendering, scrolling, selection, and action completion; its controller refreshes data and reconciles permitted actions. **Tech Stack:** Node.js, TypeScript 7, Pi 1.0 extension and TUI APIs, TypeBox 1.3, Vitest 5, Biome 2, pnpm @@ -12,349 +12,374 @@ ## Global Constraints -- Execute this phase after `2026-10-03-phase-04-pi-dcp-lifecycle-integration.md` and start from v0.9.0. +- Execute after Phase 4, starting from v0.9.0; prepare v0.10.0 without publishing. - Permission is exactly `allow | ask | deny`; the default remains `allow`. -- `ask` confirms every compression call and fails closed when no interactive UI exists. -- Existing `dcp:*` commands remain supported and use the same domain mutations as panel actions. -- Preserve Phase 4's inactive-tool selection, run-boundary prompt snapshots, and restored-loadout precedence. Allow/ask do not implicitly activate `compress`; guidance additionally requires an active tool. -- State-change callbacks have type `(ctx: ExtensionCommandContext) => void`; commands and panel actions pass their current context for immediate reconciliation. -- `@earendil-works/pi-tui` is host-provided: exact `"*"` peer, compatible development dependency, never a runtime dependency. +- Ask supports TUI and RPC dialogs; print/JSON and unavailable dialog UI fail closed. +- Confirm inside execute for every ask call, including direct execution; never reuse approvals. +- Existing `dcp:*` commands remain supported and use the same domain mutations and pipeline policy guard as panel actions. +- Preserve inactive-tool selection, run-boundary prompt snapshots, sequential compression execution, and restored-loadout precedence. Allow/ask do not implicitly activate `compress`; guidance additionally requires an active tool. +- State-change callbacks remain `(ctx: ExtensionCommandContext) => void`; commands and permitted panel actions pass their current context. +- Snapshot v2 writers retain canonical padded references and block shape; readers and lifetime aggregation accept historical v1 unchanged. +- The single custom panel requires `ctx.mode === "tui"`, independently of RPC's `hasUI`. +- `@earendil-works/pi-tui` is host-provided: exact `"*"` peer and `^1.0.1` development dependency, never a runtime dependency; retain its locked v1.0.1 resolution. +- Pi is the API authority; OpenCode DCP is behavioral inspiration only. Do not copy its AGPL source, adapters, or host permission machinery. + +## Readiness Review — 2026-10-05 + +The earlier plan required revision: Pi emits execution-start before authorization, RPC has dialog UI +but cannot render custom terminal components, panel actions would bypass command-registration guards, +and `manualCommand(state, "")` reports status rather than toggling it. + +Reviewed baseline: clean v0.9.0 at `fba7462`, after the Phase 4 merge. `pnpm check` passed formatting, +warning-free lint, typecheck, and 710 tests across 55 files. Schema comparison and package dry-run +passed. The benchmark ran successfully with `node --import tsx scripts/benchmark.ts`; the normal tsx +CLI was blocked by sandbox IPC restrictions. These are baseline results, not verification of Phase 5. + +References: Pi `1b094148b91d737fb398bf1591604de58ec169e1` (v1.0.2) and OpenCode DCP +`f8232fde1e63c2251687e4d9634bd53ce11568cb` (v3.2.0). Installed Pi v1.0.1 types support the required +mode, dialog, component, and event-ordering contracts. Pi's extension types, agent-loop ordering, RPC +UI implementation, and question-component example are the API references. OpenCode's compression +pipeline and panel availability are behavioral references; its separate views are outside this phase. ## Review Focus -- A rejected `ask` call must not start compression timing or mutate/persist block state; Task 2 checks the blocked lifecycle. -- Restoring a v1 snapshot must preserve allow/deny, while malformed v2 permission values must reject the snapshot; Task 1 covers both versions. -- A headless/RPC call in `ask` mode must block rather than silently allow; Task 2 adds this boundary. -- A panel action against a block that disappeared after the model was built must return a readable message and leave state valid; Task 3 adds the stale-row case. -- Lifetime-stat loading or narrow rendering failure must not remove command access or strand the custom UI; Task 4 tests fallback and close paths. +- Pending/rejected approval must create no compression timing or durable changes despite Pi's early start event; Task 2 reproduces the real ordering. +- RPC confirmations must work while RPC custom UI is never called; Tasks 2 and 4 test modes independently of `hasUI`. +- Confirmation abort/failure or policy changes during the wait must prevent compression; Task 2 checks these boundaries and direct execute. +- Disabled pipelines and stale/non-reactivatable block rows must not bypass command policy or corrupt state; Task 3 checks guard parity and block eligibility. +- Lifetime failure, rendering failure, long block lists, and undefined custom results must retain a usable close path; Task 4 checks fallbacks and scrolling. --- -### Task 1: Add `ask` permission and version-2 persistence +### Task 1: Add ask permission and compatible v2 persistence **Files:** -- Modify: `src/config-schema.ts` -- Modify: `src/state/types.ts` -- Modify: `src/state/persistence.ts` -- Modify: `src/commands/permission.ts` -- Test: `tests/config.test.ts` -- Test: `tests/persistence.test.ts` -- Test: `tests/commands-permission.test.ts` +- Modify: `src/config-schema.ts`, `src/state/types.ts`, `src/state/persistence.ts` +- Modify: `src/commands/permission.ts`, `src/commands/register.ts` +- Test: `tests/config.test.ts`, `tests/commands-permission.test.ts`, `tests/persistence.test.ts` +- Test: `tests/index.test.ts`, `tests/commands-lifetime.test.ts`, `tests/session-analysis.test.ts` **Interfaces:** -- Consumes: existing allow/deny configuration and version-1 snapshots. -- Produces: `export type CompressPermission = "allow" | "ask" | "deny"`, `DcpSnapshotV2`, and a permission command cycling allow -> ask -> deny -> allow. +- Produces: `CompressPermission = "allow" | "ask" | "deny"`, `DcpSnapshotV2`, and `DcpSnapshot = DcpSnapshotV1 | DcpSnapshotV2`. +- `serializeDcpSnapshot` returns `DcpSnapshotV2 | undefined`; `parseDcpSnapshot` returns `DcpSnapshot | undefined`. Preserve existing arguments and restoration behavior. +- `permissionCommand(state: SessionState, defaultPermission: CompressPermission = "allow"): string` cycles from `state.compressPermission ?? defaultPermission`. -- [ ] **Step 1: Add failing configuration and cycle tests** +- [ ] **Step 1: Add failing configuration and command tests** -Assert `compress.permission: "ask"` loads without warnings and defaults remain `"allow"`. Update -permission-command tests to assert the exact sequence `allow -> ask -> deny -> allow`, including an -undefined session value beginning from the effective `allow` default. +Assert ask loads without warnings, the built-in default is allow, and cycling follows +`allow -> ask -> deny -> allow`. Preserve the exact `Compress permission: ` response. +For undefined state, test the standalone default and configured ask/deny fallbacks: -- [ ] **Step 2: Add failing v1/v2 persistence tests** - -Keep literal v1 allow and deny fixtures and assert both parse and restore. Add a v2 fixture with -`compressPermission: "ask"` and assert round-trip preservation. Assert version 2 with any other -permission is rejected and version 1 with `ask` is rejected as invalid historical data. +```ts +expect(permissionCommand(createSessionState(), "deny")).toBe("Compress permission: allow"); +``` -- [ ] **Step 3: Run focused tests and verify `ask` is unsupported** +- [ ] **Step 2: Add failing persistence-consumer tests** -Run: `pnpm vitest run tests/config.test.ts tests/commands-permission.test.ts tests/persistence.test.ts` +Retain literal v1 allow/deny fixtures and reject v1 ask. Round-trip all three v2 permissions and +reject invalid v2 permissions and unknown versions. Update new-writer assertions to v2 while +retaining v1 reader fixtures. Test mixed v1/v2 lifetime aggregation, selecting each owner's newest +snapshot without double counting. Keep session-analysis and branch restoration behavior unchanged. -Expected: FAIL on the new enum, cycle, and v2 snapshot cases. +- [ ] **Step 3: Verify the new tests fail** -- [ ] **Step 4: Introduce the shared permission type** +Run: `pnpm vitest run tests/config.test.ts tests/commands-permission.test.ts tests/persistence.test.ts tests/index.test.ts tests/commands-lifetime.test.ts tests/session-analysis.test.ts` -Export `CompressPermission` from `src/state/types.ts` and use it in `SessionState` and config-facing -type annotations. Add `"ask"` to the TypeBox union without changing its default. +Expected: new ask/v2 cases fail against the existing implementation. -- [ ] **Step 5: Implement snapshot version 2 with a v1 reader** +- [ ] **Step 4: Implement types, v2 serialization, and version-aware reading** -Define `DcpSnapshotV2` with `version: 2` and the three-value permission. Change serialization to -write v2. Parse v1 with its historical allow/deny union and v2 with the new union, returning a -shared snapshot type that restoration can consume. Do not mutate canonical message IDs or block -shape. +Keep `DcpSnapshotV1` and its historical permission union intact. Define v2 by reusing the existing +fields with only version and permission changed; do not duplicate block definitions. Update the +lifetime snapshot map to accept the union. Preserve parser subentry validation, restoration, +owner/statistics semantics, and fingerprint behavior. New serialization preserves ask. -- [ ] **Step 6: Implement permission cycling** +- [ ] **Step 5: Implement cycling and configured fallback** -Make `permissionCommand(state)` advance through the exact three-value sequence and retain the -existing human-readable `Compress permission: ` response. +Update command registration to pass `config.compress.permission`. Keep the existing current-context +callback and pipeline guard; update the command description to advertise allow/ask/deny. -- [ ] **Step 7: Run configuration, command, and persistence tests** +- [ ] **Step 6: Run the Step 3 command and verify all cases pass** -Run: `pnpm vitest run tests/config.test.ts tests/commands-permission.test.ts tests/persistence.test.ts tests/session-analysis.test.ts` +Expected: both reader versions, v2 writers, configured fallbacks, and existing consumers pass. -Expected: PASS for both snapshot versions and all permission transitions. +- [ ] **Step 7: Commit the permission/persistence changes** -- [ ] **Step 8: Commit permission persistence** +Stage only this task's changed files. Suggested message: `feat: add ask compression permission`. -```bash -git add src/config-schema.ts src/state/types.ts src/state/persistence.ts src/commands/permission.ts tests/config.test.ts tests/persistence.test.ts tests/commands-permission.test.ts tests/session-analysis.test.ts -git commit -m "feat: add ask compression permission" -``` - -### Task 2: Confirm `ask` calls and reconcile tool exposure +### Task 2: Authorize ask inside execute and correct timing **Files:** - Create: `src/compress/permission.ts` -- Modify: `src/capabilities.ts` -- Modify: `src/index.ts` -- Test: `tests/compress-permission.test.ts` -- Test: `tests/capabilities.test.ts` -- Test: `tests/index.test.ts` -- Test: `tests/integration.test.ts` +- Modify: `src/index.ts`, `tests/extension-harness.ts` +- Test: `tests/compress-permission.test.ts`, `tests/capabilities.test.ts`, `tests/index.test.ts` +- Test: `tests/integration.test.ts`, `tests/compression-timing.test.ts`, `tests/pi-contract.test.ts`, `tests/sdk-contract.test.ts` **Interfaces:** -- Consumes: `CompressPermission`, Pi `tool_call` input, and `ctx.ui.confirm`. -- Produces: `describeCompressionRequest(input: Record): { topic: string; targetCount: number; targetLabel: "range" | "target" }` in `src/compress/permission.ts` and per-call allow/block decisions. - -- [ ] **Step 1: Extend capability tests for `ask`** +- Consumes: Task 1 permission types, `CompressConfig["mode"]`, Pi `ExtensionContext`, and the execute call's abort signal. +- Produces: `describeCompressionRequest(mode: CompressConfig["mode"], input: Record): { topic: string; targetCount: number; targetLabel: "range" | "target" }`. +- Produces: `requestCompressionApproval(mode: CompressConfig["mode"], input: Record, ctx: ExtensionContext, signal?: AbortSignal): Promise`; undefined means approved, otherwise the string is the error reason. -Assert `ask` leaves `pipelineEnabled` and `compressionEnabled` true when no other policy reason -suppresses them. With an active `compress` tool it includes the DCP prompt section and permits -nudges. With an inactive tool it preserves that selection and omits guidance. Only `deny` adds a -permission suppression reason; global/model/sub-agent suppression continues to apply. +- [ ] **Step 1: Add failing description and dialog tests** -- [ ] **Step 2: Add failing confirmation tests** +Assert topic fallback `Untitled compression`, title `Allow DCP compression?`, and message inclusion +of the topic and `2 ranges` or `1 target`. Range counting accepts records with string +`startId`/`endId`/`summary`; message counting accepts string `messageId`/`summary`. Count malformed or +absent arrays as zero valid items, choosing the array by configured mode. Whitespace-only topics use +the fallback. Test singular/plural labels, acceptance, rejection, cancellation, and thrown confirmation. -In `tests/compress-permission.test.ts`, for range input with two `content` ranges, assert the dialog title is `Allow DCP compression?` and -the message includes the topic and `2 ranges`. For message mode with one target, assert `1 target`. -Test approval returns no block result, rejection returns -`{ block: true, reason: "Compression was not approved" }`, and no UI returns -`{ block: true, reason: "Compression requires interactive approval" }` without calling confirm. +- [ ] **Step 2: Add failing mode and lifecycle tests** -- [ ] **Step 3: Add the blocked-lifecycle assertion** +Extend the typed harness with configurable `mode`, observable confirm/custom mocks, and an execute +signal/call ID. Test TUI and RPC with `hasUI: true`, and print/JSON or `hasUI: false` without confirm. +Reproduce `tool_execution_start -> tool_call -> execute -> tool_execution_end`: deferred approval +must start no timing; rejected calls create no blocks, statistic changes, or DCP entries. Assert +approval delay is excluded from successful block durations. Test direct execute, two successive ask +calls, abort during confirmation, and policy suppression introduced while confirmation is pending. +Exercise the public SDK execution path offline without real model requests; preserve Phase 4 loadout +contract tests. Update timing tests that previously treated the early start event as compression work. -After a rejected call, assert no compression timing entry, block, statistics change, or persisted -custom entry is created. Keep the existing defensive deny assertion. +- [ ] **Step 3: Verify approval and timing tests fail** -- [ ] **Step 4: Run capability and lifecycle tests** +Run: `pnpm vitest run tests/compress-permission.test.ts tests/capabilities.test.ts tests/index.test.ts tests/integration.test.ts tests/compression-timing.test.ts tests/pi-contract.test.ts tests/sdk-contract.test.ts` -Run: `pnpm vitest run tests/capabilities.test.ts tests/index.test.ts tests/integration.test.ts` +Expected: ask authorization and post-approval timing assertions fail. -Expected: FAIL because `ask` is not yet authorized or confirmed. +- [ ] **Step 4: Implement confirmation at the execution boundary** -- [ ] **Step 5: Implement request description and confirmation** +Make `executeCompressTool` asynchronous. Keep policy guards in both `tool_call` and execute; the hook +must not confirm. For effective ask permission, await the helper before any timing or compression +mutation. Require both a TUI/RPC mode and dialog-capable UI; pass `_signal ?? ctx.signal` to confirm. +Return an error tool result (`isError: true`) with `Compression requires interactive approval` when +UI is unavailable, or `Compression was not approved` for false/cancelled/aborted/failed confirmation. +Recheck the abort signal and capabilities after the wait, returning the existing policy error when +suppressed. Do not introduce approval caches or tokens. -In `src/compress/permission.ts`, extract a string topic when present, otherwise use -`Untitled compression`; count valid array items from `content` or `targets`, otherwise zero. In the `tool_call` handler, block deny first, then for -ask require `ctx.hasUI` and await `ctx.ui.confirm`. Do not cache approval across calls. +- [ ] **Step 5: Move compression timing to actual work** -- [ ] **Step 6: Reconcile runtime permission changes immediately** +Remove the compression start-time mutation from `tool_execution_start`; remove that handler if it +has no remaining responsibility. Set the start time immediately before `handleCompress`. Keep +execution-end timing cleanup unconditional even if permission/model policy changed after work began. +Ask and allow share existing capabilities: only deny contributes the permission suppression reason. -Treat allow and ask as compression-enabled in capabilities; deny remains suppressed. Ensure the -permission command invokes the state-change callback with its current command context, reconciling -active tools immediately and the prompt on the next run. Preserve the pre-suppression active state -within a branch and Pi's selected loadout across restoration as established in Phase 4. Retain the -defensive capability guard in the registered execute function. +- [ ] **Step 6: Run authorization and compression regressions** -- [ ] **Step 7: Run focused and complete compression tests** +Run the Step 3 command, then: +`pnpm vitest run tests/compress-range.test.ts tests/compress-message.test.ts tests/benchmark.test.ts` -Run: `pnpm vitest run tests/compress-permission.test.ts tests/capabilities.test.ts tests/index.test.ts tests/integration.test.ts tests/compression-timing.test.ts tests/compress-range.test.ts tests/compress-message.test.ts` +Expected: every ask call confirms once, no denied-call residue, approval wait excluded, existing +loadout/guidance rules and benchmark gates preserved. -Expected: PASS; approval occurs for every ask call and blocked calls leave no timing/state residue. +- [ ] **Step 7: Commit authorization and timing changes** -- [ ] **Step 8: Commit ask authorization** +Stage only this task's changed files. Suggested message: `feat: confirm compression before execution`. -```bash -git add src/compress/permission.ts src/capabilities.ts src/index.ts tests/compress-permission.test.ts tests/capabilities.test.ts tests/index.test.ts tests/integration.test.ts -git commit -m "feat: confirm compression calls in ask mode" -``` - -### Task 3: Build the panel view model and action executor +### Task 3: Build the panel model and guarded command-backed actions **Files:** - Create: `src/ui/panel-model.ts` -- Modify: `src/utils/context-limits.ts` -- Modify: `src/commands/lifetime.ts` -- Test: `tests/panel-model.test.ts` -- Test: `tests/context-limits.test.ts` -- Test: `tests/commands-lifetime.test.ts` +- Modify: `src/capabilities.ts`, `src/commands/register.ts`, `src/utils/context-limits.ts`, `src/state/persistence.ts` +- Test: `tests/panel-model.test.ts`, `tests/capabilities.test.ts`, `tests/commands-register.test.ts` +- Test: `tests/context-limits.test.ts`, `tests/commands-lifetime.test.ts` **Interfaces:** -- Consumes: state, config, current model/context usage, resolved limits, and structured lifetime statistics. -- Produces: `resolveContextLimits(...)`, `DcpPanelModel`, `DcpPanelAction`, `buildDcpPanelModel(...)`, and `applyDcpPanelAction(action, state, config): string`. - -- [ ] **Step 1: Add failing resolved-limit tests** +- Produces: `getDcpPipelineDisabledMessage(capabilities: DcpCapabilities): string | undefined` in capabilities; undefined means pipeline-eligible, otherwise preserve existing command messages and reason precedence. +- Produces: `ResolvedContextLimits { min: number | undefined; max: number | undefined }` and `resolveContextLimits(config: { compress: CompressConfig }, state: SessionState, contextUsage: ContextUsage | undefined): ResolvedContextLimits`. +- Export `LifetimeStats` from persistence and use it as `loadAllSessionStats`' existing aggregate return type; preserve the loader and lifetime-command behavior. +- Define `DcpPanelAction` as `toggle-manual | cycle-permission | sweep | toggle-block` (with numeric `blockId`) `| close`, using a `type` discriminant. +- `DcpPanelModelInput` contains state/config, current model provider/id/contextWindow or undefined, context usage or undefined, lifetime stats or undefined, and `compressToolActive: boolean`. +- `buildDcpPanelModel(input: DcpPanelModelInput): DcpPanelModel` produces formatted status/statistic fields and structured numeric-sorted block rows with availability reasons and actions. Stable row IDs are action types or `block:`. +- `applyDcpPanelAction(action: DcpPanelAction, state: SessionState, config: DcpConfig, capabilities: DcpCapabilities): { permitted: boolean; message?: string }` contains no Pi UI calls. Close returns `{ permitted: false }`; policy rejection returns false plus its message; permitted commands return true plus their command response, including stale-row responses. -Export `resolveContextLimits(config, state, contextUsage)` and assert it returns absolute min/max -values after model override, global absolute, and percentage resolution. Assert unavailable context -windows yield `undefined` only for percentage limits; existing over-limit decisions must delegate -to the same result. +- [ ] **Step 1: Add failing limit and model tests** -- [ ] **Step 2: Add failing panel-model tests** +Assert shared resolution preserves per-model override, global absolute, and legacy percentage +precedence. Percentage limits with no usable window are undefined; absolute limits still resolve. +Existing over-limit decisions must use the shared result. Assert all planned panel fields, explicit +unavailable values, numeric block ordering, active/user-deactivated/otherwise-inactive distinctions, +and tool/policy availability. Test current model identity/window overriding stale cached display +inputs without mutating state; do not fabricate current usage from missing data. -Build a state with active and user-deactivated blocks. Assert the model contains model key, usage, -resolved limits, compression/manual/permission modes, session statistics, structured lifetime -totals, and deterministic block rows sorted by block ID. Assert unavailable usage/model/lifetime -data renders explicit `unavailable` values rather than throwing. +- [ ] **Step 2: Add failing action and guard-parity tests** -- [ ] **Step 3: Add failing action-executor tests** +Test manual toggle from both states, permission cycling with configured fallback, sweep, block +deactivation/reactivation, and exact stale message `Block not found.`. Non-user-deactivated +inactive blocks must not become reactivatable. Assert global/model/sub-agent suppression rejects +all mutations with the same messages as direct commands. Deny permission alone permits panel +mutations when pipeline-eligible. Recheck policy against current context after building a model. -Assert actions: +- [ ] **Step 3: Verify model and guard tests fail** -- toggle manual mode through `manualCommand`; -- cycle permission through `permissionCommand`; -- sweep through `sweepCommand`; -- deactivate an active block through `decompressCommand`; -- reactivate a user-deactivated block through `recompressCommand`; -- return `Block not found.` for a stale block row. +Run: `pnpm vitest run tests/panel-model.test.ts tests/capabilities.test.ts tests/commands-register.test.ts tests/context-limits.test.ts tests/commands-lifetime.test.ts` -- [ ] **Step 4: Run model and command tests** +Expected: missing shared helpers/model and guard-parity cases fail. -Run: `pnpm vitest run tests/panel-model.test.ts tests/context-limits.test.ts tests/commands-lifetime.test.ts` +- [ ] **Step 4: Implement shared policy and data resolution** -Expected: FAIL because structured panel data and exported limit resolution do not exist. +Delegate registration's existing pipeline guard to the shared message helper. Keep informational +commands available and compression's additional permission/active-tool checks intact. Export the +limit resolver and delegate `isContextOverLimits` to it without changing precedence. Build a +read-only current-model view for panel resolution rather than updating session state from rendering. -- [ ] **Step 5: Expose shared resolved limits and lifetime types** +- [ ] **Step 5: Implement model and domain action delegation** -Add `ResolvedContextLimits { min: number | undefined; max: number | undefined }`; make -`isContextOverLimits` consume `resolveContextLimits`. Export the existing lifetime aggregate as -`LifetimeStats` so the panel consumes data rather than parsing command text. +Manual toggle passes `state.manualMode === "active" ? "off" : "on"` to `manualCommand`. Permission +cycling passes configured fallback; sweep and block controls call their existing commands. Reject +policy-disabled actions before domain mutation. Only active blocks offer deactivation and only +user-deactivated blocks offer reactivation; display other blocks without a toggle. Re-read block +state when acting, preserving existing command eligibility/error responses. Close does nothing. -- [ ] **Step 6: Implement the pure panel model and actions** +- [ ] **Step 6: Run model, guard, and command regressions** -Define action variants `toggle-manual`, `cycle-permission`, `sweep`, `toggle-block` with `blockId`, -and `close`. The action executor delegates to existing command functions and contains no Pi UI -calls. The model contains already-formatted scalar labels plus structured block rows for rendering. +Run the Step 3 command, then: +`pnpm vitest run tests/commands-manual.test.ts tests/commands-permission.test.ts tests/commands-sweep.test.ts tests/commands-decompress.test.ts tests/commands-recompress.test.ts` -- [ ] **Step 7: Run panel-model and regression tests** +Expected: shared resolution and policy pass without duplicating mutation logic or changing existing +no-argument manual-command status behavior. -Run: `pnpm vitest run tests/panel-model.test.ts tests/context-limits.test.ts tests/commands-lifetime.test.ts tests/commands-manual.test.ts tests/commands-permission.test.ts tests/commands-sweep.test.ts tests/commands-decompress.test.ts` +- [ ] **Step 7: Commit panel domain behavior** -Expected: PASS with no duplicated mutation logic. +Stage only this task's changed files. Suggested message: `feat: add guarded DCP panel actions`. -- [ ] **Step 8: Commit panel domain behavior** - -```bash -git add src/ui/panel-model.ts src/utils/context-limits.ts src/commands/lifetime.ts tests/panel-model.test.ts tests/context-limits.test.ts tests/commands-lifetime.test.ts -git commit -m "feat: add DCP panel view model and actions" -``` - -### Task 4: Add the Pi custom panel and `dcp` command +### Task 4: Add the TUI component, controller, and dcp command **Files:** - Create: `src/ui/panel.ts` -- Modify: `src/commands/register.ts` -- Modify: `package.json` -- Modify: `pnpm-lock.yaml` -- Modify: `tests/package-metadata.test.ts` -- Test: `tests/panel.test.ts` -- Test: `tests/commands-register.test.ts` +- Modify: `src/commands/register.ts`, `src/commands/help.ts`, `package.json`, `pnpm-lock.yaml` +- Test: `tests/panel.test.ts`, `tests/commands-register.test.ts`, `tests/commands-help.test.ts`, `tests/package-metadata.test.ts` **Interfaces:** -- Consumes: `DcpPanelModel`, `DcpPanelAction`, Pi `Theme`, `ctx.ui.custom`, and pi-tui key/width helpers. -- Produces: `DcpPanelComponent` and `openDcpPanel(state, config, ctx, onStateChange): Promise`, registered as command `dcp`, with `onStateChange: (ctx: ExtensionCommandContext) => void`. - -- [ ] **Step 1: Add the host-provided pi-tui metadata test** +- Consumes: Task 3 model/actions, Pi Theme/custom UI, and pi-tui Component/key/width helpers. +- `DcpPanelComponent` implements `render(width)`, `invalidate()`, and `handleInput(data)`; constructor inputs are the model and options containing theme, `getHeight(): number`, `requestRender(): void`, `onAction(action): void`, optional initial row ID, and optional last action message. +- `openDcpPanel(state: SessionState, config: DcpConfig, ctx: ExtensionCommandContext, onStateChange: (ctx: ExtensionCommandContext) => void, getActiveTools: () => string[]): Promise` supplies current policy/tool/model inputs and registers through the existing command-registration function. -Append `@earendil-works/pi-tui` to `hostProvidedPackages`. Assert it is absent from dependencies, -present as peer `"*"`, and present in devDependencies. Run the test and verify it fails before -changing package metadata. +- [ ] **Step 1: Add failing metadata, rendering, and key tests** -- [ ] **Step 2: Add failing rendering and key tests** +Assert pi-tui is absent from runtime dependencies, has peer `"*"`, development specifier `^1.0.1`, and +locked v1.0.1. At 60/80/120 columns and 24 rows, assert visible widths and total height remain bounded +with long topics, wide characters, and dozens of blocks. Selection stays visible while scrolling +and after resize. Arrow keys and `j`/`k` navigate selectable actions; Enter completes the selected +action; Escape/`q` returns close exactly once. Assert selection changes request rendering and an +injected rendering failure produces a bounded unthemed fallback with functional close keys. -Instantiate `DcpPanelComponent` with a fixed model and action callback. At widths 60, 80, and 120, -assert every rendered line's visible width is within the requested width and the title/status/actions -remain present. Assert arrows and `j`/`k` change selection, Enter returns the selected action, and -Escape/`q` returns `close` exactly once. +- [ ] **Step 2: Add failing controller and command tests** -- [ ] **Step 3: Add failing command-controller tests** +Assert all eleven commands are registered and help lists `dcp`. In RPC mode with `hasUI: true`, and +print/JSON or unavailable TUI, assert exactly one error notification and no lifetime/custom call. +Mock permission action then close: assert one callback with current ctx, updated model, retained +selection, and a displayed command response. Verify close and policy rejection do not call the +callback. Recheck policy before actions even if the displayed model allowed them. Lifetime rejection +opens with unavailable totals; loading occurs once per invocation. Undefined custom results close +without looping; custom rejection notifies once and existing commands remain accessible. Keep +missing-directory zero totals compatible with the current loader. -Assert `dcp` is registered. In TUI mode, mock `ctx.ui.custom` to return a permission action then -close; assert state changes, `onStateChange(ctx)` runs, and the next model reflects the new value. In -non-TUI mode, assert one error notification and no custom UI call. Make lifetime loading reject and -assert the panel still opens with unavailable totals and existing commands remain registered. +- [ ] **Step 3: Verify UI and metadata tests fail** -- [ ] **Step 4: Run UI and metadata tests** +Run: `pnpm vitest run tests/panel.test.ts tests/commands-register.test.ts tests/commands-help.test.ts tests/package-metadata.test.ts` -Run: `pnpm vitest run tests/panel.test.ts tests/commands-register.test.ts tests/package-metadata.test.ts` +Expected: missing panel/command and peer metadata assertions fail. -Expected: FAIL because the panel, command, and pi-tui peer are absent. +- [ ] **Step 4: Add the host-provided development peer** -- [ ] **Step 5: Add pi-tui as a host-provided package** +Add devDependency `"@earendil-works/pi-tui": "^1.0.1"` and peer `"*"`. Refresh the lockfile using +`pnpm install --lockfile-only`; ensure the new root importer uses existing v1.0.1 without unrelated +resolution changes. Materialize dependencies as needed for UI tests without upgrading other packages. -Add `"@earendil-works/pi-tui": "^1.0.0"` to devDependencies and exact `"*"` to peerDependencies. -Run `pnpm install --lockfile-only` and verify the root importer changes without unrelated resolution -updates. +- [ ] **Step 5: Implement the bounded terminal component** -- [ ] **Step 6: Implement the terminal component** +Use Pi Theme plus pi-tui `matchesKey`, `Key`, `truncateToWidth`, and `visibleWidth`. Keep the compact +status/statistics header and close-key footer visible, allocating remaining height to a scrolling +core-action/block list. Clamp navigation at the first/last selectable row. Flatten multiline row +labels, bound every line, and keep selected actions visible. Use `tui.terminal.rows` for height and +`tui.requestRender()` for selection updates. Track selection/completion only; rendering formatting +failure returns a minimal unthemed closable view. The component performs no domain mutations. -Use `Theme` from pi-coding-agent and `matchesKey`/`truncateToWidth` from pi-tui. Render a compact -header, usage/threshold/status rows, selectable core actions, block rows, lifetime totals, and a -keyboard footer. Track only selection and completion state; return actions through the callback. +- [ ] **Step 6: Implement controller, registration, and help** -- [ ] **Step 7: Implement the panel controller and command** +Require `ctx.mode === "tui" && ctx.hasUI` before any lifetime/custom work; otherwise notify +`DCP panel requires interactive TUI mode. Use dcp:help for available commands.` once and return. +Load lifetime totals once from the same parent directory as `dcp:lifetime`, using undefined on +rejection. Build from current ctx and `getActiveTools()`, await one component action, recompute +capabilities, and call the Task 3 executor. For permitted actions call `onStateChange(ctx)`; retain +its message and rebuild. Restore the acted-on row ID, falling back to the first selectable action +if it vanished. Close/undefined results return; custom rejection notifies once and returns. Register +`dcp` and add help without removing or renaming any existing command. -In TUI mode, load lifetime totals, build a fresh model, await one component action, execute it, call -`onStateChange(ctx)`, and reopen until `close`. Catch lifetime-read failures as unavailable data. Register -`dcp` without removing or renaming any `dcp:*` command. +- [ ] **Step 7: Run UI, command, metadata, and type checks** -- [ ] **Step 8: Run UI, command, package, and type checks** +Run the Step 3 command, then `pnpm typecheck && pnpm lint`. -Run: `pnpm vitest run tests/panel.test.ts tests/commands-register.test.ts tests/package-metadata.test.ts && pnpm typecheck && pnpm lint` +Expected: UI completion/scrolling/fallback, guarded mutations, command preservation, and dependency +metadata pass; zero lint warnings. -Expected: PASS with zero warnings and pi-tui present only as a host-provided development peer. +- [ ] **Step 8: Commit the custom panel** -- [ ] **Step 9: Commit the custom panel** +Stage only this task's changed files. Suggested message: `feat: add interactive DCP status panel`. -```bash -git add src/ui/panel.ts src/commands/register.ts package.json pnpm-lock.yaml tests/package-metadata.test.ts tests/panel.test.ts tests/commands-register.test.ts -git commit -m "feat: add interactive DCP status panel" -``` - -### Task 5: Finalize and verify the v0.10.0 release +### Task 5: Document and verify v0.10.0 **Files:** -- Modify: `dcp.schema.json` -- Modify: `README.md` -- Modify: `CHANGELOG.md` -- Modify: `package.json` +- Modify: `dcp.schema.json`, `README.md`, `CHANGELOG.md`, `package.json` **Interfaces:** -- Consumes: ask permission, v2 persistence, and panel behavior from Tasks 1-4. -- Produces: documented and packaged v0.10.0 UX with generated configuration schema. +- Consumes: Tasks 1–4; produces documented, verified v0.10.0 release metadata without publishing. -- [ ] **Step 1: Document permissions and panel controls** +- [ ] **Step 1: Document permissions and panel behavior** -Document allow/ask/deny semantics, headless ask denial, the permission cycle, `dcp` panel fields and -keys, and unchanged direct commands. Add v0.10.0 release notes and set the package version to -`0.10.0`. +Document allow/ask/deny, TUI/RPC confirmation, print/JSON denial, rejection/abort behavior, permission +cycling, and backward-readable snapshots. Document the single panel's fields, keys, scrolling, +policy-disabled actions, and direct command fallback. Set version `0.10.0` and add release notes. -- [ ] **Step 2: Regenerate schema and run complete verification** +- [ ] **Step 2: Regenerate and compare the schema** -Run: `pnpm generate:schema && pnpm check && pnpm run pack:dry-run && pnpm benchmark` +Run: `pnpm generate:schema`, then `node --import tsx scripts/generate-schema.ts | diff -u dcp.schema.json -`. -Expected: exit 0, zero lint warnings, all compact-marker benchmark gates pass, and the schema -contains the three-value permission union. +Expected: generated schema contains allow/ask/deny and comparison exits 0 with no differences. +If the tsx CLI is sandbox-blocked, generate with +`node --import tsx scripts/generate-schema.ts > dcp.schema.json` instead. -- [ ] **Step 3: Manually verify permission flows in Pi** +- [ ] **Step 3: Run complete automated verification** -In interactive Pi, test one approved and one rejected ask call, switch to deny and confirm the tool -and prompt disappear, then return to allow and confirm prior active-tool state is restored. Resume, -fork, and navigate the session tree; confirm v2 permission and blocks restore. +Run: `pnpm check && pnpm run pack:dry-run && pnpm benchmark`. -- [ ] **Step 4: Manually verify the panel** +Expected: exit 0, zero lint warnings, all tests including compact-marker token gates pass, and the +package includes the new UI/permission modules. Token gates live in `tests/benchmark.test.ts` and +run under `pnpm check`; the benchmark command reports measurements. If its launcher is blocked by +sandbox IPC, run `node --import tsx scripts/benchmark.ts` instead and record that substitution. -Open `dcp` at approximately 60, 80, and 120 columns. Verify arrows, `j`/`k`, Enter, Escape, `q`, -manual toggle, permission cycle, sweep, and block deactivate/reactivate. Confirm direct `dcp:*` -commands still work after closing the panel. +- [ ] **Step 4: Manually verify permission and restoration flows** -- [ ] **Step 5: Review and commit v0.10.0 metadata** +In Pi TUI and a dialog-capable RPC client, approve one ask call and reject another; cancel/abort a +pending call and confirm no compression occurred. Verify print/JSON ask fails closed. Switch to +deny, then allow; tool exposure and prompt guidance must obey Phase 4 selection rules, including +an intentionally inactive tool. Resume, fork, navigate the tree, and reload with v1/v2 snapshots; +confirm permissions/blocks restore and branch loadouts remain authoritative. -Run: `git diff --check && git status --short` +- [ ] **Step 5: Manually verify panel usability** -```bash -git add dcp.schema.json README.md CHANGELOG.md package.json -git commit -m "chore: prepare pi-dcp 0.10.0" -``` +At 60/80/120 columns and approximately 24 rows, exercise navigation, Enter, Escape/q, every action, +long block-list scrolling, and retained selection. Confirm disabled-policy actions cannot mutate, +non-user-deactivated blocks cannot be reactivated, and direct commands work after closing. If a +manual environment is unavailable, record the unverified cases rather than claiming them complete. + +- [ ] **Step 6: Review and commit release metadata** + +Run: `git diff --check && git status --short`; verify scope and all acceptance results. +Stage only this task's changed files. Suggested message: `chore: prepare pi-dcp 0.10.0`. + +## Implementation Handoff + +The previous Phase 5 plan is superseded by this revision. The agreed design uses execute-boundary +approval, TUI/RPC dialogs, and one compact scrolling panel. Implementation has not started. +Review this saved plan before execution and choose native or subagent-driven execution if no method +has already been selected. Publishing remains a separate decision. diff --git a/docs/superpowers/specs/2026-10-03-pi-dcp-improvement-roadmap-design.md b/docs/superpowers/specs/2026-10-03-pi-dcp-improvement-roadmap-design.md index a79d367..bd934fc 100644 --- a/docs/superpowers/specs/2026-10-03-pi-dcp-improvement-roadmap-design.md +++ b/docs/superpowers/specs/2026-10-03-pi-dcp-improvement-roadmap-design.md @@ -2,10 +2,11 @@ **Date:** 2026-10-03 -**Revised:** 2026-10-04 +**Revised:** 2026-10-05 -**Status:** Approved in chat; reordered from simplest to most complex; Phases 2 and 4 clarified -after implementation-readiness reviews, including Phase 4 tool-guidance and restoration decisions +**Status:** Approved in chat; reordered from simplest to most complex; Phases 2, 4, and 5 clarified +after implementation-readiness reviews, including Phase 4 restoration and Phase 5 execution-boundary +approval, RPC dialogs, and single-panel decisions ## Purpose @@ -200,30 +201,67 @@ names, marker syntax, and pruning/protection/benchmark behavior. Compression permission becomes `allow | ask | deny`: - `allow` exposes and executes `compress` normally. -- `ask` exposes it and requests Pi confirmation for every model-issued call. The dialog includes - the topic and number of targets/ranges. Rejection blocks the call. A non-interactive session - fails closed with an explanatory reason. +- `ask` exposes it and requests Pi confirmation inside the registered execute function for every + call, including direct execution. The dialog includes the topic and number of targets/ranges. + TUI and RPC support confirmation; print/JSON and unavailable dialog UI fail closed. Rejection, + cancellation, abort, or confirmation failure returns an error tool result without compression. - `deny` hides the tool, removes its prompt section and nudges, and blocks defensive direct calls. Allow and ask preserve Phase 4's selection rules: neither activates a user-deselected tool, and instructions/nudges require an active tool. Permission changes and panel actions reconcile through the current command context; restored Pi loadouts remain authoritative across session/tree changes. -The permission command cycles `allow -> ask -> deny -> allow`. Snapshot version 2 persists the new -union; its parser continues accepting version 1 unchanged. - -The new `dcp` command opens a Pi custom panel. It displays the active model, context usage, resolved -thresholds, compression and manual modes, permission, compression blocks, session savings, and -lifetime totals. Selectable actions toggle manual mode, cycle permission, sweep eligible outputs, -and deactivate or reactivate a selected block. Existing commands remain stable shortcuts and the -fallback when no TUI is available. +Pi emits `tool_execution_start` before argument validation and `tool_call`. Compression timing +therefore starts inside execute, after approval and a fresh capability check, immediately before +compression work. The tool-call hook retains policy suppression, but does not request approval. +Confirmation receives the call's abort signal; approval is never reused between calls. Waiting for +approval or rejecting a call must not create compression timing, blocks, statistics, or DCP state +entries. Execution-end cleanup remains unconditional for calls that actually started compression. + +The permission command cycles `allow -> ask -> deny -> allow`. An undefined session override uses +the configured permission, with `allow` as the standalone command's default. Snapshot version 2 +persists the new union; parsing, restoration, and lifetime aggregation continue accepting version 1 +with its historical allow/deny validation. New serialization writes version 2 without changing +canonical references or block shape. + +The new `dcp` command opens one Pi custom panel only when `ctx.mode === "tui"`; `hasUI` is true in +RPC too and cannot guard terminal components. Outside TUI, notify once and return without loading +lifetime data or calling custom UI. It displays the current model, context usage, resolved +thresholds, compression and manual modes, permission, policy/tool availability, compression blocks, +session savings, and lifetime totals. Selectable actions toggle manual mode, cycle permission, +sweep eligible outputs, and deactivate or reactivate a selected block. Existing commands remain +stable shortcuts and the fallback when no TUI is available. + +Panel mutations and direct commands share the same pipeline policy guard and existing domain +commands. Recheck current capabilities before every action; globally disabled, model-disabled, and +disallowed sub-agent sessions retain informational access but cannot mutate state. Permission denial +alone does not disable sweep, manual mode, or block controls. Manual toggle passes explicit `on` or +`off` to the existing command; its no-argument status behavior remains unchanged. Blocks are sorted +numerically and distinguish active, user-deactivated, and otherwise inactive states. Only +user-deactivated blocks offer reactivation. A stale block row returns a readable not-found message. The panel uses arrow keys or `j`/`k`, Enter, and Escape or `q`. Rendering is usable at 60, 80, and -120 columns. A pure view-model builder and action executor isolate behavior from the terminal -component; the component only renders, tracks selection, and returns an action. - -`@earendil-works/pi-tui` is added as a host-provided `"*"` peer and a development dependency, not a -runtime dependency. +120 columns and 24 rows, with a bounded scrolling action/block list that keeps selection visible. +Selection survives action-driven rebuilds by action/block identity; each component completes only +once. A pure view-model builder and action executor isolate behavior from the terminal component; +the component only renders, tracks selection, and returns an action. The controller supplies current +model/context data, rebuilds after actions, shows their results, and reconciles through +`onStateChange(ctx)` after permitted actions. Closing or rejecting a policy-disabled action does not +invoke that callback. + +Load lifetime totals once per panel opening. Missing data is explicit; rejected lifetime loading +must not prevent the panel from opening. Preserve the current lifetime loader's zero-total behavior +for empty, missing, or inaccessible directories. Undefined custom-UI results close the controller; +custom-UI rejection produces one notification. Rendering failure uses a minimal bounded view with +working close keys, so command access is not stranded. + +`@earendil-works/pi-tui` is added as a host-provided `"*"` peer and a `^1.0.1` development dependency, +retaining its existing v1.0.1 lockfile resolution, not as a runtime dependency. + +The Phase 5 review used the same Pi and OpenCode DCP revisions as Phase 4. Pi's mode, confirmation, +component, and event-ordering APIs are supported by the installed v1.0.1 development packages. +OpenCode supplies behavioral references for permission before compression work and panel availability; +its source, adapters, separate context/statistics screens, and host permission machinery are not copied. ## Acceptance From 7aef4a7c68515121e08084ea51ed01035cfda545 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Mon, 5 Oct 2026 20:19:04 -0400 Subject: [PATCH 02/11] feat: add ask compression permission --- src/commands/permission.ts | 17 +++- src/commands/register.ts | 20 ++-- src/config-schema.ts | 6 +- src/state/persistence.ts | 48 ++++++---- src/state/types.ts | 36 ++++++- tests/commands-permission.test.ts | 48 +++++++--- tests/config.test.ts | 24 ++++- tests/index.test.ts | 14 +-- tests/persistence.test.ts | 151 ++++++++++++++++++++++++++++-- tests/pi-contract.test.ts | 1 + 10 files changed, 298 insertions(+), 67 deletions(-) diff --git a/src/commands/permission.ts b/src/commands/permission.ts index 121b22d..5e7abb7 100644 --- a/src/commands/permission.ts +++ b/src/commands/permission.ts @@ -1,7 +1,16 @@ -import type { SessionState } from "../state/types.ts"; +import type { CompressPermission, SessionState } from "../state/types.ts"; -export function permissionCommand(state: SessionState): string { - const current = state.compressPermission ?? "allow"; - state.compressPermission = current === "allow" ? "deny" : "allow"; +const CYCLE: Record = { + allow: "ask", + ask: "deny", + deny: "allow", +}; + +export function permissionCommand( + state: SessionState, + defaultPermission: CompressPermission = "allow", +): string { + const current = state.compressPermission ?? defaultPermission; + state.compressPermission = CYCLE[current]; return `Compress permission: ${state.compressPermission}`; } diff --git a/src/commands/register.ts b/src/commands/register.ts index 6b2c4cf..9651451 100644 --- a/src/commands/register.ts +++ b/src/commands/register.ts @@ -1,18 +1,18 @@ import * as path from "node:path"; import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; -import type { SessionState } from "../state/types.ts"; -import { isDcpEnabledForModel, type DcpConfig } from "../config.ts"; import { getDcpCapabilities } from "../capabilities.ts"; -import { helpCommand } from "./help.ts"; +import { type DcpConfig, isDcpEnabledForModel } from "../config.ts"; +import type { SessionState } from "../state/types.ts"; +import { compressCommand } from "./compress.ts"; import { contextCommand } from "./context.ts"; -import { statsCommand } from "./stats.ts"; -import { sweepCommand } from "./sweep.ts"; -import { manualCommand } from "./manual.ts"; import { decompressCommand } from "./decompress.ts"; -import { recompressCommand } from "./recompress.ts"; +import { helpCommand } from "./help.ts"; import { lifetimeCommand } from "./lifetime.ts"; +import { manualCommand } from "./manual.ts"; import { permissionCommand } from "./permission.ts"; -import { compressCommand } from "./compress.ts"; +import { recompressCommand } from "./recompress.ts"; +import { statsCommand } from "./stats.ts"; +import { sweepCommand } from "./sweep.ts"; export function registerDcpCommands( pi: ExtensionAPI, @@ -131,10 +131,10 @@ export function registerDcpCommands( }); pi.registerCommand("dcp:permission", { - description: "Toggle compress permission (allow/deny)", + description: "Cycle compress permission (allow/ask/deny)", handler: async (_args, ctx) => { if (rejectWhenDisabled(ctx)) return; - const message = permissionCommand(state); + const message = permissionCommand(state, config.compress.permission); onStateChange(ctx); ctx.ui.notify(message, "info"); }, diff --git a/src/config-schema.ts b/src/config-schema.ts index af13620..a89839f 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -1,4 +1,4 @@ -import { Type, type Static } from "typebox"; +import { type Static, Type } from "typebox"; export const DeduplicationConfigSchema = Type.Object( { @@ -60,9 +60,9 @@ export const CompressConfigSchema = Type.Object( description: "Compression mode: range (compress spans) or message (compress individual messages)", }), - permission: Type.Union([Type.Literal("allow"), Type.Literal("deny")], { + permission: Type.Union([Type.Literal("allow"), Type.Literal("ask"), Type.Literal("deny")], { default: "allow", - description: "Whether the compress tool is allowed to run", + description: "Compression permission: allow, ask for approval, or deny", }), showCompression: Type.Boolean({ default: false, diff --git a/src/state/persistence.ts b/src/state/persistence.ts index 8742327..521fb05 100644 --- a/src/state/persistence.ts +++ b/src/state/persistence.ts @@ -1,9 +1,16 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as readline from "node:readline"; -import type { CompressionBlock, DcpSnapshotBlockV1, DcpSnapshotV1, SessionState } from "./types.ts"; -import { resetSessionState } from "./state.ts"; import { isCanonicalMessageRef, parseMessageRef } from "../utils/message-ids.ts"; +import { resetSessionState } from "./state.ts"; +import type { + CompressionBlock, + CompressPermission, + DcpSnapshot, + DcpSnapshotBlockV1, + DcpSnapshotV2, + SessionState, +} from "./types.ts"; function sorted(values: Iterable, compare: (a: T, b: T) => number): T[] { return [...values].sort(compare); @@ -35,7 +42,7 @@ function serializeBlock(block: CompressionBlock): DcpSnapshotBlockV1 | undefined export function serializeDcpSnapshot( state: SessionState, ownerSessionId = state.sessionId, -): DcpSnapshotV1 | undefined { +): DcpSnapshotV2 | undefined { if (!ownerSessionId) return undefined; const blocks = sorted( @@ -46,10 +53,10 @@ export function serializeDcpSnapshot( ); return { - version: 1, + version: 2, ownerSessionId, manualMode: state.manualMode === "active" ? "active" : false, - compressPermission: state.compressPermission === "deny" ? "deny" : "allow", + compressPermission: state.compressPermission ?? "allow", stats: { ...state.stats }, lastCompaction: state.lastCompaction, pruneTools: sorted(state.prune.tools, ([a], [b]) => a.localeCompare(b)), @@ -188,17 +195,23 @@ function parseBlock(value: unknown): DcpSnapshotBlockV1 | undefined { export function parseDcpSnapshot( value: unknown, warn: SnapshotWarning = () => {}, -): DcpSnapshotV1 | undefined { +): DcpSnapshot | undefined { + if (!isRecord(value) || !isString(value.ownerSessionId) || !value.ownerSessionId) { + return undefined; + } + const version = value.version; + if (version !== 1 && version !== 2) return undefined; + if (value.manualMode !== false && value.manualMode !== "active") return undefined; if ( - !isRecord(value) || - value.version !== 1 || - !isString(value.ownerSessionId) || - !value.ownerSessionId + value.compressPermission !== "allow" && + value.compressPermission !== "ask" && + value.compressPermission !== "deny" ) { return undefined; } - if (value.manualMode !== false && value.manualMode !== "active") return undefined; - if (value.compressPermission !== "allow" && value.compressPermission !== "deny") return undefined; + // Version 1 predates the ask permission; only allow|deny are historical values. + if (version === 1 && value.compressPermission === "ask") return undefined; + const permission: CompressPermission = value.compressPermission; if ( !isRecord(value.stats) || !isNonNegativeInteger(value.stats.pruneTokenCounter) || @@ -241,11 +254,10 @@ export function parseDcpSnapshot( ); } - return { - version: 1, + const parsed: Omit = { ownerSessionId: value.ownerSessionId, manualMode: value.manualMode, - compressPermission: value.compressPermission, + compressPermission: permission, stats: { pruneTokenCounter: value.stats.pruneTokenCounter as number, totalPruneTokens: value.stats.totalPruneTokens as number, @@ -267,6 +279,10 @@ export function parseDcpSnapshot( iterationAnchors: parseStrings(value.nudges.iterationAnchors), }, }; + + return version === 1 + ? { version: 1, ...parsed, compressPermission: parsed.compressPermission as "allow" | "deny" } + : { version: 2, ...parsed }; } function restoreBlock(block: DcpSnapshotBlockV1): CompressionBlock { @@ -336,7 +352,7 @@ export async function loadAllSessionStats(parentDir: string): Promise<{ totalMessagesCompressed: number; sessionCount: number; }> { - const snapshots = new Map(); + const snapshots = new Map(); async function scanFile(file: string): Promise { let hasHeader = false; diff --git a/src/state/types.ts b/src/state/types.ts index 8f1c4d6..ec08224 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -5,13 +5,19 @@ * All Maps/Sets are used in-memory; persistence serializes to plain objects. */ +/** + * Compression permission shared by configuration, session override, and snapshots. + * `allow` exposes and runs compression, `ask` confirms per call, `deny` suppresses it. + */ +export type CompressPermission = "allow" | "ask" | "deny"; + export interface SessionState { /** Current session identifier (set on session_start). */ sessionId: string | null; /** Manual mode: false = auto, "active" = manual. */ manualMode: false | "active"; /** Effective compress permission for this session. */ - compressPermission: "allow" | "deny" | undefined; + compressPermission: CompressPermission | undefined; /** Pruning state (tools + message compression). */ prune: Prune; /** Nudge anchor tracking. */ @@ -177,6 +183,34 @@ export interface DcpSnapshotV1 { }; } +/** + * Version 2 durable state. Reuses the version-1 field and block shape and only + * widens the permission union to include `ask`; readers continue to accept v1. + */ +export interface DcpSnapshotV2 { + version: 2; + ownerSessionId: string; + manualMode: false | "active"; + compressPermission: CompressPermission; + stats: SessionStats; + lastCompaction: number; + pruneTools: Array<[string, number]>; + blocks: DcpSnapshotBlockV1[]; + nextBlockId: number; + nextRunId: number; + messageIds: { + byRawId: Array<[string, string]>; + nextRefIndex: number; + }; + nudges: { + contextLimitAnchors: string[]; + turnAnchors: string[]; + iterationAnchors: string[]; + }; +} + +export type DcpSnapshot = DcpSnapshotV1 | DcpSnapshotV2; + export interface MessageIdState { /** Content-derived key -> ref string (e.g. "user:1719100000000:0" -> "m0001"). */ byRawId: Map; diff --git a/tests/commands-permission.test.ts b/tests/commands-permission.test.ts index 2f5b021..ef94c09 100644 --- a/tests/commands-permission.test.ts +++ b/tests/commands-permission.test.ts @@ -1,40 +1,58 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { permissionCommand } from "../src/commands/permission.ts"; import { createSessionState } from "../src/state/state.ts"; describe("permissionCommand", () => { - it("toggles from allow to deny", () => { + it("cycles allow -> ask", () => { const state = createSessionState(); state.compressPermission = "allow"; - const result = permissionCommand(state); + expect(permissionCommand(state)).toBe("Compress permission: ask"); + expect(state.compressPermission).toBe("ask"); + }); + + it("cycles ask -> deny", () => { + const state = createSessionState(); + state.compressPermission = "ask"; + + expect(permissionCommand(state)).toBe("Compress permission: deny"); expect(state.compressPermission).toBe("deny"); - expect(result).toContain("deny"); }); - it("toggles from deny to allow", () => { + it("cycles deny -> allow", () => { const state = createSessionState(); state.compressPermission = "deny"; - const result = permissionCommand(state); + expect(permissionCommand(state)).toBe("Compress permission: allow"); expect(state.compressPermission).toBe("allow"); - expect(result).toContain("allow"); }); - it("treats undefined as allow (toggles to deny)", () => { + it("treats undefined as the standalone allow default (allow -> ask)", () => { + const state = createSessionState(); + + expect(permissionCommand(state)).toBe("Compress permission: ask"); + expect(state.compressPermission).toBe("ask"); + }); + + it("uses a configured ask fallback for an undefined session override (ask -> deny)", () => { const state = createSessionState(); - // compressPermission starts undefined from createSessionState() - const result = permissionCommand(state); + expect(permissionCommand(state, "ask")).toBe("Compress permission: deny"); expect(state.compressPermission).toBe("deny"); - expect(result).toContain("deny"); }); - it("returns human-readable status string", () => { + it("uses a configured deny fallback for an undefined session override (deny -> allow)", () => { const state = createSessionState(); - state.compressPermission = "allow"; - const result = permissionCommand(state); - expect(result).toBe("Compress permission: deny"); + expect(permissionCommand(state, "deny")).toBe("Compress permission: allow"); + expect(state.compressPermission).toBe("allow"); + }); + + it("prefers the session override over the configured fallback", () => { + const state = createSessionState(); + state.compressPermission = "ask"; + + expect(permissionCommand(state, "deny")).toBe("Compress permission: deny"); + expect(state.compressPermission).toBe("deny"); }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 22107ac..2345af3 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,16 +1,16 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - loadConfig, - isDcpEnabledForModel, BASE_PROTECTED_TOOLS, DEFAULT_CONFIG, + isDcpEnabledForModel, + loadConfig, } from "../src/config.ts"; -import { isContextOverLimits } from "../src/utils/context-limits.ts"; -import { createSessionState } from "../src/state/state.ts"; import { CompressConfigSchema, DcpConfigSchema } from "../src/config-schema.ts"; +import { createSessionState } from "../src/state/state.ts"; +import { isContextOverLimits } from "../src/utils/context-limits.ts"; describe("config loading", () => { let tempDir: string; @@ -258,6 +258,20 @@ describe("config loading", () => { expect(config.compress.showCompression).toBe(true); }); + it("accepts ask compression permission without warnings", () => { + const configPath = path.join(tempDir, "dcp.json"); + fs.writeFileSync(configPath, JSON.stringify({ compress: { permission: "ask" } })); + const { config, warnings } = loadConfig(configPath); + expect(config.compress.permission).toBe("ask"); + expect(warnings).toEqual([]); + }); + + it("defaults compression permission to allow", () => { + const configPath = path.join(tempDir, "missing.json"); + const { config } = loadConfig(configPath); + expect(config.compress.permission).toBe("allow"); + }); + it("parses turnProtection", () => { const configPath = path.join(tempDir, "dcp.json"); fs.writeFileSync( diff --git a/tests/index.test.ts b/tests/index.test.ts index 7d529f0..a11f630 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,15 +1,15 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { afterEach, describe, expect, it, vi } from "vitest"; import createExtension from "../src/index.ts"; -import * as subagentResults from "../src/subagents/subagent-results.ts"; -import { createSessionState } from "../src/state/state.ts"; -import { restoreDcpSnapshot, serializeDcpSnapshot } from "../src/state/persistence.ts"; -import { assignMessageRefs } from "../src/messages/inject.ts"; -import { makeAssistantMessage, getMessageText, requireMessage, requireDefined } from "./helpers.ts"; import { Logger } from "../src/logger.ts"; +import { assignMessageRefs } from "../src/messages/inject.ts"; +import { restoreDcpSnapshot, serializeDcpSnapshot } from "../src/state/persistence.ts"; +import { createSessionState } from "../src/state/state.ts"; +import * as subagentResults from "../src/subagents/subagent-results.ts"; +import { getMessageText, makeAssistantMessage, requireDefined, requireMessage } from "./helpers.ts"; const agentDir = vi.hoisted(() => `/tmp/dcp-index-test-${Date.now()}-${Math.random()}`); const disabledModel = { provider: "openai-codex", id: "gpt-5.6-sol" }; @@ -815,7 +815,7 @@ describe("dcp extension", () => { }; await permission.handler("", ctx); expect(entries).toHaveLength(2); - expect(entries[1]?.data).toMatchObject({ compressPermission: "deny" }); + expect(entries[1]?.data).toMatchObject({ compressPermission: "ask" }); }); it("reconstructs branch refs through session_tree without ID-only snapshots", async () => { diff --git a/tests/persistence.test.ts b/tests/persistence.test.ts index c9f56b0..9612ba5 100644 --- a/tests/persistence.test.ts +++ b/tests/persistence.test.ts @@ -1,13 +1,13 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { loadAllSessionStats } from "../src/state/persistence.ts"; +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { assignMessageRefs, injectMessageIds } from "../src/messages/inject.ts"; import * as persistence from "../src/state/persistence.ts"; +import { loadAllSessionStats } from "../src/state/persistence.ts"; import { createSessionState } from "../src/state/state.ts"; import { getMessageText, requireDefined } from "./helpers.ts"; -import { assignMessageRefs, injectMessageIds } from "../src/messages/inject.ts"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; function sessionHeader(id: string) { return { @@ -94,6 +94,64 @@ describe("persistence", () => { expect(result.sessionCount).toBe(2); }); + it("aggregates mixed v1 and v2 snapshots by each owner's newest without double counting", async () => { + const dir = path.join(tempDir, "mixed"); + fs.mkdirSync(dir, { recursive: true }); + const v1 = (owner: string, total: number) => ({ + version: 1, + ownerSessionId: owner, + manualMode: false, + compressPermission: "allow", + stats: { + pruneTokenCounter: 0, + totalPruneTokens: total, + toolsPruned: 1, + messagesCompressed: 1, + }, + lastCompaction: 0, + pruneTools: [], + blocks: [], + nextBlockId: 1, + nextRunId: 1, + messageIds: { byRawId: [], nextRefIndex: 1 }, + nudges: { contextLimitAnchors: [], turnAnchors: [], iterationAnchors: [] }, + }); + const v2 = (owner: string, total: number) => ({ + ...v1(owner, total), + version: 2, + compressPermission: "ask", + }); + fs.writeFileSync( + path.join(dir, "session.jsonl"), + [ + JSON.stringify(sessionHeader("mixed")), + JSON.stringify({ + type: "custom", + customType: "pi-dcp-state", + timestamp: "2026-07-29T00:00:01.000Z", + data: v1("one", 100), + }), + JSON.stringify({ + type: "custom", + customType: "pi-dcp-state", + timestamp: "2026-07-29T00:00:02.000Z", + data: v2("one", 400), + }), + JSON.stringify({ + type: "custom", + customType: "pi-dcp-state", + timestamp: "2026-07-29T00:00:01.000Z", + data: v2("two", 700), + }), + ].join("\n"), + ); + + const result = await loadAllSessionStats(tempDir); + expect(result.totalTokensSaved).toBe(1100); + expect(result.totalToolsPruned).toBe(2); + expect(result.sessionCount).toBe(2); + }); + it("returns zeros when directory does not exist", async () => { const result = await loadAllSessionStats("/tmp/nonexistent-dcp-dir-xyz"); expect(result.totalTokensSaved).toBe(0); @@ -216,7 +274,7 @@ describe("persistence", () => { const snapshot = persistence.serializeDcpSnapshot(state); expect(snapshot).toMatchObject({ - version: 1, + version: 2, ownerSessionId: "owner", manualMode: "active", compressPermission: "allow", @@ -367,6 +425,7 @@ describe("persistence", () => { const parsed = persistence.parseDcpSnapshot(fixture); expect(parsed).toBeDefined(); + expect(parsed?.version).toBe(1); expect(parsed?.manualMode).toBe(manualMode); const state = createSessionState(); @@ -376,11 +435,91 @@ describe("persistence", () => { const reserialized = persistence.serializeDcpSnapshot(state); expect(reserialized).toBeDefined(); - expect(reserialized?.version).toBe(1); + expect(reserialized?.version).toBe(2); expect(reserialized?.manualMode).toBe(manualMode); }, ); + it.each(["allow", "ask", "deny"] as const)( + "round-trips a v2 snapshot with permission %s", + (permission) => { + const state = createSessionState(); + state.sessionId = "owner"; + state.compressPermission = permission; + const snapshot = persistence.serializeDcpSnapshot(state); + expect(snapshot?.version).toBe(2); + expect(snapshot?.compressPermission).toBe(permission); + + const parsed = persistence.parseDcpSnapshot(snapshot); + expect(parsed?.version).toBe(2); + expect(parsed?.compressPermission).toBe(permission); + + const restored = createSessionState(); + expect(persistence.restoreDcpSnapshot(snapshot, restored, "owner")).toBe(true); + expect(restored.compressPermission).toBe(permission); + }, + ); + + it.each(["allow", "deny"] as const)( + "keeps a literal v1 snapshot with permission %s readable", + (permission) => { + const fixture = { + version: 1, + ownerSessionId: "owner", + manualMode: false, + compressPermission: permission, + stats: { + pruneTokenCounter: 0, + totalPruneTokens: 0, + toolsPruned: 0, + messagesCompressed: 0, + }, + lastCompaction: 0, + pruneTools: [], + blocks: [], + nextBlockId: 1, + nextRunId: 1, + messageIds: { byRawId: [], nextRefIndex: 1 }, + nudges: { contextLimitAnchors: [], turnAnchors: [], iterationAnchors: [] }, + }; + expect(persistence.parseDcpSnapshot(fixture)?.compressPermission).toBe(permission); + }, + ); + + it("rejects a v1 snapshot with permission ask", () => { + const fixture = { + version: 1, + ownerSessionId: "owner", + manualMode: false, + compressPermission: "ask", + stats: { + pruneTokenCounter: 0, + totalPruneTokens: 0, + toolsPruned: 0, + messagesCompressed: 0, + }, + lastCompaction: 0, + pruneTools: [], + blocks: [], + nextBlockId: 1, + nextRunId: 1, + messageIds: { byRawId: [], nextRefIndex: 1 }, + nudges: { contextLimitAnchors: [], turnAnchors: [], iterationAnchors: [] }, + }; + expect(persistence.parseDcpSnapshot(fixture)).toBeUndefined(); + }); + + it("rejects invalid v2 permissions and unknown versions", () => { + const state = createSessionState(); + state.sessionId = "owner"; + const snapshot = persistence.serializeDcpSnapshot(state); + if (!snapshot) throw new Error("expected snapshot"); + expect(persistence.parseDcpSnapshot({ ...snapshot, version: 3 })).toBeUndefined(); + expect( + persistence.parseDcpSnapshot({ ...snapshot, compressPermission: "maybe" }), + ).toBeUndefined(); + }); + it("rejects invalid roots and salvages valid snapshot entries", () => { expect(persistence.parseDcpSnapshot(null)).toBeUndefined(); const state = createSessionState(); diff --git a/tests/pi-contract.test.ts b/tests/pi-contract.test.ts index 0e6a501..dbd0c9f 100644 --- a/tests/pi-contract.test.ts +++ b/tests/pi-contract.test.ts @@ -173,6 +173,7 @@ describe("DCP lifecycle reconciliation", () => { await harness.emit("session_start", { type: "session_start", reason: "new" }); expect(harness.activeTools()).toContain("compress"); + await harness.runCommand("dcp:permission", ""); await harness.runCommand("dcp:permission", ""); expect(harness.activeTools()).toEqual(["read"]); From 998aaf5089082d45bd692b0d1c5c3b4191f7349d Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Mon, 5 Oct 2026 20:38:19 -0400 Subject: [PATCH 03/11] feat: confirm compression before execution --- src/compress/permission.ts | 83 +++++++++++++ src/index.ts | 53 ++++++-- tests/capabilities.test.ts | 19 +++ tests/compress-permission.test.ts | 198 ++++++++++++++++++++++++++++++ tests/extension-harness.ts | 28 +++-- tests/index.test.ts | 41 ++++--- tests/integration.test.ts | 37 ++++++ tests/pi-contract.test.ts | 179 ++++++++++++++++++++++++++- tests/sdk-contract.test.ts | 18 +++ 9 files changed, 618 insertions(+), 38 deletions(-) create mode 100644 src/compress/permission.ts create mode 100644 tests/compress-permission.test.ts diff --git a/src/compress/permission.ts b/src/compress/permission.ts new file mode 100644 index 0000000..b5ef15d --- /dev/null +++ b/src/compress/permission.ts @@ -0,0 +1,83 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { CompressConfig } from "../config.ts"; + +/** Human-readable description of a pending compression request. */ +export interface CompressionRequestDescription { + topic: string; + targetCount: number; + targetLabel: "range" | "target"; +} + +const UNTITLED_TOPIC = "Untitled compression"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isValidRange(value: unknown): boolean { + return ( + isRecord(value) && + typeof value.startId === "string" && + typeof value.endId === "string" && + typeof value.summary === "string" + ); +} + +function isValidTarget(value: unknown): boolean { + return ( + isRecord(value) && typeof value.messageId === "string" && typeof value.summary === "string" + ); +} + +/** + * Summarize a compress call for the confirmation dialog. Malformed or absent + * arrays count as zero valid items; the array chosen follows the configured mode. + */ +export function describeCompressionRequest( + mode: CompressConfig["mode"], + input: Record, +): CompressionRequestDescription { + const rawTopic = typeof input.topic === "string" ? input.topic.trim() : ""; + const topic = rawTopic.length > 0 ? rawTopic : UNTITLED_TOPIC; + + if (mode === "message") { + const targets = Array.isArray(input.targets) ? input.targets : []; + return { topic, targetCount: targets.filter(isValidTarget).length, targetLabel: "target" }; + } + + const content = Array.isArray(input.content) ? input.content : []; + return { topic, targetCount: content.filter(isValidRange).length, targetLabel: "range" }; +} + +/** Modes whose UI can render an extension confirmation dialog. */ +function isDialogCapable(ctx: ExtensionContext): boolean { + return (ctx.mode === "tui" || ctx.mode === "rpc") && ctx.hasUI; +} + +/** + * Request per-call approval for an `ask` compression. + * + * Returns `undefined` when approved; otherwise the error reason to surface. + * Fails closed when the mode cannot render dialogs or dialog UI is unavailable. + */ +export async function requestCompressionApproval( + mode: CompressConfig["mode"], + input: Record, + ctx: ExtensionContext, + signal?: AbortSignal, +): Promise { + if (!isDialogCapable(ctx)) return "Compression requires interactive approval"; + + const { topic, targetCount, targetLabel } = describeCompressionRequest(mode, input); + const pluralized = `${targetCount} ${targetLabel}${targetCount === 1 ? "" : "s"}`; + const message = `Compress "${topic}"?\n\nThis call will compress ${pluralized}.`; + + try { + const approved = await ctx.ui.confirm("Allow DCP compression?", message, { + signal: signal ?? ctx.signal, + }); + return approved ? undefined : "Compression was not approved"; + } catch { + return "Compression was not approved"; + } +} diff --git a/src/index.ts b/src/index.ts index 02976b3..4aa6e01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { buildCompressNotificationDetailed, } from "./ui/notification.ts"; import { handleCompress, type CompressArgs, type CompressResult } from "./compress/handler.ts"; +import { requestCompressionApproval } from "./compress/permission.ts"; import { stripHallucinationsFromString } from "./messages/strip.ts"; import { mapText } from "./utils/message-content.ts"; import { COMPRESS_MESSAGE_PROMPT } from "./prompts/compress-message.ts"; @@ -330,11 +331,12 @@ export default function createExtension(pi: ExtensionAPI): void { persistIfChanged(); }; - function executeCompressTool( + async function executeCompressTool( mode: CompressArgs["mode"], toolCallId: string, params: Record, ctx: ExtensionContext, + signal: AbortSignal | undefined, ) { const reasons = evaluateCapabilities(ctx).reasons; if (reasons.length > 0) { @@ -344,6 +346,40 @@ export default function createExtension(pi: ExtensionAPI): void { isError: true, }; } + + const permission = state.compressPermission ?? config.compress.permission; + if (permission === "ask") { + const effectiveSignal = signal ?? ctx.signal; + const denial = await requestCompressionApproval(mode, params, ctx, effectiveSignal); + if (denial !== undefined) { + return { + content: [{ type: "text" as const, text: denial }], + details: {}, + isError: true, + }; + } + // Recheck after the wait: an abort or a policy change must prevent work. + if (effectiveSignal?.aborted) { + return { + content: [{ type: "text" as const, text: "Compression was not approved" }], + details: {}, + isError: true, + }; + } + const suppressedReasons = evaluateCapabilities(ctx).reasons; + if (suppressedReasons.length > 0) { + return { + content: [ + { type: "text" as const, text: executeSuppressionMessage(suppressedReasons[0]) }, + ], + details: {}, + isError: true, + }; + } + } + + // Timing starts at actual compression work, after approval and a fresh policy check. + state.compressionTiming.startTimes.set(toolCallId, Date.now()); const result = handleCompress(state, config, latestMessages, toolCallId, { ...params, mode, @@ -386,6 +422,7 @@ export default function createExtension(pi: ExtensionAPI): void { _toolCallId, params as Record, ctx, + _signal, ); }, }); @@ -415,7 +452,13 @@ export default function createExtension(pi: ExtensionAPI): void { ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - return executeCompressTool("range", _toolCallId, params as Record, ctx); + return executeCompressTool( + "range", + _toolCallId, + params as Record, + ctx, + _signal, + ); }, }); } @@ -547,12 +590,6 @@ export default function createExtension(pi: ExtensionAPI): void { return { block: true, reason: toolCallSuppressionReason(reasons[0]) }; }); - pi.on("tool_execution_start", async (event, ctx) => { - if (event.toolName !== "compress") return; - if (evaluateCapabilities(ctx).reasons.length > 0) return; - state.compressionTiming.startTimes.set(event.toolCallId, Date.now()); - }); - pi.on("tool_execution_end", async (event, ctx) => { // Compression timing completion is unconditional: a call that started under // an allowed policy must have its timing cleared even if policy changed. diff --git a/tests/capabilities.test.ts b/tests/capabilities.test.ts index b5a71b3..26a4aee 100644 --- a/tests/capabilities.test.ts +++ b/tests/capabilities.test.ts @@ -55,6 +55,25 @@ describe("getDcpCapabilities", () => { }); }); + it("keeps compression enabled for the ask permission", () => { + const state = createSessionState(); + state.compressPermission = "ask"; + expect(getDcpCapabilities(makeDefaultConfig(), state, provider, modelId)).toEqual({ + pipelineEnabled: true, + compressionEnabled: true, + reasons: [], + }); + }); + + it("treats a configured ask default as enabled when the session has no override", () => { + const config = makeDefaultConfig({ permission: "ask" }); + expect(getDcpCapabilities(config, createSessionState(), provider, modelId)).toEqual({ + pipelineEnabled: true, + compressionEnabled: true, + reasons: [], + }); + }); + it("lets the session permission override the configuration default", () => { const config = makeDefaultConfig({ permission: "deny" }); const state = createSessionState(); diff --git a/tests/compress-permission.test.ts b/tests/compress-permission.test.ts new file mode 100644 index 0000000..00bbded --- /dev/null +++ b/tests/compress-permission.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { + describeCompressionRequest, + requestCompressionApproval, +} from "../src/compress/permission.ts"; + +function ctxFor(options: { + mode?: ExtensionContext["mode"]; + hasUI?: boolean; + confirm?: ReturnType; + signal?: AbortSignal; +}) { + const confirm = options.confirm ?? vi.fn(); + const ctx = { + mode: options.mode ?? "tui", + hasUI: options.hasUI ?? true, + signal: options.signal, + ui: { confirm }, + } as unknown as ExtensionContext; + return { ctx, confirm }; +} + +describe("describeCompressionRequest", () => { + it("falls back to an untitled topic for missing, empty, and whitespace topics", () => { + expect(describeCompressionRequest("range", {}).topic).toBe("Untitled compression"); + expect(describeCompressionRequest("range", { topic: "" }).topic).toBe("Untitled compression"); + expect(describeCompressionRequest("range", { topic: " " }).topic).toBe( + "Untitled compression", + ); + }); + + it("uses the trimmed topic when provided", () => { + expect(describeCompressionRequest("range", { topic: " parser fix " }).topic).toBe( + "parser fix", + ); + }); + + it("counts valid range records and labels them ranges", () => { + const primary = describeCompressionRequest("range", { + topic: "ranges", + content: [ + { startId: "m1", endId: "m4", summary: "a" }, + { startId: "m5", endId: "m8", summary: "b" }, + { startId: "m9", endId: 4, summary: "c" }, + { nope: true }, + ], + }); + + expect(primary).toEqual({ topic: "ranges", targetCount: 2, targetLabel: "range" }); + }); + + it("counts valid message targets and labels them targets", () => { + const primary = describeCompressionRequest("message", { + topic: "targets", + targets: [{ messageId: "m1", summary: "a" }], + }); + + expect(primary).toEqual({ topic: "targets", targetCount: 1, targetLabel: "target" }); + }); + + it("treats malformed or absent arrays as zero valid items", () => { + expect(describeCompressionRequest("range", { content: "nope" })).toEqual({ + topic: "Untitled compression", + targetCount: 0, + targetLabel: "range", + }); + expect(describeCompressionRequest("message", { targets: [{ messageId: "m1" }] })).toEqual({ + topic: "Untitled compression", + targetCount: 0, + targetLabel: "target", + }); + }); +}); + +describe("requestCompressionApproval", () => { + it("confirms in TUI with the topic and pluralized range count", async () => { + const confirm = vi.fn().mockResolvedValue(true); + const { ctx } = ctxFor({ confirm }); + + const result = await requestCompressionApproval( + "range", + { + topic: "database migrations", + content: [ + { startId: "m1", endId: "m4", summary: "a" }, + { startId: "m5", endId: "m8", summary: "b" }, + ], + }, + ctx, + ); + + expect(result).toBeUndefined(); + expect(confirm).toHaveBeenCalledTimes(1); + const [title, message] = confirm.mock.calls[0] as [string, string]; + expect(title).toBe("Allow DCP compression?"); + expect(message).toContain("database migrations"); + expect(message).toContain("2 ranges"); + }); + + it("uses a singular target label in message mode", async () => { + const confirm = vi.fn().mockResolvedValue(true); + const { ctx } = ctxFor({ confirm }); + + await requestCompressionApproval( + "message", + { topic: "one", targets: [{ messageId: "m1", summary: "a" }] }, + ctx, + ); + + const [, message] = confirm.mock.calls[0] as [string, string]; + expect(message).toContain("1 target"); + expect(message).not.toContain("1 targets"); + }); + + it("confirms in RPC mode with dialog UI", async () => { + const confirm = vi.fn().mockResolvedValue(true); + const { ctx } = ctxFor({ mode: "rpc", hasUI: true, confirm }); + + await expect( + requestCompressionApproval("range", { content: [] }, ctx), + ).resolves.toBeUndefined(); + expect(confirm).toHaveBeenCalledTimes(1); + }); + + it("reports a rejected confirmation", async () => { + const confirm = vi.fn().mockResolvedValue(false); + const { ctx } = ctxFor({ confirm }); + + await expect(requestCompressionApproval("range", {}, ctx)).resolves.toBe( + "Compression was not approved", + ); + }); + + it("reports a cancelled confirmation", async () => { + const confirm = vi.fn().mockResolvedValue(undefined); + const { ctx } = ctxFor({ confirm }); + + await expect(requestCompressionApproval("range", {}, ctx)).resolves.toBe( + "Compression was not approved", + ); + }); + + it("reports a thrown confirmation", async () => { + const confirm = vi.fn().mockRejectedValue(new Error("boom")); + const { ctx } = ctxFor({ confirm }); + + await expect(requestCompressionApproval("range", {}, ctx)).resolves.toBe( + "Compression was not approved", + ); + }); + + it("fails closed without dialog UI in print and JSON modes", async () => { + for (const mode of ["print", "json"] as const) { + const confirm = vi.fn(); + const { ctx } = ctxFor({ mode, hasUI: false, confirm }); + + await expect(requestCompressionApproval("range", {}, ctx)).resolves.toBe( + "Compression requires interactive approval", + ); + expect(confirm).not.toHaveBeenCalled(); + } + }); + + it("fails closed in TUI when dialog UI is unavailable", async () => { + const confirm = vi.fn(); + const { ctx } = ctxFor({ mode: "tui", hasUI: false, confirm }); + + await expect(requestCompressionApproval("range", {}, ctx)).resolves.toBe( + "Compression requires interactive approval", + ); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("passes the call signal to the dialog", async () => { + const confirm = vi.fn().mockResolvedValue(true); + const controller = new AbortController(); + const { ctx } = ctxFor({ confirm }); + + await requestCompressionApproval("range", {}, ctx, controller.signal); + + expect(confirm).toHaveBeenCalledWith(expect.any(String), expect.any(String), { + signal: controller.signal, + }); + }); + + it("falls back to the context signal when no call signal is provided", async () => { + const confirm = vi.fn().mockResolvedValue(true); + const controller = new AbortController(); + const { ctx } = ctxFor({ confirm, signal: controller.signal }); + + await requestCompressionApproval("range", {}, ctx); + + expect(confirm).toHaveBeenCalledWith(expect.any(String), expect.any(String), { + signal: controller.signal, + }); + }); +}); diff --git a/tests/extension-harness.ts b/tests/extension-harness.ts index 488e4a6..e1bb21b 100644 --- a/tests/extension-harness.ts +++ b/tests/extension-harness.ts @@ -52,6 +52,8 @@ export interface ExtensionHarnessOptions { sessionId?: string; sessionDir?: string; hasUI?: boolean; + /** Extension run mode. Default: "tui". */ + mode?: ExtensionContext["mode"]; contextUsage?: HarnessContextUsage | undefined; } @@ -160,7 +162,7 @@ export function createExtensionHarness(options: ExtensionHarnessOptions = {}) { const extensionContext = { ui, - mode: "tui", + mode: options.mode ?? "tui", hasUI: options.hasUI ?? false, cwd: options.cwd ?? process.cwd(), sessionManager, @@ -275,19 +277,27 @@ export function createExtensionHarness(options: ExtensionHarnessOptions = {}) { return results; } - async function executeTool(name: string, args: unknown, ctx = extensionContext) { + async function executeTool( + name: string, + args: unknown, + options: { ctx?: ExtensionContext; signal?: AbortSignal; toolCallId?: string } = {}, + ) { + const ctx = options.ctx ?? extensionContext; const tool = tools.get(name); if (!tool) throw new Error(`Tool "${name}" is not registered`); - const toolContext = { - ...ctx, + const toolContext = Object.assign(Object.create(ctx), { tools: [], executeTool: async () => { throw new Error("nested tool calls are not supported by the harness"); }, - } as unknown as ExtensionToolContext; - const signal = undefined; - const onUpdate = undefined; - return tool.execute("harness-call", args as never, signal, onUpdate, toolContext); + }) as unknown as ExtensionToolContext; + return tool.execute( + options.toolCallId ?? "harness-call", + args as never, + options.signal, + undefined, + toolContext, + ); } async function runCommand(name: string, args: string, ctx = buildCommandContext()) { @@ -334,7 +344,7 @@ export function createExtensionHarness(options: ExtensionHarnessOptions = {}) { commands, entries, sentMessages, - ui: { notify, setStatus }, + ui: { notify, setStatus, confirm: ui.confirm, custom: ui.custom }, context: extensionContext as unknown as ExtensionContext, commandContext: commandContext as unknown as ExtensionCommandContext, get model() { diff --git a/tests/index.test.ts b/tests/index.test.ts index a11f630..2703ab0 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1064,11 +1064,6 @@ describe("dcp extension", () => { entries.length = 0; vi.setSystemTime(1_000); - const toolStart = handlers.get("tool_execution_start")?.[0]; - await (toolStart as (...args: unknown[]) => Promise)( - { toolName: "compress", toolCallId: "compress-call" }, - ctx, - ); const compress = tools.get("compress") as { execute: (...args: unknown[]) => Promise; }; @@ -1252,7 +1247,7 @@ describe("static model disablement", () => { vi.useFakeTimers(); try { writeDisabledModelConfig("openai-codex/gpt-5.6-sol"); - const { api, handlers, entries } = createMockApi(); + const { api, handlers, entries, tools } = createMockApi(); createExtension(api); const ctx = sessionContext(disabledModel, [ { type: "custom", customType: "pi-dcp-state", data: persistedCompressionSnapshot }, @@ -1261,10 +1256,18 @@ describe("static model disablement", () => { entries.length = 0; vi.setSystemTime(1_000); - await registeredHandler(handlers, "tool_execution_start")( - { toolName: "compress", toolCallId: "compress-1" }, - ctx, - ); + const compress = tools.get("compress") as { + execute: (...args: unknown[]) => Promise<{ isError?: boolean }>; + }; + await expect( + compress.execute( + "compress-1", + { topic: "topic", content: [{ startId: "m0001", endId: "m0002", summary: "summary" }] }, + undefined, + undefined, + ctx, + ), + ).resolves.toMatchObject({ isError: true }); vi.setSystemTime(2_500); await registeredHandler(handlers, "tool_execution_end")( { toolName: "compress", toolCallId: "compress-1", isError: false }, @@ -1281,7 +1284,7 @@ describe("static model disablement", () => { vi.useFakeTimers(); try { writeDisabledModelConfig("openai-codex/gpt-5.6-sol"); - const { api, handlers, entries } = createMockApi(); + const { api, handlers, entries, tools } = createMockApi(); createExtension(api); const disabledCtx = sessionContext(disabledModel, [ { @@ -1294,10 +1297,18 @@ describe("static model disablement", () => { entries.length = 0; vi.setSystemTime(1_000); - await registeredHandler(handlers, "tool_execution_start")( - { toolName: "compress", toolCallId: "compress-1" }, - disabledCtx, - ); + const compress = tools.get("compress") as { + execute: (...args: unknown[]) => Promise<{ isError?: boolean }>; + }; + await expect( + compress.execute( + "compress-1", + { topic: "topic", content: [{ startId: "m0001", endId: "m0002", summary: "summary" }] }, + undefined, + undefined, + disabledCtx, + ), + ).resolves.toMatchObject({ isError: true }); await selectModel(handlers, enabledModel, disabledModel); vi.setSystemTime(2_500); await registeredHandler(handlers, "tool_execution_end")( diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 084b372..abb8294 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -573,6 +573,43 @@ describe("integration", () => { expect(entries).toHaveLength(entryCount); }); + it("keeps ask permission non-suppressing for tool exposure and tool_call", async () => { + fs.mkdirSync(path.join(agentDir, "extensions"), { recursive: true }); + fs.writeFileSync( + path.join(agentDir, "extensions", "dcp.json"), + JSON.stringify({ compress: { permission: "ask" } }), + ); + + const { api, handlers, tools } = createMockApi(); + createExtension(api); + const confirm = vi.fn().mockResolvedValue(true); + const mockCtx = { + mode: "tui", + cwd: agentDir, + isProjectTrusted: () => false, + sessionManager: { getSessionDir: () => "/tmp/test-integration-session" }, + getContextUsage: () => ({ tokens: 1000, contextWindow: 200000, percent: 0.5 }), + hasUI: true, + ui: { setStatus: () => {}, notify: () => {}, confirm }, + }; + + for (const handler of handlers.get("session_start") ?? []) { + await handler({ reason: "new" }, mockCtx); + } + + expect(tools.has("compress")).toBe(true); + expect(api.getActiveTools()).toContain("compress"); + + const [toolCall] = await Promise.all( + (handlers.get("tool_call") ?? []).map((handler) => + Promise.resolve( + handler({ toolName: "compress", toolCallId: "call-1", input: {} }, mockCtx), + ), + ), + ); + expect(toolCall).toBeUndefined(); + }); + it("uses project prompt overrides only when the project is trusted", async () => { const globalConfigPath = path.join(agentDir, "extensions", "dcp.json"); const projectCwd = path.join(agentDir, "project"); diff --git a/tests/pi-contract.test.ts b/tests/pi-contract.test.ts index dbd0c9f..b0c03ef 100644 --- a/tests/pi-contract.test.ts +++ b/tests/pi-contract.test.ts @@ -411,12 +411,6 @@ describe("DCP defensive authorization", () => { harness.entries.length = 0; vi.setSystemTime(1_000); - await harness.emit("tool_execution_start", { - type: "tool_execution_start", - toolName: "compress", - toolCallId: "harness-call", - args: {}, - }); await harness.executeTool("compress", validCompression); // Permission changes after the call started. @@ -621,3 +615,176 @@ describe("DCP structured prompts", () => { }); }); }); + +describe("DCP ask authorization", () => { + const validCompression = { + topic: "ask topic", + content: [{ startId: "m0001", endId: "m0001", summary: "summary" }], + }; + + async function askHarness(options: { + mode?: "tui" | "rpc" | "json" | "print"; + hasUI?: boolean; + config?: unknown; + model?: { provider: string; id: string }; + }) { + writeConfig(options.config ?? { compress: { permission: "ask" } }); + const harness = createExtensionHarness({ + activeTools: ["read", "compress"], + mode: options.mode ?? "tui", + hasUI: options.hasUI ?? true, + model: options.model, + contextUsage: { tokens: 1000, contextWindow: 200_000, percent: 0.5 }, + }); + createExtension(harness.api); + await harness.emit("session_start", { type: "session_start", reason: "new" }); + await harness.emit("context", { + type: "context", + messages: [ + { role: "user", content: [{ type: "text", text: "one" }], timestamp: 1 }, + { + ...assistantMetadata, + role: "assistant", + content: [{ type: "text", text: "two" }], + stopReason: "stop", + timestamp: 2, + }, + ] satisfies AgentMessage[], + }); + harness.entries.length = 0; + return harness; + } + + it.each(["tui", "rpc"] as const)( + "confirms once in %s and starts timing only after approval", + async (mode) => { + vi.useFakeTimers(); + try { + const harness = await askHarness({ mode }); + harness.ui.confirm.mockImplementation(async () => { + vi.setSystemTime(1_500); + return true; + }); + + vi.setSystemTime(1_000); + await expect(harness.executeTool("compress", validCompression)).resolves.toMatchObject({ + content: [{ text: expect.stringContaining("Compressed") }], + }); + expect(harness.ui.confirm).toHaveBeenCalledTimes(1); + + vi.setSystemTime(2_500); + await harness.emit("tool_execution_end", { + type: "tool_execution_end", + toolName: "compress", + toolCallId: "harness-call", + result: {}, + isError: false, + }); + + const persisted = harness.entries + .filter((entry) => entry.customType === "pi-dcp-state") + .at(-1); + expect(persisted?.data).toMatchObject({ blocks: [{ durationMs: 1_000 }] }); + } finally { + vi.useRealTimers(); + } + }, + ); + + it.each(["print", "json"] as const)("fails closed without confirm in %s", async (mode) => { + const harness = await askHarness({ mode, hasUI: false }); + + await expect(harness.executeTool("compress", validCompression)).resolves.toMatchObject({ + isError: true, + content: [{ text: "Compression requires interactive approval" }], + }); + expect(harness.ui.confirm).not.toHaveBeenCalled(); + expect(harness.entries).toHaveLength(0); + }); + + it("fails closed in TUI when dialog UI is unavailable", async () => { + const harness = await askHarness({ mode: "tui", hasUI: false }); + + await expect(harness.executeTool("compress", validCompression)).resolves.toMatchObject({ + isError: true, + content: [{ text: "Compression requires interactive approval" }], + }); + expect(harness.ui.confirm).not.toHaveBeenCalled(); + }); + + it("creates no block, statistics, or durable entry when rejected", async () => { + const harness = await askHarness({}); + harness.ui.confirm.mockResolvedValue(false); + + await expect(harness.executeTool("compress", validCompression)).resolves.toMatchObject({ + isError: true, + content: [{ text: "Compression was not approved" }], + }); + await harness.emit("tool_execution_end", { + type: "tool_execution_end", + toolName: "compress", + toolCallId: "harness-call", + result: {}, + isError: true, + }); + + expect(harness.entries).toHaveLength(0); + + // A later allowed compression still starts from a clean state. + harness.ui.confirm.mockResolvedValue(true); + await expect(harness.executeTool("compress", validCompression)).resolves.toMatchObject({ + content: [{ text: expect.stringContaining("Compressed") }], + }); + }); + + it("does not reuse an approval between successive ask calls", async () => { + const harness = await askHarness({}); + harness.ui.confirm.mockResolvedValue(true); + + await harness.executeTool("compress", validCompression); + await harness.executeTool("compress", { + topic: "second", + content: [{ startId: "m0002", endId: "m0002", summary: "summary two" }], + }); + + expect(harness.ui.confirm).toHaveBeenCalledTimes(2); + }); + + it("prevents compression when the call aborts during confirmation", async () => { + const harness = await askHarness({}); + const controller = new AbortController(); + harness.ui.confirm.mockImplementation(async () => { + controller.abort(); + return true; + }); + + await expect( + harness.executeTool("compress", validCompression, { signal: controller.signal }), + ).resolves.toMatchObject({ + isError: true, + content: [{ text: "Compression was not approved" }], + }); + expect(harness.entries).toHaveLength(0); + }); + + it("prevents compression when policy suppression appears while confirmation is pending", async () => { + const harness = await askHarness({ + config: { disabledModels: ["test/disabled-model"], compress: { permission: "ask" } }, + }); + harness.ui.confirm.mockImplementation(async () => { + harness.setModel({ provider: "test", id: "disabled-model" }); + await harness.emit("model_select", { + type: "model_select", + model: createHarnessModel({ provider: "test", id: "disabled-model" }), + previousModel: undefined, + source: "set", + }); + return true; + }); + + await expect(harness.executeTool("compress", validCompression)).resolves.toMatchObject({ + isError: true, + content: [{ text: "Compression is disabled for the current model." }], + }); + }); +}); diff --git a/tests/sdk-contract.test.ts b/tests/sdk-contract.test.ts index 60c905d..553aa88 100644 --- a/tests/sdk-contract.test.ts +++ b/tests/sdk-contract.test.ts @@ -89,6 +89,24 @@ afterEach(() => { }); describe("DCP public SDK contract", () => { + it("keeps the compress tool exposed and guided for ask permission", async () => { + const { session, agentDir } = await createHostSession({ + setup: (dir) => { + fs.mkdirSync(path.join(dir, "extensions"), { recursive: true }); + fs.writeFileSync( + path.join(dir, "extensions", "dcp.json"), + '{"compress":{"permission":"ask"}}', + ); + }, + }); + + expect(session.getActiveToolNames()).toContain("compress"); + const result = await session.extensionRunner.emitBeforeAgentStart("hello", undefined, { + cwd: agentDir, + }); + expect(result.systemPromptOptions.sections.dcp).toContain("compress"); + }); + it("activates the early-registered compress tool for a fresh default session", async () => { const { session } = await createHostSession(); From 6f57b73a3cf389502d653c9dc0b4afc6c922dab5 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Mon, 5 Oct 2026 20:42:31 -0400 Subject: [PATCH 04/11] feat: add guarded DCP panel actions --- src/capabilities.ts | 11 ++ src/commands/register.ts | 14 +- src/state/persistence.ts | 9 +- src/ui/panel-model.ts | 230 ++++++++++++++++++++++++ src/utils/context-limits.ts | 44 +++-- tests/capabilities.test.ts | 62 ++++++- tests/context-limits.test.ts | 62 ++++++- tests/panel-model.test.ts | 339 +++++++++++++++++++++++++++++++++++ 8 files changed, 744 insertions(+), 27 deletions(-) create mode 100644 src/ui/panel-model.ts create mode 100644 tests/panel-model.test.ts diff --git a/src/capabilities.ts b/src/capabilities.ts index ef6f575..a33fb39 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -53,3 +53,14 @@ export function getDcpCapabilities( reasons, }; } + +/** + * User-facing message for why the pipeline cannot run, or undefined when it can. + * Preserves the existing command-message precedence: config, then sub-agent, then model. + */ +export function getDcpPipelineDisabledMessage(capabilities: DcpCapabilities): string | undefined { + if (capabilities.pipelineEnabled) return undefined; + if (capabilities.reasons.includes("config")) return "DCP is disabled by configuration."; + if (capabilities.reasons.includes("subagent")) return "DCP is disabled in sub-agent sessions."; + return "DCP is disabled for the current model."; +} diff --git a/src/commands/register.ts b/src/commands/register.ts index 9651451..e2bcf49 100644 --- a/src/commands/register.ts +++ b/src/commands/register.ts @@ -1,6 +1,6 @@ import * as path from "node:path"; import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; -import { getDcpCapabilities } from "../capabilities.ts"; +import { getDcpCapabilities, getDcpPipelineDisabledMessage } from "../capabilities.ts"; import { type DcpConfig, isDcpEnabledForModel } from "../config.ts"; import type { SessionState } from "../state/types.ts"; import { compressCommand } from "./compress.ts"; @@ -29,15 +29,9 @@ export function registerDcpCommands( ); const rejectWhenDisabled = (ctx: ExtensionCommandContext): boolean => { - const { pipelineEnabled, reasons } = capabilities(ctx); - if (pipelineEnabled) return false; - if (reasons.includes("config")) { - ctx.ui.notify("DCP is disabled by configuration.", "info"); - } else if (reasons.includes("subagent")) { - ctx.ui.notify("DCP is disabled in sub-agent sessions.", "info"); - } else { - ctx.ui.notify("DCP is disabled for the current model.", "info"); - } + const message = getDcpPipelineDisabledMessage(capabilities(ctx)); + if (message === undefined) return false; + ctx.ui.notify(message, "info"); return true; }; diff --git a/src/state/persistence.ts b/src/state/persistence.ts index 521fb05..16757fa 100644 --- a/src/state/persistence.ts +++ b/src/state/persistence.ts @@ -345,13 +345,16 @@ export function restoreDcpSnapshot( return true; } -/** Aggregate each owner's newest native snapshot from Pi session JSONL files. */ -export async function loadAllSessionStats(parentDir: string): Promise<{ +/** Aggregate lifetime totals across sessions. */ +export interface LifetimeStats { totalTokensSaved: number; totalToolsPruned: number; totalMessagesCompressed: number; sessionCount: number; -}> { +} + +/** Aggregate each owner's newest native snapshot from Pi session JSONL files. */ +export async function loadAllSessionStats(parentDir: string): Promise { const snapshots = new Map(); async function scanFile(file: string): Promise { diff --git a/src/ui/panel-model.ts b/src/ui/panel-model.ts new file mode 100644 index 0000000..9b31ad5 --- /dev/null +++ b/src/ui/panel-model.ts @@ -0,0 +1,230 @@ +import type { DcpConfig } from "../config.ts"; +import { + getDcpCapabilities, + getDcpPipelineDisabledMessage, + type DcpCapabilities, +} from "../capabilities.ts"; +import type { CompressionBlock, ContextUsage, SessionState } from "../state/types.ts"; +import type { LifetimeStats } from "../state/persistence.ts"; +import { resolveContextLimits } from "../utils/context-limits.ts"; +import { manualCommand } from "../commands/manual.ts"; +import { permissionCommand } from "../commands/permission.ts"; +import { sweepCommand } from "../commands/sweep.ts"; +import { decompressCommand } from "../commands/decompress.ts"; +import { recompressCommand } from "../commands/recompress.ts"; + +/** Selectable panel action with a type discriminant. */ +export type DcpPanelAction = + | { type: "toggle-manual" } + | { type: "cycle-permission" } + | { type: "sweep" } + | { type: "toggle-block"; blockId: number } + | { type: "close" }; + +export interface DcpPanelModelInput { + state: SessionState; + config: DcpConfig; + model: + | { provider: string | undefined; id: string | undefined; contextWindow: number | undefined } + | undefined; + contextUsage: ContextUsage | undefined; + lifetimeStats: LifetimeStats | undefined; + compressToolActive: boolean; +} + +export interface DcpPanelField { + label: string; + value: string; +} + +export interface DcpPanelRow { + /** Stable row identity: action type or `block:`. */ + id: string; + label: string; + detail?: string; + /** Present only when the row can be activated. */ + action?: DcpPanelAction; + /** Why the row is present but unavailable, or absent when it is eligible. */ + unavailableReason?: string; +} + +export interface DcpPanelModel { + status: DcpPanelField[]; + statistics: DcpPanelField[]; + /** Global actions in display order. */ + actions: DcpPanelRow[]; + /** Compression blocks sorted numerically. */ + blocks: DcpPanelRow[]; +} + +function formatContext(usage: ContextUsage | undefined): string { + if (usage === undefined || usage.tokens === null) return "Unavailable"; + const percent = usage.percent === null ? "unknown" : `${usage.percent}%`; + return `${usage.tokens} / ${usage.contextWindow} (${percent})`; +} + +function formatLimit(value: number | undefined): string { + return value === undefined ? "Unavailable" : String(value); +} + +function formatLifetime( + stats: LifetimeStats | undefined, + select: (stats: LifetimeStats) => number, +): string { + return stats === undefined ? "Unavailable" : String(select(stats)); +} + +function buildBlockRow(block: CompressionBlock, policyMessage: string | undefined): DcpPanelRow { + const label = `Block b${block.blockId}: ${block.topic}`; + const detail = `${block.mode ?? "unknown"} · ${block.compressedTokens} tokens`; + if (block.active) { + return { + id: `block:${block.blockId}`, + label, + detail: `${detail} · Deactivate`, + action: { type: "toggle-block", blockId: block.blockId }, + ...(policyMessage === undefined ? {} : { unavailableReason: policyMessage }), + }; + } + if (block.deactivatedByUser) { + return { + id: `block:${block.blockId}`, + label, + detail: `${detail} · Reactivate`, + action: { type: "toggle-block", blockId: block.blockId }, + ...(policyMessage === undefined ? {} : { unavailableReason: policyMessage }), + }; + } + return { + id: `block:${block.blockId}`, + label, + detail, + unavailableReason: "Inactive", + }; +} + +/** + * Build the read-only panel view model. Uses the supplied current model view for + * identity/threshold resolution without mutating cached session state. + */ +export function buildDcpPanelModel(input: DcpPanelModelInput): DcpPanelModel { + const { state, config, model, contextUsage, lifetimeStats, compressToolActive } = input; + const provider = model?.provider; + const modelId = model?.id; + const capabilities = getDcpCapabilities(config, state, provider, modelId); + const policyMessage = getDcpPipelineDisabledMessage(capabilities); + + const viewState: SessionState = { + ...state, + modelProvider: provider, + modelId, + modelContextWindow: model?.contextWindow, + }; + const resolved = resolveContextLimits(config, viewState, contextUsage); + const effectivePermission = state.compressPermission ?? config.compress.permission; + + const status: DcpPanelField[] = [ + { label: "Model", value: provider && modelId ? `${provider}/${modelId}` : "Unavailable" }, + { label: "Context", value: formatContext(contextUsage) }, + { label: "Max limit", value: formatLimit(resolved.max) }, + { label: "Min limit", value: formatLimit(resolved.min) }, + { label: "Compression mode", value: config.compress.mode }, + { label: "Permission", value: effectivePermission }, + { label: "Manual mode", value: state.manualMode === "active" ? "on" : "off" }, + { + label: "Pipeline", + value: capabilities.pipelineEnabled ? "enabled" : (policyMessage ?? "disabled"), + }, + { label: "Compress tool", value: compressToolActive ? "active" : "inactive" }, + ]; + + const statistics: DcpPanelField[] = [ + { label: "Session tokens saved", value: String(state.stats.totalPruneTokens) }, + { label: "Session tools pruned", value: String(state.stats.toolsPruned) }, + { label: "Session messages compressed", value: String(state.stats.messagesCompressed) }, + { + label: "Lifetime tokens saved", + value: formatLifetime(lifetimeStats, (stats) => stats.totalTokensSaved), + }, + { + label: "Lifetime tools pruned", + value: formatLifetime(lifetimeStats, (stats) => stats.totalToolsPruned), + }, + { + label: "Lifetime messages compressed", + value: formatLifetime(lifetimeStats, (stats) => stats.totalMessagesCompressed), + }, + { + label: "Lifetime sessions", + value: formatLifetime(lifetimeStats, (stats) => stats.sessionCount), + }, + ]; + + const actions: DcpPanelRow[] = [ + { + id: "toggle-manual", + label: `Manual mode: ${state.manualMode === "active" ? "on" : "off"}`, + detail: state.manualMode === "active" ? "Turn off" : "Turn on", + action: { type: "toggle-manual" }, + ...(policyMessage === undefined ? {} : { unavailableReason: policyMessage }), + }, + { + id: "cycle-permission", + label: `Compress permission: ${effectivePermission}`, + detail: "Cycle permission", + action: { type: "cycle-permission" }, + ...(policyMessage === undefined ? {} : { unavailableReason: policyMessage }), + }, + { + id: "sweep", + label: "Sweep eligible outputs", + action: { type: "sweep" }, + ...(policyMessage === undefined ? {} : { unavailableReason: policyMessage }), + }, + { id: "close", label: "Close panel", action: { type: "close" } }, + ]; + + const blocks = [...state.prune.messages.blocksById.values()] + .sort((a, b) => a.blockId - b.blockId) + .map((block) => buildBlockRow(block, policyMessage)); + + return { status, statistics, actions, blocks }; +} + +/** + * Apply a panel action through the existing domain commands. Performs no UI work + * and rejects policy-disabled mutations before any domain command runs. + */ +export function applyDcpPanelAction( + action: DcpPanelAction, + state: SessionState, + config: DcpConfig, + capabilities: DcpCapabilities, +): { permitted: boolean; message?: string } { + if (action.type === "close") return { permitted: false }; + + const disabled = getDcpPipelineDisabledMessage(capabilities); + if (disabled !== undefined) return { permitted: false, message: disabled }; + + switch (action.type) { + case "toggle-manual": + return { + permitted: true, + message: manualCommand(state, state.manualMode === "active" ? "off" : "on"), + }; + case "cycle-permission": + return { + permitted: true, + message: permissionCommand(state, config.compress.permission), + }; + case "sweep": + return { permitted: true, message: sweepCommand(state, config) }; + case "toggle-block": { + const block = state.prune.messages.blocksById.get(action.blockId); + const message = block?.active + ? decompressCommand(state, String(action.blockId)) + : recompressCommand(state, String(action.blockId)); + return { permitted: true, message }; + } + } +} diff --git a/src/utils/context-limits.ts b/src/utils/context-limits.ts index f7023f1..8414cef 100644 --- a/src/utils/context-limits.ts +++ b/src/utils/context-limits.ts @@ -26,6 +26,35 @@ export function resolveContextTokenLimit( return Math.round((percent / 100) * contextWindow); } +/** Resolved absolute token thresholds for the current model and window. */ +export interface ResolvedContextLimits { + min: number | undefined; + max: number | undefined; +} + +/** + * Resolve the effective min/max context limits without mutating session state. + * Precedence per threshold: per-model override, global absolute, legacy percentage. + */ +export function resolveContextLimits( + config: { compress: CompressConfig }, + state: SessionState, + contextUsage: ContextUsage | undefined, +): ResolvedContextLimits { + const modelKey = + state.modelProvider && state.modelId ? `${state.modelProvider}/${state.modelId}` : undefined; + const effectiveWindow = + state.modelContextWindow ?? + (contextUsage !== undefined && contextUsage.contextWindow > 0 + ? contextUsage.contextWindow + : undefined); + + return { + max: resolveMaxLimit(config.compress, effectiveWindow, modelKey), + min: resolveMinLimit(config.compress, effectiveWindow, modelKey), + }; +} + /** * Determine if context usage exceeds the configured limits. * Resolution order for each threshold: @@ -47,20 +76,11 @@ export function isContextOverLimits( } const tokens = contextUsage.tokens; - const modelKey = - state.modelProvider && state.modelId ? `${state.modelProvider}/${state.modelId}` : undefined; - - // Effective window: prefer state (persisted), fall back to contextUsage (current) - const effectiveWindow = - state.modelContextWindow ?? - (contextUsage.contextWindow > 0 ? contextUsage.contextWindow : undefined); - - const maxLimit = resolveMaxLimit(config.compress, effectiveWindow, modelKey); - const minLimit = resolveMinLimit(config.compress, effectiveWindow, modelKey); + const { max, min } = resolveContextLimits(config, state, contextUsage); return { - overMaxLimit: maxLimit !== undefined ? tokens >= maxLimit : false, - overMinLimit: minLimit !== undefined ? tokens >= minLimit : false, + overMaxLimit: max !== undefined ? tokens >= max : false, + overMinLimit: min !== undefined ? tokens >= min : false, }; } diff --git a/tests/capabilities.test.ts b/tests/capabilities.test.ts index 26a4aee..348607f 100644 --- a/tests/capabilities.test.ts +++ b/tests/capabilities.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getDcpCapabilities } from "../src/capabilities.ts"; +import { getDcpCapabilities, getDcpPipelineDisabledMessage } from "../src/capabilities.ts"; import { createSessionState } from "../src/state/state.ts"; import { makeDefaultConfig } from "./helpers.ts"; @@ -133,3 +133,63 @@ describe("getDcpCapabilities", () => { }); }); }); + +describe("getDcpPipelineDisabledMessage", () => { + it("returns undefined for an eligible pipeline", () => { + const state = createSessionState(); + state.compressPermission = "deny"; + const caps = getDcpCapabilities(makeDefaultConfig(), state, provider, modelId); + expect(getDcpPipelineDisabledMessage(caps)).toBeUndefined(); + }); + + it.each([ + { + name: "config", + build: () => ({ + config: { ...makeDefaultConfig(), enabled: false }, + state: createSessionState(), + }), + message: "DCP is disabled by configuration.", + }, + { + name: "subagent", + build: () => { + const state = createSessionState(); + state.isSubAgent = true; + return { config: makeDefaultConfig(), state }; + }, + message: "DCP is disabled in sub-agent sessions.", + }, + { + name: "model", + build: () => ({ + config: { ...makeDefaultConfig(), disabledModels: [`${provider}/${modelId}`] }, + state: createSessionState(), + }), + message: "DCP is disabled for the current model.", + }, + ])("returns the $name suppression message", ({ build, message }) => { + const { config, state } = build(); + expect( + getDcpPipelineDisabledMessage(getDcpCapabilities(config, state, provider, modelId)), + ).toBe(message); + }); + + it("keeps the config reason ahead of model suppression", () => { + const config = { + ...makeDefaultConfig(), + enabled: false, + disabledModels: [`${provider}/${modelId}`], + }; + const caps = getDcpCapabilities(config, createSessionState(), provider, modelId); + expect(getDcpPipelineDisabledMessage(caps)).toBe("DCP is disabled by configuration."); + }); + + it("keeps the sub-agent reason ahead of model suppression", () => { + const config = { ...makeDefaultConfig(), disabledModels: [`${provider}/${modelId}`] }; + const state = createSessionState(); + state.isSubAgent = true; + const caps = getDcpCapabilities(config, state, provider, modelId); + expect(getDcpPipelineDisabledMessage(caps)).toBe("DCP is disabled in sub-agent sessions."); + }); +}); diff --git a/tests/context-limits.test.ts b/tests/context-limits.test.ts index fb535cd..8707402 100644 --- a/tests/context-limits.test.ts +++ b/tests/context-limits.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { resolveContextTokenLimit, isContextOverLimits } from "../src/utils/context-limits.ts"; +import { + resolveContextTokenLimit, + resolveContextLimits, + isContextOverLimits, +} from "../src/utils/context-limits.ts"; import { injectCompressNudges, assignMessageRefs } from "../src/messages/inject.ts"; import { createSessionState } from "../src/state/state.ts"; import { makeDefaultConfig, makeUserMessage } from "./helpers.ts"; @@ -46,6 +50,62 @@ describe("resolveContextTokenLimit", () => { }); }); +describe("resolveContextLimits", () => { + it("preserves per-model override precedence over global and legacy limits", () => { + const state = createSessionState(); + state.modelProvider = "google"; + state.modelId = "gemini"; + state.modelContextWindow = 1_000_000; + const config = makeDefaultConfig({ + maxContextLimit: 200_000, + minContextLimit: 100_000, + modelMaxLimits: { "google/gemini": 400_000 }, + modelMinLimits: { "google/gemini": 300_000 }, + }); + + expect( + resolveContextLimits(config, state, { + tokens: 250_000, + contextWindow: 1_000_000, + percent: 25, + }), + ).toEqual({ max: 400_000, min: 300_000 }); + }); + + it("resolves percentage limits when a window is available", () => { + const state = createSessionState(); + const config = makeDefaultConfig({ maxContextLimit: "80%", minContextLimit: "50%" }); + expect( + resolveContextLimits(config, state, { tokens: 1, contextWindow: 200_000, percent: 0 }), + ).toEqual({ max: 160_000, min: 100_000 }); + }); + + it("returns undefined for percentage limits without a usable window but keeps absolute limits", () => { + const state = createSessionState(); + const config = makeDefaultConfig({ + maxContextLimit: "80%", + minContextLimit: 50_000, + }); + expect(resolveContextLimits(config, state, undefined)).toEqual({ + max: undefined, + min: 50_000, + }); + }); + + it("falls back to the usage window when the cached window is unset", () => { + const state = createSessionState(); + const config = makeDefaultConfig({ + maxContextLimit: undefined, + minContextLimit: undefined, + maxContextPercent: 80, + minContextPercent: 50, + }); + expect( + resolveContextLimits(config, state, { tokens: 1, contextWindow: 200_000, percent: 0 }), + ).toEqual({ max: 160_000, min: 100_000 }); + }); +}); + describe("isContextOverLimits", () => { it("uses maxContextLimit and minContextLimit when set", () => { const state = createSessionState(); diff --git a/tests/panel-model.test.ts b/tests/panel-model.test.ts new file mode 100644 index 0000000..5e48e8b --- /dev/null +++ b/tests/panel-model.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, it } from "vitest"; +import { + applyDcpPanelAction, + buildDcpPanelModel, + type DcpPanelModelInput, +} from "../src/ui/panel-model.ts"; +import { getDcpCapabilities } from "../src/capabilities.ts"; +import { createSessionState } from "../src/state/state.ts"; +import { makeDefaultConfig } from "./helpers.ts"; +import type { CompressionBlock, SessionState } from "../src/state/types.ts"; +import type { DcpConfig } from "../src/config.ts"; + +function addBlock(state: SessionState, overrides: Partial): CompressionBlock { + const block: CompressionBlock = { + blockId: 1, + runId: 1, + active: true, + deactivatedByUser: false, + compressedTokens: 100, + summaryTokens: 10, + durationMs: 5, + mode: "range", + topic: "topic", + batchTopic: undefined, + startIndex: 0, + endIndex: 1, + anchorIndex: 0, + compressToolCallId: "call", + startKey: "k1", + endKey: "k2", + anchorKey: "k1", + consumedBlockIds: [], + parentBlockIds: [], + directMessageIndices: [], + directToolIds: [], + effectiveMessageIndices: [], + effectiveToolIds: [], + createdAt: 1, + deactivatedAt: undefined, + deactivatedByBlockId: undefined, + summary: "summary", + ...overrides, + }; + state.prune.messages.blocksById.set(block.blockId, block); + return block; +} + +function baseInput(overrides: Partial = {}): DcpPanelModelInput { + return { + state: createSessionState(), + config: makeDefaultConfig(), + model: { provider: "test", id: "test-model", contextWindow: 200_000 }, + contextUsage: { tokens: 20_000, contextWindow: 200_000, percent: 10 }, + lifetimeStats: { + totalTokensSaved: 1_000, + totalToolsPruned: 4, + totalMessagesCompressed: 2, + sessionCount: 3, + }, + compressToolActive: true, + ...overrides, + }; +} + +function field(model: ReturnType, label: string): string | undefined { + return model.status.find((entry) => entry.label === label)?.value; +} + +function statistic( + model: ReturnType, + label: string, +): string | undefined { + return model.statistics.find((entry) => entry.label === label)?.value; +} + +describe("buildDcpPanelModel", () => { + it("formats current model, context, limits, and statistics", () => { + const input = baseInput({ + config: makeDefaultConfig({ maxContextLimit: 100_000, minContextLimit: 50_000 }), + }); + + const model = buildDcpPanelModel(input); + + expect(field(model, "Model")).toBe("test/test-model"); + expect(field(model, "Context")).toContain("20000"); + expect(field(model, "Context")).toContain("200000"); + expect(field(model, "Max limit")).toBe("100000"); + expect(field(model, "Min limit")).toBe("50000"); + expect(field(model, "Compression mode")).toBe("range"); + expect(field(model, "Permission")).toBe("allow"); + expect(field(model, "Manual mode")).toBe("off"); + expect(field(model, "Pipeline")).toBe("enabled"); + expect(field(model, "Compress tool")).toBe("active"); + expect(statistic(model, "Lifetime tokens saved")).toBe("1000"); + expect(statistic(model, "Lifetime sessions")).toBe("3"); + }); + + it("uses the current model view for thresholds without mutating cached state", () => { + const state = createSessionState(); + state.modelProvider = "stale"; + state.modelId = "stale-model"; + state.modelContextWindow = 100; + + const input = baseInput({ + state, + model: { provider: "anthropic", id: "claude", contextWindow: 200_000 }, + config: makeDefaultConfig({ maxContextLimit: "50%" }), + contextUsage: { tokens: 90_000, contextWindow: 200_000, percent: 45 }, + }); + + const model = buildDcpPanelModel(input); + + expect(field(model, "Model")).toBe("anthropic/claude"); + expect(field(model, "Max limit")).toBe("100000"); + expect(state.modelProvider).toBe("stale"); + expect(state.modelId).toBe("stale-model"); + expect(state.modelContextWindow).toBe(100); + }); + + it("shows explicit unavailable values when data is missing", () => { + const state = createSessionState(); + addBlock(state, { blockId: 1 }); + const input = baseInput({ + state, + model: undefined, + contextUsage: undefined, + lifetimeStats: undefined, + compressToolActive: false, + }); + + const model = buildDcpPanelModel(input); + + expect(field(model, "Model")).toBe("Unavailable"); + expect(field(model, "Context")).toBe("Unavailable"); + expect(field(model, "Max limit")).toBe("Unavailable"); + expect(field(model, "Min limit")).toBe("Unavailable"); + expect(field(model, "Compress tool")).toBe("inactive"); + expect(statistic(model, "Lifetime tokens saved")).toBe("Unavailable"); + expect(statistic(model, "Lifetime sessions")).toBe("Unavailable"); + }); + + it("sorts block rows numerically and distinguishes block states", () => { + const state = createSessionState(); + addBlock(state, { blockId: 3, active: true, topic: "three" }); + addBlock(state, { blockId: 1, active: true, topic: "one" }); + addBlock(state, { + blockId: 2, + active: false, + deactivatedByUser: true, + topic: "two", + }); + addBlock(state, { blockId: 5, active: false, deactivatedByUser: false, topic: "five" }); + + const model = buildDcpPanelModel(baseInput({ state })); + + expect(model.blocks.map((row) => row.id)).toEqual(["block:1", "block:2", "block:3", "block:5"]); + + const active = model.blocks.find((row) => row.id === "block:1"); + expect(active?.action).toEqual({ type: "toggle-block", blockId: 1 }); + expect(active?.unavailableReason).toBeUndefined(); + + const deactivated = model.blocks.find((row) => row.id === "block:2"); + expect(deactivated?.action).toEqual({ type: "toggle-block", blockId: 2 }); + expect(deactivated?.detail).toContain("Reactivate"); + + const inactive = model.blocks.find((row) => row.id === "block:5"); + expect(inactive?.action).toBeUndefined(); + expect(inactive?.unavailableReason).toBeDefined(); + }); + + it("marks policy-disabled actions unavailable with the policy message", () => { + const state = createSessionState(); + addBlock(state, { blockId: 1, active: true }); + + const model = buildDcpPanelModel( + baseInput({ state, config: { ...makeDefaultConfig(), enabled: false } }), + ); + + const manual = model.actions.find((row) => row.id === "toggle-manual"); + expect(manual?.action).toEqual({ type: "toggle-manual" }); + expect(manual?.unavailableReason).toBe("DCP is disabled by configuration."); + const close = model.actions.find((row) => row.id === "close"); + expect(close?.unavailableReason).toBeUndefined(); + expect(model.blocks[0]?.unavailableReason).toBe("DCP is disabled by configuration."); + }); + + it("does not fabricate context values when usage tokens are null", () => { + const model = buildDcpPanelModel( + baseInput({ contextUsage: { tokens: null, contextWindow: 200_000, percent: null } }), + ); + expect(field(model, "Context")).toBe("Unavailable"); + }); + + it("prefers the session permission override over the configured default", () => { + const state = createSessionState(); + state.compressPermission = "ask"; + const model = buildDcpPanelModel( + baseInput({ state, config: makeDefaultConfig({ permission: "deny" }) }), + ); + expect(field(model, "Permission")).toBe("ask"); + }); +}); + +describe("applyDcpPanelAction", () => { + function capabilities(config: DcpConfig, state: SessionState) { + return getDcpCapabilities(config, state, "test", "test-model"); + } + + it("toggles manual mode from both states", () => { + const config = makeDefaultConfig(); + const state = createSessionState(); + + expect( + applyDcpPanelAction({ type: "toggle-manual" }, state, config, capabilities(config, state)), + ).toMatchObject({ permitted: true, message: expect.stringContaining("on") }); + expect(state.manualMode).toBe("active"); + + expect( + applyDcpPanelAction({ type: "toggle-manual" }, state, config, capabilities(config, state)), + ).toMatchObject({ permitted: true, message: expect.stringContaining("off") }); + expect(state.manualMode).toBe(false); + }); + + it("cycles permission using the configured fallback", () => { + const config = makeDefaultConfig({ permission: "deny" }); + const state = createSessionState(); + + const result = applyDcpPanelAction( + { type: "cycle-permission" }, + state, + config, + capabilities(config, state), + ); + + expect(result).toEqual({ permitted: true, message: "Compress permission: allow" }); + expect(state.compressPermission).toBe("allow"); + }); + + it("sweeps eligible outputs", () => { + const config = makeDefaultConfig(); + const state = createSessionState(); + const result = applyDcpPanelAction( + { type: "sweep" }, + state, + config, + capabilities(config, state), + ); + expect(result.permitted).toBe(true); + expect(result.message).toContain("Sweep complete"); + }); + + it("deactivates an active block and reactivates a user-deactivated block", () => { + const config = makeDefaultConfig(); + const state = createSessionState(); + const block = addBlock(state, { blockId: 1, active: true }); + + const deactivated = applyDcpPanelAction( + { type: "toggle-block", blockId: 1 }, + state, + config, + capabilities(config, state), + ); + expect(deactivated).toMatchObject({ permitted: true }); + expect(block.active).toBe(false); + expect(block.deactivatedByUser).toBe(true); + + const reactivated = applyDcpPanelAction( + { type: "toggle-block", blockId: 1 }, + state, + config, + capabilities(config, state), + ); + expect(reactivated).toMatchObject({ permitted: true }); + expect(block.active).toBe(true); + expect(block.deactivatedByUser).toBe(false); + }); + + it("returns the stale block message for a missing block", () => { + const config = makeDefaultConfig(); + const state = createSessionState(); + const result = applyDcpPanelAction( + { type: "toggle-block", blockId: 99 }, + state, + config, + capabilities(config, state), + ); + expect(result).toEqual({ permitted: true, message: "Block 99 not found." }); + }); + + it("rejects every mutation when the pipeline is disabled", () => { + const config = { ...makeDefaultConfig(), enabled: false }; + const state = createSessionState(); + state.manualMode = false; + const block = addBlock(state, { blockId: 1, active: true }); + const caps = capabilities(config, state); + + for (const action of [ + { type: "toggle-manual" } as const, + { type: "cycle-permission" } as const, + { type: "sweep" } as const, + { type: "toggle-block", blockId: 1 } as const, + ]) { + expect(applyDcpPanelAction(action, state, config, caps)).toEqual({ + permitted: false, + message: "DCP is disabled by configuration.", + }); + } + + expect(state.manualMode).toBe(false); + expect(state.compressPermission).toBeUndefined(); + expect(block.active).toBe(true); + }); + + it("allows mutations when only the permission is denied", () => { + const config = makeDefaultConfig({ permission: "deny" }); + const state = createSessionState(); + const result = applyDcpPanelAction( + { type: "toggle-manual" }, + state, + config, + capabilities(config, state), + ); + expect(result.permitted).toBe(true); + expect(state.manualMode).toBe("active"); + }); + + it("closes without mutation", () => { + const config = makeDefaultConfig(); + const state = createSessionState(); + const result = applyDcpPanelAction( + { type: "close" }, + state, + config, + capabilities(config, state), + ); + expect(result).toEqual({ permitted: false }); + expect(state.manualMode).toBe(false); + }); +}); From bcf41180e9299384810c8368c564a277828e678f Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Mon, 5 Oct 2026 21:06:18 -0400 Subject: [PATCH 05/11] feat: add interactive DCP status panel --- package.json | 2 + pnpm-lock.yaml | 3 + src/commands/help.ts | 1 + src/commands/register.ts | 8 + src/ui/panel.ts | 277 +++++++++++++++++++++ tests/commands-help.test.ts | 1 + tests/commands-register.test.ts | 3 +- tests/extension-harness.ts | 5 +- tests/package-metadata.test.ts | 14 ++ tests/panel.test.ts | 410 ++++++++++++++++++++++++++++++++ 10 files changed, 720 insertions(+), 4 deletions(-) create mode 100644 src/ui/panel.ts create mode 100644 tests/panel.test.ts diff --git a/package.json b/package.json index 48fb83f..b51c7bf 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@biomejs/biome": "^2.5.15", "@earendil-works/pi-agent-core": "^1.0.1", "@earendil-works/pi-coding-agent": "^1.0.1", + "@earendil-works/pi-tui": "^1.0.1", "@types/node": "^26.6.3", "tsx": "^4.23.15", "typebox": "^1.3.34", @@ -63,6 +64,7 @@ "peerDependencies": { "@earendil-works/pi-agent-core": "*", "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", "typebox": "*" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a156fd..2d9878a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@earendil-works/pi-coding-agent': specifier: ^1.0.1 version: 1.0.1(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(ws@8.21.0) + '@earendil-works/pi-tui': + specifier: ^1.0.1 + version: 1.0.1 '@types/node': specifier: ^26.6.3 version: 26.6.4 diff --git a/src/commands/help.ts b/src/commands/help.ts index fe15561..d01133c 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -2,6 +2,7 @@ export function helpCommand(): string { return [ "DCP Commands:", "", + " dcp - Open the interactive DCP panel", " dcp:help - Show this help", " dcp:context - Show context usage breakdown", " dcp:stats - Show compression statistics", diff --git a/src/commands/register.ts b/src/commands/register.ts index e2bcf49..a449910 100644 --- a/src/commands/register.ts +++ b/src/commands/register.ts @@ -13,6 +13,7 @@ import { permissionCommand } from "./permission.ts"; import { recompressCommand } from "./recompress.ts"; import { statsCommand } from "./stats.ts"; import { sweepCommand } from "./sweep.ts"; +import { openDcpPanel } from "../ui/panel.ts"; export function registerDcpCommands( pi: ExtensionAPI, @@ -133,4 +134,11 @@ export function registerDcpCommands( ctx.ui.notify(message, "info"); }, }); + + pi.registerCommand("dcp", { + description: "Open the interactive DCP panel", + handler: async (_args, ctx) => { + await openDcpPanel(state, config, ctx, onStateChange, () => pi.getActiveTools()); + }, + }); } diff --git a/src/ui/panel.ts b/src/ui/panel.ts new file mode 100644 index 0000000..8cda1e5 --- /dev/null +++ b/src/ui/panel.ts @@ -0,0 +1,277 @@ +import * as path from "node:path"; +import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui"; +import type { Theme } from "@earendil-works/pi-coding-agent"; +import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import type { DcpConfig } from "../config.ts"; +import type { SessionState } from "../state/types.ts"; +import { getDcpCapabilities } from "../capabilities.ts"; +import { loadAllSessionStats, type LifetimeStats } from "../state/persistence.ts"; +import { + applyDcpPanelAction, + buildDcpPanelModel, + type DcpPanelAction, + type DcpPanelModel, + type DcpPanelRow, +} from "./panel-model.ts"; + +export interface DcpPanelComponentOptions { + theme: Theme; + getHeight: () => number; + requestRender: () => void; + onAction: (action: DcpPanelAction) => void; + initialRowId?: string; + lastActionMessage?: string; +} + +function flattenLabel(value: string): string { + return value.replace(/\s*\n\s*/g, " ").trim(); +} + +/** + * Bounded, scrolling terminal component for the DCP panel. + * + * Renders a compact status/statistics header, a scrolling action/block list, and + * a close-key footer. Performs no domain mutations; it only tracks selection and + * reports one action back to the controller. + */ +export class DcpPanelComponent { + private readonly model: DcpPanelModel; + private readonly options: DcpPanelComponentOptions; + private readonly rows: DcpPanelRow[]; + private readonly selectableIndices: number[]; + private selectedPosition = 0; + private scrollOffset = 0; + private completed = false; + + constructor(model: DcpPanelModel, options: DcpPanelComponentOptions) { + this.model = model; + this.options = options; + this.rows = [...model.actions, ...model.blocks]; + this.selectableIndices = this.rows.flatMap((row, index) => (row.action ? [index] : [])); + if (options.initialRowId !== undefined) { + const index = this.rows.findIndex((row) => row.id === options.initialRowId); + if (index >= 0 && this.rows[index]?.action) { + this.selectedPosition = Math.max(0, this.selectableIndices.indexOf(index)); + } + } + } + + invalidate(): void { + // No cached render state; every render is recomputed from the model. + } + + handleInput(data: string): void { + if (this.completed) return; + if (matchesKey(data, Key.escape) || data === "q") { + this.complete({ type: "close" }); + return; + } + if (matchesKey(data, Key.up) || data === "k") { + this.move(-1); + return; + } + if (matchesKey(data, Key.down) || data === "j") { + this.move(1); + return; + } + if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) { + const row = this.rows[this.selectableIndices[this.selectedPosition] ?? -1]; + if (row?.action) this.complete(row.action); + } + } + + render(width: number): string[] { + try { + return this.renderThemed(width); + } catch { + return this.renderFallback(width); + } + } + + private move(delta: number): void { + if (this.selectableIndices.length === 0) return; + const next = Math.min( + Math.max(this.selectedPosition + delta, 0), + this.selectableIndices.length - 1, + ); + if (next === this.selectedPosition) return; + this.selectedPosition = next; + this.options.requestRender(); + } + + private complete(action: DcpPanelAction): void { + if (this.completed) return; + this.completed = true; + this.options.onAction(action); + } + + private statusValue(label: string): string { + return this.model.status.find((entry) => entry.label === label)?.value ?? "Unavailable"; + } + + private statisticValue(label: string): string { + return this.model.statistics.find((entry) => entry.label === label)?.value ?? "Unavailable"; + } + + private renderThemed(width: number): string[] { + const theme = this.options.theme; + const height = Math.max(1, this.options.getHeight()); + + const footer: string[] = []; + if (this.options.lastActionMessage) { + footer.push( + theme.fg( + "accent", + truncateToWidth( + `Last action: ${flattenLabel(this.options.lastActionMessage)}`, + width, + "…", + ), + ), + ); + } + footer.push( + theme.fg("dim", truncateToWidth("↑/↓ j/k navigate · Enter run · Esc/q close", width, "…")), + ); + + const header: string[] = [ + theme.bold(theme.fg("accent", "DCP Panel")), + theme.fg( + "text", + `Model ${this.statusValue("Model")} · Context ${this.statusValue("Context")}`, + ), + theme.fg( + "text", + `Limits max ${this.statusValue("Max limit")} / min ${this.statusValue("Min limit")} · Mode ${this.statusValue("Compression mode")}`, + ), + theme.fg( + "text", + `Permission ${this.statusValue("Permission")} · Manual ${this.statusValue("Manual mode")} · Pipeline ${this.statusValue("Pipeline")} · Tool ${this.statusValue("Compress tool")}`, + ), + theme.fg( + "text", + `Session saved ${this.statisticValue("Session tokens saved")} · pruned ${this.statisticValue("Session tools pruned")} · compressed ${this.statisticValue("Session messages compressed")}`, + ), + theme.fg( + "text", + `Lifetime saved ${this.statisticValue("Lifetime tokens saved")} · sessions ${this.statisticValue("Lifetime sessions")}`, + ), + ]; + + const maxHeaderLines = Math.max(0, height - footer.length - 1 - (this.rows.length > 0 ? 1 : 0)); + const headerLines = header + .slice(0, maxHeaderLines) + .map((line) => truncateToWidth(line, width, "…")); + const listHeight = Math.max(0, height - headerLines.length - footer.length - 1); + const divider = theme.fg("borderMuted", truncateToWidth("─".repeat(Math.max(0, width)), width)); + + const selectedRowIndex = this.selectableIndices[this.selectedPosition] ?? -1; + const maxScroll = Math.max(0, this.rows.length - listHeight); + this.scrollOffset = Math.min(this.scrollOffset, maxScroll); + if (selectedRowIndex >= 0 && listHeight > 0) { + if (selectedRowIndex < this.scrollOffset) this.scrollOffset = selectedRowIndex; + if (selectedRowIndex >= this.scrollOffset + listHeight) { + this.scrollOffset = selectedRowIndex - listHeight + 1; + } + } + + const listLines = this.rows + .slice(this.scrollOffset, this.scrollOffset + listHeight) + .map((row, index) => + this.renderRow(row, this.scrollOffset + index === selectedRowIndex, width), + ); + + return [...headerLines, divider, ...listLines, ...footer]; + } + + private renderRow(row: DcpPanelRow, selected: boolean, width: number): string { + const theme = this.options.theme; + const marker = selected ? "> " : " "; + const detail = row.detail === undefined ? "" : ` · ${flattenLabel(row.detail)}`; + const reason = + row.unavailableReason === undefined ? "" : ` [${flattenLabel(row.unavailableReason)}]`; + const text = `${marker}${flattenLabel(row.label)}${detail}${reason}`; + if (selected) return theme.inverse(truncateToWidth(text, width, "…")); + if (row.action === undefined) return theme.fg("dim", truncateToWidth(text, width, "…")); + return truncateToWidth(text, width, "…"); + } + + private renderFallback(width: number): string[] { + const height = Math.max(1, this.options.getHeight()); + return [ + truncateToWidth("DCP panel (rendering unavailable)", width, "…"), + "", + truncateToWidth("Esc/q close", width, "…"), + ].slice(0, height); + } +} + +/** Open the interactive DCP panel until the user closes it. */ +export async function openDcpPanel( + state: SessionState, + config: DcpConfig, + ctx: ExtensionCommandContext, + onStateChange: (ctx: ExtensionCommandContext) => void, + getActiveTools: () => string[], +): Promise { + if (ctx.mode !== "tui" || !ctx.hasUI) { + ctx.ui.notify( + "DCP panel requires interactive TUI mode. Use dcp:help for available commands.", + "error", + ); + return; + } + + const parentDir = path.resolve(ctx.sessionManager.getSessionDir(), ".."); + let lifetimeStats: LifetimeStats | undefined; + try { + lifetimeStats = await loadAllSessionStats(parentDir); + } catch { + lifetimeStats = undefined; + } + + let lastActionMessage: string | undefined; + let initialRowId: string | undefined; + + for (;;) { + const model = buildDcpPanelModel({ + state, + config, + model: ctx.model + ? { + provider: ctx.model.provider, + id: ctx.model.id, + contextWindow: ctx.model.contextWindow, + } + : undefined, + contextUsage: ctx.getContextUsage() ?? undefined, + lifetimeStats, + compressToolActive: getActiveTools().includes("compress"), + }); + + let action: DcpPanelAction | undefined; + try { + action = await ctx.ui.custom((tui, theme, _keybindings, done) => { + return new DcpPanelComponent(model, { + theme, + getHeight: () => tui.terminal.rows, + requestRender: () => tui.requestRender(), + onAction: (next) => done(next), + initialRowId, + lastActionMessage, + }); + }); + } catch { + ctx.ui.notify("DCP panel closed: interactive UI failed.", "error"); + return; + } + + if (action === undefined || action.type === "close") return; + + initialRowId = action.type === "toggle-block" ? `block:${action.blockId}` : action.type; + const capabilities = getDcpCapabilities(config, state, ctx.model?.provider, ctx.model?.id); + const result = applyDcpPanelAction(action, state, config, capabilities); + lastActionMessage = result.message; + if (result.permitted) onStateChange(ctx); + } +} diff --git a/tests/commands-help.test.ts b/tests/commands-help.test.ts index 507b7e0..4cea754 100644 --- a/tests/commands-help.test.ts +++ b/tests/commands-help.test.ts @@ -4,6 +4,7 @@ import { helpCommand } from "../src/commands/help.ts"; describe("help command", () => { it("returns help text listing all commands", () => { const result = helpCommand(); + expect(result).toContain("dcp - Open the interactive DCP panel"); expect(result).toContain("dcp:context"); expect(result).toContain("dcp:stats"); expect(result).toContain("dcp:sweep"); diff --git a/tests/commands-register.test.ts b/tests/commands-register.test.ts index 011474b..e92404c 100644 --- a/tests/commands-register.test.ts +++ b/tests/commands-register.test.ts @@ -31,7 +31,8 @@ describe("registerDcpCommands", () => { expect(registered).toContain("dcp:lifetime"); expect(registered).toContain("dcp:permission"); expect(registered).toContain("dcp:compress"); - expect(registered).toHaveLength(10); + expect(registered).toContain("dcp"); + expect(registered).toHaveLength(11); }); it("rejects every mutating command for a disabled model", async () => { diff --git a/tests/extension-harness.ts b/tests/extension-harness.ts index e1bb21b..0681e2f 100644 --- a/tests/extension-harness.ts +++ b/tests/extension-harness.ts @@ -307,8 +307,7 @@ export function createExtensionHarness(options: ExtensionHarnessOptions = {}) { } function buildCommandContext() { - return { - ...extensionContext, + return Object.assign(Object.create(extensionContext), { getSystemPromptOptions: () => ({ selectedTools: [...activeToolNames], toolSnippets: {}, @@ -326,7 +325,7 @@ export function createExtensionHarness(options: ExtensionHarnessOptions = {}) { navigateTree: async () => ({ cancelled: true }), switchSession: async () => ({ cancelled: true }), reload: async () => {}, - }; + }); } function setHarnessModel(next: HarnessModel) { diff --git a/tests/package-metadata.test.ts b/tests/package-metadata.test.ts index 7f2f3e1..232211e 100644 --- a/tests/package-metadata.test.ts +++ b/tests/package-metadata.test.ts @@ -27,4 +27,18 @@ describe("package metadata", () => { expect(manifest.devDependencies?.[packageName]).toBeDefined(); }, ); + + it("declares @earendil-works/pi-tui as a host-provided peer pinned to ^1.0.1 for development", () => { + expect(manifest.dependencies?.["@earendil-works/pi-tui"]).toBeUndefined(); + expect(manifest.peerDependencies?.["@earendil-works/pi-tui"]).toBe("*"); + expect(manifest.devDependencies?.["@earendil-works/pi-tui"]).toBe("^1.0.1"); + }); + + it("locks @earendil-works/pi-tui at v1.0.1", () => { + const lockfile = fs.readFileSync( + fileURLToPath(new URL("../pnpm-lock.yaml", import.meta.url)), + "utf8", + ); + expect(lockfile).toContain("'@earendil-works/pi-tui@1.0.1':"); + }); }); diff --git a/tests/panel.test.ts b/tests/panel.test.ts new file mode 100644 index 0000000..4339ab0 --- /dev/null +++ b/tests/panel.test.ts @@ -0,0 +1,410 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import type { Theme } from "@earendil-works/pi-coding-agent"; +import createExtension from "../src/index.ts"; +import { DcpPanelComponent } from "../src/ui/panel.ts"; +import { + buildDcpPanelModel, + type DcpPanelAction, + type DcpPanelModel, +} from "../src/ui/panel-model.ts"; +import { createSessionState } from "../src/state/state.ts"; +import * as persistence from "../src/state/persistence.ts"; +import type { CompressionBlock } from "../src/state/types.ts"; +import { makeDefaultConfig } from "./helpers.ts"; +import { createExtensionHarness, type ExtensionHarness } from "./extension-harness.ts"; + +const agentDir = `${os.tmpdir()}/dcp-panel-test-${Date.now()}-${Math.random()}`; +const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + +beforeEach(() => { + process.env.PI_CODING_AGENT_DIR = agentDir; + fs.mkdirSync(agentDir, { recursive: true }); +}); + +afterEach(() => { + fs.rmSync(agentDir, { recursive: true, force: true }); + if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; +}); + +function writeDcpConfig(config: unknown): void { + const configDir = path.join(agentDir, "extensions"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, "dcp.json"), JSON.stringify(config)); +} + +function fakeTheme(): Theme { + const identity = (text: string) => text; + return { + fg: (_color: string, text: string) => text, + bg: (_color: string, text: string) => text, + bold: identity, + italic: identity, + underline: identity, + inverse: identity, + strikethrough: identity, + } as unknown as Theme; +} + +function makeBlock(blockId: number, topic: string): CompressionBlock { + return { + blockId, + runId: 1, + active: true, + deactivatedByUser: false, + compressedTokens: 100, + summaryTokens: 10, + durationMs: 5, + mode: "range", + topic, + batchTopic: undefined, + startIndex: 0, + endIndex: 1, + anchorIndex: 0, + compressToolCallId: "call", + startKey: `k${blockId}`, + endKey: `e${blockId}`, + anchorKey: `k${blockId}`, + consumedBlockIds: [], + parentBlockIds: [], + directMessageIndices: [], + directToolIds: [], + effectiveMessageIndices: [], + effectiveToolIds: [], + createdAt: blockId, + deactivatedAt: undefined, + deactivatedByBlockId: undefined, + summary: "summary", + }; +} + +function modelWithBlocks(count: number, topic = "topic"): DcpPanelModel { + const state = createSessionState(); + for (let blockId = 1; blockId <= count; blockId++) { + state.prune.messages.blocksById.set(blockId, makeBlock(blockId, `${topic} ${blockId}`)); + } + return buildDcpPanelModel({ + state, + config: makeDefaultConfig(), + model: { provider: "test", id: "test-model", contextWindow: 200_000 }, + contextUsage: { tokens: 20_000, contextWindow: 200_000, percent: 10 }, + lifetimeStats: { + totalTokensSaved: 1_000, + totalToolsPruned: 4, + totalMessagesCompressed: 2, + sessionCount: 3, + }, + compressToolActive: true, + }); +} + +function makeComponent( + model: DcpPanelModel, + overrides: Partial<{ + theme: Theme; + getHeight: () => number; + initialRowId: string; + lastActionMessage: string; + }> = {}, +) { + const actions: DcpPanelAction[] = []; + const requestRender = vi.fn(); + const component = new DcpPanelComponent(model, { + theme: overrides.theme ?? fakeTheme(), + getHeight: overrides.getHeight ?? (() => 24), + requestRender, + onAction: (action) => actions.push(action), + initialRowId: overrides.initialRowId, + lastActionMessage: overrides.lastActionMessage, + }); + return { component, actions, requestRender }; +} + +describe("DcpPanelComponent", () => { + it.each([60, 80, 120])("bounds every rendered line to %i columns", (width) => { + const model = modelWithBlocks(40, "日本語 very long topic with wide characters"); + const { component } = makeComponent(model, { getHeight: () => 24 }); + + const lines = component.render(width); + + expect(lines.length).toBeLessThanOrEqual(24); + for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(width); + }); + + it("keeps the selection visible while scrolling and after resize", () => { + let height = 24; + const { component } = makeComponent(modelWithBlocks(30), { getHeight: () => height }); + + for (let step = 0; step < 25; step++) component.handleInput("j"); + expect(component.render(80).some((line) => line.startsWith("> "))).toBe(true); + + height = 8; + const resized = component.render(80); + expect(resized.length).toBeLessThanOrEqual(8); + expect(resized.some((line) => line.startsWith("> "))).toBe(true); + }); + + it("navigates with j/k and arrow keys and completes the selected action", () => { + const { component, actions } = makeComponent(modelWithBlocks(2)); + + component.handleInput("j"); + component.handleInput("k"); + component.handleInput("k"); + component.handleInput("\r"); + + expect(actions).toEqual([{ type: "toggle-manual" }]); + }); + + it("moves down with an arrow key", () => { + const { component, actions } = makeComponent(modelWithBlocks(2)); + component.handleInput("\u001b[B"); + component.handleInput("\r"); + expect(actions).toEqual([{ type: "cycle-permission" }]); + }); + + it("requests a render when the selection changes", () => { + const { component, requestRender } = makeComponent(modelWithBlocks(3)); + + component.handleInput("j"); + + expect(requestRender).toHaveBeenCalledTimes(1); + }); + + it("completes a close exactly once on Escape or q", () => { + const { component, actions } = makeComponent(modelWithBlocks(1)); + + component.handleInput("\u001b"); + component.handleInput("q"); + component.handleInput("\r"); + + expect(actions).toEqual([{ type: "close" }]); + }); + + it("selects the initial row by stable identity", () => { + const { component } = makeComponent(modelWithBlocks(2), { initialRowId: "sweep" }); + const selected = component.render(80).find((line) => line.startsWith("> ")); + expect(selected).toContain("Sweep"); + }); + + it("renders the last action message", () => { + const { component } = makeComponent(modelWithBlocks(1), { + lastActionMessage: "Manual mode: on. Automatic compression is paused.", + }); + expect(component.render(120).join("\n")).toContain("Manual mode: on"); + }); + + it("falls back to a bounded unthemed view with working close keys on render failure", () => { + const badTheme = { + fg: () => { + throw new Error("theme failure"); + }, + } as unknown as Theme; + const { component, actions } = makeComponent(modelWithBlocks(3), { theme: badTheme }); + + const lines = component.render(80); + + expect(lines.length).toBeLessThanOrEqual(24); + for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(80); + expect(lines.join("\n")).toContain("close"); + + component.handleInput("q"); + expect(actions).toEqual([{ type: "close" }]); + }); +}); + +function createPanelHarness(options: Parameters[0] = {}) { + return createExtensionHarness({ + activeTools: ["read", "compress"], + mode: "tui", + hasUI: true, + sessionDir: path.join(agentDir, "sessions", "session"), + ...options, + }); +} + +function drivePanel( + harness: ExtensionHarness, + queue: Array, +): DcpPanelComponent[] { + const components: DcpPanelComponent[] = []; + harness.ui.custom.mockImplementation( + (factory: unknown) => + new Promise((resolve) => { + const tui = { terminal: { rows: 24 }, requestRender: () => {} }; + const component = ( + factory as ( + tui: unknown, + theme: Theme, + keybindings: unknown, + done: (action: DcpPanelAction | undefined) => void, + ) => DcpPanelComponent + )(tui, fakeTheme(), {}, resolve); + components.push(component); + resolve(queue.shift()); + }), + ); + return components; +} + +describe("dcp panel controller", () => { + it.each([ + { name: "RPC", mode: "rpc" as const, hasUI: true }, + { name: "print", mode: "print" as const, hasUI: false }, + { name: "JSON", mode: "json" as const, hasUI: false }, + { name: "TUI without dialog UI", mode: "tui" as const, hasUI: false }, + ])("notifies once and skips lifetime/custom in $name mode", async ({ mode, hasUI }) => { + const harness = createPanelHarness({ mode, hasUI }); + createExtension(harness.api); + await harness.emit("session_start", { type: "session_start", reason: "new" }); + const loadSpy = vi.spyOn(persistence, "loadAllSessionStats"); + try { + await harness.runCommand("dcp", ""); + + expect(harness.ui.notify).toHaveBeenCalledTimes(1); + expect(harness.ui.notify).toHaveBeenCalledWith( + "DCP panel requires interactive TUI mode. Use dcp:help for available commands.", + "error", + ); + expect(harness.ui.custom).not.toHaveBeenCalled(); + expect(loadSpy).not.toHaveBeenCalled(); + } finally { + loadSpy.mockRestore(); + } + }); + + it("applies a permitted action, persists once, and rebuilds with retained selection and message", async () => { + writeDcpConfig({}); + const harness = createPanelHarness(); + createExtension(harness.api); + await harness.emit("session_start", { type: "session_start", reason: "new" }); + harness.entries.length = 0; + + const components = drivePanel(harness, [{ type: "toggle-manual" }, undefined]); + await harness.runCommand("dcp", ""); + + expect(harness.ui.custom).toHaveBeenCalledTimes(2); + expect(components).toHaveLength(2); + expect(components[1].render(120).join("\n")).toContain( + "Manual mode: on. Automatic compression is paused.", + ); + expect( + harness.entries.some( + (entry) => (entry.data as { manualMode?: unknown })?.manualMode === "active", + ), + ).toBe(true); + }); + + it("does not invoke the state callback on close", async () => { + writeDcpConfig({}); + const harness = createPanelHarness(); + createExtension(harness.api); + await harness.emit("session_start", { type: "session_start", reason: "new" }); + harness.entries.length = 0; + + drivePanel(harness, [undefined]); + await harness.runCommand("dcp", ""); + + expect(harness.entries).toHaveLength(0); + }); + + it("rejects a policy-disabled action without invoking the state callback", async () => { + writeDcpConfig({ enabled: false }); + const harness = createPanelHarness(); + createExtension(harness.api); + await harness.emit("session_start", { type: "session_start", reason: "new" }); + harness.entries.length = 0; + + const components = drivePanel(harness, [{ type: "toggle-manual" }, undefined]); + await harness.runCommand("dcp", ""); + + expect(components[1].render(120).join("\n")).toContain("DCP is disabled by configuration."); + expect(harness.entries).toHaveLength(0); + }); + + it("rechecks policy against the current context before acting", async () => { + writeDcpConfig({ disabledModels: ["test/disabled"] }); + const harness = createPanelHarness({ model: { provider: "test", id: "enabled" } }); + createExtension(harness.api); + await harness.emit("session_start", { type: "session_start", reason: "new" }); + harness.entries.length = 0; + + const components: DcpPanelComponent[] = []; + const queue: Array = [{ type: "toggle-manual" }, undefined]; + harness.ui.custom.mockImplementation( + (factory: unknown) => + new Promise((resolve) => { + const tui = { terminal: { rows: 24 }, requestRender: () => {} }; + const component = ( + factory as ( + tui: unknown, + theme: Theme, + keybindings: unknown, + done: (action: DcpPanelAction | undefined) => void, + ) => DcpPanelComponent + )(tui, fakeTheme(), {}, resolve); + components.push(component); + if (components.length === 1) harness.setModel({ provider: "test", id: "disabled" }); + resolve(queue.shift()); + }), + ); + + await harness.runCommand("dcp", ""); + + expect(components[1].render(120).join("\n")).toContain( + "DCP is disabled for the current model.", + ); + expect(harness.entries).toHaveLength(0); + }); + + it("loads lifetime once and opens with unavailable totals when loading fails", async () => { + writeDcpConfig({}); + const harness = createPanelHarness(); + createExtension(harness.api); + await harness.emit("session_start", { type: "session_start", reason: "new" }); + const loadSpy = vi + .spyOn(persistence, "loadAllSessionStats") + .mockRejectedValue(new Error("no sessions")); + try { + const components = drivePanel(harness, [undefined]); + await harness.runCommand("dcp", ""); + + expect(loadSpy).toHaveBeenCalledTimes(1); + expect(components[0].render(120).join("\n")).toContain("Lifetime saved Unavailable"); + } finally { + loadSpy.mockRestore(); + } + }); + + it("closes without looping when the custom component returns undefined", async () => { + writeDcpConfig({}); + const harness = createPanelHarness(); + createExtension(harness.api); + await harness.emit("session_start", { type: "session_start", reason: "new" }); + + drivePanel(harness, [undefined]); + await harness.runCommand("dcp", ""); + + expect(harness.ui.custom).toHaveBeenCalledTimes(1); + }); + + it("notifies once and keeps commands accessible when the custom UI rejects", async () => { + writeDcpConfig({}); + const harness = createPanelHarness(); + createExtension(harness.api); + await harness.emit("session_start", { type: "session_start", reason: "new" }); + + harness.ui.custom.mockRejectedValue(new Error("ui failed")); + await harness.runCommand("dcp", ""); + + expect(harness.ui.notify).toHaveBeenCalledTimes(1); + expect(harness.ui.notify).toHaveBeenCalledWith( + "DCP panel closed: interactive UI failed.", + "error", + ); + expect(harness.commands.has("dcp:help")).toBe(true); + }); +}); From a3f5c61eacc93b2efbf4fe3760169d40ea8b7ad9 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Mon, 5 Oct 2026 21:23:58 -0400 Subject: [PATCH 06/11] chore: prepare pi-dcp 0.10.0 --- CHANGELOG.md | 17 +++++++++++++++++ README.md | 28 +++++++++++++++++++++++++--- dcp.schema.json | 6 +++++- package.json | 2 +- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2faae2c..e996b00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## 2026-10-05 - [0.10.0] + +### Added + +- Compression permission `ask`. The `compress` tool stays exposed and requests a per-call confirmation in TUI and RPC modes. The dialog names the topic and the number of ranges or targets. Direct executions confirm too, and an approval is never reused between calls. +- Interactive `dcp` panel. One Pi-native TUI view shows the current model, context usage, resolved thresholds, compression and manual modes, permission, policy and tool availability, compression blocks, session savings, and lifetime totals. Arrow keys or `j`/`k` move, Enter runs the selected action, and Escape or `q` closes. +- Snapshot version 2, which persists the wider permission union while readers keep accepting version 1 snapshots with their historical allow/deny validation. +- `@earendil-works/pi-tui` as a host-provided `"*"` peer and a `^1.0.1` development dependency. + +### Changed + +- `dcp:permission` cycles `allow -> ask -> deny -> allow`. An undefined session override falls back to the configured permission, and the standalone command defaults to `allow`. +- Compression timing starts inside the registered `execute`, after approval and a fresh capability check. Waiting for approval, rejection, cancellation, abort, and confirmation failure create no timing, blocks, statistics, or DCP state entries. +- TUI and RPC confirm `ask`; print/JSON sessions and any mode without dialog UI fail closed with `Compression requires interactive approval`. False, cancelled, aborted, or failed confirmation returns `Compression was not approved`. +- The panel and the direct commands share the same pipeline policy guard. Globally disabled, model-disabled, and disallowed sub-agent sessions retain informational panel access but cannot mutate state. Permission denial alone does not disable sweep, manual mode, or block controls. +- Panel actions toggle manual mode, cycle permission, sweep eligible outputs, and deactivate or reactivate blocks through the existing commands. Only active blocks offer deactivation and only user-deactivated blocks offer reactivation; a stale block returns `Block not found.` + ## 2026-10-05 - [0.9.0] ### Added diff --git a/README.md b/README.md index 50f193c..6896875 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,13 @@ Use `dcp:context` to see token usage and active DCP state, `dcp:help` to list co - **Shows operational feedback** — pruning and compression can surface in toast or status notifications. - **Lets you tune behavior** — config, manual mode, runtime permission control, and schema-backed validation are all built in. +## What's new in 0.10.0 + +- **Per-call `ask` permission** — compression permission is now `allow`, `ask`, or `deny`. `ask` keeps the tool exposed and confirms every call inside the registered `execute` function, including direct executions. The dialog names the topic and the number of ranges or targets, and approvals are never reused. +- **Interactive `dcp` panel** — one Pi-native TUI view shows the current model, context usage, resolved thresholds, compression and manual modes, permission, policy and tool availability, compression blocks, session savings, and lifetime totals. Arrow keys or `j`/`k` move, Enter runs the selected action, and Escape or `q` closes. Panel actions use the same guarded commands as the direct commands and recheck policy before acting. +- **Fail-closed confirmations** — TUI and RPC can confirm; print/JSON sessions and any mode without dialog UI refuse the call. Rejection, cancellation, abort, or confirmation failure returns an error tool result and creates no compression timing, blocks, statistics, or DCP state entries. +- **Backward-readable snapshots** — version 2 adds the full permission union, while readers keep accepting version 1 allow/deny snapshots unchanged. + ## What's new in 0.9.0 - **Lifecycle-aware tool reconciliation** — DCP registers the `compress` tool during extension creation with `defaultActive: false`, then refreshes its mode-specific schema from trusted project configuration at session start. Pi's restored loadout is authoritative: a tool you deselected stays deselected across model switches, permission toggles, definition refreshes, resume, and reload. DCP remembers a temporary suppression within the current branch and restores the tool only when every policy reason clears. @@ -98,6 +105,7 @@ All commands are also discoverable in-session via `dcp:help`. | Command | Purpose | | -------------------------- | ----------------------------------------------- | +| `dcp` | Open the interactive DCP status panel | | `dcp:help` | List all available commands | | `dcp:context` | Show context usage and DCP state | | `dcp:stats` | Show compression and token savings statistics | @@ -107,9 +115,19 @@ All commands are also discoverable in-session via `dcp:help`. | `dcp:decompress ` | Deactivate a compression block | | `dcp:recompress ` | Reactivate a compression block | | `dcp:lifetime` | Show aggregate statistics across saved sessions | -| `dcp:permission` | Toggle compress permission between allow/deny | +| `dcp:permission` | Cycle compress permission (allow/ask/deny) | | `dcp:compress [focus]` | Ask Pi to run compression on stale context | +## Permission and the DCP panel + +Compression permission is exactly `allow`, `ask`, or `deny` and defaults to `allow`. `allow` exposes and runs `compress` normally. `ask` exposes it and confirms each call inside the registered execute function, including direct executions; the dialog includes the topic and the number of ranges or targets. `deny` hides the tool, removes its prompt section and nudges, and blocks defensive direct calls. `dcp:permission` cycles `allow -> ask -> deny -> allow`, using the configured permission as the fallback when the session has no override. + +TUI and RPC sessions can confirm an `ask` call. Print/JSON sessions and any mode without dialog UI fail closed with `Compression requires interactive approval`; a rejected, cancelled, aborted, or failed confirmation returns `Compression was not approved`. Waiting for approval or refusing a call creates no compression timing, blocks, statistics, or DCP state entries. + +Run `dcp` to open the single status panel in TUI mode. It shows the current model, context usage, and resolved max/min thresholds; compression mode, manual mode, permission, pipeline policy, and whether `compress` is active; session savings and lifetime totals; and every compression block, sorted numerically, with its mode, tokens, and whether it can be deactivated or reactivated. + +Use the arrow keys or `j`/`k` to move, Enter to run the selected action, and Escape or `q` to close. The list scrolls to keep the selection visible and stays bounded at 60, 80, and 120 columns. Policy-disabled actions cannot mutate state; globally disabled, model-disabled, and disallowed sub-agent sessions keep informational access only. Permission denial alone does not disable sweep, manual mode, or block controls. Existing `dcp:*` commands remain stable shortcuts and the fallback when no TUI is available. + ## Typical workflows **Default:** install it and let DCP prune duplicates and stale failed inputs automatically. @@ -118,7 +136,11 @@ All commands are also discoverable in-session via `dcp:help`. **Want manual compression control?** Use `dcp:manual on`, compress selectively, then `dcp:manual off`. -**Need to block compression temporarily?** Run `dcp:permission` to flip between `allow` and `deny`. +**Need to block compression temporarily?** Run `dcp:permission` to cycle between `allow`, `ask`, and `deny`. + +**Need to approve each compression?** Cycle `dcp:permission` until it reports `ask`. Every `compress` call then shows a confirmation in TUI or RPC; print/JSON sessions refuse the call instead. Rejection, cancellation, or abort leaves context unchanged. + +**Want everything in one place?** Run `dcp` for the interactive panel, then act on manual mode, permission, sweep, and individual blocks without leaving it. **Need compression now?** Run `dcp:compress [focus]`. It sends Pi a hidden follow-up that asks it to use the `compress` tool; it does nothing while DCP or compression permission is disabled, or while the `compress` tool is inactive. @@ -225,7 +247,7 @@ For a session using `openai-codex/gpt-5.6-sol`, DCP leaves messages unchanged, r ### `compress` - `mode` — compression mode: `"range"` or `"message"`. -- `permission` — runtime allow/deny gate for the `compress` tool; `dcp:permission` toggles it in-session. +- `permission` — compression permission: `"allow"`, `"ask"`, or `"deny"`. `allow` runs normally, `ask` keeps the tool exposed and confirms each call, and `deny` hides the tool and blocks direct calls. `dcp:permission` cycles the in-session value. - `showCompression` — when `true`, detailed notifications include the compression summary text. - `maxContextPercent` / `minContextPercent` — legacy percentage thresholds. - `maxContextLimit` / `minContextLimit` — accept a positive integer token count or a percentage string greater than 0 and at most 100 (for example `"80%"`). Anything else is dropped with a warning. diff --git a/dcp.schema.json b/dcp.schema.json index cc3d618..ac6d92c 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -91,13 +91,17 @@ "type": "string", "const": "allow" }, + { + "type": "string", + "const": "ask" + }, { "type": "string", "const": "deny" } ], "default": "allow", - "description": "Whether the compress tool is allowed to run" + "description": "Compression permission: allow, ask for approval, or deny" }, "showCompression": { "type": "boolean", diff --git a/package.json b/package.json index b51c7bf..ea277c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pi-vault/pi-dcp", - "version": "0.9.0", + "version": "0.10.0", "type": "module", "description": "Pi extension for dynamic context pruning — incremental tool output pruning and conversation compression", "author": "Lanh Hoang ", From 32eb51801c31c1e0ed395fbd4346b23c284bc884 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Mon, 5 Oct 2026 23:04:57 -0400 Subject: [PATCH 07/11] test: add SDK contract tests and fix permission panel UX regressions --- src/commands/help.ts | 2 +- src/ui/panel-model.ts | 7 +- src/ui/panel.ts | 61 ++++++++------ tests/panel.test.ts | 157 +++++++++++++++++++++++++++++++++++++ tests/sdk-contract.test.ts | 153 +++++++++++++++++++++++++++++++++++- 5 files changed, 354 insertions(+), 26 deletions(-) diff --git a/src/commands/help.ts b/src/commands/help.ts index d01133c..a10da9e 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -11,7 +11,7 @@ export function helpCommand(): string { " dcp:decompress - Deactivate a compression block", " dcp:recompress - Reactivate a deactivated block", " dcp:lifetime - Show aggregate statistics across all sessions", - " dcp:permission - Toggle compression permission", + " dcp:permission - Cycle compression permission (allow/ask/deny)", " dcp:compress [focus] - Trigger manual compression", ].join("\n"); } diff --git a/src/ui/panel-model.ts b/src/ui/panel-model.ts index 9b31ad5..d73b9eb 100644 --- a/src/ui/panel-model.ts +++ b/src/ui/panel-model.ts @@ -111,7 +111,12 @@ export function buildDcpPanelModel(input: DcpPanelModelInput): DcpPanelModel { const { state, config, model, contextUsage, lifetimeStats, compressToolActive } = input; const provider = model?.provider; const modelId = model?.id; - const capabilities = getDcpCapabilities(config, state, provider, modelId); + const capabilities = getDcpCapabilities( + config, + state, + provider ?? state.modelProvider, + modelId ?? state.modelId, + ); const policyMessage = getDcpPipelineDisabledMessage(capabilities); const viewState: SessionState = { diff --git a/src/ui/panel.ts b/src/ui/panel.ts index 8cda1e5..7d0fa3b 100644 --- a/src/ui/panel.ts +++ b/src/ui/panel.ts @@ -1,5 +1,5 @@ import * as path from "node:path"; -import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui"; +import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { Theme } from "@earendil-works/pi-coding-agent"; import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import type { DcpConfig } from "../config.ts"; @@ -24,7 +24,7 @@ export interface DcpPanelComponentOptions { } function flattenLabel(value: string): string { - return value.replace(/\s*\n\s*/g, " ").trim(); + return value.replace(/\s+/g, " ").trim(); } /** @@ -38,21 +38,18 @@ export class DcpPanelComponent { private readonly model: DcpPanelModel; private readonly options: DcpPanelComponentOptions; private readonly rows: DcpPanelRow[]; - private readonly selectableIndices: number[]; private selectedPosition = 0; private scrollOffset = 0; private completed = false; + private actionsVisible = true; constructor(model: DcpPanelModel, options: DcpPanelComponentOptions) { this.model = model; this.options = options; this.rows = [...model.actions, ...model.blocks]; - this.selectableIndices = this.rows.flatMap((row, index) => (row.action ? [index] : [])); if (options.initialRowId !== undefined) { const index = this.rows.findIndex((row) => row.id === options.initialRowId); - if (index >= 0 && this.rows[index]?.action) { - this.selectedPosition = Math.max(0, this.selectableIndices.indexOf(index)); - } + if (index >= 0) this.selectedPosition = index; } } @@ -66,6 +63,7 @@ export class DcpPanelComponent { this.complete({ type: "close" }); return; } + if (!this.actionsVisible) return; if (matchesKey(data, Key.up) || data === "k") { this.move(-1); return; @@ -75,7 +73,7 @@ export class DcpPanelComponent { return; } if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) { - const row = this.rows[this.selectableIndices[this.selectedPosition] ?? -1]; + const row = this.rows[this.selectedPosition]; if (row?.action) this.complete(row.action); } } @@ -84,16 +82,14 @@ export class DcpPanelComponent { try { return this.renderThemed(width); } catch { + this.actionsVisible = false; return this.renderFallback(width); } } private move(delta: number): void { - if (this.selectableIndices.length === 0) return; - const next = Math.min( - Math.max(this.selectedPosition + delta, 0), - this.selectableIndices.length - 1, - ); + if (this.rows.length === 0) return; + const next = Math.min(Math.max(this.selectedPosition + delta, 0), this.rows.length - 1); if (next === this.selectedPosition) return; this.selectedPosition = next; this.options.requestRender(); @@ -133,21 +129,25 @@ export class DcpPanelComponent { footer.push( theme.fg("dim", truncateToWidth("↑/↓ j/k navigate · Enter run · Esc/q close", width, "…")), ); + if (height < footer.length + 2) { + this.actionsVisible = false; + return footer.slice(-height); + } const header: string[] = [ theme.bold(theme.fg("accent", "DCP Panel")), - theme.fg( - "text", - `Model ${this.statusValue("Model")} · Context ${this.statusValue("Context")}`, - ), + theme.fg("text", `Model ${this.statusValue("Model")}`), + theme.fg("text", `Context ${this.statusValue("Context")}`), theme.fg( "text", `Limits max ${this.statusValue("Max limit")} / min ${this.statusValue("Min limit")} · Mode ${this.statusValue("Compression mode")}`, ), theme.fg( "text", - `Permission ${this.statusValue("Permission")} · Manual ${this.statusValue("Manual mode")} · Pipeline ${this.statusValue("Pipeline")} · Tool ${this.statusValue("Compress tool")}`, + `Permission ${this.statusValue("Permission")} · Manual ${this.statusValue("Manual mode")}`, ), + theme.fg("text", `Pipeline ${this.statusValue("Pipeline")}`), + theme.fg("text", `Tool ${this.statusValue("Compress tool")}`), theme.fg( "text", `Session saved ${this.statisticValue("Session tokens saved")} · pruned ${this.statisticValue("Session tools pruned")} · compressed ${this.statisticValue("Session messages compressed")}`, @@ -156,6 +156,10 @@ export class DcpPanelComponent { "text", `Lifetime saved ${this.statisticValue("Lifetime tokens saved")} · sessions ${this.statisticValue("Lifetime sessions")}`, ), + theme.fg( + "text", + `Lifetime tools ${this.statisticValue("Lifetime tools pruned")} · messages ${this.statisticValue("Lifetime messages compressed")}`, + ), ]; const maxHeaderLines = Math.max(0, height - footer.length - 1 - (this.rows.length > 0 ? 1 : 0)); @@ -163,9 +167,10 @@ export class DcpPanelComponent { .slice(0, maxHeaderLines) .map((line) => truncateToWidth(line, width, "…")); const listHeight = Math.max(0, height - headerLines.length - footer.length - 1); + this.actionsVisible = listHeight > 0; const divider = theme.fg("borderMuted", truncateToWidth("─".repeat(Math.max(0, width)), width)); - const selectedRowIndex = this.selectableIndices[this.selectedPosition] ?? -1; + const selectedRowIndex = this.rows.length > 0 ? this.selectedPosition : -1; const maxScroll = Math.max(0, this.rows.length - listHeight); this.scrollOffset = Math.min(this.scrollOffset, maxScroll); if (selectedRowIndex >= 0 && listHeight > 0) { @@ -190,7 +195,13 @@ export class DcpPanelComponent { const detail = row.detail === undefined ? "" : ` · ${flattenLabel(row.detail)}`; const reason = row.unavailableReason === undefined ? "" : ` [${flattenLabel(row.unavailableReason)}]`; - const text = `${marker}${flattenLabel(row.label)}${detail}${reason}`; + const label = flattenLabel(row.label); + const availableWidth = Math.max(0, width - visibleWidth(marker)); + // Keep the row identifiable when its policy reason also needs truncation. + const minimumLabelWidth = Math.min(visibleWidth(label), Math.floor(availableWidth / 3)); + const suffix = truncateToWidth(`${detail}${reason}`, availableWidth - minimumLabelWidth, "…"); + const labelWidth = Math.max(0, availableWidth - visibleWidth(suffix)); + const text = `${marker}${truncateToWidth(label, labelWidth, "…")}${suffix}`; if (selected) return theme.inverse(truncateToWidth(text, width, "…")); if (row.action === undefined) return theme.fg("dim", truncateToWidth(text, width, "…")); return truncateToWidth(text, width, "…"); @@ -200,9 +211,8 @@ export class DcpPanelComponent { const height = Math.max(1, this.options.getHeight()); return [ truncateToWidth("DCP panel (rendering unavailable)", width, "…"), - "", truncateToWidth("Esc/q close", width, "…"), - ].slice(0, height); + ].slice(-height); } } @@ -269,7 +279,12 @@ export async function openDcpPanel( if (action === undefined || action.type === "close") return; initialRowId = action.type === "toggle-block" ? `block:${action.blockId}` : action.type; - const capabilities = getDcpCapabilities(config, state, ctx.model?.provider, ctx.model?.id); + const capabilities = getDcpCapabilities( + config, + state, + ctx.model?.provider ?? state.modelProvider, + ctx.model?.id ?? state.modelId, + ); const result = applyDcpPanelAction(action, state, config, capabilities); lastActionMessage = result.message; if (result.permitted) onStateChange(ctx); diff --git a/tests/panel.test.ts b/tests/panel.test.ts index 4339ab0..e931260 100644 --- a/tests/panel.test.ts +++ b/tests/panel.test.ts @@ -135,6 +135,121 @@ describe("DcpPanelComponent", () => { for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(width); }); + it.each([60, 80, 120])( + "shows policy, tool availability, and all lifetime totals at %i columns", + (width) => { + const model = modelWithBlocks(1); + const { component } = makeComponent(model); + + const rendered = component.render(width).join("\n"); + + expect(rendered).toContain("Pipeline enabled"); + expect(rendered).toContain("Tool active"); + expect(rendered).toContain("Lifetime saved 1000"); + expect(rendered).toContain("sessions 3"); + expect(rendered).toContain("Lifetime tools 4"); + expect(rendered).toContain("messages 2"); + }, + ); + + it("can browse a long list of inactive blocks without activating them", () => { + const model = modelWithBlocks(40); + for (const row of model.blocks) { + row.action = undefined; + row.unavailableReason = "Inactive"; + } + const { component, actions } = makeComponent(model); + + for (let step = 0; step < 50; step++) component.handleInput("j"); + + expect(component.render(60).find((line) => line.startsWith("> "))).toContain("Block b40:"); + component.handleInput("\r"); + expect(actions).toEqual([]); + component.handleInput("q"); + expect(actions).toEqual([{ type: "close" }]); + }); + + it.each(["\n", "\r", "\r\n", "\t"])( + "keeps block labels on one terminal line for %j", + (separator) => { + const { component } = makeComponent(modelWithBlocks(1, `first${separator}second`)); + + const row = component.render(120).find((line) => line.includes("Block b1:")); + + expect(row).toContain("first second"); + expect(row).not.toMatch(/[\r\n\t]/); + }, + ); + + it.each([60, 80, 120])( + "keeps long block topics from hiding actions or state at %i columns", + (width) => { + const state = createSessionState(); + for (let id = 1; id <= 3; id++) { + const block = makeBlock(id, "very long topic ".repeat(30)); + block.active = id === 1; + block.deactivatedByUser = id === 2; + state.prune.messages.blocksById.set(id, block); + } + const { component } = makeComponent( + buildDcpPanelModel({ + state, + config: makeDefaultConfig(), + model: undefined, + contextUsage: undefined, + lifetimeStats: undefined, + compressToolActive: false, + }), + ); + + const rows = component.render(width).filter((line) => line.includes("Block b")); + + expect(rows).toHaveLength(3); + expect(rows[0]).toContain("Deactivate"); + expect(rows[1]).toContain("Reactivate"); + expect(rows[2]).toContain("Inactive"); + for (const row of rows) expect(visibleWidth(row)).toBeLessThanOrEqual(width); + }, + ); + + it("keeps policy-disabled block identifiers and actions visible at 60 columns", () => { + const state = createSessionState(); + state.prune.messages.blocksById.set(1, makeBlock(1, "long topic ".repeat(30))); + state.prune.messages.blocksById.set(2, makeBlock(2, "another long topic ".repeat(30))); + const config = makeDefaultConfig(); + config.enabled = false; + const { component } = makeComponent( + buildDcpPanelModel({ + state, + config, + model: undefined, + contextUsage: undefined, + lifetimeStats: undefined, + compressToolActive: false, + }), + ); + + const lines = component.render(60); + + for (const id of [1, 2]) { + const row = lines.find((line) => line.includes(`Block b${id}:`)); + expect(row).toContain("Deactivate"); + expect(visibleWidth(row ?? "")).toBeLessThanOrEqual(60); + } + }); + + it.each([1, 2, 3])("keeps close instructions visible within a %i-row terminal", (height) => { + const { component } = makeComponent(modelWithBlocks(2), { + getHeight: () => height, + lastActionMessage: "Manual mode: on", + }); + + const lines = component.render(60); + + expect(lines.length).toBeLessThanOrEqual(height); + expect(lines.join("\n")).toContain("Esc/q close"); + }); + it("keeps the selection visible while scrolling and after resize", () => { let height = 24; const { component } = makeComponent(modelWithBlocks(30), { getHeight: () => height }); @@ -214,6 +329,23 @@ describe("DcpPanelComponent", () => { component.handleInput("q"); expect(actions).toEqual([{ type: "close" }]); }); + + it("does not run unseen actions while rendering is unavailable", () => { + const badTheme = { + fg: () => { + throw new Error("theme failure"); + }, + } as unknown as Theme; + const { component, actions } = makeComponent(modelWithBlocks(3), { theme: badTheme }); + + component.render(60); + component.handleInput("j"); + component.handleInput("\r"); + + expect(actions).toEqual([]); + component.handleInput("q"); + expect(actions).toEqual([{ type: "close" }]); + }); }); function createPanelHarness(options: Parameters[0] = {}) { @@ -360,6 +492,31 @@ describe("dcp panel controller", () => { expect(harness.entries).toHaveLength(0); }); + it("preserves command policy when the current model is unavailable", async () => { + writeDcpConfig({ disabledModels: ["test/disabled"] }); + const harness = createPanelHarness({ model: { provider: "test", id: "disabled" } }); + createExtension(harness.api); + await harness.emit("session_start", { type: "session_start", reason: "new" }); + harness.context.model = undefined; + harness.entries.length = 0; + + await harness.runCommand("dcp:manual", "on"); + const components = drivePanel(harness, [{ type: "toggle-manual" }, undefined]); + await harness.runCommand("dcp", ""); + + expect(harness.ui.notify).toHaveBeenCalledWith( + "DCP is disabled for the current model.", + "info", + ); + expect(harness.entries).toHaveLength(0); + expect(components[0].render(120).join("\n")).toContain( + "Pipeline DCP is disabled for the current model.", + ); + expect(components[1].render(120).join("\n")).toContain( + "DCP is disabled for the current model.", + ); + }); + it("loads lifetime once and opens with unavailable totals when loading fails", async () => { writeDcpConfig({}); const harness = createPanelHarness(); diff --git a/tests/sdk-contract.test.ts b/tests/sdk-contract.test.ts index 553aa88..a3fec31 100644 --- a/tests/sdk-contract.test.ts +++ b/tests/sdk-contract.test.ts @@ -1,7 +1,8 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core"; import { createAgentSession, DefaultResourceLoader, @@ -11,6 +12,7 @@ import { } from "@earendil-works/pi-coding-agent"; import type { AgentSession, ExtensionContext } from "@earendil-works/pi-coding-agent"; import createExtension from "../src/index.ts"; +import * as sessionState from "../src/state/state.ts"; const FAKE_MODEL = { provider: "test", @@ -83,12 +85,161 @@ async function createHostSession( afterEach(() => { for (const session of sessions.splice(0)) session.dispose(); + vi.restoreAllMocks(); for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousAgentDir; }); describe("DCP public SDK contract", () => { + it.each([true, false])( + "runs the complete host lifecycle with deferred approval %s", + async (approved) => { + const stateFactory = vi.spyOn(sessionState, "createSessionState"); + const { session } = await createHostSession({ + setup: (dir) => { + fs.mkdirSync(path.join(dir, "extensions"), { recursive: true }); + fs.writeFileSync( + path.join(dir, "extensions", "dcp.json"), + '{"compress":{"permission":"ask"}}', + ); + }, + }); + // Observe the real extension state without replacing its implementation. + const state = stateFactory.mock.results[0]?.value; + expect(state).toBeDefined(); + let resolveApproval!: (approved: boolean) => void; + const approval = new Promise((resolve) => { + resolveApproval = resolve; + }); + let resolveConfirmationEntered!: () => void; + const confirmationEntered = new Promise((resolve) => { + resolveConfirmationEntered = resolve; + }); + const sequence: string[] = []; + let now = 1_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + session.extensionRunner.setUIContext( + { + ...session.extensionRunner.getUIContext(), + confirm: async () => { + sequence.push("confirm"); + resolveConfirmationEntered(); + return approval; + }, + }, + "rpc", + ); + const beforeToolCall = session.agent.beforeToolCall; + session.agent.beforeToolCall = async (context, signal) => { + sequence.push("tool_call"); + return beforeToolCall?.(context, signal); + }; + const afterToolCall = session.agent.afterToolCall; + session.agent.afterToolCall = async (context, signal) => { + const result = await afterToolCall?.(context, signal); + now += 7; + return result; + }; + const dcpEntries = () => + session.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom" && entry.customType === "pi-dcp-state"); + let entriesAtEnd = -1; + let isErrorAtEnd: boolean | undefined; + session.agent.subscribe((event) => { + if (event.type === "tool_execution_start") sequence.push(event.type); + if (event.type === "tool_execution_end") { + sequence.push(event.type); + entriesAtEnd = dcpEntries().length; + isErrorAtEnd = event.isError; + } + }); + let responses = 0; + session.agent.getApiKey = () => undefined; + session.agent.streamFunction = () => { + const firstResponse = responses++ === 0; + const message = { + role: "assistant", + content: firstResponse + ? [ + { + type: "toolCall", + id: "host-compress", + name: "compress", + arguments: { + topic: "Host lifecycle", + content: [{ startId: "m1", endId: "m1", summary: "summary" }], + }, + }, + ] + : [{ type: "text", text: "Finished" }], + api: "openai-completions", + provider: "test", + model: "test-model", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: firstResponse ? "toolUse" : "stop", + timestamp: now, + } satisfies Extract; + // Only the provider stream is substituted; Pi owns all tool lifecycle events. + return { + async *[Symbol.asyncIterator]() { + yield { type: "done", reason: message.stopReason, message }; + }, + result: async () => message, + } as unknown as Awaited>; + }; + session.sessionManager.appendMessage({ + role: "user", + content: "Older conversation to compress", + timestamp: 1, + }); + const running = session.agent.prompt("Please compress the older conversation"); + await Promise.race([ + confirmationEntered, + running.then(() => { + throw new Error("Agent finished before confirmation"); + }), + ]); + expect(sequence).toEqual(["tool_execution_start", "tool_call", "confirm"]); + expect(state.compressionTiming.startTimes.size).toBe(0); + expect(state.prune.messages.blocksById.size).toBe(0); + const statsBefore = { ...state.stats }; + const entriesBefore = dcpEntries().length; + now += 5_000; + resolveApproval(approved); + await running; + + expect(sequence).toEqual([ + "tool_execution_start", + "tool_call", + "confirm", + "tool_execution_end", + ]); + expect(responses).toBe(2); + expect(isErrorAtEnd).toBe(!approved); + expect(state.compressionTiming.startTimes.size).toBe(0); + if (approved) { + expect([...state.prune.messages.blocksById.values()]).toMatchObject([ + { compressToolCallId: "host-compress", durationMs: 7 }, + ]); + expect(state.stats.messagesCompressed).toBe(1); + expect(entriesAtEnd).toBeGreaterThan(entriesBefore); + } else { + expect(state.prune.messages.blocksById.size).toBe(0); + expect(state.stats).toEqual(statsBefore); + expect(entriesAtEnd).toBe(entriesBefore); + } + }, + ); + it("keeps the compress tool exposed and guided for ask permission", async () => { const { session, agentDir } = await createHostSession({ setup: (dir) => { From 62fc883a93a6f8550d3c8bdfb3b66e7cfbf67d1f Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Mon, 5 Oct 2026 23:05:02 -0400 Subject: [PATCH 08/11] docs: update Phase 5 permission panel implementation plan with review fixes and test results --- ...-03-phase-05-pi-dcp-permission-panel-ux.md | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md b/docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md index 596f757..d83b906 100644 --- a/docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md +++ b/docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md @@ -380,6 +380,33 @@ Stage only this task's changed files. Suggested message: `chore: prepare pi-dcp ## Implementation Handoff The previous Phase 5 plan is superseded by this revision. The agreed design uses execute-boundary -approval, TUI/RPC dialogs, and one compact scrolling panel. Implementation has not started. -Review this saved plan before execution and choose native or subagent-driven execution if no method -has already been selected. Publishing remains a separate decision. +approval, TUI/RPC dialogs, and one compact scrolling panel. Implementation was committed through +`a3f5c61` on the Phase 5 branch; review fixes follow in the working tree. Publishing remains a +separate decision. + +## Implementation Review — 2026-10-05 + +The initial implementation passed 800 automated tests. Review regressions reproduced omitted +lifetime counters, clipped tool availability at 60 columns, inaccessible inactive block rows, +invisible actions remaining executable in rendering fallback, overflowing one/two-row terminals, +and long block topics hiding action/state text at 60/80/120 columns. +The panel now keeps those counters/status fields visible, permits browsing inactive rows without +activation, and disables actions in fallback or views too short to display them while preserving +close instructions. Block rows reserve space for action/state text before truncating topics, while +retaining their identifiers when a disabled-policy reason also needs truncation. Row +labels also normalize carriage returns and tabs to keep terminal lines valid. Command help now +describes cycling through allow/ask/deny. + +A separate regression reproduced panel mutations being allowed when the current model was missing +even though direct commands rejected the cached disabled model. Both the panel model and controller +now use the same current-or-cached policy identity as command registration; missing display data +remains explicitly unavailable. Offline public-SDK tests run Pi's actual agent loop with a simulated +provider response and deferred RPC approval. They observe the full execution-start, tool-call, +confirmation, execution-end sequence; pending approval creates no timing or blocks, rejection changes +no compression statistics or DCP entries, and a simulated 5,000 ms approval wait is excluded from +the successful 7 ms block duration. + +After these fixes, `pnpm check` passed formatting, warning-free lint, typecheck, and 819 tests across +58 files. Generated-schema comparison and package dry-run passed; benchmarks ran with +`node --import tsx scripts/benchmark.ts` to avoid the sandbox's tsx CLI IPC restriction. Live Pi +TUI/RPC verification and publishing have not been performed in this review. From 1806904e56864b1584f69d40ae5e2627843e8eb2 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Mon, 5 Oct 2026 23:14:52 -0400 Subject: [PATCH 09/11] docs: add implementation plan for renaming ui directory to tui --- .../plans/2026-10-05-rename-ui-to-tui.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-05-rename-ui-to-tui.md diff --git a/docs/superpowers/plans/2026-10-05-rename-ui-to-tui.md b/docs/superpowers/plans/2026-10-05-rename-ui-to-tui.md new file mode 100644 index 0000000..fbbdeae --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-rename-ui-to-tui.md @@ -0,0 +1,112 @@ +# Rename UI Directory to TUI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rename the complete `src/ui` directory to `src/tui` and update its consumers and documentation. + +**Architecture:** Move the three existing modules together and update direct import paths. Preserve their implementations, exports, and relative imports within the directory; package discovery already includes all of `src`. + +**Tech Stack:** TypeScript, Node.js, Pi extension/TUI APIs, Vitest, Biome, pnpm, Git. + +**Spec:** The bounded in-chat design titled “Rename `src/ui` to `src/tui`,” reproduced below. No separate spec file is required for this rename; the user subsequently requested this written plan. + +## Design + +Move `notification.ts`, `panel.ts`, and `panel-model.ts` into `src/tui`. Update source imports, imports in the four affected test files, and the two source-path references in the Phase 5 implementation plan. Preserve filenames, exported functions, types, test names, and runtime behavior. Use direct import updates without compatibility files at the old paths. + +## Global Constraints + +- Rename the whole directory, including notifications; do not split or reorganize its modules. +- Preserve existing behavior, exports, commands, permissions, persistence, and test assertions. +- Add no dependencies, compatibility re-exports, package-version changes, or new behavior tests. +- Keep package metadata unchanged: its `files` list includes `src`, and its extension entrypoint remains `src/index.ts`. +- Limit documentation edits to correcting the two paths in the existing Phase 5 plan. +- Do not publish; implementation commits must contain only this rename and its reference updates. + +## Review Focus + +- Extension startup must resolve the moved notification module; verify through the full suite. +- Command registration must resolve the moved panel; verify through panel/controller tests. +- The panel must still resolve its sibling view model; verify through panel/model tests. +- Package contents must include all three moved modules and omit the old directory; verify through the package dry-run. +- Consumers and Phase 5 documentation must contain no stale paths; verify through the scoped reference search. + +--- + +### Task 1: Move the terminal presentation directory and update references + +**Files:** + +- Rename: `src/ui/notification.ts` → `src/tui/notification.ts` — notification formatting. +- Rename: `src/ui/panel.ts` → `src/tui/panel.ts` — terminal component and controller. +- Rename: `src/ui/panel-model.ts` → `src/tui/panel-model.ts` — panel data and guarded actions. +- Modify: `src/index.ts`, `src/commands/register.ts` — notification and panel imports. +- Modify/Test: `tests/notification.test.ts`, `tests/compress-notification.test.ts`, `tests/panel.test.ts`, `tests/panel-model.test.ts` — imports only. +- Modify: `docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md` — the two module-path references. + +**Interfaces:** + +- Consumes: the existing module exports and their current callers/tests. +- Produces: the same exports and signatures at `src/tui/notification.ts`, `src/tui/panel.ts`, and `src/tui/panel-model.ts`; no new interfaces or aliases. + +- [ ] **Step 1: Establish the targeted test baseline** + +Run: `pnpm vitest run tests/notification.test.ts tests/compress-notification.test.ts tests/panel.test.ts tests/panel-model.test.ts` + +Expected: all four files pass before any edits. Investigate baseline failures before proceeding. + +- [ ] **Step 2: Update test imports to the destination paths** + +In the four listed test files, replace `../src/ui/` with `../src/tui/`. Preserve all test names, assertions, and fixtures. + +- [ ] **Step 3: Verify the new imports fail before the move** + +Run the Step 1 command. + +Expected: module-resolution failures for `src/tui` imports because the destination modules do not exist yet. + +- [ ] **Step 4: Perform the directory rename and update source consumers** + +Run: `git mv src/ui src/tui` + +Change `./ui/notification.ts` to `./tui/notification.ts` in `src/index.ts`, and `../ui/panel.ts` to `../tui/panel.ts` in `src/commands/register.ts`. Relative imports within the moved modules remain valid and require no edits. + +- [ ] **Step 5: Correct the Phase 5 documentation paths** + +In the listed Phase 5 plan, replace `src/ui/panel-model.ts` and `src/ui/panel.ts` with their `src/tui` equivalents. Preserve all other plan content. + +- [ ] **Step 6: Verify targeted tests pass after the move** + +Run the Step 1 command. + +Expected: all four files pass with their original assertions. + +- [ ] **Step 7: Run the full automated checks** + +Run: `pnpm check` + +Expected: formatting, warning-free lint, type checking, and all existing tests pass. The reviewed starting baseline is 819 tests across 58 files; this rename adds or removes no tests. + +- [ ] **Step 8: Verify package contents** + +Run: `pnpm run pack:dry-run` + +Expected: exit 0; the listing includes all three `src/tui` modules and contains no `src/ui` files. + +- [ ] **Step 9: Check stale references and diff scope** + +Run: `rg -n 'src/ui|\./ui/|\.\./ui/' src tests scripts README.md CHANGELOG.md package.json docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md` + +Expected: no matches, with ripgrep exit 1. This rename plan intentionally retains old paths to describe the migration and is excluded from the search. + +Run: `git diff HEAD --check` and `git diff HEAD --stat`. + +Expected: no whitespace errors; only the three moves, import updates in six source/test files, and two Phase 5 documentation references. Inspect the full diff to confirm module bodies and assertions are unchanged. + +- [ ] **Step 10: Commit the verified rename** + +Stage only the listed files and moves. Commit with: `refactor: rename ui directory to tui`. + +## Execution Handoff + +Native execution is recommended: this is one tightly coupled task with mechanical edits and existing coverage. Review this saved plan and select Native or Subagent-driven execution before implementation. No implementation is included in the plan-writing task. From 27d96758093c72728721ee18252820ebff515522 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Mon, 5 Oct 2026 23:24:41 -0400 Subject: [PATCH 10/11] refactor: rename ui directory to tui --- .../plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md | 4 ++-- src/commands/register.ts | 2 +- src/index.ts | 2 +- src/{ui => tui}/notification.ts | 0 src/{ui => tui}/panel-model.ts | 0 src/{ui => tui}/panel.ts | 0 tests/compress-notification.test.ts | 2 +- tests/notification.test.ts | 2 +- tests/panel-model.test.ts | 2 +- tests/panel.test.ts | 4 ++-- 10 files changed, 9 insertions(+), 9 deletions(-) rename src/{ui => tui}/notification.ts (100%) rename src/{ui => tui}/panel-model.ts (100%) rename src/{ui => tui}/panel.ts (100%) diff --git a/docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md b/docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md index d83b906..dd76da5 100644 --- a/docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md +++ b/docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md @@ -182,7 +182,7 @@ Stage only this task's changed files. Suggested message: `feat: confirm compress **Files:** -- Create: `src/ui/panel-model.ts` +- Create: `src/tui/panel-model.ts` - Modify: `src/capabilities.ts`, `src/commands/register.ts`, `src/utils/context-limits.ts`, `src/state/persistence.ts` - Test: `tests/panel-model.test.ts`, `tests/capabilities.test.ts`, `tests/commands-register.test.ts` - Test: `tests/context-limits.test.ts`, `tests/commands-lifetime.test.ts` @@ -251,7 +251,7 @@ Stage only this task's changed files. Suggested message: `feat: add guarded DCP **Files:** -- Create: `src/ui/panel.ts` +- Create: `src/tui/panel.ts` - Modify: `src/commands/register.ts`, `src/commands/help.ts`, `package.json`, `pnpm-lock.yaml` - Test: `tests/panel.test.ts`, `tests/commands-register.test.ts`, `tests/commands-help.test.ts`, `tests/package-metadata.test.ts` diff --git a/src/commands/register.ts b/src/commands/register.ts index a449910..06ced92 100644 --- a/src/commands/register.ts +++ b/src/commands/register.ts @@ -13,7 +13,7 @@ import { permissionCommand } from "./permission.ts"; import { recompressCommand } from "./recompress.ts"; import { statsCommand } from "./stats.ts"; import { sweepCommand } from "./sweep.ts"; -import { openDcpPanel } from "../ui/panel.ts"; +import { openDcpPanel } from "../tui/panel.ts"; export function registerDcpCommands( pi: ExtensionAPI, diff --git a/src/index.ts b/src/index.ts index 4aa6e01..f7dbef9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,7 @@ import { buildDetailedMessage, buildCompressNotificationMinimal, buildCompressNotificationDetailed, -} from "./ui/notification.ts"; +} from "./tui/notification.ts"; import { handleCompress, type CompressArgs, type CompressResult } from "./compress/handler.ts"; import { requestCompressionApproval } from "./compress/permission.ts"; import { stripHallucinationsFromString } from "./messages/strip.ts"; diff --git a/src/ui/notification.ts b/src/tui/notification.ts similarity index 100% rename from src/ui/notification.ts rename to src/tui/notification.ts diff --git a/src/ui/panel-model.ts b/src/tui/panel-model.ts similarity index 100% rename from src/ui/panel-model.ts rename to src/tui/panel-model.ts diff --git a/src/ui/panel.ts b/src/tui/panel.ts similarity index 100% rename from src/ui/panel.ts rename to src/tui/panel.ts diff --git a/tests/compress-notification.test.ts b/tests/compress-notification.test.ts index 1c02eb0..cfa4dff 100644 --- a/tests/compress-notification.test.ts +++ b/tests/compress-notification.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { buildCompressNotificationMinimal, buildCompressNotificationDetailed, -} from "../src/ui/notification.ts"; +} from "../src/tui/notification.ts"; describe("compression notification", () => { it("minimal: shows tokens and message count", () => { diff --git a/tests/notification.test.ts b/tests/notification.test.ts index f3b5e7c..ddf2533 100644 --- a/tests/notification.test.ts +++ b/tests/notification.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildMinimalMessage, buildDetailedMessage } from "../src/ui/notification.ts"; +import { buildMinimalMessage, buildDetailedMessage } from "../src/tui/notification.ts"; describe("buildMinimalMessage", () => { it("formats token count and prune count", () => { diff --git a/tests/panel-model.test.ts b/tests/panel-model.test.ts index 5e48e8b..9c119d3 100644 --- a/tests/panel-model.test.ts +++ b/tests/panel-model.test.ts @@ -3,7 +3,7 @@ import { applyDcpPanelAction, buildDcpPanelModel, type DcpPanelModelInput, -} from "../src/ui/panel-model.ts"; +} from "../src/tui/panel-model.ts"; import { getDcpCapabilities } from "../src/capabilities.ts"; import { createSessionState } from "../src/state/state.ts"; import { makeDefaultConfig } from "./helpers.ts"; diff --git a/tests/panel.test.ts b/tests/panel.test.ts index e931260..d9f4fde 100644 --- a/tests/panel.test.ts +++ b/tests/panel.test.ts @@ -5,12 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { visibleWidth } from "@earendil-works/pi-tui"; import type { Theme } from "@earendil-works/pi-coding-agent"; import createExtension from "../src/index.ts"; -import { DcpPanelComponent } from "../src/ui/panel.ts"; +import { DcpPanelComponent } from "../src/tui/panel.ts"; import { buildDcpPanelModel, type DcpPanelAction, type DcpPanelModel, -} from "../src/ui/panel-model.ts"; +} from "../src/tui/panel-model.ts"; import { createSessionState } from "../src/state/state.ts"; import * as persistence from "../src/state/persistence.ts"; import type { CompressionBlock } from "../src/state/types.ts"; From 4e3156a238297c42a39aaceec5ee8f02a49cac0c Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Mon, 5 Oct 2026 23:40:30 -0400 Subject: [PATCH 11/11] chore: remove outdated superpowers plans and design specs --- ...03-fix-pi-host-provided-typebox-warning.md | 127 ------ ...se-01-pi-dcp-maintenance-cache-evidence.md | 372 ---------------- ...ase-02-pi-dcp-correctness-config-safety.md | 327 -------------- ...6-10-03-phase-03-pi-dcp-compact-markers.md | 348 --------------- ...3-phase-04-pi-dcp-lifecycle-integration.md | 408 ----------------- ...-03-phase-05-pi-dcp-permission-panel-ux.md | 412 ------------------ .../plans/2026-10-05-rename-ui-to-tui.md | 112 ----- ...10-03-pi-dcp-improvement-roadmap-design.md | 272 ------------ 8 files changed, 2378 deletions(-) delete mode 100644 docs/superpowers/plans/2026-10-03-fix-pi-host-provided-typebox-warning.md delete mode 100644 docs/superpowers/plans/2026-10-03-phase-01-pi-dcp-maintenance-cache-evidence.md delete mode 100644 docs/superpowers/plans/2026-10-03-phase-02-pi-dcp-correctness-config-safety.md delete mode 100644 docs/superpowers/plans/2026-10-03-phase-03-pi-dcp-compact-markers.md delete mode 100644 docs/superpowers/plans/2026-10-03-phase-04-pi-dcp-lifecycle-integration.md delete mode 100644 docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md delete mode 100644 docs/superpowers/plans/2026-10-05-rename-ui-to-tui.md delete mode 100644 docs/superpowers/specs/2026-10-03-pi-dcp-improvement-roadmap-design.md diff --git a/docs/superpowers/plans/2026-10-03-fix-pi-host-provided-typebox-warning.md b/docs/superpowers/plans/2026-10-03-fix-pi-host-provided-typebox-warning.md deleted file mode 100644 index a087656..0000000 --- a/docs/superpowers/plans/2026-10-03-fix-pi-host-provided-typebox-warning.md +++ /dev/null @@ -1,127 +0,0 @@ -# Pi Host-Provided TypeBox Warning Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Eliminate Pi's host-provided `typebox` warning without changing pi-dcp runtime behavior, and prevent the invalid package metadata from recurring. - -**Architecture:** Treat `typebox` like the other modules supplied by Pi: the published extension declares an exact `"*"` peer so Pi's extension loader owns the runtime instance, while local development keeps a compatible pinned copy in `devDependencies`. A focused Vitest contract test protects this boundary, and package-artifact inspection verifies that the published manifest preserves it. - -**Tech Stack:** Node.js >=24.15.0, TypeScript, pnpm 11.24.0, Vitest 4, Pi 1.0.1 - -**Spec:** Approved in-chat design from 2026-10-03 (bounded change; no separate design document) - -## Global Constraints - -- `typebox` must be absent from `dependencies`, present in `peerDependencies` with the exact range `"*"`, and available locally from `devDependencies` at `^1.3.19`. -- Existing peer contracts for `@earendil-works/pi-agent-core` and `@earendil-works/pi-coding-agent` remain exact `"*"` ranges with their current development versions unchanged. -- Do not change TypeScript imports, extension behavior, package version, changelog, or release workflow. -- Do not edit the installed v0.6.0 manifest under `~/.config/pi/npm`; it remains stale until a corrected package is installed or published. -- Keep the diff limited to package metadata, its lockfile representation, and one metadata regression test. - -## Review Focus - -- A host-provided package appearing in both `dependencies` and `peerDependencies` must fail the metadata test; being a peer does not excuse a bundled runtime copy. -- A host-provided peer using a semver range such as `^1.3.19` instead of exact `"*"` must fail the metadata test. -- Removing the local development copy of a host-provided package must fail the metadata test before typechecking or tests encounter an unresolved import. -- The packed tarball must retain the corrected dependency sections rather than only making the source manifest look correct. -- Pi loading the repository directly must still resolve `typebox` and register the extension; the metadata-only fix must not require source import changes. - ---- - -### Task 1: Restore the host-provided package contract - -**Files:** - -- Create: `tests/package-metadata.test.ts` -- Modify: `package.json:53-68` -- Modify: `pnpm-lock.yaml:9-29` - -**Interfaces:** - -- Consumes: the top-level `dependencies`, `devDependencies`, and `peerDependencies` maps in `package.json`. -- Produces: the npm metadata contract that `typebox`, `@earendil-works/pi-agent-core`, and `@earendil-works/pi-coding-agent` are host-provided `"*"` peers, never runtime dependencies, and remain available for local development. - -- [ ] **Step 1: Add the failing package-metadata contract test** - -Create `tests/package-metadata.test.ts` using `node:fs`, `node:url`, and Vitest. Resolve `../package.json` from `import.meta.url`, parse it into an interface with optional `Record` dependency maps, and exercise this exact package list: - -```ts -const hostProvidedPackages = [ - "@earendil-works/pi-agent-core", - "@earendil-works/pi-coding-agent", - "typebox", -] as const; -``` - -Use one parameterized test named `"declares %s as a host-provided development peer"`. For each package, assert: - -```ts -expect(manifest.dependencies?.[packageName]).toBeUndefined(); -expect(manifest.peerDependencies?.[packageName]).toBe("*"); -expect(manifest.devDependencies?.[packageName]).toBeDefined(); -``` - -- [ ] **Step 2: Run the focused test and verify the current manifest violates the contract** - -Run: `pnpm vitest run tests/package-metadata.test.ts` - -Expected: FAIL only for `typebox`, showing it is currently `^1.3.19` under `dependencies`, missing from `peerDependencies`, and missing from `devDependencies`. - -- [ ] **Step 3: Correct `package.json` dependency ownership** - -Remove the `dependencies` block containing `typebox`; add `"typebox": "^1.3.19"` to `devDependencies`; add `"typebox": "*"` to `peerDependencies`. Preserve the existing ordering convention and leave all other versions unchanged. - -- [ ] **Step 4: Regenerate the lockfile with the repository's pnpm version** - -Run: `pnpm install --lockfile-only` - -Inspect: `git diff -- package.json pnpm-lock.yaml` - -Expected: the root importer moves `typebox` from `dependencies` to `devDependencies` with specifier `^1.3.19` and resolved version `1.3.19`; no unrelated package resolutions change. - -- [ ] **Step 5: Run the focused test and static checks** - -Run: `pnpm vitest run tests/package-metadata.test.ts && pnpm typecheck` - -Expected: the three parameterized metadata cases pass and TypeScript resolves all existing `typebox` imports without source changes. - -- [ ] **Step 6: Run the complete repository checks** - -Run: `pnpm check` - -Expected: exit 0. The baseline before this change is 51 test files and 511 passing tests; existing non-failing Biome warnings are unrelated and must not be edited as part of this task. - -- [ ] **Step 7: Verify the package artifact preserves the contract** - -Pack to a `mktemp -d` directory with `pnpm pack --pack-destination `. Extract `package/package.json` from the generated tarball and verify: - -- `dependencies.typebox` is absent. -- `peerDependencies.typebox` is exactly `"*"`. -- `devDependencies.typebox` is present in the source manifest for local development but is not required in the installed production dependency graph. -- No nested `node_modules/typebox` is included in the tarball. - -- [ ] **Step 8: Smoke-test the local extension with Pi** - -Launch an isolated interactive Pi process from the repository: - -```bash -SMOKE_ROOT=$(mktemp -d) -XDG_CONFIG_HOME="$SMOKE_ROOT/config" \ - PI_CODING_AGENT_DIR="$SMOKE_ROOT/agent" \ - PI_PACKAGE_DIR="$SMOKE_ROOT/packages" \ - pi --offline --no-session --no-extensions --no-skills \ - --no-prompt-templates --no-themes -e "$PWD" -``` - -At the prompt, enter `dcp:help`, confirm Pi displays the DCP command help, then exit. Startup must emit no extension load error. This validates that moving `typebox` out of runtime dependencies did not break local loading; the npm-install warning itself is conclusively covered by the manifest test and packed-manifest inspection until a corrected release is installed. - -- [ ] **Step 9: Review the final diff and commit** - -Run: `git diff --check && git status --short` - -Expected: only `package.json`, `pnpm-lock.yaml`, and `tests/package-metadata.test.ts` are implementation changes; this plan document may also be present if it is committed with the work. - -```bash -git add package.json pnpm-lock.yaml tests/package-metadata.test.ts -git commit -m "fix: declare typebox as a host-provided peer" -``` diff --git a/docs/superpowers/plans/2026-10-03-phase-01-pi-dcp-maintenance-cache-evidence.md b/docs/superpowers/plans/2026-10-03-phase-01-pi-dcp-maintenance-cache-evidence.md deleted file mode 100644 index 7798112..0000000 --- a/docs/superpowers/plans/2026-10-03-phase-01-pi-dcp-maintenance-cache-evidence.md +++ /dev/null @@ -1,372 +0,0 @@ -# Phase 1 — Pi DCP Maintenance and Cache Evidence Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make repository checks warning-free, remove confirmed dead state, document provenance, and collect privacy-safe prompt-cache evidence without changing pruning policy. - -**Architecture:** First turn the existing lint warnings into typed test helpers and explicit control flow, then enforce zero warnings. Remove the orphaned Pi adaptation state while preserving the version-1 snapshot wire shape and recording factual provenance. Extend the streaming session analyzer with every Pi 1.0.1 usage carrier, separate usage/latency diagnostics, and identifier-free reports; incremental pruning remains untouched. - -**Tech Stack:** Node.js, TypeScript 7, Pi session JSONL format, Vitest 5, Biome 2, GitHub Actions, pnpm - -**Spec:** `docs/superpowers/specs/2026-10-03-pi-dcp-improvement-roadmap-design.md` - -## Global Constraints - -- Execute this phase first from v0.6.0. -- Resolve warnings with types and control flow; do not add blanket Biome suppressions or weaken rules. -- Session reports must never retain message text, tool arguments, IDs, summaries, credentials, or reversible hashes of them. -- Session reports must also omit input paths, basenames, provider/model names, usage kinds, and notes; files are identified only by one-based input order. -- Do not change incremental pruning timing or strategy eligibility in this release. -- Lower `engines.node` only after the full suite passes on exact Node 22.19.0 and current Node 24. -- Treat Pi checkout `83692682f` (released v1.0.1 tag `a7229ddc`) as the session/API authority. Treat OpenCode DCP `85b6f5c` and reviewed checkout `f8232fd` as AGPL behavioral/provenance references only. - -## Review Focus - -- Required assistant or standalone usage that is absent or invalid increments `malformedUsage` once without corrupting later totals; Task 3 adds mixed valid/invalid records. -- Optional tool-result, compaction, and branch-summary usage is counted when valid, ignored when absent, and diagnosed when supplied but invalid; Task 3 covers every carrier. -- Zero-valued required, optional, and cost fields are valid; missing optional `cacheWrite1h` and `reasoning` remain absent from totals until at least one call reports them; Task 3 pins both cases. -- Invalid or out-of-order candidate timestamps increment `malformedLatency`, never `malformedUsage`, and never create negative latency; Task 3 tests both conditions. -- A Node 22.19.0 CI failure leaves the engine floor unchanged; Task 4 separates compatibility evidence from release metadata. - ---- - -### Task 1: Eliminate and enforce zero Biome warnings - -**Files:** - -- Modify: `src/messages/priority.ts` -- Modify: `tests/helpers.ts` -- Modify: `tests/compress-range.test.ts` -- Modify: `tests/context-limits.test.ts` -- Modify: `tests/index.test.ts` -- Modify: `tests/inject.test.ts` -- Modify: `tests/integration.test.ts` -- Modify: `tests/persistence.test.ts` -- Modify: `tests/pipeline.test.ts` -- Modify: `tests/priority.test.ts` -- Modify: `tests/prune.test.ts` -- Modify: `tests/tool-cache.test.ts` -- Modify: `package.json` - -**Interfaces:** - -- Consumes: current AgentMessage test fixtures and Biome's recommended rules. -- Produces: `requireDefined(value: T | undefined, label: string): T` and `getMessageText(message: AgentMessage): string` test helpers, plus a lint command that fails on warnings. - -- [ ] **Step 1: Record the warning baseline** - -Run: `pnpm biome lint --max-diagnostics=none .` - -Expected: 57 warnings across the production priority map and the listed tests. Save the output in -the implementation review notes; do not commit generated diagnostics. - -- [ ] **Step 2: Add typed test assertion helpers** - -In `tests/helpers.ts`, add `requireDefined` that throws a label-specific error for `undefined`, and -`getMessageText` that safely narrows user, assistant, and tool-result content and joins text parts. -Exercise `requireDefined` through required lookups in `tests/priority.test.ts` and -`getMessageText` through string and multipart content assertions in `tests/inject.test.ts`; do not -create a helper-only test file. - -- [ ] **Step 3: Remove production and test non-null assertions** - -Carry `ref` forward in the sortable entry inside `buildPriorityMap` so production code does not -perform a second asserted lookup. Replace test non-null assertions with `requireDefined`; use -optional property assertions only where `undefined` is the behavior under test. A missing required -fixture value must fail with a readable test error. - -- [ ] **Step 4: Remove explicit `any`, unused imports, and stale suppressions** - -Use `getMessageText` and typed AgentMessage narrowing in index, injection, integration, pipeline, -and prune tests. Remove the unused context-limit and nudge imports and the ineffective prune-test -suppression. Do not replace `any` with `unknown` followed by an equally broad unchecked cast. - -- [ ] **Step 5: Verify zero warnings before changing the script** - -Run: `pnpm biome lint --max-diagnostics=none .` - -Expected: `Found 0 warnings` or equivalent successful output with no diagnostics. - -- [ ] **Step 6: Make warnings fatal** - -Change the `lint` script to `biome lint --error-on-warnings .`. Run `pnpm lint && pnpm typecheck && pnpm test`. - -Expected: all commands pass with zero lint warnings and at least 52 test files / 514 tests. - -- [ ] **Step 7: Commit warning cleanup** - -```bash -git add src/messages/priority.ts tests/helpers.ts tests/compress-range.test.ts tests/context-limits.test.ts tests/index.test.ts tests/inject.test.ts tests/integration.test.ts tests/persistence.test.ts tests/pipeline.test.ts tests/priority.test.ts tests/prune.test.ts tests/tool-cache.test.ts package.json -git commit -m "chore: enforce warning-free checks" -``` - -### Task 2: Remove dead state and document provenance - -**Files:** - -- Modify: `src/state/types.ts` -- Modify: `src/state/state.ts` -- Create: `docs/provenance.md` -- Test: `tests/commands-manual.test.ts` -- Test: `tests/persistence.test.ts` - -**Interfaces:** - -- Consumes: runtime and persistence references to `SessionState`. -- Produces: `manualMode: false | "active"` with no pending-trigger state, unchanged version-1 snapshot compatibility, and a factual provenance record. - -- [ ] **Step 1: Prove the pending state is unused** - -Run: `rg -n "compress-pending|pendingManualTrigger|PendingManualTrigger" src tests` - -Expected: matches in `src/state/types.ts`, `src/state/state.ts`, and the stale behavioral test in -`tests/commands-manual.test.ts`; no command, handler, serializer, or restorer consumes it. Record -that persistence already normalizes runtime state to `false | "active"`. - -- [ ] **Step 2: Write narrowed-state and compatibility tests** - -In `tests/commands-manual.test.ts`, remove the stale `compress-pending` behavior test, add a no-arg -`false` assertion that reports `off`, and use `expectTypeOf()` to pin the -union to `false | "active"`. In `tests/persistence.test.ts`, use version-1 fixtures with -`manualMode: false` and `manualMode: "active"`; assert both parse, restore, and serialize unchanged. - -- [ ] **Step 3: Run tests to verify the stale state still exists** - -Run: `pnpm vitest run tests/commands-manual.test.ts tests/persistence.test.ts && pnpm typecheck` - -Expected: FAIL because `SessionState["manualMode"]` still includes `"compress-pending"`; existing -runtime tests otherwise remain green. - -- [ ] **Step 4: Remove the orphaned type and fields** - -Narrow `SessionState.manualMode`, remove `pendingManualTrigger` and its interface, and remove the -initialization/reset assignments. Do not remove `pruneTokenCounter` or other statistics in this -task because they have live command output. - -- [ ] **Step 5: Write the provenance document** - -In `docs/provenance.md`, record this repository at baseline `2471ec2` under MIT; Pi checkout -`83692682f` and released v1.0.1 tag `a7229ddc` under MIT as the API authority; OpenCode DCP v3.1.14 -at `85b6f5c` plus reviewed checkout `f8232fd` under AGPL-3.0-or-later as behavioral/provenance -references; and the earlier provenance audit at pi-dcp commit `75008c7`. Describe the historical -adaptation neutrally, prohibit future copying from AGPL source into this MIT package, and state that -the document is not a legal or clean-room determination. Replace the state-file “Adapted from” -comment with a pointer to this document rather than an independence claim. - -- [ ] **Step 6: Run state, persistence, and type checks** - -Run: `pnpm vitest run tests/persistence.test.ts tests/commands-manual.test.ts && pnpm typecheck && pnpm lint` - -Expected: PASS with zero warnings and unchanged v1 persistence behavior. - -- [ ] **Step 7: Commit dead-state and provenance cleanup** - -```bash -git add src/state/types.ts src/state/state.ts tests/commands-manual.test.ts tests/persistence.test.ts docs/provenance.md -git commit -m "refactor: remove dead manual trigger state" -``` - -### Task 3: Add privacy-safe cache and latency evidence - -**Files:** - -- Modify: `scripts/analyze-sessions.ts` -- Modify: `tests/session-analysis.test.ts` -- Create: `docs/cache-evaluation.md` - -**Interfaces:** - -- Consumes: Pi 1.0.1 usage on assistant messages, standalone usage entries, optional tool-result messages, compactions, and branch summaries, plus adjacent session-entry timestamps. -- Produces: `UsageTotals`, `LatencySummary`, and expanded `SessionCounts` fields `usage: UsageTotals`, `responseLatency: LatencySummary`, `malformedUsage: number`, and `malformedLatency: number` from `analyzeSessionFiles(files: string[]): Promise`. `SessionFileReport` exposes `fileIndex: number`, never the input path. - -- [ ] **Step 1: Define the exact report types in the tests** - -Write assertions for this exact shape: - -```ts -interface UsageTotals { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - cacheWrite1h?: number; - reasoning?: number; - totalTokens: number; - cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }; -} - -interface LatencySummary { - count: number; - totalMs: number; - minMs: number | null; - maxMs: number | null; -} -``` - -Assert that each per-file report has a one-based `fileIndex` and no `file` property, and that -`report.totals` contains the same nested aggregate types plus `files`. - -- [ ] **Step 2: Add failing coverage for every Pi usage carrier** - -Build a two-file corpus containing valid usage on an assistant message, standalone `usage` entry, -tool-result message, compaction, and branch summary. Use distinct positive values for every required -token and cost field, include `cacheWrite1h` on one call and `reasoning` on another, and include one -all-zero usage object. Assert exact per-file and corpus sums. Missing optional usage on a tool result, -compaction, or branch summary must not increment `malformedUsage`. - -- [ ] **Step 3: Add failing malformed-usage tests** - -Add required assistant and standalone entries with missing usage, plus supplied usage objects with a -negative, nonnumeric, infinite, or missing required value. Assert `malformedUsage` increments once -per invalid usage object, no partial values enter totals, and later valid entries still aggregate. -When `cacheWrite1h` or `reasoning` is absent from every valid usage, assert the corresponding total -property is absent; a reported zero must create the property with value zero. - -- [ ] **Step 4: Add failing latency tests** - -Assert that an assistant immediately following a user and one immediately following a tool result -produce `responseLatency: { count, totalMs, minMs, maxMs }`. An invalid timestamp or negative delta -for an otherwise eligible pair increments `malformedLatency`, records no sample, and does not affect -`malformedUsage`. An assistant following any other valid entry is not a latency candidate. With no -samples, both extrema are `null`. - -- [ ] **Step 5: Extend the privacy test** - -Put unique secrets in the input directory and filename, session/message/tool-call IDs, provider and -model, standalone usage kind and note, message text, model errors, tool arguments, summaries, and DCP -state. Assert the serialized report contains none of them and exposes only one-based file ordinals, -counts, numeric usage/cost values, normalized stop reasons, and existing non-reversible transition -evidence. - -- [ ] **Step 6: Run analyzer tests and verify the new contract is absent** - -Run: `pnpm vitest run tests/session-analysis.test.ts` - -Expected: FAIL because the current analyzer ignores usage and response latency and emits input paths. - -- [ ] **Step 7: Implement strict usage parsing and aggregation** - -Add `UsageTotals`, `LatencySummary`, `usageTotals(): UsageTotals`, -`latencySummary(): LatencySummary`, `parseUsage(value: unknown): UsageTotals | undefined`, and -`addUsage(target: UsageTotals, usage: UsageTotals): void` in `scripts/analyze-sessions.ts`. Required -numeric fields and all cost components must be finite and nonnegative. Validate optional -`cacheWrite1h` and `reasoning` only when present; preserve their absence until at least one valid call -reports them. Do not require `totalTokens` or `cost.total` to equal component sums because Pi -preserves provider-reported totals. - -Count required usage on assistant and standalone entries. Count optional usage on tool-result, -compaction, and branch-summary entries only when present. A required missing usage or supplied -invalid usage increments `malformedUsage` once; optional absence is ignored. - -- [ ] **Step 8: Implement identifier-free file reports and latency aggregation** - -Change the private signature to -`analyzeFile(file: string, fileIndex: number): Promise`, pass `index + 1` from -`analyzeSessionFiles`, emit `fileIndex`, and never retain the path in the returned report. Track the -immediately preceding structurally valid entry independently from DCP state-transition timestamps. -Only user/tool-result-to-assistant pairs are candidates; invalid or negative deltas increment -`malformedLatency` and are skipped. - -- [ ] **Step 9: Run analyzer tests** - -Run: `pnpm vitest run tests/session-analysis.test.ts` - -Expected: PASS with exact per-file/corpus totals, separated diagnostics, nullable empty extrema, and -no secret strings in serialized output. - -- [ ] **Step 10: Document the comparison protocol** - -In `docs/cache-evaluation.md`, define paired control/treatment corpora that use the same tasks, -provider/model version, system prompt, toolset, cache-retention setting, region, and warm/cold cache -condition. Document the analyzer command and raw metrics: uncached input, output, cache read/write, -optional one-hour cache write and reasoning totals, provider-reported total tokens, each cost -component, total cost, and latency summary. State that no pruning-policy change is justified without -comparable workloads and a clear cost or latency improvement. Do not commit raw sessions or reports. - -- [ ] **Step 11: Exercise the CLI and complete focused checks** - -Run: `pnpm vitest run tests/session-analysis.test.ts && pnpm typecheck && pnpm lint` - -Also run the CLI through the existing test's temporary synthetic JSONL fixture. Local private -sessions are optional and must never be committed. Expected: tests pass, CLI output parses as JSON, -and no report contains raw session identifiers or content. - -- [ ] **Step 12: Commit cache evidence tooling** - -```bash -git add scripts/analyze-sessions.ts tests/session-analysis.test.ts docs/cache-evaluation.md -git commit -m "feat: report privacy-safe prompt cache evidence" -``` - -### Task 4: Verify Node support and prepare v0.6.1 - -**Files:** - -- Modify: `.github/workflows/quality.yml` -- Modify: `package.json` -- Modify: `README.md` -- Modify: `CHANGELOG.md` - -**Interfaces:** - -- Consumes: the warning-free check command and current Pi minimum Node requirement. -- Produces: CI evidence for exact Node 22.19.0 and current Node 24, followed by truthful package engine and v0.6.1 metadata. - -- [ ] **Step 1: Add the Node compatibility matrix without lowering the engine floor** - -Change the quality job to a matrix containing exact `22.19.0` and major `24`, and use the matrix -value in `actions/setup-node`. Keep one pnpm install and `pnpm check` per matrix entry. Leave -`engines.node` at `>=24.15.0` in this commit so the metadata does not claim unverified support. - -- [ ] **Step 2: Commit the CI evidence gate** - -```bash -git add .github/workflows/quality.yml -git commit -m "ci: test Node 22 and 24" -``` - -- [ ] **Step 3: Run the full local release checks** - -Run: `pnpm check && pnpm run pack:dry-run && pnpm benchmark` - -Expected: exit 0 with zero lint warnings and all three current benchmark workloads completing. - -- [ ] **Step 4: Resolve the engine floor from CI evidence** - -Push or otherwise run the branch workflow and inspect both matrix jobs. If exact Node 22.19.0 and -Node 24 pass unchanged, set `engines.node` to `>=22.19.0` and update the README badge to match Pi. -If Node 22.19.0 fails, keep `>=24.15.0` and document the exact failing API or toolchain constraint; -do not add a compatibility shim in this maintenance release. Do not continue to release metadata -until one of these evidence branches is recorded. - -- [ ] **Step 5: Update release metadata** - -Add v0.6.1 notes covering warning enforcement, dead-state removal, provenance, cache reporting, and -the verified Node support result. Set the package version to `0.6.1`. Update the README's “What's -new” section and Node badge only with the outcome established in Step 4. - -- [ ] **Step 6: Run the complete release verification** - -Run: - -```bash -pnpm check -pnpm generate:schema -git diff --exit-code -- dcp.schema.json -pnpm run pack:dry-run -pnpm benchmark -git diff --check -``` - -Expected: 52 or more test files and 514 or more tests pass, lint emits zero warnings, schema -generation creates no diff, packaging succeeds, all three benchmark workloads complete, and the diff -has no whitespace errors. - -- [ ] **Step 7: Review and commit v0.6.1** - -Run: `git diff --check && git status --short` - -```bash -git add package.json README.md CHANGELOG.md -git commit -m "chore: prepare pi-dcp 0.6.1" -``` diff --git a/docs/superpowers/plans/2026-10-03-phase-02-pi-dcp-correctness-config-safety.md b/docs/superpowers/plans/2026-10-03-phase-02-pi-dcp-correctness-config-safety.md deleted file mode 100644 index 0044ca9..0000000 --- a/docs/superpowers/plans/2026-10-03-phase-02-pi-dcp-correctness-config-safety.md +++ /dev/null @@ -1,327 +0,0 @@ -# Phase 2 — Pi DCP Correctness and Configuration Safety Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make protected-file handling match current Pi tool data, reject unsafe context-limit values without losing valid configuration layers, and make `nudgeForce` select the configured message role. - -**Architecture:** Align development types with Pi 1.0.1, normalize direct and nested file paths once in the tool cache, and let every pruning strategy consume that cache. Sanitize global and trusted-project configuration independently before merging so invalid higher-precedence values inherit the previous valid layer. Preserve the existing anchored-nudge and version-1 snapshot models while recording both sides of each eligible user/assistant pair. - -**Tech Stack:** Node.js >=24.15.0, TypeScript 7, TypeBox 1.3, Pi 1.0.1 extension APIs, Vitest 5, Biome 2, pnpm - -**Spec:** `docs/superpowers/specs/2026-10-03-pi-dcp-improvement-roadmap-design.md` - -## Global Constraints - -- Execute this phase after `2026-10-03-phase-01-pi-dcp-maintenance-cache-evidence.md` and start from the clean v0.6.1 baseline. -- Use Pi revision `83692682f` as the API authority and OpenCode DCP revision `f8232fd` as behavioral reference only; do not copy AGPL source. -- Keep `@earendil-works/pi-agent-core`, `@earendil-works/pi-coding-agent`, and `typebox` as exact `"*"` peer dependencies; add no runtime dependencies. -- Preserve existing JSON configuration and legacy `filePath` inputs; do not add JSONC. -- Use current Pi exported message types for valid new fixtures; do not hide host-shape mismatches behind `as unknown as`. -- Invalid configuration warns and falls back field-by-field; it never aborts startup. -- Automatic pruning timing, canonical IDs, compression behavior, and the version-1 persisted snapshot shape remain unchanged. -- Existing version-1 snapshots with legacy user-only turn anchors must regain their preceding assistant anchor when the current messages make that pair identifiable. -- Use test-driven development for each behavioral task and commit only after its focused checks pass. - -## Review Focus - -- The checked-in Pi 1.0.0 types do not expose `nestedCalls`; dependency alignment must happen before typed nested-call fixtures are added. -- A generic `path` property on a non-file tool must not accidentally protect that tool result from pruning. -- An omitted oversized nested-call argument beside valid nested calls must not erase the valid paths. -- An invalid project override must inherit a valid global value instead of resetting directly to the built-in default. -- Soft nudging must inject into an assistant message containing only tool calls by creating a synthetic text part before the first tool call. - ---- - -### Task 1: Align Pi types and cache direct and nested protected paths - -**Files:** - -- Modify: `package.json` -- Modify: `pnpm-lock.yaml` -- Modify: `src/strategies/protected-patterns.ts` -- Modify: `src/state/tool-cache.ts` -- Modify: `src/state/types.ts` -- Modify: `src/strategies/runner.ts` -- Test: `tests/protected-patterns.test.ts` -- Test: `tests/tool-cache.test.ts` -- Test: `tests/strategy-runner.test.ts` - -**Interfaces:** - -- Consumes: Pi 1.0.1 `AgentMessage` values, including optional `toolResult.nestedCalls`, and tool arguments shaped as `Record`. -- Produces: `getFilePathsFromParameters(toolName: string, parameters: Record): string[]`, `getFilePathsFromNestedCalls(nestedCalls: unknown): string[]`, and required `ToolParameterEntry.filePaths: string[]` values containing unique normalized paths. - -- [ ] **Step 1: Align development dependencies with the authoritative Pi checkout** - -Set both Pi development dependency ranges to `^1.0.1`, leave peer ranges at `"*"`, update the lockfile, and verify the resolved packages. - -Run: `pnpm install && pnpm list @earendil-works/pi-agent-core @earendil-works/pi-coding-agent --depth 0` - -Expected: both packages resolve to 1.0.1 and `package.json` contains no new runtime dependency. - -- [ ] **Step 2: Add failing direct-path extraction tests** - -Add assertions in `tests/protected-patterns.test.ts` for the exact behavior: - -```ts -expect(getFilePathsFromParameters("read", { path: "src/secret.ts" })).toEqual([ - "src/secret.ts", -]); -expect(getFilePathsFromParameters("edit", { path: "src\\secret.ts" })).toEqual([ - "src/secret.ts", -]); -expect( - getFilePathsFromParameters("legacy-tool", { filePath: "legacy/file.ts" }), -).toEqual(["legacy/file.ts"]); -expect( - getFilePathsFromParameters("custom-tool", { path: "not-a-file-selector" }), -).toEqual([]); -``` - -Keep `matchesGlob("src\\config.ts", "src/**/*.ts")` false. Add an `isFilePathProtected(["src\\config.ts"], ["src/**/*.ts"])` assertion that is true, proving normalization occurs at the protection boundary rather than changing glob semantics. - -- [ ] **Step 3: Run the direct-path tests and confirm the intended failures** - -Run: `pnpm vitest run tests/protected-patterns.test.ts` - -Expected: FAIL because Pi-native `path` is ignored and candidate backslashes are not normalized. - -- [ ] **Step 4: Implement direct extraction and normalization** - -In `src/strategies/protected-patterns.ts`, add a private `normalizeFilePath(path: string): string` using `path.replaceAll("\\", "/")`. `getFilePathsFromParameters` must accept `parameters.path` only for `read`, `write`, and `edit`; accept `parameters.filePath` for every tool; process `path` before `filePath`; and remove empty or duplicate normalized strings while preserving first-seen order. - -Normalize candidate paths again inside `isFilePathProtected`; do not normalize or otherwise change configured glob patterns. - -- [ ] **Step 5: Add failing typed nested-call cache tests** - -In `tests/tool-cache.test.ts`, create a valid Pi 1.0.1 `AgentMessage` fixture with `satisfies AgentMessage`, not a double cast. Its parent `codemode` result must contain nested `read` arguments `{ path: "secrets/token.txt" }`, nested `edit` arguments `{ path: "src\\config.ts" }`, a duplicate `read` path, a `write` record with `argumentsBytes` but no `arguments`, and `complete: false`. - -Assert the parent cache entry has exactly `["secrets/token.txt", "src/config.ts"]`. Add direct helper assertions showing `undefined`, arrays, non-objects, missing `calls`, and malformed call elements return only paths from well-formed sibling records and never throw. - -- [ ] **Step 6: Run the cache tests and confirm `filePaths` is missing** - -Run: `pnpm vitest run tests/tool-cache.test.ts` - -Expected: FAIL because nested calls are not inspected and `ToolParameterEntry` has no `filePaths` field. - -- [ ] **Step 7: Implement nested extraction and cache aggregation** - -Implement `getFilePathsFromNestedCalls(nestedCalls: unknown): string[]`. Accept only a non-array record with a `calls` array; for each non-array record with string `name` and non-array object `arguments`, delegate to `getFilePathsFromParameters`. Ignore `complete`, status, omitted arguments, and malformed records. - -Extend the first pass of `syncToolCache` to cache nested paths with result metadata. In the second pass, populate every `ToolParameterEntry.filePaths` with the stable union of direct parent paths followed by nested result paths. Update existing manually constructed `ToolParameterEntry` fixtures with `filePaths` so type checking remains explicit. - -- [ ] **Step 8: Make every pruning strategy consume cached paths** - -Replace the three calls to `getFilePathsFromParameters` in `src/strategies/runner.ts` with `entry.filePaths`. Remove the now-unused runner import. - -In `tests/strategy-runner.test.ts`, add protected nested-path and nonmatching control cases for deduplication of repeated parent calls, stale failed-input purging, and `sweepAll` of completed parent calls. Each protected case must keep the parent call out of `state.prune.tools`; each control must remain eligible under the same strategy conditions. - -- [ ] **Step 9: Run focused path, cache, strategy, and type checks** - -Run: `pnpm vitest run tests/protected-patterns.test.ts tests/tool-cache.test.ts tests/strategy-runner.test.ts && pnpm typecheck` - -Expected: PASS, including Pi-native paths, Windows separators, unrelated generic paths, omitted nested arguments, malformed records, duplicates, and all three pruning entry points. - -- [ ] **Step 10: Commit the protected-path work** - -```bash -git add package.json pnpm-lock.yaml src/strategies/protected-patterns.ts src/state/tool-cache.ts src/state/types.ts src/strategies/runner.ts tests/protected-patterns.test.ts tests/tool-cache.test.ts tests/strategy-runner.test.ts -git commit -m "fix: protect Pi file tool paths from pruning" -``` - -### Task 2: Sanitize configuration layers and surface actionable warnings - -**Files:** - -- Create: `src/config-validation.ts` -- Modify: `src/config-schema.ts` -- Modify: `src/config.ts` -- Modify: `src/index.ts` -- Test: `tests/config.test.ts` -- Test: `tests/context-limits.test.ts` -- Test: `tests/index.test.ts` - -**Interfaces:** - -- Consumes: parsed global and optional trusted-project configuration records plus `DcpConfigSchema`. -- Produces: exported `ContextLimitSchema`; `sanitizeConfigLayer(value: Record, sourcePath: string): { value: Record; warnings: string[] }`; and the unchanged `loadConfig(...): { config: DcpConfig; warnings: string[] }` API. - -- [ ] **Step 1: Add failing global context-limit validation tests** - -In `tests/config.test.ts`, add table tests showing that `1`, `200000`, `"0.5%"`, and `"100%"` survive loading. Add table tests showing that `0`, `-1`, `1.5`, `"bogus"`, `"0%"`, and `"100.1%"` are omitted and therefore use `DEFAULT_CONFIG.compress.maxContextLimit`. Every rejected value must produce exactly one warning containing the source path and `#/compress/maxContextLimit`. - -- [ ] **Step 2: Add failing per-model and layer-precedence tests** - -Add cases proving that one invalid `modelMaxLimits` entry is removed while a valid sibling remains; `isContextOverLimits` for the removed key uses the global maximum; an invalid global maximum uses the built-in default; an invalid project maximum inherits a valid global maximum; and an invalid project per-model entry inherits the same valid global per-model entry. - -Use distinct token thresholds so each assertion proves which layer won rather than merely comparing object values. - -- [ ] **Step 3: Add failing unknown-key diagnostics tests** - -Write a global config with `unknownTop` and `compress.unknownNested`, and a project config with `strategies.deduplication.unknownStrategy`. Assert that all warnings contain the originating absolute path and the correct JSON pointer; unknown properties are absent; and valid siblings from both layers still apply. Add one key containing `~` or `/` and assert RFC 6901 escaping uses `~0` or `~1`. - -- [ ] **Step 4: Run configuration tests and confirm unsafe values are accepted or misattributed** - -Run: `pnpm vitest run tests/config.test.ts tests/context-limits.test.ts` - -Expected: FAIL for permissive limit schemas, whole-merge fallback, and missing source-qualified unknown-key warnings. - -- [ ] **Step 5: Define the shared context-limit schema** - -Export `ContextLimitSchema` from `src/config-schema.ts` as a union of `Type.Integer({ minimum: 1 })` and `Type.String({ pattern: "^\\d+(?:\\.\\d+)?%$" })`. Reuse it for global limits and values in both per-model records. Keep concrete defaults in `DEFAULT_CONFIG`; do not add defaults to optional schema fields. Set `additionalProperties: false` on declared configuration objects so the shipped schema matches runtime unknown-key handling; keep `Type.Record` maps open. - -- [ ] **Step 6: Implement schema-driven layer sanitization** - -In `src/config-validation.ts`, implement `sanitizeConfigLayer` with these rules: - -- declared object schemas recurse through only supplied properties without enforcing absent required siblings; -- `Type.Record` keys are open, but each map value is validated independently; -- arrays, unions, and scalar leaves use `Value.Check` against their field schema; -- context-limit strings additionally parse to a finite percentage greater than zero and no greater than 100; -- unknown or invalid fields are omitted, valid siblings are retained, and union errors collapse to one warning per JSON pointer; -- warnings use `${sourcePath}#${pointer}: ${message}` with RFC 6901-escaped segments. - -Do not use `Value.Clean` before diagnostics because it would erase the evidence required for unknown-key warnings. - -- [ ] **Step 7: Sanitize before merging and preserve lower-precedence values** - -In `loadConfig`, parse and sanitize each layer independently, append its warnings, and deep-merge only its sanitized value over the accumulated config. Invalid global fields leave built-in defaults intact; invalid project fields leave global values intact; invalid map entries do not delete valid siblings or lower-layer values. - -Validate the individual legacy `maxContextPercent` and `minContextPercent` upper bounds in each layer so an invalid project value inherits a valid global value. Track the effective source of both fields and include both source paths and JSON pointers when the merged `maxContextPercent > minContextPercent` relationship fails. - -- [ ] **Step 8: Add failing visible-warning lifecycle tests** - -In `tests/index.test.ts`, spy on the logger's always-persist warning path, start a session with multiple invalid fields, and assert: - -```ts -expect(ctx.ui.notify).toHaveBeenCalledTimes(1); -expect(ctx.ui.notify).toHaveBeenCalledWith( - expect.stringContaining("configuration problems"), - "warning", -); -expect(loggerWarnSpy).toHaveBeenCalledTimes(problemCount); -``` - -The notification must include the problem count and at least one affected config path. Add a `hasUI: false`, `debug: false` case that reads the actual session log and proves detailed warnings persist while `notify` remains untouched. Add an unusable log-path case proving best-effort logging cannot abort startup. Keep the initial module-level load silent; `session_start` owns the visible reload. - -- [ ] **Step 9: Surface one summary per reload** - -Update `reloadConfig` in `src/index.ts` to use an explicit always-persist logger path for each configuration detail, independent of the debug flag. If `ctx.hasUI` and warnings are nonempty, call `ctx.ui.notify` exactly once with a concise count and the participating global/project paths. Do not expose raw config values in the UI summary. - -- [ ] **Step 10: Run focused configuration, lifecycle, and type checks** - -Run: `pnpm vitest run tests/config.test.ts tests/context-limits.test.ts tests/index.test.ts && pnpm typecheck` - -Expected: PASS; invalid files cannot abort startup, valid siblings survive, lower layers win after invalid overrides, and every reload emits at most one visible warning. - -- [ ] **Step 11: Commit configuration safety** - -```bash -git add src/config-validation.ts src/config-schema.ts src/config.ts src/index.ts tests/config.test.ts tests/context-limits.test.ts tests/index.test.ts -git commit -m "fix: validate and surface DCP configuration errors" -``` - -### Task 3: Implement strong and soft paired turn nudges - -**Files:** - -- Modify: `src/messages/inject.ts` -- Test: `tests/anchored-nudges.test.ts` -- Test: `tests/inject.test.ts` -- Test: `tests/pipeline.test.ts` -- Test: `tests/persistence.test.ts` - -**Interfaces:** - -- Consumes: `config.compress.nudgeForce`, stable message keys, the existing `turnAnchors: Set`, and Pi user/assistant message content. -- Produces: paired user/assistant anchor keys whose rendered role is user for `strong` and assistant for `soft`, without changing snapshot version or field names. - -- [ ] **Step 1: Add failing paired-role selection tests** - -For a conversation ending in assistant then user with usage between the configured minimum and maximum, assert that `strong` injects `TURN_NUDGE` only into the user; `soft` injects it only into the assistant; and both stable keys are stored in either mode. - -- [ ] **Step 2: Add failing missing-pair and tool-only assistant tests** - -Add a user-only conversation and assert soft mode creates no turn anchor and injects nothing. Add a preceding assistant whose content contains only a tool call; soft mode must prepend a synthetic text part containing `TURN_NUDGE` before that tool call without changing the call object. - -- [ ] **Step 3: Add failing frequency, restoration, and force-switch tests** - -Assert frequency is measured against existing user-role turn anchors, not the assistant half of a pair. Serialize and restore a version-1 snapshot containing both keys, rerun with fresh message objects, and assert soft role filtering remains stable. Restore a legacy version-1 snapshot containing only the user key and assert the preceding assistant key is recovered before soft rendering. Change the same config to `strong` and assert injection moves to the user without adding keys. - -- [ ] **Step 4: Run focused nudge tests and confirm `nudgeForce` is ignored** - -Run: `pnpm vitest run tests/anchored-nudges.test.ts tests/inject.test.ts tests/persistence.test.ts` - -Expected: FAIL because the current implementation stores and injects only the last user key and cannot append to a tool-only assistant. - -- [ ] **Step 5: Store eligible turn pairs atomically** - -In `injectCompressNudges`, reconcile legacy user-only anchors against current messages before applying them. When a user turn qualifies, find the nearest earlier assistant by scanning backward; skip turn-anchor creation if none exists; calculate frequency using only existing keys whose current message role is `user`; and when allowed add the assistant and user stable keys in the same branch. Leave context-limit and iteration anchor creation unchanged. - -- [ ] **Step 6: Filter turn anchors by configured role during application** - -Pass `config` into the internal anchored-nudge application function. For `turnAnchors`, inject only when the current message role equals `user` for `strong` or `assistant` for `soft`. Continue applying context-limit and iteration anchors without role-filtering changes. - -When a targeted assistant has array content but no text part, insert `{ type: "text", text: nudgeText }` immediately before its first tool call. Keep the shared `appendText` behavior unchanged so message-ID injection is not broadened accidentally. - -- [ ] **Step 7: Run focused and pipeline checks** - -Run: `pnpm vitest run tests/anchored-nudges.test.ts tests/inject.test.ts tests/pipeline.test.ts tests/persistence.test.ts` - -Expected: PASS with context-limit, iteration, frequency, summary-buffer, custom-prompt, and version-1 snapshot behavior unchanged. - -- [ ] **Step 8: Commit nudge-force behavior** - -```bash -git add src/messages/inject.ts tests/anchored-nudges.test.ts tests/inject.test.ts tests/pipeline.test.ts tests/persistence.test.ts -git commit -m "fix: honor configured compression nudge force" -``` - -### Task 4: Document and verify the v0.7.0 release - -**Files:** - -- Modify: `README.md` -- Modify: `dcp.schema.json` -- Modify: `CHANGELOG.md` -- Modify: `package.json` - -**Interfaces:** - -- Consumes: the completed path, validation, and nudge behavior from Tasks 1-3. -- Produces: documented v0.7.0 package metadata and a generated JSON schema matching the TypeBox source. - -- [ ] **Step 1: Update user documentation and release notes** - -Document Pi `read`/`write`/`edit` path support and nested-call protection; positive integer and `(0, 100]` percentage limits; invalid project fields inheriting global values; one UI summary per reload with individual log warnings; and strong user-role versus soft assistant-role nudges. Add a v0.7.0 changelog entry and change the package version to `0.7.0`; leave peer ranges unchanged. - -- [ ] **Step 2: Regenerate and inspect the schema** - -Run: `pnpm run generate:schema && git diff --check` - -Expected: `dcp.schema.json` shows integer minimum `1` and the percentage pattern for all global and per-model limit values; no whitespace errors are reported. - -- [ ] **Step 3: Run complete repository verification** - -Run: `pnpm check` - -Expected: formatting, warning-free lint, TypeScript, and all Vitest tests pass. - -- [ ] **Step 4: Run package and benchmark smoke checks** - -Run: `pnpm run pack:dry-run && pnpm benchmark` - -Expected: the package payload contains only intended shipped files and all benchmark workloads complete without correctness failures. - -- [ ] **Step 5: Review the complete release diff** - -Run: `git diff --check && git status --short` - -Confirm every changed line belongs to dependency alignment, one of the three Phase 2 behaviors, generated schema, or release documentation. - -- [ ] **Step 6: Commit the release preparation** - -```bash -git add README.md dcp.schema.json CHANGELOG.md package.json -git commit -m "chore: prepare pi-dcp 0.7.0" -``` diff --git a/docs/superpowers/plans/2026-10-03-phase-03-pi-dcp-compact-markers.md b/docs/superpowers/plans/2026-10-03-phase-03-pi-dcp-compact-markers.md deleted file mode 100644 index 360f539..0000000 --- a/docs/superpowers/plans/2026-10-03-phase-03-pi-dcp-compact-markers.md +++ /dev/null @@ -1,348 +0,0 @@ -# Phase 3 — Pi DCP Compact Message Markers Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace verbose model-visible XML message IDs with compact markers while preserving canonical persisted references and every supported legacy tool input. - -**Architecture:** Keep padded message references as the sole state and snapshot representation, and treat compact markers as a model-facing display protocol. The boundary codec accepts compact or legacy tool input, lookup explicitly normalizes it back to a padded reference, and injection plus sanitization own the compact syntax without changing Pi's lifecycle integration. - -**Tech Stack:** Node.js, TypeScript 7, Pi `AgentMessage` and extension APIs, Vitest 5, the existing deterministic benchmark harness, pnpm - -**Spec:** `docs/superpowers/specs/2026-10-03-pi-dcp-improvement-roadmap-design.md` - -## Global Constraints - -- Execute this phase after `2026-10-03-phase-02-pi-dcp-correctness-config-safety.md` and start from `v0.7.0`. -- Keep state and version-1 snapshot references canonical and padded (`m0001`); do not migrate stored maps or change the snapshot version. -- Keep `parseMessageRef(ref)` canonical-only. Compact aliases belong only in `parseBoundaryId(id)`. -- Continue accepting padded tool inputs and `bN` block references, and continue sanitizing legacy XML markers. -- Emit exactly `@mN@` without priority and `@mN:P@` with priority, where `P` is an integer from 1 through 5. -- Compact input grammar is exact, case-sensitive, and whitespace-free. Reject partial padding such as `m01`, wrapped padded forms such as `@m0001@`, and malformed numeric forms. -- Compact marker sanitization must be line-bounded and must not strip ordinary prose, email addresses, mentions, or unrelated identifiers. -- Do not change dependencies, configuration, the generated schema, or Pi lifecycle hooks in this phase. - -## Review Focus - -- A copied `@m12:3@` must resolve to the stored canonical `m0012`, not merely parse successfully; Task 1 tests `resolveBoundaryIndex` directly. -- Zero, partial padding, negatives, decimals, malformed priorities, and unsafe message or block integers must be rejected; Task 1 owns the invalid-input table. -- Standalone complete and truncated markers must be stripped while `person@m1@example.com`, inline `@m1@`, `@mention`, and CRLF text survive; Task 2 covers these boundaries. -- A version-1 snapshot containing `m0001` must display `@m1@` after restore and reserialize as `m0001`; Task 3 covers both directions. -- IDs beyond four digits must remain unique and compact (`m10000` displays as `@m10000@`), and retained benchmark ratios must not regress by more than five percentage points; Tasks 1 and 4 pin these cases. - ---- - -### Task 1: Add the compact marker codec and canonical lookup - -**Files:** - -- Modify: `src/utils/message-ids.ts` -- Modify: `src/compress/search.ts` -- Test: `tests/message-ids.test.ts` -- Test: `tests/compress-search.test.ts` - -**Interfaces:** - -- Consumes: canonical padded refs, bare compact refs, wrapped compact markers, priority-wrapped markers, and block refs. -- Produces: `formatMessageMarker(ref: string, priority?: number): string`, `isCanonicalMessageRef(ref: string): boolean`, and the existing `parseBoundaryId(id: string): ParsedBoundaryId | undefined` return shape extended to compact inputs. -- Invariant: `resolveBoundaryIndex` compares `formatMessageRef(parsed.index)` with `SessionState.messageIds.byIndex`; it never compares the raw tool argument with stored state. - -- [ ] **Step 1: Add failing compact formatter and canonical-ref tests** - -In `tests/message-ids.test.ts`, assert: - -```ts -expect(formatMessageMarker("m0001")).toBe("@m1@"); -expect(formatMessageMarker("m0012", 3)).toBe("@m12:3@"); -expect(formatMessageMarker("m10000")).toBe("@m10000@"); -expect(isCanonicalMessageRef("m0001")).toBe(true); -expect(isCanonicalMessageRef("m10000")).toBe(true); -expect(isCanonicalMessageRef("m1")).toBe(false); -expect(isCanonicalMessageRef("@m1@")).toBe(false); -``` - -Assert `formatMessageMarker` throws `RangeError` for noncanonical refs and priorities `0`, `6`, and `1.5`. - -- [ ] **Step 2: Add failing boundary grammar tests** - -Use a table to assert `m1`, `m0001`, `@m1@`, and `@m1:3@` each produce -`{ type: "message", index: 1 }`, while `b3` remains `{ type: "block", blockId: 3 }`. - -Use an invalid table covering `m0`, `m0000`, `m01`, `m001`, `m00001`, `@m01@`, `@m0001@`, -negative and decimal forms, `@m1:0@`, `@m1:6@`, `@m1:1.5@`, incomplete wrappers, surrounding -whitespace, uppercase variants, and message/block values above `Number.MAX_SAFE_INTEGER`. - -- [ ] **Step 3: Add failing normalized lookup tests** - -In `tests/compress-search.test.ts`, store `m0001` at an index and assert all four accepted message -forms resolve to that index. Retain the existing block lookup and unknown-ref cases. - -- [ ] **Step 4: Run the focused tests and verify the compact cases fail** - -Run: `pnpm vitest run tests/message-ids.test.ts tests/compress-search.test.ts` - -Expected: FAIL because compact formatting/parsing does not exist and lookup still compares the raw input. - -- [ ] **Step 5: Implement the compact codec** - -Keep `formatMessageRef(index)` and `parseMessageRef(ref)` unchanged in purpose. Add a private compact -message parser for exact `m[1-9]\d*` and `@m[1-9]\d*(?::[1-5])?@` forms, validate positive safe -integers, and let `parseBoundaryId` try canonical, compact, then block parsing. Harden block parsing -to reject unsafe integers. - -Implement `formatMessageMarker` by parsing its canonical input with `parseMessageRef`; emit the -unpadded numeric index and optional validated priority. Implement `isCanonicalMessageRef` as the -strict canonical predicate. - -- [ ] **Step 6: Normalize lookup before state comparison** - -In `resolveBoundaryIndex`, convert a parsed message index back through `formatMessageRef` and scan -`byIndex` for that canonical value. Leave block anchor lookup unchanged. - -- [ ] **Step 7: Run the focused tests** - -Run: `pnpm vitest run tests/message-ids.test.ts tests/compress-search.test.ts` - -Expected: PASS for canonical, compact, wrapped, priority-wrapped, block, high-index, and invalid inputs. - -- [ ] **Step 8: Commit the codec and lookup** - -```bash -git add src/utils/message-ids.ts src/compress/search.ts tests/message-ids.test.ts tests/compress-search.test.ts -git commit -m "feat: add compact DCP message marker codec" -``` - -### Task 2: Emit, sanitize, and describe compact markers - -**Files:** - -- Modify: `src/messages/inject.ts` -- Modify: `src/messages/strip.ts` -- Modify: `src/prompts/system.ts` -- Modify: `src/prompts/compress-message.ts` -- Modify: `src/index.ts` -- Modify: `src/compress/handler.ts` -- Test: `tests/inject.test.ts` -- Test: `tests/strip.test.ts` -- Test: `tests/message-end.test.ts` -- Test: `tests/index.test.ts` -- Test: `tests/integration.test.ts` -- Test: `tests/pipeline.test.ts` -- Test: `tests/compress-cycle.test.ts` - -**Interfaces:** - -- Consumes: canonical refs from `SessionState.messageIds`, optional `PriorityMap` entries, and `formatMessageMarker` from Task 1. -- Produces: one standalone compact marker in each currently injectable user/assistant text message, plus line-bounded removal of complete or truncated compact marker lines. -- Compatibility: legacy XML cleanup stays active; `formatMessageIdTag` is removed after its only production consumer migrates. - -- [ ] **Step 1: Change injection expectations to compact markers** - -In `tests/inject.test.ts`, expect exact `@m1@` and `@m2@` markers without priorities and -`@mN:P@` with priorities. Preserve string-content conversion, stale-marker replacement, repeated -injection, missing-text behavior, and custom-prompt tests. Assert `byIndex`, `byRawId`, and `byRef` -still contain padded `m0001` and `m0002` values. - -- [ ] **Step 2: Add line-bounded sanitizer tests** - -Put the core cases in `tests/strip.test.ts` and retain `tests/message-end.test.ts` as handler-level -coverage. Test standalone `@m1@`, `@m2:4@`, `@m3`, `@m3:`, and `@m3:2` lines at string end and -between surrounding lines. Cover LF, CRLF, optional horizontal whitespace, multiple marker lines, -and existing XML cases. - -Assert these remain unchanged: - -```text -email person@m1@example.com -The literal marker @m1@ appears inline here. -@mention -prefix @m2:4@ -``` - -- [ ] **Step 3: Update integration expectations to compact output** - -Change marker-sensitive assertions in `tests/index.test.ts`, `tests/integration.test.ts`, -`tests/pipeline.test.ts`, and `tests/compress-cycle.test.ts`. Add index-level assertions for the -registered range/message tool descriptions and unavailable-ID guidance so no text claims padded -IDs are visible in the current context. - -- [ ] **Step 4: Run the focused tests and verify XML output still fails expectations** - -Run: `pnpm vitest run tests/inject.test.ts tests/strip.test.ts tests/message-end.test.ts tests/index.test.ts tests/integration.test.ts tests/pipeline.test.ts tests/compress-cycle.test.ts` - -Expected: FAIL on compact injection, compact stripping, and model-facing copy. - -- [ ] **Step 5: Emit compact markers** - -Replace `formatMessageIdTag` with `formatMessageMarker` in injection. Preserve current eligible -roles, first-text-part placement, and the two-newline separator. Do not broaden injection to -tool-result or textless messages. Remove the obsolete XML formatter and its formatter-only tests. - -- [ ] **Step 6: Add tolerant, line-bounded compact stripping** - -Strip a compact marker only when the whole line consists of optional horizontal whitespace and a -marker-like `@m` form with an optional numeric priority and optional closing `@`. This -sanitizer may remove structurally marker-like but invalid numeric values because it protects stored -assistant output; the tool-input parser remains strict. Preserve the remaining line boundary so -surrounding prose is not concatenated. Run compact stripping before the legacy XML fallbacks. - -- [ ] **Step 7: Update model-facing instructions and errors** - -Teach the system prompt that `@mN@`, `@mN:P@`, and `` are injected metadata and -must not be output. Explain priority values in `COMPRESS_MESSAGE_PROMPT`. In `src/index.ts`, use -`m1`/`@m1@` range examples, `m1`/`@m1:P@` message examples, and retain `bN` examples. In -`src/compress/handler.ts`, replace claims that `m0001` is visible with compact examples while -retaining the unavailable/pruned explanation. - -- [ ] **Step 8: Run focused and integration tests** - -Run: `pnpm vitest run tests/inject.test.ts tests/strip.test.ts tests/message-end.test.ts tests/index.test.ts tests/integration.test.ts tests/pipeline.test.ts tests/compress-cycle.test.ts` - -Expected: PASS with compact output, legacy cleanup, bounded malformed cleanup, and no listed false positives. - -- [ ] **Step 9: Commit compact injection, sanitization, and copy** - -```bash -git add src/messages/inject.ts src/messages/strip.ts src/prompts/system.ts src/prompts/compress-message.ts src/index.ts src/compress/handler.ts tests/inject.test.ts tests/strip.test.ts tests/message-end.test.ts tests/index.test.ts tests/integration.test.ts tests/pipeline.test.ts tests/compress-cycle.test.ts -git commit -m "feat: emit compact DCP message markers" -``` - -### Task 3: Prove compression and persistence compatibility - -**Files:** - -- Modify: `src/state/persistence.ts` -- Test: `tests/compress-range.test.ts` -- Test: `tests/compress-message.test.ts` -- Test: `tests/persistence.test.ts` - -**Interfaces:** - -- Consumes: the boundary codec from Task 1, compact injection from Task 2, and version-1 snapshots containing canonical padded refs. -- Produces: identical compression selections for every accepted tool-input form and an explicit canonical-only persistence boundary. - -- [ ] **Step 1: Add end-to-end range compatibility coverage** - -For each input pair, create a fresh prepared state before calling `handleCompress` so one case cannot -mutate another. Assert `m1`/`m3`, `m0001`/`m0003`, `@m1@`/`@m3@`, and -`@m1:3@`/`@m3:2@` produce identical selected boundaries and block membership. - -- [ ] **Step 2: Add end-to-end message compatibility coverage** - -For fresh prepared states, target the same message with `m2`, `m0002`, `@m2@`, and `@m2:1@`. -Assert every case records the same canonical start/end key and rejects the existing invalid cases. - -- [ ] **Step 3: Add the version-1 snapshot round-trip test** - -Restore a literal snapshot containing `messageIds.byRawId: [["user:1:0", "m0001"]]`, rebuild -runtime indices with the matching timestamped message, inject IDs, and assert the model-visible text -ends in `@m1@`. Serialize again and assert the pair remains exactly `m0001`. - -Add a malformed snapshot containing compact persisted `m1`; assert that pair is discarded rather -than normalized or migrated. Its declared `nextRefIndex` remains authoritative, consistent with the -existing version-1 recovery behavior. - -- [ ] **Step 4: Run the compatibility group** - -Run: `pnpm vitest run tests/compress-search.test.ts tests/compress-range.test.ts tests/compress-message.test.ts tests/persistence.test.ts tests/stable-ids.test.ts tests/compress-cycle.test.ts` - -Expected: PASS once Tasks 1 and 2 are complete; all accepted aliases select identical canonical state. - -- [ ] **Step 5: Make the persistence boundary explicit** - -Use `isCanonicalMessageRef` in snapshot `byRawId` pair validation instead of any permissive boundary -parser. Keep compression search on `parseBoundaryId`, and do not change the version-1 wire shape. - -- [ ] **Step 6: Run the full compatibility group again** - -Run: `pnpm vitest run tests/compress-search.test.ts tests/compress-range.test.ts tests/compress-message.test.ts tests/persistence.test.ts tests/stable-ids.test.ts tests/compress-cycle.test.ts` - -Expected: PASS with no snapshot migration and no targeting difference across accepted formats. - -- [ ] **Step 7: Commit compatibility coverage** - -```bash -git add src/state/persistence.ts tests/compress-range.test.ts tests/compress-message.test.ts tests/persistence.test.ts -git commit -m "test: preserve canonical DCP ID compatibility" -``` - -### Task 4: Enforce marker benchmarks and prepare v0.8.0 - -**Files:** - -- Modify: `tests/benchmark.test.ts` -- Modify: `benchmarks/result.json` -- Modify: `README.md` -- Modify: `CHANGELOG.md` -- Modify: `package.json` - -**Interfaces:** - -- Consumes: deterministic workloads from `scripts/benchmark.ts` and the retained v0.7.0 token totals. -- Produces: release-blocking token gates, updated retained evidence, and v0.8.0 protocol documentation. - -- [ ] **Step 1: Add deterministic benchmark gates** - -Locate each workload by name and assert: - -```ts -clean.outputEstimatedTokens - clean.inputEstimatedTokens <= 6_000; -repeated.reductionEstimatedTokens / repeated.inputEstimatedTokens >= - 962_873 / 1_017_575 - 0.05; -nested.reductionEstimatedTokens / nested.inputEstimatedTokens >= - 1_171 / 1_291 - 0.05; -``` - -The ratio formulas preserve the exact retained baselines and enforce the spec's five-percentage-point limit rather than rounded 89/85 percent approximations. - -- [ ] **Step 2: Run the benchmark tests** - -Run: `pnpm vitest run tests/benchmark.test.ts` - -Expected: PASS with compact markers; any token regression beyond the three gates fails the test. - -- [ ] **Step 3: Refresh retained benchmark evidence** - -Run: `pnpm benchmark` - -Expected: one JSON report on stdout. Use the captured JSON to update `benchmarks/result.json` with -normal file-editing tooling; the command does not write the file itself. Run the benchmark test once -more against the retained values. - -- [ ] **Step 4: Document the compact protocol and release** - -In `README.md`, add `What's new in 0.8.0`, document `@mN@`, `@mN:P@`, accepted tool-input aliases, -legacy XML cleanup, and padded snapshot refs. Update the benchmark section to distinguish enforced -token gates from informational timing and retain the documented stdout-redirection example. - -In `CHANGELOG.md`, add `2026-10-04 - [0.8.0]` entries for compact display markers, accepted legacy -inputs, canonical persistence, safer sanitization, and benchmark gates. Set `package.json` to -`0.8.0`; do not change dependencies or the lockfile. - -- [ ] **Step 5: Run complete automated verification** - -Run: `pnpm check` - -Expected: format, lint, typecheck, and all tests pass. - -Run: `pnpm run pack:dry-run` - -Expected: package dry run succeeds and includes the expected source, schema, README, changelog, and license files. - -Run: `git diff --check` - -Expected: exit 0 with no whitespace errors. Confirm `dcp.schema.json`, `pnpm-lock.yaml`, and dependency declarations are unchanged. - -- [ ] **Step 6: Smoke-test both Pi compression modes** - -Run separate range- and message-mode Pi sessions against the local extension. Confirm request context -contains `@mN@` or `@mN:P@`, copied wrapped markers are accepted by `compress`, a resumed session -retains canonical refs and block ownership, and assistant-emitted standalone marker lines do not -reappear after resume. Record any unavailable credential or interactive limitation rather than -claiming the smoke test passed. - -- [ ] **Step 7: Commit the release metadata and benchmark** - -```bash -git add tests/benchmark.test.ts benchmarks/result.json README.md CHANGELOG.md package.json -git commit -m "chore: prepare pi-dcp 0.8.0" -``` diff --git a/docs/superpowers/plans/2026-10-03-phase-04-pi-dcp-lifecycle-integration.md b/docs/superpowers/plans/2026-10-03-phase-04-pi-dcp-lifecycle-integration.md deleted file mode 100644 index a80280c..0000000 --- a/docs/superpowers/plans/2026-10-03-phase-04-pi-dcp-lifecycle-integration.md +++ /dev/null @@ -1,408 +0,0 @@ -# Phase 4 — Pi DCP Lifecycle Integration Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Integrate pi-dcp with Pi structured prompts, public session APIs, and active-tool lifecycle while preserving automatic pruning semantics and the user's tool selection. - -**Architecture:** A pure capability calculation separates pipeline eligibility from compression policy. Pi owns the restored tool loadout; DCP remembers temporary suppression only within the current branch. Compression guidance additionally requires an active `compress` tool, and custom prompts are captured once before each agent run. - -**Tech Stack:** Node.js, TypeScript 7, Pi 1.0 extension APIs, TypeBox 1.3, Vitest 5, Biome 2, pnpm - -**Spec:** `docs/superpowers/specs/2026-10-03-pi-dcp-improvement-roadmap-design.md` - -## Global Constraints - -- Execute this phase after `2026-10-03-phase-03-pi-dcp-compact-markers.md` and start from v0.8.0; prepare v0.9.0. -- Use `event.systemPromptOptions.sections.dcp`; never return a full DCP `systemPrompt` override or change another extension's forced prompt. -- `deny` suppresses model-driven compression but does not disable deduplication or stale-error pruning. -- Preserve an inactive `compress` selection across model, permission, definition-refresh, resume, and reload transitions. Hide its instructions and nudges while inactive. -- Pi's restored loadout is authoritative on session/tree changes. Do not carry the previous branch's suppression memory into the restored branch or persist that memory in snapshots. -- Existing command names, snapshot version 1, configuration keys, canonical IDs, marker syntax, protection rules, and benchmark budgets remain unchanged. -- Register one `compress` definition with `defaultActive: false` during extension creation, then refresh its mode-specific schema from trusted project configuration at session start. Activate it implicitly only on the first fresh-session initialization described in Task 2. -- Add no dependencies or Pi package upgrades. Keep host-provided packages as exact `"*"` peers. -- OpenCode DCP is behavioral inspiration only; do not copy its AGPL source or add OpenCode-specific infrastructure. -- Manual mode retains its existing behavior; `ask` permission and the interactive panel belong to Phase 5. - -## Readiness Review and References - -The previous plan required revision: the public-manager test already passed through compatibility -casts, the mocks reactivated existing tools incorrectly, startup reconciled before restoration and -sub-agent detection, and direct execution did not enforce all suppression reasons. - -The 2026-10-04 review used these clean checkouts: - -- pi-dcp: `9188118`, package v0.8.0, after Phase 3 merged. -- Pi: `/Users/lanh/Developer/pi-packages/pi` at `1b094148b91d737fb398bf1591604de58ec169e1` (v1.0.2). -- OpenCode DCP: `/Users/lanh/Developer/pi-packages/opencode-dynamic-context-pruning` at `f8232fde1e63c2251687e4d9634bd53ce11568cb` (v3.2.0). - -Pi's relevant extension types, loader, runner, agent session, SDK, system prompt, and session manager -are unchanged from the roadmap's `83692682f` reference. Installed Pi development packages are -v1.0.1 and already expose the required APIs. Consult Pi's `system-prompt-updates.test.ts` and tool -allowlist regression tests for host behavior; OpenCode's prompt and permission hooks inform only -the separation of compression from automatic pruning. - -Baseline verification passed: `pnpm check` (52 files, 661 tests), `pnpm run pack:dry-run`, and -`node scripts/generate-schema.ts | diff - dcp.schema.json`. Interactive Pi checks remain part of -Task 4. This records the review baseline, not completion of the implementation tasks below. - -## Implementation Review — 2026-10-04 - -Reviewed `854e592..f2f4bb6` against this plan and the local Pi contracts. No confirmed production -defect was found. Review corrections are limited to tests: the SDK fixture now binds extensions -so reload emits lifecycle events, exercises actual deselection/reload and both supplied resume -selections, and checks trusted-project schema changes. Model/message fixtures use checked Pi -shapes, and prompt tests cover system and turn-nudge edits before a run, stability through repeated -context passes after mid-run edits, and refresh on the next run. An independent follow-up review -found no unresolved findings in these test changes. - -Verification passed: `pnpm check` (55 files, 710 tests), generated-schema comparison, -`pnpm run pack:dry-run`, and `git diff --check`. No provider requests were made. Interactive Pi -smoke checks in Task 4 remain unperformed. - -Host limitation: the current Pi SDK always supplies `initialActiveToolNames`, which skips its -constructor's transcript loadout restoration during initial resume. DCP respects the loadout Pi -supplies; this review does not change the approved restored-loadout ownership rule. - -## Review Focus - -- Resume/reload or a trusted-project mode change must preserve an inactive tool; Task 2 checks definition refresh through the real host. -- Overlapping model, permission, and sub-agent suppression must not briefly reactivate compression; Task 2 checks both transition orders. -- Restored permission, model identity, and sub-agent status must be established before reconciliation; Task 2 checks initial denial and branch changes. -- Files changed before a run must update both instructions and nudges, while edits during the run wait until the next run; Task 3 checks reload count and event order. -- Direct `execute` calls must reject every policy suppression reason and never use another branch's cached messages; Task 2 checks authorization and transient reset. - ---- - -### Task 1: Establish accurate Pi extension contracts - -**Files:** - -- Create: `tests/extension-harness.ts` -- Create: `tests/pi-contract.test.ts` -- Modify: `tests/index.test.ts` -- Modify: `tests/integration.test.ts` -- Modify: `src/index.ts` - -**Interfaces:** - -- Consumes: Pi `ExtensionAPI`, `ExtensionContext`, `ExtensionEvent`, `AgentMessage`, and public session-manager methods. -- Produces: `createExtensionHarness(options?: ExtensionHarnessOptions): ExtensionHarness`, with typed API/context adapters, registered tool/command maps, active-tool inspection, UI spies, sent messages, and persisted entries. -- Produces: `emit(name: K, event: Extract): Promise`. The harness uses its current context and reports a named error for missing handlers. - -- [ ] **Step 1: Build the typed harness and passing characterization fixtures** - -Check implemented API/context surfaces with `satisfies Pick<...>`. Confine necessary casts to the -harness adapters and callback storage; use no explicit `any` or unchecked fixture casts. Supply -complete Pi model, session-entry, and message fixtures. Use `satisfies AgentMessage` for user, -assistant/tool-call, and tool-result messages, including `nestedCalls`. - -The manager exposes `getSessionId()`, `getSessionDir()`, and `getBranch()`. Distinguish ID from -path, restore the selected branch's v1 snapshot, and assert owner identity through appended entries. -Registration activates a newly registered direct tool unless `defaultActive` is false, but -preserves the active state when replacing an existing definition. `setActiveTools` ignores -unknown names. The harness can distinguish a fresh branch from a branch with a structured system -message declaring a tool loadout. - -- [ ] **Step 2: Run characterization tests before removing casts** - -Run: `pnpm vitest run tests/pi-contract.test.ts && pnpm typecheck` - -Expected: PASS. These are refactor safety tests; production compatibility casts do not make this -behavior fail and must not be presented as an expected red test. - -- [ ] **Step 3: Use public session methods and migrate duplicated mocks** - -In `src/index.ts`, call `ctx.sessionManager.getSessionId()` and `getBranch()` directly and narrow -custom entries by their discriminants. Remove optional compatibility fallbacks and manager casts. -Migrate index/integration lifecycle tests to the harness without changing their assertions. Replace -partial or cast message/model fixtures touched by the migration with checked fixtures. - -- [ ] **Step 4: Add an offline SDK contract fixture** - -In `tests/pi-contract.test.ts`, use the installed package's public `createAgentSession`, -`DefaultResourceLoader`, `SessionManager.inMemory`, and `SettingsManager.inMemory` APIs with the -DCP factory. Isolate agent configuration, model storage, resource discovery, and environment in -temporary directories; disable model network refresh and make no provider requests. Dispose -sessions and restore environment in cleanup. - -Use the public extension runner to emit events and inspect structured prompt results. Establish -that initial registration activates a direct tool and replacing an inactive definition leaves it -inactive. This fixture will exercise DCP's registration and prompt changes in Tasks 2 and 3; do -not import Pi checkout test internals or rely solely on the mock harness for host behavior. - -- [ ] **Step 5: Verify the migrated contracts** - -Run: `pnpm vitest run tests/pi-contract.test.ts tests/index.test.ts tests/integration.test.ts && pnpm typecheck && pnpm lint` - -Expected: PASS, including existing branch restoration behavior and zero lint warnings. - -- [ ] **Step 6: Commit the contract refactor** - -```bash -git add tests/extension-harness.ts tests/pi-contract.test.ts tests/index.test.ts tests/integration.test.ts src/index.ts -git commit -m "test: exercise DCP through current Pi contracts" -``` - -### Task 2: Centralize capabilities, tool selection, and authorization - -**Files:** - -- Create: `src/capabilities.ts` -- Create: `tests/capabilities.test.ts` -- Modify: `src/index.ts` -- Modify: `src/commands/register.ts` -- Test: `tests/pi-contract.test.ts` -- Test: `tests/index.test.ts` -- Test: `tests/commands-register.test.ts` -- Test: `tests/integration.test.ts` - -**Interfaces:** - -- Produces: `DcpSuppressionReason = "config" | "model" | "subagent" | "permission"` and `DcpCapabilities { pipelineEnabled: boolean; compressionEnabled: boolean; reasons: DcpSuppressionReason[] }`. -- Produces: `getDcpCapabilities(config: DcpConfig, state: SessionState, provider?: string, modelId?: string): DcpCapabilities`. -- Changes: `registerDcpCommands` keeps four arguments; its fourth becomes `onStateChange: (ctx: ExtensionCommandContext) => void`. -- Produces: one `compress` definition, registered before session initialization, with `defaultActive: false` and `executionMode: "sequential"` in both modes. - -- [ ] **Step 1: Add failing policy tests** - -Name cases for enabled defaults, each suppression reason, combined reasons, session permission -overriding configuration, and missing model identity. Assert independent applicable reasons in -`config`, `model`, `subagent`, `permission` order. Missing provider/model retains the existing -model-enablement behavior. - -```ts -expect(getDcpCapabilities(config, deniedState, provider, modelId)).toEqual({ - pipelineEnabled: true, - compressionEnabled: false, - reasons: ["permission"], -}); -``` - -Global/model/sub-agent suppression makes both capabilities false; permission denial alone leaves -the pipeline enabled. Combined global/model suppression must report both reasons. - -- [ ] **Step 2: Verify policy tests fail** - -Run: `pnpm vitest run tests/capabilities.test.ts` - -Expected: FAIL because `src/capabilities.ts` does not yet exist. - -- [ ] **Step 3: Implement the pure capability calculation** - -Reuse `isDcpEnabledForModel` for the model check with an enabled configuration view so global -disablement does not mask the model reason. Resolve permission with -`state.compressPermission ?? config.compress.permission`. Do not include active-tool selection -or manual mode in these policy capabilities. - -- [ ] **Step 4: Add failing lifecycle and authorization cases** - -In the harness, test active and inactive tools through suppression, both overlap orders, restored -deny/allow permissions, initially denied sessions, disallowed child processes, allowed child -processes, permission commands immediately after startup, and global disablement after a prior -enabled session. Assert other active tools are preserved. - -Add real-host cases named `activates compress once for a fresh default session`, -`preserves inactive compress across resume and reload`, and -`refreshes trusted project mode without activating compress`. Supply an inactive Pi loadout and a -structured branch for resume, and use the public reload path after deselecting the tool. Assert -the effective range/message schema, `defaultActive === false`, and -`executionMode === "sequential"`. Assert registration exists during extension creation. Cover -`tools: ["read"]`, `excludeTools: ["compress"]`, and `noTools: "all"` so implicit activation -cannot override host exclusions. - -On session/tree changes, establish the host-restored selection before emitting the event; assert -it wins over the previous branch's suppression memory. Refresh model identity after restoring -state. Assert cached previous-branch messages cannot be compressed before the new context arrives. - -For each policy suppression reason, emit `tool_call` and invoke the registered `execute` function -with a valid compression request. Assert block/error results and unchanged compression state. -Verify non-compress calls remain unaffected, and denied permission still allows deduplication and -stale-error pruning. An inactive `dcp:compress` call must not enqueue a follow-up or activate tools. -If permission changes after a permitted compression starts, assert execution end clears its timing -entry and records the completed duration. - -- [ ] **Step 5: Verify lifecycle tests expose the current gaps** - -Run: `pnpm vitest run tests/pi-contract.test.ts tests/index.test.ts tests/commands-register.test.ts tests/integration.test.ts` - -Expected: FAIL on early registration, restored suppression, inactive-trigger, and defensive -execution cases. Existing unrelated assertions must continue to pass. - -- [ ] **Step 6: Reconcile lifecycle state and enforce authorization** - -Register the provisional global-mode definition with `defaultActive: false` during extension -creation and retain that flag on schema refresh. Pi reload uses `includeAllExtensionTools`, so -early registration with the default activation flag would still reactivate a deselected tool. -At session start, reload configuration; reset transient state and cached messages; restore branch state; set -`state.modelProvider`, `state.modelId`, and process sub-agent status; initialize prompts; refresh -the effective tool definition; initialize fresh-session selection once; reconcile; then persist -using the existing policy. Do not let a global-disabled early return skip suppression of a -previously registered tool. - -Track whether this extension instance has processed `session_start`. Treat a system message with -`sections`, `toolsAdded`, or `toolsRemoved` as a declared loadout, including empty collections. -On its first event only, implicitly select `compress` if the reason is not `reload`, the active branch contains no -structured system message declaring a tool loadout, and `pi.getAllTools()` includes `compress`. -This preserves the default for fresh sessions while respecting host exclusions. Existing active -selection is left intact. Never implicitly select it on resume of a declared loadout, subsequent -session events, tree navigation, or reload. Run this initialization before policy suppression so -an initially denied fresh session can restore its default selection when permission later allows. - -Keep `compressWasActiveBeforeSuppression: boolean | undefined` outside persisted state. Record -selection only on the first suppression transition; remove `compress` while any policy reason -remains; restore only a recorded true value when all reasons clear. Clear the record on session -and tree restoration so the host-selected loadout is authoritative. Never activate an already -inactive tool merely because compression policy allows it. - -Reconcile after model selection, session/tree restoration, command state changes, and before -prompt/context processing. The command callback receives its context, refreshes model identity, -reconciles, and persists. Gate mutating commands with pipeline eligibility; `dcp:compress` -additionally requires compression policy and an active tool. Keep informational commands usable. - -Use the capability calculation in both `tool_call` and `execute`; global/model/sub-agent/permission -suppression must block even if the tool was invoked despite hidden exposure. Use the first -applicable reason in the deterministic order. Preserve existing configuration/model/permission -error messages and use `Compression is disabled in sub-agent sessions.` for sub-agent denial. -Mark both mode definitions sequential. Keep timing completion/cleanup for calls that actually -started; exposure changes must -not strand their timing entries. - -- [ ] **Step 7: Verify capabilities and lifecycle behavior** - -Run: `pnpm vitest run tests/capabilities.test.ts tests/pi-contract.test.ts tests/index.test.ts tests/commands-register.test.ts tests/integration.test.ts tests/compression-timing.test.ts && pnpm typecheck && pnpm lint` - -Expected: PASS across both tool modes, restored selection, combined suppression, command callback, -and defensive execution cases, with automatic strategies still available under deny. - -- [ ] **Step 8: Commit lifecycle reconciliation** - -```bash -git add src/capabilities.ts src/index.ts src/commands/register.ts tests/capabilities.test.ts tests/pi-contract.test.ts tests/index.test.ts tests/commands-register.test.ts tests/integration.test.ts -git commit -m "fix: reconcile DCP capabilities with Pi lifecycle" -``` - -### Task 3: Adopt structured prompts and one prompt snapshot per run - -**Files:** - -- Modify: `src/index.ts` -- Modify: `src/pipeline.ts` -- Modify: `src/prompts/store.ts` -- Test: `tests/index.test.ts` -- Test: `tests/pi-contract.test.ts` -- Test: `tests/pipeline.test.ts` -- Test: `tests/prompt-store.test.ts` - -**Interfaces:** - -- Consumes: Task 2 capabilities and the reconciled active tools. -- Produces: guidance availability equal to `capabilities.compressionEnabled && pi.getActiveTools().includes("compress")`. -- Changes: `runPipeline(state: SessionState, config: DcpConfig, messages: AgentMessage[], contextUsage: ContextUsage | undefined, runtimePrompts?: RuntimePrompts, compressionEnabled?: boolean): PipelineResult`. -- Produces: one refreshed `RuntimePrompts` snapshot shared by `before_agent_start` and subsequent context passes until the next `before_agent_start`. - -- [ ] **Step 1: Add failing structured-section assertions** - -Test `sets only the dcp section`, `replaces dcp idempotently`, and `removes dcp when guidance is -unavailable`. Preserve unrelated sections, selected tools, guidelines, and any forced prompt. -Assert the handler returns no full `systemPrompt` and enabled defaults set the section to -`DCP_SYSTEM_PROMPT`. Verify the structured result through the real extension runner. - -Test an otherwise enabled session with an inactive tool: the section and nudges are absent while -automatic strategies, message references, and existing compression summaries continue working. - -- [ ] **Step 2: Add failing custom-prompt event-order assertions** - -In a trusted temporary project, change `system.md` and `turn-nudge.md` before -`before_agent_start`. Assert that run uses both new values. Change them again after -`before_agent_start`; assert repeated context passes retain the captured values and the next run -uses the later edits. Spy on `PromptStore.reload()` after startup: one call per -`before_agent_start`, none from context. Retain project/global/default precedence and untrusted -project exclusion tests. - -- [ ] **Step 3: Verify prompt and pipeline tests fail** - -Run: `pnpm vitest run tests/index.test.ts tests/pi-contract.test.ts tests/pipeline.test.ts tests/prompt-store.test.ts` - -Expected: FAIL on full-prompt replacement, context reloads, and inactive-tool guidance. - -- [ ] **Step 4: Populate the section and gate nudge injection** - -At the beginning of `before_agent_start`, reload the prompt store once and refresh runtime -prompts, then reconcile and calculate guidance availability. Set `sections.dcp` to the effective -system text or delete only that section; return no full prompt. Remove filesystem reloads from -context, which consumes the captured prompt snapshot. - -The optional sixth pipeline parameter defaults to effective permission being different from -`deny`, preserving existing callers. The extension passes current guidance availability. Use it -only to skip `injectCompressNudges`; retain reference assignment, compression restoration, -strategies, priority/marker handling, and pruning. Do not overwrite permission or remove nudge -anchors to simulate an inactive tool. Update the prompt-store reload documentation. - -- [ ] **Step 5: Verify prompts, nudges, and pipeline compatibility** - -Run: `pnpm vitest run tests/index.test.ts tests/pi-contract.test.ts tests/pipeline.test.ts tests/prompt-store.test.ts tests/anchored-nudges.test.ts tests/integration.test.ts && pnpm typecheck && pnpm lint` - -Expected: PASS with one prompt reload per run, preserved unrelated options, no inactive-tool -guidance, and unchanged automatic pruning and benchmark inputs. - -- [ ] **Step 6: Commit structured prompt integration** - -```bash -git add src/index.ts src/pipeline.ts src/prompts/store.ts tests/index.test.ts tests/pi-contract.test.ts tests/pipeline.test.ts tests/prompt-store.test.ts -git commit -m "refactor: integrate DCP through Pi prompt sections" -``` - -### Task 4: Finalize and verify the v0.9.0 release - -**Files:** - -- Modify: `README.md` -- Modify: `CHANGELOG.md` -- Modify: `package.json` - -**Interfaces:** - -- Consumes: typed contracts, capabilities, selection reconciliation, and structured prompts from Tasks 1–3. -- Produces: documented v0.9.0 lifecycle behavior with existing configuration and snapshot compatibility. - -- [ ] **Step 1: Prepare release documentation and metadata** - -Describe the named prompt section, run-boundary reloads, early registration with explicit fresh -activation and trusted schema refresh, inactive-tool guidance, restored-loadout precedence, -suppression, and defensive execution. Document that `dcp:compress` does not activate an inactive -tool. Add v0.9.0 release notes and set -the package version to `0.9.0` without dependency changes. - -- [ ] **Step 2: Run complete verification and compare the generated schema** - -```bash -pnpm check -node scripts/generate-schema.ts | diff - dcp.schema.json -pnpm run pack:dry-run -git diff --check -``` - -Expected: all commands exit 0; no schema difference, lint warnings, new dependencies, or -compact-marker benchmark regressions. Unlike `generate:schema`, this comparison does not rewrite -the tracked schema. - -- [ ] **Step 3: Smoke-test lifecycle behavior in Pi** - -Run the extension locally with isolated configuration. Inspect structured sections and tool -visibility on startup, resume, reload, fork/tree navigation, trusted-project compression-mode -changes, model switches, permission toggles, and disallowed/allowed child processes. Verify an -inactive tool remains inactive, guidance follows the loadout, no suppressed tool reaches the first -request, and automatic pruning results are unchanged under denied permission. Restore environment -and configuration after testing; explicitly report any unperformed interactive checks. - -- [ ] **Step 4: Review and commit release metadata** - -Run: `git diff --check && git status --short` - -```bash -git add README.md CHANGELOG.md package.json -git commit -m "chore: prepare pi-dcp 0.9.0" -``` - -Publishing is outside this phase. The release is ready when Tasks 1–4 pass, existing v1 snapshots -remain readable, and all manual verification results or remaining limitations are recorded. diff --git a/docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md b/docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md deleted file mode 100644 index dd76da5..0000000 --- a/docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md +++ /dev/null @@ -1,412 +0,0 @@ -# Phase 5 — Pi DCP Permission and Panel UX Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add per-call compression approval and one Pi-native status/action panel while retaining every existing command and safe headless behavior. - -**Architecture:** Authorize ask calls inside the registered execute function before compression timing or mutation. Build the panel from a pure view model and command-backed action executor sharing the existing command policy guard. A thin Pi TUI component handles rendering, scrolling, selection, and action completion; its controller refreshes data and reconciles permitted actions. - -**Tech Stack:** Node.js, TypeScript 7, Pi 1.0 extension and TUI APIs, TypeBox 1.3, Vitest 5, Biome 2, pnpm - -**Spec:** `docs/superpowers/specs/2026-10-03-pi-dcp-improvement-roadmap-design.md` - -## Global Constraints - -- Execute after Phase 4, starting from v0.9.0; prepare v0.10.0 without publishing. -- Permission is exactly `allow | ask | deny`; the default remains `allow`. -- Ask supports TUI and RPC dialogs; print/JSON and unavailable dialog UI fail closed. -- Confirm inside execute for every ask call, including direct execution; never reuse approvals. -- Existing `dcp:*` commands remain supported and use the same domain mutations and pipeline policy guard as panel actions. -- Preserve inactive-tool selection, run-boundary prompt snapshots, sequential compression execution, and restored-loadout precedence. Allow/ask do not implicitly activate `compress`; guidance additionally requires an active tool. -- State-change callbacks remain `(ctx: ExtensionCommandContext) => void`; commands and permitted panel actions pass their current context. -- Snapshot v2 writers retain canonical padded references and block shape; readers and lifetime aggregation accept historical v1 unchanged. -- The single custom panel requires `ctx.mode === "tui"`, independently of RPC's `hasUI`. -- `@earendil-works/pi-tui` is host-provided: exact `"*"` peer and `^1.0.1` development dependency, never a runtime dependency; retain its locked v1.0.1 resolution. -- Pi is the API authority; OpenCode DCP is behavioral inspiration only. Do not copy its AGPL source, adapters, or host permission machinery. - -## Readiness Review — 2026-10-05 - -The earlier plan required revision: Pi emits execution-start before authorization, RPC has dialog UI -but cannot render custom terminal components, panel actions would bypass command-registration guards, -and `manualCommand(state, "")` reports status rather than toggling it. - -Reviewed baseline: clean v0.9.0 at `fba7462`, after the Phase 4 merge. `pnpm check` passed formatting, -warning-free lint, typecheck, and 710 tests across 55 files. Schema comparison and package dry-run -passed. The benchmark ran successfully with `node --import tsx scripts/benchmark.ts`; the normal tsx -CLI was blocked by sandbox IPC restrictions. These are baseline results, not verification of Phase 5. - -References: Pi `1b094148b91d737fb398bf1591604de58ec169e1` (v1.0.2) and OpenCode DCP -`f8232fde1e63c2251687e4d9634bd53ce11568cb` (v3.2.0). Installed Pi v1.0.1 types support the required -mode, dialog, component, and event-ordering contracts. Pi's extension types, agent-loop ordering, RPC -UI implementation, and question-component example are the API references. OpenCode's compression -pipeline and panel availability are behavioral references; its separate views are outside this phase. - -## Review Focus - -- Pending/rejected approval must create no compression timing or durable changes despite Pi's early start event; Task 2 reproduces the real ordering. -- RPC confirmations must work while RPC custom UI is never called; Tasks 2 and 4 test modes independently of `hasUI`. -- Confirmation abort/failure or policy changes during the wait must prevent compression; Task 2 checks these boundaries and direct execute. -- Disabled pipelines and stale/non-reactivatable block rows must not bypass command policy or corrupt state; Task 3 checks guard parity and block eligibility. -- Lifetime failure, rendering failure, long block lists, and undefined custom results must retain a usable close path; Task 4 checks fallbacks and scrolling. - ---- - -### Task 1: Add ask permission and compatible v2 persistence - -**Files:** - -- Modify: `src/config-schema.ts`, `src/state/types.ts`, `src/state/persistence.ts` -- Modify: `src/commands/permission.ts`, `src/commands/register.ts` -- Test: `tests/config.test.ts`, `tests/commands-permission.test.ts`, `tests/persistence.test.ts` -- Test: `tests/index.test.ts`, `tests/commands-lifetime.test.ts`, `tests/session-analysis.test.ts` - -**Interfaces:** - -- Produces: `CompressPermission = "allow" | "ask" | "deny"`, `DcpSnapshotV2`, and `DcpSnapshot = DcpSnapshotV1 | DcpSnapshotV2`. -- `serializeDcpSnapshot` returns `DcpSnapshotV2 | undefined`; `parseDcpSnapshot` returns `DcpSnapshot | undefined`. Preserve existing arguments and restoration behavior. -- `permissionCommand(state: SessionState, defaultPermission: CompressPermission = "allow"): string` cycles from `state.compressPermission ?? defaultPermission`. - -- [ ] **Step 1: Add failing configuration and command tests** - -Assert ask loads without warnings, the built-in default is allow, and cycling follows -`allow -> ask -> deny -> allow`. Preserve the exact `Compress permission: ` response. -For undefined state, test the standalone default and configured ask/deny fallbacks: - -```ts -expect(permissionCommand(createSessionState(), "deny")).toBe("Compress permission: allow"); -``` - -- [ ] **Step 2: Add failing persistence-consumer tests** - -Retain literal v1 allow/deny fixtures and reject v1 ask. Round-trip all three v2 permissions and -reject invalid v2 permissions and unknown versions. Update new-writer assertions to v2 while -retaining v1 reader fixtures. Test mixed v1/v2 lifetime aggregation, selecting each owner's newest -snapshot without double counting. Keep session-analysis and branch restoration behavior unchanged. - -- [ ] **Step 3: Verify the new tests fail** - -Run: `pnpm vitest run tests/config.test.ts tests/commands-permission.test.ts tests/persistence.test.ts tests/index.test.ts tests/commands-lifetime.test.ts tests/session-analysis.test.ts` - -Expected: new ask/v2 cases fail against the existing implementation. - -- [ ] **Step 4: Implement types, v2 serialization, and version-aware reading** - -Keep `DcpSnapshotV1` and its historical permission union intact. Define v2 by reusing the existing -fields with only version and permission changed; do not duplicate block definitions. Update the -lifetime snapshot map to accept the union. Preserve parser subentry validation, restoration, -owner/statistics semantics, and fingerprint behavior. New serialization preserves ask. - -- [ ] **Step 5: Implement cycling and configured fallback** - -Update command registration to pass `config.compress.permission`. Keep the existing current-context -callback and pipeline guard; update the command description to advertise allow/ask/deny. - -- [ ] **Step 6: Run the Step 3 command and verify all cases pass** - -Expected: both reader versions, v2 writers, configured fallbacks, and existing consumers pass. - -- [ ] **Step 7: Commit the permission/persistence changes** - -Stage only this task's changed files. Suggested message: `feat: add ask compression permission`. - -### Task 2: Authorize ask inside execute and correct timing - -**Files:** - -- Create: `src/compress/permission.ts` -- Modify: `src/index.ts`, `tests/extension-harness.ts` -- Test: `tests/compress-permission.test.ts`, `tests/capabilities.test.ts`, `tests/index.test.ts` -- Test: `tests/integration.test.ts`, `tests/compression-timing.test.ts`, `tests/pi-contract.test.ts`, `tests/sdk-contract.test.ts` - -**Interfaces:** - -- Consumes: Task 1 permission types, `CompressConfig["mode"]`, Pi `ExtensionContext`, and the execute call's abort signal. -- Produces: `describeCompressionRequest(mode: CompressConfig["mode"], input: Record): { topic: string; targetCount: number; targetLabel: "range" | "target" }`. -- Produces: `requestCompressionApproval(mode: CompressConfig["mode"], input: Record, ctx: ExtensionContext, signal?: AbortSignal): Promise`; undefined means approved, otherwise the string is the error reason. - -- [ ] **Step 1: Add failing description and dialog tests** - -Assert topic fallback `Untitled compression`, title `Allow DCP compression?`, and message inclusion -of the topic and `2 ranges` or `1 target`. Range counting accepts records with string -`startId`/`endId`/`summary`; message counting accepts string `messageId`/`summary`. Count malformed or -absent arrays as zero valid items, choosing the array by configured mode. Whitespace-only topics use -the fallback. Test singular/plural labels, acceptance, rejection, cancellation, and thrown confirmation. - -- [ ] **Step 2: Add failing mode and lifecycle tests** - -Extend the typed harness with configurable `mode`, observable confirm/custom mocks, and an execute -signal/call ID. Test TUI and RPC with `hasUI: true`, and print/JSON or `hasUI: false` without confirm. -Reproduce `tool_execution_start -> tool_call -> execute -> tool_execution_end`: deferred approval -must start no timing; rejected calls create no blocks, statistic changes, or DCP entries. Assert -approval delay is excluded from successful block durations. Test direct execute, two successive ask -calls, abort during confirmation, and policy suppression introduced while confirmation is pending. -Exercise the public SDK execution path offline without real model requests; preserve Phase 4 loadout -contract tests. Update timing tests that previously treated the early start event as compression work. - -- [ ] **Step 3: Verify approval and timing tests fail** - -Run: `pnpm vitest run tests/compress-permission.test.ts tests/capabilities.test.ts tests/index.test.ts tests/integration.test.ts tests/compression-timing.test.ts tests/pi-contract.test.ts tests/sdk-contract.test.ts` - -Expected: ask authorization and post-approval timing assertions fail. - -- [ ] **Step 4: Implement confirmation at the execution boundary** - -Make `executeCompressTool` asynchronous. Keep policy guards in both `tool_call` and execute; the hook -must not confirm. For effective ask permission, await the helper before any timing or compression -mutation. Require both a TUI/RPC mode and dialog-capable UI; pass `_signal ?? ctx.signal` to confirm. -Return an error tool result (`isError: true`) with `Compression requires interactive approval` when -UI is unavailable, or `Compression was not approved` for false/cancelled/aborted/failed confirmation. -Recheck the abort signal and capabilities after the wait, returning the existing policy error when -suppressed. Do not introduce approval caches or tokens. - -- [ ] **Step 5: Move compression timing to actual work** - -Remove the compression start-time mutation from `tool_execution_start`; remove that handler if it -has no remaining responsibility. Set the start time immediately before `handleCompress`. Keep -execution-end timing cleanup unconditional even if permission/model policy changed after work began. -Ask and allow share existing capabilities: only deny contributes the permission suppression reason. - -- [ ] **Step 6: Run authorization and compression regressions** - -Run the Step 3 command, then: -`pnpm vitest run tests/compress-range.test.ts tests/compress-message.test.ts tests/benchmark.test.ts` - -Expected: every ask call confirms once, no denied-call residue, approval wait excluded, existing -loadout/guidance rules and benchmark gates preserved. - -- [ ] **Step 7: Commit authorization and timing changes** - -Stage only this task's changed files. Suggested message: `feat: confirm compression before execution`. - -### Task 3: Build the panel model and guarded command-backed actions - -**Files:** - -- Create: `src/tui/panel-model.ts` -- Modify: `src/capabilities.ts`, `src/commands/register.ts`, `src/utils/context-limits.ts`, `src/state/persistence.ts` -- Test: `tests/panel-model.test.ts`, `tests/capabilities.test.ts`, `tests/commands-register.test.ts` -- Test: `tests/context-limits.test.ts`, `tests/commands-lifetime.test.ts` - -**Interfaces:** - -- Produces: `getDcpPipelineDisabledMessage(capabilities: DcpCapabilities): string | undefined` in capabilities; undefined means pipeline-eligible, otherwise preserve existing command messages and reason precedence. -- Produces: `ResolvedContextLimits { min: number | undefined; max: number | undefined }` and `resolveContextLimits(config: { compress: CompressConfig }, state: SessionState, contextUsage: ContextUsage | undefined): ResolvedContextLimits`. -- Export `LifetimeStats` from persistence and use it as `loadAllSessionStats`' existing aggregate return type; preserve the loader and lifetime-command behavior. -- Define `DcpPanelAction` as `toggle-manual | cycle-permission | sweep | toggle-block` (with numeric `blockId`) `| close`, using a `type` discriminant. -- `DcpPanelModelInput` contains state/config, current model provider/id/contextWindow or undefined, context usage or undefined, lifetime stats or undefined, and `compressToolActive: boolean`. -- `buildDcpPanelModel(input: DcpPanelModelInput): DcpPanelModel` produces formatted status/statistic fields and structured numeric-sorted block rows with availability reasons and actions. Stable row IDs are action types or `block:`. -- `applyDcpPanelAction(action: DcpPanelAction, state: SessionState, config: DcpConfig, capabilities: DcpCapabilities): { permitted: boolean; message?: string }` contains no Pi UI calls. Close returns `{ permitted: false }`; policy rejection returns false plus its message; permitted commands return true plus their command response, including stale-row responses. - -- [ ] **Step 1: Add failing limit and model tests** - -Assert shared resolution preserves per-model override, global absolute, and legacy percentage -precedence. Percentage limits with no usable window are undefined; absolute limits still resolve. -Existing over-limit decisions must use the shared result. Assert all planned panel fields, explicit -unavailable values, numeric block ordering, active/user-deactivated/otherwise-inactive distinctions, -and tool/policy availability. Test current model identity/window overriding stale cached display -inputs without mutating state; do not fabricate current usage from missing data. - -- [ ] **Step 2: Add failing action and guard-parity tests** - -Test manual toggle from both states, permission cycling with configured fallback, sweep, block -deactivation/reactivation, and exact stale message `Block not found.`. Non-user-deactivated -inactive blocks must not become reactivatable. Assert global/model/sub-agent suppression rejects -all mutations with the same messages as direct commands. Deny permission alone permits panel -mutations when pipeline-eligible. Recheck policy against current context after building a model. - -- [ ] **Step 3: Verify model and guard tests fail** - -Run: `pnpm vitest run tests/panel-model.test.ts tests/capabilities.test.ts tests/commands-register.test.ts tests/context-limits.test.ts tests/commands-lifetime.test.ts` - -Expected: missing shared helpers/model and guard-parity cases fail. - -- [ ] **Step 4: Implement shared policy and data resolution** - -Delegate registration's existing pipeline guard to the shared message helper. Keep informational -commands available and compression's additional permission/active-tool checks intact. Export the -limit resolver and delegate `isContextOverLimits` to it without changing precedence. Build a -read-only current-model view for panel resolution rather than updating session state from rendering. - -- [ ] **Step 5: Implement model and domain action delegation** - -Manual toggle passes `state.manualMode === "active" ? "off" : "on"` to `manualCommand`. Permission -cycling passes configured fallback; sweep and block controls call their existing commands. Reject -policy-disabled actions before domain mutation. Only active blocks offer deactivation and only -user-deactivated blocks offer reactivation; display other blocks without a toggle. Re-read block -state when acting, preserving existing command eligibility/error responses. Close does nothing. - -- [ ] **Step 6: Run model, guard, and command regressions** - -Run the Step 3 command, then: -`pnpm vitest run tests/commands-manual.test.ts tests/commands-permission.test.ts tests/commands-sweep.test.ts tests/commands-decompress.test.ts tests/commands-recompress.test.ts` - -Expected: shared resolution and policy pass without duplicating mutation logic or changing existing -no-argument manual-command status behavior. - -- [ ] **Step 7: Commit panel domain behavior** - -Stage only this task's changed files. Suggested message: `feat: add guarded DCP panel actions`. - -### Task 4: Add the TUI component, controller, and dcp command - -**Files:** - -- Create: `src/tui/panel.ts` -- Modify: `src/commands/register.ts`, `src/commands/help.ts`, `package.json`, `pnpm-lock.yaml` -- Test: `tests/panel.test.ts`, `tests/commands-register.test.ts`, `tests/commands-help.test.ts`, `tests/package-metadata.test.ts` - -**Interfaces:** - -- Consumes: Task 3 model/actions, Pi Theme/custom UI, and pi-tui Component/key/width helpers. -- `DcpPanelComponent` implements `render(width)`, `invalidate()`, and `handleInput(data)`; constructor inputs are the model and options containing theme, `getHeight(): number`, `requestRender(): void`, `onAction(action): void`, optional initial row ID, and optional last action message. -- `openDcpPanel(state: SessionState, config: DcpConfig, ctx: ExtensionCommandContext, onStateChange: (ctx: ExtensionCommandContext) => void, getActiveTools: () => string[]): Promise` supplies current policy/tool/model inputs and registers through the existing command-registration function. - -- [ ] **Step 1: Add failing metadata, rendering, and key tests** - -Assert pi-tui is absent from runtime dependencies, has peer `"*"`, development specifier `^1.0.1`, and -locked v1.0.1. At 60/80/120 columns and 24 rows, assert visible widths and total height remain bounded -with long topics, wide characters, and dozens of blocks. Selection stays visible while scrolling -and after resize. Arrow keys and `j`/`k` navigate selectable actions; Enter completes the selected -action; Escape/`q` returns close exactly once. Assert selection changes request rendering and an -injected rendering failure produces a bounded unthemed fallback with functional close keys. - -- [ ] **Step 2: Add failing controller and command tests** - -Assert all eleven commands are registered and help lists `dcp`. In RPC mode with `hasUI: true`, and -print/JSON or unavailable TUI, assert exactly one error notification and no lifetime/custom call. -Mock permission action then close: assert one callback with current ctx, updated model, retained -selection, and a displayed command response. Verify close and policy rejection do not call the -callback. Recheck policy before actions even if the displayed model allowed them. Lifetime rejection -opens with unavailable totals; loading occurs once per invocation. Undefined custom results close -without looping; custom rejection notifies once and existing commands remain accessible. Keep -missing-directory zero totals compatible with the current loader. - -- [ ] **Step 3: Verify UI and metadata tests fail** - -Run: `pnpm vitest run tests/panel.test.ts tests/commands-register.test.ts tests/commands-help.test.ts tests/package-metadata.test.ts` - -Expected: missing panel/command and peer metadata assertions fail. - -- [ ] **Step 4: Add the host-provided development peer** - -Add devDependency `"@earendil-works/pi-tui": "^1.0.1"` and peer `"*"`. Refresh the lockfile using -`pnpm install --lockfile-only`; ensure the new root importer uses existing v1.0.1 without unrelated -resolution changes. Materialize dependencies as needed for UI tests without upgrading other packages. - -- [ ] **Step 5: Implement the bounded terminal component** - -Use Pi Theme plus pi-tui `matchesKey`, `Key`, `truncateToWidth`, and `visibleWidth`. Keep the compact -status/statistics header and close-key footer visible, allocating remaining height to a scrolling -core-action/block list. Clamp navigation at the first/last selectable row. Flatten multiline row -labels, bound every line, and keep selected actions visible. Use `tui.terminal.rows` for height and -`tui.requestRender()` for selection updates. Track selection/completion only; rendering formatting -failure returns a minimal unthemed closable view. The component performs no domain mutations. - -- [ ] **Step 6: Implement controller, registration, and help** - -Require `ctx.mode === "tui" && ctx.hasUI` before any lifetime/custom work; otherwise notify -`DCP panel requires interactive TUI mode. Use dcp:help for available commands.` once and return. -Load lifetime totals once from the same parent directory as `dcp:lifetime`, using undefined on -rejection. Build from current ctx and `getActiveTools()`, await one component action, recompute -capabilities, and call the Task 3 executor. For permitted actions call `onStateChange(ctx)`; retain -its message and rebuild. Restore the acted-on row ID, falling back to the first selectable action -if it vanished. Close/undefined results return; custom rejection notifies once and returns. Register -`dcp` and add help without removing or renaming any existing command. - -- [ ] **Step 7: Run UI, command, metadata, and type checks** - -Run the Step 3 command, then `pnpm typecheck && pnpm lint`. - -Expected: UI completion/scrolling/fallback, guarded mutations, command preservation, and dependency -metadata pass; zero lint warnings. - -- [ ] **Step 8: Commit the custom panel** - -Stage only this task's changed files. Suggested message: `feat: add interactive DCP status panel`. - -### Task 5: Document and verify v0.10.0 - -**Files:** - -- Modify: `dcp.schema.json`, `README.md`, `CHANGELOG.md`, `package.json` - -**Interfaces:** - -- Consumes: Tasks 1–4; produces documented, verified v0.10.0 release metadata without publishing. - -- [ ] **Step 1: Document permissions and panel behavior** - -Document allow/ask/deny, TUI/RPC confirmation, print/JSON denial, rejection/abort behavior, permission -cycling, and backward-readable snapshots. Document the single panel's fields, keys, scrolling, -policy-disabled actions, and direct command fallback. Set version `0.10.0` and add release notes. - -- [ ] **Step 2: Regenerate and compare the schema** - -Run: `pnpm generate:schema`, then `node --import tsx scripts/generate-schema.ts | diff -u dcp.schema.json -`. - -Expected: generated schema contains allow/ask/deny and comparison exits 0 with no differences. -If the tsx CLI is sandbox-blocked, generate with -`node --import tsx scripts/generate-schema.ts > dcp.schema.json` instead. - -- [ ] **Step 3: Run complete automated verification** - -Run: `pnpm check && pnpm run pack:dry-run && pnpm benchmark`. - -Expected: exit 0, zero lint warnings, all tests including compact-marker token gates pass, and the -package includes the new UI/permission modules. Token gates live in `tests/benchmark.test.ts` and -run under `pnpm check`; the benchmark command reports measurements. If its launcher is blocked by -sandbox IPC, run `node --import tsx scripts/benchmark.ts` instead and record that substitution. - -- [ ] **Step 4: Manually verify permission and restoration flows** - -In Pi TUI and a dialog-capable RPC client, approve one ask call and reject another; cancel/abort a -pending call and confirm no compression occurred. Verify print/JSON ask fails closed. Switch to -deny, then allow; tool exposure and prompt guidance must obey Phase 4 selection rules, including -an intentionally inactive tool. Resume, fork, navigate the tree, and reload with v1/v2 snapshots; -confirm permissions/blocks restore and branch loadouts remain authoritative. - -- [ ] **Step 5: Manually verify panel usability** - -At 60/80/120 columns and approximately 24 rows, exercise navigation, Enter, Escape/q, every action, -long block-list scrolling, and retained selection. Confirm disabled-policy actions cannot mutate, -non-user-deactivated blocks cannot be reactivated, and direct commands work after closing. If a -manual environment is unavailable, record the unverified cases rather than claiming them complete. - -- [ ] **Step 6: Review and commit release metadata** - -Run: `git diff --check && git status --short`; verify scope and all acceptance results. -Stage only this task's changed files. Suggested message: `chore: prepare pi-dcp 0.10.0`. - -## Implementation Handoff - -The previous Phase 5 plan is superseded by this revision. The agreed design uses execute-boundary -approval, TUI/RPC dialogs, and one compact scrolling panel. Implementation was committed through -`a3f5c61` on the Phase 5 branch; review fixes follow in the working tree. Publishing remains a -separate decision. - -## Implementation Review — 2026-10-05 - -The initial implementation passed 800 automated tests. Review regressions reproduced omitted -lifetime counters, clipped tool availability at 60 columns, inaccessible inactive block rows, -invisible actions remaining executable in rendering fallback, overflowing one/two-row terminals, -and long block topics hiding action/state text at 60/80/120 columns. -The panel now keeps those counters/status fields visible, permits browsing inactive rows without -activation, and disables actions in fallback or views too short to display them while preserving -close instructions. Block rows reserve space for action/state text before truncating topics, while -retaining their identifiers when a disabled-policy reason also needs truncation. Row -labels also normalize carriage returns and tabs to keep terminal lines valid. Command help now -describes cycling through allow/ask/deny. - -A separate regression reproduced panel mutations being allowed when the current model was missing -even though direct commands rejected the cached disabled model. Both the panel model and controller -now use the same current-or-cached policy identity as command registration; missing display data -remains explicitly unavailable. Offline public-SDK tests run Pi's actual agent loop with a simulated -provider response and deferred RPC approval. They observe the full execution-start, tool-call, -confirmation, execution-end sequence; pending approval creates no timing or blocks, rejection changes -no compression statistics or DCP entries, and a simulated 5,000 ms approval wait is excluded from -the successful 7 ms block duration. - -After these fixes, `pnpm check` passed formatting, warning-free lint, typecheck, and 819 tests across -58 files. Generated-schema comparison and package dry-run passed; benchmarks ran with -`node --import tsx scripts/benchmark.ts` to avoid the sandbox's tsx CLI IPC restriction. Live Pi -TUI/RPC verification and publishing have not been performed in this review. diff --git a/docs/superpowers/plans/2026-10-05-rename-ui-to-tui.md b/docs/superpowers/plans/2026-10-05-rename-ui-to-tui.md deleted file mode 100644 index fbbdeae..0000000 --- a/docs/superpowers/plans/2026-10-05-rename-ui-to-tui.md +++ /dev/null @@ -1,112 +0,0 @@ -# Rename UI Directory to TUI Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Rename the complete `src/ui` directory to `src/tui` and update its consumers and documentation. - -**Architecture:** Move the three existing modules together and update direct import paths. Preserve their implementations, exports, and relative imports within the directory; package discovery already includes all of `src`. - -**Tech Stack:** TypeScript, Node.js, Pi extension/TUI APIs, Vitest, Biome, pnpm, Git. - -**Spec:** The bounded in-chat design titled “Rename `src/ui` to `src/tui`,” reproduced below. No separate spec file is required for this rename; the user subsequently requested this written plan. - -## Design - -Move `notification.ts`, `panel.ts`, and `panel-model.ts` into `src/tui`. Update source imports, imports in the four affected test files, and the two source-path references in the Phase 5 implementation plan. Preserve filenames, exported functions, types, test names, and runtime behavior. Use direct import updates without compatibility files at the old paths. - -## Global Constraints - -- Rename the whole directory, including notifications; do not split or reorganize its modules. -- Preserve existing behavior, exports, commands, permissions, persistence, and test assertions. -- Add no dependencies, compatibility re-exports, package-version changes, or new behavior tests. -- Keep package metadata unchanged: its `files` list includes `src`, and its extension entrypoint remains `src/index.ts`. -- Limit documentation edits to correcting the two paths in the existing Phase 5 plan. -- Do not publish; implementation commits must contain only this rename and its reference updates. - -## Review Focus - -- Extension startup must resolve the moved notification module; verify through the full suite. -- Command registration must resolve the moved panel; verify through panel/controller tests. -- The panel must still resolve its sibling view model; verify through panel/model tests. -- Package contents must include all three moved modules and omit the old directory; verify through the package dry-run. -- Consumers and Phase 5 documentation must contain no stale paths; verify through the scoped reference search. - ---- - -### Task 1: Move the terminal presentation directory and update references - -**Files:** - -- Rename: `src/ui/notification.ts` → `src/tui/notification.ts` — notification formatting. -- Rename: `src/ui/panel.ts` → `src/tui/panel.ts` — terminal component and controller. -- Rename: `src/ui/panel-model.ts` → `src/tui/panel-model.ts` — panel data and guarded actions. -- Modify: `src/index.ts`, `src/commands/register.ts` — notification and panel imports. -- Modify/Test: `tests/notification.test.ts`, `tests/compress-notification.test.ts`, `tests/panel.test.ts`, `tests/panel-model.test.ts` — imports only. -- Modify: `docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md` — the two module-path references. - -**Interfaces:** - -- Consumes: the existing module exports and their current callers/tests. -- Produces: the same exports and signatures at `src/tui/notification.ts`, `src/tui/panel.ts`, and `src/tui/panel-model.ts`; no new interfaces or aliases. - -- [ ] **Step 1: Establish the targeted test baseline** - -Run: `pnpm vitest run tests/notification.test.ts tests/compress-notification.test.ts tests/panel.test.ts tests/panel-model.test.ts` - -Expected: all four files pass before any edits. Investigate baseline failures before proceeding. - -- [ ] **Step 2: Update test imports to the destination paths** - -In the four listed test files, replace `../src/ui/` with `../src/tui/`. Preserve all test names, assertions, and fixtures. - -- [ ] **Step 3: Verify the new imports fail before the move** - -Run the Step 1 command. - -Expected: module-resolution failures for `src/tui` imports because the destination modules do not exist yet. - -- [ ] **Step 4: Perform the directory rename and update source consumers** - -Run: `git mv src/ui src/tui` - -Change `./ui/notification.ts` to `./tui/notification.ts` in `src/index.ts`, and `../ui/panel.ts` to `../tui/panel.ts` in `src/commands/register.ts`. Relative imports within the moved modules remain valid and require no edits. - -- [ ] **Step 5: Correct the Phase 5 documentation paths** - -In the listed Phase 5 plan, replace `src/ui/panel-model.ts` and `src/ui/panel.ts` with their `src/tui` equivalents. Preserve all other plan content. - -- [ ] **Step 6: Verify targeted tests pass after the move** - -Run the Step 1 command. - -Expected: all four files pass with their original assertions. - -- [ ] **Step 7: Run the full automated checks** - -Run: `pnpm check` - -Expected: formatting, warning-free lint, type checking, and all existing tests pass. The reviewed starting baseline is 819 tests across 58 files; this rename adds or removes no tests. - -- [ ] **Step 8: Verify package contents** - -Run: `pnpm run pack:dry-run` - -Expected: exit 0; the listing includes all three `src/tui` modules and contains no `src/ui` files. - -- [ ] **Step 9: Check stale references and diff scope** - -Run: `rg -n 'src/ui|\./ui/|\.\./ui/' src tests scripts README.md CHANGELOG.md package.json docs/superpowers/plans/2026-10-03-phase-05-pi-dcp-permission-panel-ux.md` - -Expected: no matches, with ripgrep exit 1. This rename plan intentionally retains old paths to describe the migration and is excluded from the search. - -Run: `git diff HEAD --check` and `git diff HEAD --stat`. - -Expected: no whitespace errors; only the three moves, import updates in six source/test files, and two Phase 5 documentation references. Inspect the full diff to confirm module bodies and assertions are unchanged. - -- [ ] **Step 10: Commit the verified rename** - -Stage only the listed files and moves. Commit with: `refactor: rename ui directory to tui`. - -## Execution Handoff - -Native execution is recommended: this is one tightly coupled task with mechanical edits and existing coverage. Review this saved plan and select Native or Subagent-driven execution before implementation. No implementation is included in the plan-writing task. diff --git a/docs/superpowers/specs/2026-10-03-pi-dcp-improvement-roadmap-design.md b/docs/superpowers/specs/2026-10-03-pi-dcp-improvement-roadmap-design.md deleted file mode 100644 index bd934fc..0000000 --- a/docs/superpowers/specs/2026-10-03-pi-dcp-improvement-roadmap-design.md +++ /dev/null @@ -1,272 +0,0 @@ -# Pi DCP Improvement Roadmap Design - -**Date:** 2026-10-03 - -**Revised:** 2026-10-05 - -**Status:** Approved in chat; reordered from simplest to most complex; Phases 2, 4, and 5 clarified -after implementation-readiness reviews, including Phase 4 restoration and Phase 5 execution-boundary -approval, RPC dialogs, and single-panel decisions - -## Purpose - -Bring pi-dcp into precise alignment with the current Pi 1.0 extension API while preserving its -Pi-native persistence, compression correctness, and incremental pruning behavior. Low-risk -maintenance establishes a clean baseline first, correctness and marker changes follow, lifecycle -coordination comes after those foundations, and the stateful interactive UX ships last. - -The work is split into five independently releasable phases in increasing implementation -complexity: - -1. Warning-free maintenance and prompt-cache evidence. -2. Pi path protection and configuration correctness. -3. Compact message markers without persisted-state migration. -4. Current Pi prompt and tool-lifecycle integration. -5. `ask` permission and a Pi-native DCP panel. - -## Project-Wide Constraints - -- Preserve support for existing JSON configuration; JSONC is explicitly out of scope. -- New releases must read existing version-1 snapshots, padded message IDs, and legacy XML markers. -- Keep canonical persisted message references as `m0001`, `m0002`, and so on. -- Use the current Pi checkout as the API authority. Phase 2 targets the Pi 1.0.1 message types at - revision `83692682f`; host-provided packages remain `"*"` peers while development dependencies - and the lockfile track that API. OpenCode DCP is behavioral inspiration only; do not copy its - AGPL source. -- Do not add OpenCode-specific adapters, authentication, auto-update, or sandbox infrastructure. -- Keep incremental deduplication and stale-error pruning behavior unchanged until cache evidence - demonstrates a better policy. -- Do not add new runtime dependencies unless Pi provides them. Host-provided packages remain exact - `"*"` peers with compatible development dependencies. -- Each phase uses test-driven development, updates its user-facing documentation, and passes the - complete repository verification before release. - -## Phase 1: Maintenance and Cache Evidence - -All existing Biome warnings are resolved without blanket rule suppression. The lint script uses -`--error-on-warnings`, making zero warnings part of `pnpm check`. - -`compress-pending` and `pendingManualTrigger` are removed after the stale command test is replaced -and tests confirm that no command, lifecycle handler, or snapshot uses them. Other state is retained -unless the same proof exists. A concise provenance document records the historical adaptation and -behavioral-comparison boundaries, repository revisions, and licenses without making a legal or -clean-room claim. - -Session analysis aggregates every usage carrier recognized by the current Pi session model: -assistant messages, standalone usage entries, and optional tool-result, compaction, and branch -summary usage. It reports uncached input, output, cache reads and writes, optional one-hour cache -writes and reasoning, reported token totals and cost components, and response-latency samples from -adjacent session-entry timestamps. Reports expose only numeric aggregates and ordinal file indices; -they never retain paths, basenames, IDs, hashes, provider/model metadata, notes, prompt contents, -tool arguments, errors, summaries, or credentials. Invalid usage and invalid latency samples have -separate diagnostics and do not abort analysis. Incremental pruning remains unchanged in this phase; -the report supplies evidence for a later proposal. - -The package is verified against exact Node 22.19.0 and the current Node 24 line. The existing engine -floor remains in place until the Node 22.19.0 CI job passes. If both jobs pass unchanged, -`engines.node` becomes `>=22.19.0`; otherwise the existing floor remains and the incompatibility is -documented instead of patched speculatively. - -## Phase 2: Correctness and Configuration Safety - -### Protected paths - -Pi's built-in `read`, `write`, and `edit` tools use `path`, while pi-dcp currently recognizes only -`filePath`. Direct calls to those three tools must recognize `path`; the legacy `filePath` key -remains supported for all tools. Candidate paths are normalized from backslashes to forward slashes -before glob matching, but `matchesGlob` itself retains its POSIX-oriented contract. A generic -`path` argument on an unrelated tool is not assumed to identify a file. - -Pi records calls made by orchestration tools under -`toolResult.nestedCalls.calls[].{name,arguments}`. The tool cache must aggregate paths from the -parent call and every well-formed nested call. If any aggregated path matches -`protectedFilePatterns`, deduplication, stale-error purging, and manual sweep must preserve the -parent tool result. Missing, truncated, or malformed nested arguments are ignored without losing -valid paths from other calls. Pi may omit `arguments` when its bounded nested-call record exceeds a -size limit and marks such records with `complete: false`; this is expected input, not a fatal error. - -### Configuration - -An absolute context limit is a positive finite integer. A percentage is a decimal string ending in -`%` whose numeric value is greater than zero and no greater than 100. The same rule applies to -global and per-model min/max limits. - -Unknown top-level and nested keys are detected before TypeBox cleaning. Global and trusted-project -layers are sanitized independently before merging: an invalid global field inherits the built-in -default, while an invalid project field inherits the valid global value. Invalid optional map -entries are removed individually so valid siblings remain and resolution falls back through the -same layer order. Every diagnostic identifies the source file and JSON-pointer path. Each -configuration reload shows at most one summarized UI warning, while the logger retains individual -warnings. A bad configuration must never abort session startup. - -### Nudge force - -When a user turn crosses the minimum threshold, pi-dcp records both that user message and the -preceding assistant message as a pair. `nudgeForce: "strong"` injects the turn nudge on the user -message; `"soft"` injects it on the preceding assistant message. If no preceding assistant exists, -the turn nudge is skipped. A preceding assistant message containing only tool calls receives a -synthetic text part before its first tool call so soft mode still injects visibly. Context-limit and -iteration nudges retain their existing behavior, and the version-1 snapshot shape is unchanged. - -## Phase 3: Compact Markers - -Canonical state references remain padded, but messages presented to the model use compact markers: - -- Range mode: `@m1@` -- Message mode, priority 3: `@m1:3@` - -Tool inputs accept bare compact IDs (`m1`), wrapped compact markers (`@m1@` and `@m1:3@`), padded -legacy IDs (`m0001`), and existing block IDs (`b1`). All message variants normalize to the padded -canonical reference before lookup. - -Sanitization removes complete and bounded malformed compact markers in addition to existing XML -forms. It must not consume email addresses, prose containing an `@`, or unrelated identifiers. -Prompts and tool descriptions teach the new marker syntax and tell the model not to reproduce it. - -The deterministic clean-session benchmark must reduce marker overhead by at least 70 percent from -the retained 20,000-token baseline. Repeated-tool and nested-block workloads must retain at least -their existing qualitative reductions and must not regress by more than five percentage points. - -## Phase 4: Current Pi Lifecycle Integration - -The DCP system instructions are written to `event.systemPromptOptions.sections.dcp`; the extension -does not return a complete `systemPrompt` or alter another extension's forced prompt. Custom prompts -reload once at the start of `before_agent_start`. The resulting prompt snapshot is shared by the -system section and every subsequent context pass until the next `before_agent_start`; edits made -during a run become effective on the next run. Trusted-project, global, and bundled precedence -remain unchanged. - -A single capability calculation supplies two related decisions: - -- The pipeline may run only when DCP is globally enabled, the model is enabled, and the current - process is an allowed sub-agent. -- Model-driven compression is available only when the pipeline may run and compression permission - is not `deny`. - -`getDcpCapabilities` exposes these policy decisions as `pipelineEnabled` and `compressionEnabled`. -Applicable suppression reasons are evaluated independently and reported in the fixed order -`config`, `model`, `subagent`, `permission`. Active-tool selection and manual mode are not policy -suppression reasons. Compression instructions and nudges additionally require that `compress` is -active. A deselected tool therefore receives no guidance, while automatic deduplication, stale-error -pruning, reference assignment, and existing compression summaries continue when the pipeline is -eligible. Nudges retain their existing manual-mode behavior. - -Register the single `compress` tool during extension creation with the global mode's provisional -schema, `defaultActive: false`, and `executionMode: "sequential"`. Session start refreshes the -definition after loading trusted project configuration. Pi's reload path activates default-active -extension tools, so early registration alone cannot preserve a user's inactive selection. - -Implicit activation happens only on this extension instance's first session-start event when the -reason is not `reload`, the active branch has no structured system message declaring a tool loadout, -and the host includes `compress` among configured tools. A system message with `sections`, -`toolsAdded`, or `toolsRemoved` declares a loadout even when those collections are empty. -This provides the fresh-session default without overriding exclusions. Perform it before policy -suppression; a fresh denied session can -then restore that default selection when permission allows. Never implicitly activate on subsequent -session events, tree navigation, reload, or resume of a declared loadout. - -Restore branch state and establish model identity and process sub-agent status before reconciling -exposure. Reconcile after session/tree restoration, model selection, command state changes, and -before prompt/context processing. Command state callbacks have type -`(ctx: ExtensionCommandContext) => void` and receive the current context so permission changes -immediately after startup cannot use stale model identity. Cached messages are cleared on -session/tree changes. - -Within a branch, remember whether the tool was active at the first suppression transition. Keep -that value through overlapping reasons and restore only a recorded active selection when all -reasons clear. On session/tree changes, discard previous-branch suppression memory and honor Pi's -selected loadout. Keep this memory out of snapshots. Definition refresh, resume, and reload must -not reactivate a host-selected inactive tool. - -Guard both the `tool_call` event and registered `execute` function against every policy suppression -reason. Mutating commands require pipeline eligibility; `dcp:compress` additionally requires -compression availability and an active tool, and never activates the tool implicitly. Informational -commands remain available. Permission denial does not disable automatic strategies or existing -block activation controls. - -The `compress` tool executes sequentially because it mutates shared session state. Public Pi -session-manager methods are used directly rather than hidden behind `unknown` casts. A typed -extension harness supplies checked events and fixtures; offline public-SDK contract tests cover -registration, selection, and structured prompts. Refactor characterization tests pass before cast -removal; behavioral changes have separate failing tests. - -The Phase 4 review used Pi revision `1b094148b91d737fb398bf1591604de58ec169e1` (v1.0.2) and OpenCode -DCP revision `f8232fde1e63c2251687e4d9634bd53ce11568cb` (v3.2.0). Pi's relevant lifecycle contracts -are unchanged from `83692682f` and supported by installed v1.0.1 development packages. This phase -adds no dependencies or package upgrades and retains snapshot v1, existing configuration, command -names, marker syntax, and pruning/protection/benchmark behavior. - -## Phase 5: Permission and Panel UX - -Compression permission becomes `allow | ask | deny`: - -- `allow` exposes and executes `compress` normally. -- `ask` exposes it and requests Pi confirmation inside the registered execute function for every - call, including direct execution. The dialog includes the topic and number of targets/ranges. - TUI and RPC support confirmation; print/JSON and unavailable dialog UI fail closed. Rejection, - cancellation, abort, or confirmation failure returns an error tool result without compression. -- `deny` hides the tool, removes its prompt section and nudges, and blocks defensive direct calls. - -Allow and ask preserve Phase 4's selection rules: neither activates a user-deselected tool, and -instructions/nudges require an active tool. Permission changes and panel actions reconcile through -the current command context; restored Pi loadouts remain authoritative across session/tree changes. - -Pi emits `tool_execution_start` before argument validation and `tool_call`. Compression timing -therefore starts inside execute, after approval and a fresh capability check, immediately before -compression work. The tool-call hook retains policy suppression, but does not request approval. -Confirmation receives the call's abort signal; approval is never reused between calls. Waiting for -approval or rejecting a call must not create compression timing, blocks, statistics, or DCP state -entries. Execution-end cleanup remains unconditional for calls that actually started compression. - -The permission command cycles `allow -> ask -> deny -> allow`. An undefined session override uses -the configured permission, with `allow` as the standalone command's default. Snapshot version 2 -persists the new union; parsing, restoration, and lifetime aggregation continue accepting version 1 -with its historical allow/deny validation. New serialization writes version 2 without changing -canonical references or block shape. - -The new `dcp` command opens one Pi custom panel only when `ctx.mode === "tui"`; `hasUI` is true in -RPC too and cannot guard terminal components. Outside TUI, notify once and return without loading -lifetime data or calling custom UI. It displays the current model, context usage, resolved -thresholds, compression and manual modes, permission, policy/tool availability, compression blocks, -session savings, and lifetime totals. Selectable actions toggle manual mode, cycle permission, -sweep eligible outputs, and deactivate or reactivate a selected block. Existing commands remain -stable shortcuts and the fallback when no TUI is available. - -Panel mutations and direct commands share the same pipeline policy guard and existing domain -commands. Recheck current capabilities before every action; globally disabled, model-disabled, and -disallowed sub-agent sessions retain informational access but cannot mutate state. Permission denial -alone does not disable sweep, manual mode, or block controls. Manual toggle passes explicit `on` or -`off` to the existing command; its no-argument status behavior remains unchanged. Blocks are sorted -numerically and distinguish active, user-deactivated, and otherwise inactive states. Only -user-deactivated blocks offer reactivation. A stale block row returns a readable not-found message. - -The panel uses arrow keys or `j`/`k`, Enter, and Escape or `q`. Rendering is usable at 60, 80, and -120 columns and 24 rows, with a bounded scrolling action/block list that keeps selection visible. -Selection survives action-driven rebuilds by action/block identity; each component completes only -once. A pure view-model builder and action executor isolate behavior from the terminal component; -the component only renders, tracks selection, and returns an action. The controller supplies current -model/context data, rebuilds after actions, shows their results, and reconciles through -`onStateChange(ctx)` after permitted actions. Closing or rejecting a policy-disabled action does not -invoke that callback. - -Load lifetime totals once per panel opening. Missing data is explicit; rejected lifetime loading -must not prevent the panel from opening. Preserve the current lifetime loader's zero-total behavior -for empty, missing, or inaccessible directories. Undefined custom-UI results close the controller; -custom-UI rejection produces one notification. Rendering failure uses a minimal bounded view with -working close keys, so command access is not stranded. - -`@earendil-works/pi-tui` is added as a host-provided `"*"` peer and a `^1.0.1` development dependency, -retaining its existing v1.0.1 lockfile resolution, not as a runtime dependency. - -The Phase 5 review used the same Pi and OpenCode DCP revisions as Phase 4. Pi's mode, confirmation, -component, and event-ordering APIs are supported by the installed v1.0.1 development packages. -OpenCode supplies behavioral references for permission before compression work and panel availability; -its source, adapters, separate context/statistics screens, and host permission machinery are not copied. - -## Acceptance - -Every phase passes formatting, warning-free lint, TypeScript, all Vitest tests, generated-schema -comparison, package dry-run, and its focused benchmarks. Manual Pi verification covers system -prompt sections, tool visibility, permission confirmation, session resume/fork/tree restoration, -and panel input at narrow and wide widths. Publishing is not part of implementation; release -metadata and changelog entries prepare each phase for a separate publish decision.