# SPEC — midcompact extension module (`src/`)

Maintenance contract for `src/`. The model-facing usage contract lives in
`skills/midcompact/` (SKILL.md + references/tool-interface.md) and is
authoritative for observable tool/command behavior; this spec records the
durable invariants a future maintainer must preserve, which are not reliably
inferable from code at a glance. Entry point: `src/index.ts` (factory in
package.json → `pi.extensions`).

## Persistence

- Exactly three custom entry types are ever persisted on the session branch:
  `midcompact-transaction`, `midcompact-draft`, `midcompact-state`
  (`state.ts`). All are `version: 1`, shape-checked before use.
- Restore is *latest entry wins* over the branch. A draft restores only when
  a transaction entry precedes it with a matching `transactionId`; the two
  form one unit. State entries are independent of transactions.
- Commit appends a new state entry — never mutates old ones. Abort writes
  nothing.
- Compatibility is a standing constraint: old transactions without
  `startMode` default to `"agent"`; old ranges lacking factual char/image
  fields are coerced (`coerceDraftRange`). Legacy `approxTokens` fields are
  deprecated: never authoritative for decisions or UI.
- The planning lock is deliberately not persisted: reload clears the owner.

## Transactions

- Cancelling a Selection/Review surface chooser changes neither the shared
  DraftPlan nor the transaction. User-manual start uses that same Selection
  chooser after the Agent acknowledgement; cancelling it leaves the new
  transaction active.
- Start freezes the current leaf as anchor, appends TXN + DRAFT, and routes
  Agent-direct or User-manual. All planning happens on the child branch; the
  discussion never enters the working context.
- Commit and abort both navigate back to the anchor (`navigateTree`); commit
  then appends state, abort appends nothing. Both refuse to run while the
  Agent holds the planning lock.
- `anchorUsage` is informational only — Pi-reported awareness, never an
  optimization target. The web workbench may additionally render a
  **display-level** post-commit projection derived from the documented
  char-class assumption table in `content-metrics` (`TOKEN_ESTIMATE`): always
  labeled `est.`, shown as a propagated band, and never feeding commit
  gating, range validation, or any decision (same rule as `projectedTokens`
  / `approxTokens`).
- Atom refs are transaction-local: re-run inspect/locate in a later
  transaction; group refs (`g...`) are never locate refs.

## Projection

- While blocks are active, the context hook replaces committed blocks with
  their summary wrapper. The original history is restored only by recall;
  recall never re-projects. Committed blocks appear as protected atoms in
  later snapshots (no double compression).

## Atoms and ranges

- The atom is the smallest selectable unit. Tool calls plus their canonical
  adjacent results form one indivisible `tool_exchange` atom. A frozen call
  with no result anywhere in the anchor is an `abandoned exchange` and may be
  compressed as a whole. Ambiguous tool protocol and orphan results remain
  protected, as do existing compressed blocks, unsupported message kinds, and
  entries lacking the persistent anchor entry.
- Draft ranges never overlap and never contain protected atoms; boundaries
  cannot be updated in place (remove + re-add). Empty `summary` = pending;
  commit rejects pending, reversed, overlapping, or protected-crossing
  ranges. `KEEP` is expressed by leaving atoms outside all ranges.

## Concurrency

- One runtime mutex over DraftPlan edits: owner ∈ {agent, ui}. Agent turns
  hold it for their whole lifetime (`agent_start` → `agent_settled`); UIs
  hold it per session via the `midcompactPlanningLock` API object (exposed
  for UI and tests).
- Blocked operations notify and return — there is no queue. Selection/Review
  commands refuse a second planning UI while the UI lock or a registered WebUI
  is held, so an old workbench's cleanup cannot release a newer UI's lock.
- `/midcompact:close-webui` closes registered WebUI servers, including a server
  already shutting down, then waits for their cleanup to release the lock.
  It is not a generic unlock: Agent-held and unrelated TUI locks remain
  unchanged. The saved plan and transaction remain unchanged;
  interactive UI confirms unsaved browser edits will not be saved, otherwise
  the command warns. No registered WebUI is a no-op with a notice.
- WebUI opening commands return once the URL is ready: Pi's idle input loop
  awaits slash commands, so waiting for page closure would queue the recovery
  command behind the workbench it must close. The registered workbench retains
  the UI lock until its asynchronous cleanup finishes.
- WebUI handles remain registered until cleanup finishes and are closed before
  session/branch state is restored, and on session shutdown. Forced close
  revokes API access before destroying sockets. Closing servers reject queued
  liveness requests and edits whose
  request bodies finish after shutdown starts; neither may revive the server
  or mutate the plan after access is revoked.

## External contracts (reference, don't duplicate)

- Tool: one `midcompact` tool whose parameters are `{ request: <union> }` —
  a root `type: "object"` wrapping a union of 11 single-purpose actions
  (inspect/measure/locate_ref/locate_search/plan_show/plan_read/plan_add/
  plan_update/plan_remove/recall_list/recall_read); each branch is closed
  (`additionalProperties: false`), so extra fields are rejected (schema where
  enforced, runtime backstop everywhere — a silently ignored field is a bug).
  Former second-level discriminators (plan `op`, locate ref-vs-filter, recall
  list-vs-render, inspect inventory-vs-spans) are flattened into branches so
  field legality is schema-visible. The `request` wrapper exists because some
  providers (e.g. DeepSeek) reject a root-level `anyOf` before the model sees
  the schema.
  Details: `skills/midcompact/references/tool-interface.md`.
- Commands: `midcompact:start|abort|commit|review|review-webui|select|select-webui|close-webui|status`;
  no composite `/midcompact`; native naming convention `name:sub` (Pi's
  `skill:<name>`). `select` and `review` accept optional `tui|webui`; omitted
  arguments require an explicit built-in surface choice when extension UI is
  available and otherwise warn. They never silently fall back between
  surfaces. `select-webui` and `review-webui` remain compatibility aliases.
- The tool never starts a transaction and never commits; both are command-
  or user-gated. Recall is the only action valid without a transaction.
- Web workbench (`review-webui.html` + `review-webui.ts`): the state payload
  carries per-atom char-class counts (`narrowChars`/`wideChars`), per-range
  replacement char-class counts (including the actual wrapper), and the
  assumption table (`est`), so the page renders the projection band and can
  update estimates while a summary is edited; `GET /api/atom/:ref` serves the
  frozen atom's full text for the original-text drawer (read-only, snapshot-local).
  `startReviewWebUiServer` owns the Pi-independent loopback HTTP contract;
  `openReviewWebUi` adapts it to Pi notification and browser launch, returning
  a handle for session-owned cleanup. `showReviewWebUi` is the blocking adapter
  for callers that explicitly await the page lifetime. Development may opt into
  a persistent server without changing the production defaults.
  User-facing copy says "can't compress" for protected atoms; "protected"
  stays the agent/tool-side term.
  Page invariants that broke once and must hold: the `<!--MIDCOMPACT_STATE-->`
  script tag is the server's template injection point (renaming it breaks
  state loading); `selectionRefs` initialization depends on helpers defined
  later in the page script (order is load-bearing, TDZ); `gbody` visibility
  is driven by the render-time `hidden` attribute, so collapse handlers must
  sync that attribute, not just a class.

## Change rules

- Model-facing copy (schema descriptions, prompts, errors, notices, skill docs) follows `.dev/docs/agent-vocabulary.md`; a new action, field, or concept is registered there **before** implementation.
- Adding a tool action → new request branch + handler type + tool-interface
  section + SKILL.md routing; adding parameters to an action → its branch
  only (the union stays nested under `request`).
- Changing persistence shapes → keep `version: 1` readable (coerce) or add
  a migration; restore predicates (`state.ts`) are the compatibility gate.
- Renaming commands → update SKILL.md/README/tests together; the stale-name
  failure mode is a doc-arbitrated contract violation.
- Test seams: `setOpenReviewWebBrowser` and the mocks in `test/` must stay
  behavior-faithful to the real extension API; suites drive commands via
  `pi.commands.get(...)` and the tool via plain param objects.