# Architecture

`pi-work-modes` is a single-extension Pi package and workflow tool, not an OS
sandbox. Pi loads `src/index.ts` via jiti (raw TypeScript in the tarball).

## Product and compatibility decisions

- The declared runtime baseline is `@earendil-works/pi-coding-agent`
  0.85.x (minimum 0.85.1) and Node.js 22.19+. Linux is the only advertised OS
  target for now; Windows/macOS support is deferred until platform runners
  and smoke tests are available. Gitea CI tests Linux on Node 22.19 and 24.x.
  Interactive Linux install and mode-switch smoke checks pass on Node
  22.19.0 and 24.10.0 with Pi 0.87.1; Pi 0.85.1 is the declared minimum.
  The baseline Pi API exposes built-in
  `bash` and `powershell` tools.
- `tools.shell: false` is the semantic strict no-shell policy: remove both
  recognized shell tools from the active list and block tool calls even if a
  shell remains visible. Other Pi tools/extensions and effects outside
  `tool_call` are not controlled.
- `bashAllow` and `bashDeny` remain backward-compatible optional heuristics.
  They inspect command text, are not a shell parser, and must never be called
  a security boundary. Users needing a security boundary should use an OS
  sandbox outside this extension.
- Bundled `plan` and `reviewer` disable Pi's built-in edit/write and shell
  tools. `explorer` keeps convenient shell access using best-effort filters
  and makes no read-only guarantee.

## Modules

| File | Responsibility |
| --- | --- |
| `src/schema.ts` | Frontmatter typebox schema + `parseModeFile()` (split, parse with `yaml`, validate, name/stem check, kebab/unknown-key/empty-body warnings). |
| `src/load.ts` | Discovery & merge: bundled < global < project, trust gate, per-file warnings, never throws. |
| `src/guard.ts` | Pure `checkToolCall()` policy: tool allow/deny, strict shell blocking, then optional heuristic shell filters. |
| `src/status.ts` | Footer label (`pi-work-modes` status key) + one-line switch summary. All UI guarded by `ctx.hasUI`. |
| `src/index.ts` | Factory: wires commands, shortcut, flags, events, state, persistence. |

## State (closure inside the factory)

- `modes: Map<string, ModeDefinition>` — merged discovery result.
- `active: ModeDefinition | null` — the running mode (kept in memory even if
  its file disappears).
- `toolsBefore: string[]` — baseline toolset, snapshotted **once** before
  first activation (includes other extensions' tools).
- `modelBefore` / `thinkingBefore` — same snapshot pattern for the
  experimental fields.
- `missingFileWarned: Set<string>` — one-time "file missing" warnings.

## Lifecycle

### Mode switch (command / flag / shortcut)

1. Snapshot session (tools/model/thinking) **if not already snapshotted**.
2. `pi.setActiveTools(deriveActiveTools(baseline, mode))` — derived from the
   stored baseline, never the current set.
3. Apply experimental `model` / `thinking` (if set).
4. `pi.appendEntry("pi-work-modes", { mode, toolsBefore })` — persist.
5. Notify (one line) + update footer.

### Clear (`/mode default`)

Restore `toolsBefore`, `modelBefore`, `thinkingBefore`; persist
`{ mode: null, toolsBefore }`; clear footer. The snapshots are then
**forgotten**, so the next activation re-captures whatever toolset/model/
thinking are active *at that time* — a tool added by another extension (or a
manually picked model) while in default is never reverted to a stale
first-activation snapshot.

### `session_start` (startup / resume / reload / fork)

1. Re-discover modes (bundled < global < project, trust-gated).
2. Restore from the **last** `pi-work-modes` custom entry on the **current
   branch** (`ctx.sessionManager.getBranch()`, not `getEntries()` — the
   latter spans the whole session tree and can land on an abandoned
   branch):
   - `mode: <name>` → re-apply that mode **quietly** (tools derived from the
     persisted `toolsBefore`; no duplicate history entry). Model/thinking
     come from Pi's replay; saved pre-mode snapshots are hydrated so a later
     `/mode default` restores the values from before activation.
   - Missing mode file → fail closed with an in-memory policy that blocks all
     tools; `/mode reload` or `/mode default` is required to recover. Never
     silently restore an unrestricted baseline when the definition is absent.
   - `mode: null` → restore the toolset to the persisted `toolsBefore`.
     Model/thinking are untouched — pi's replay already restored them
     (including any later manual selection).
3. **Flag precedence**: `pi --<name>` wins over the persisted mode — the
   flag applies as a normal switch (and appends its own entry).
4. Update footer (state restoration happens here — a resumed session must
   show the right label immediately).

### `before_agent_start` (once per user prompt)

If a mode is active, inject
`{ customType: "pi-work-modes:<name>", content: <body>, display: false }`.
The message is stored in the session.

### `context` (before every LLM call)

Stale-context hygiene, one rule:

- Mode active → keep **only the last** message with
  `customType === "pi-work-modes:<active>`; drop older copies of the same
  mode and every other mode's messages.
- No mode → drop every `pi-work-modes:*` message.

This is what keeps the token cost flat: the per-prompt injection never
accumulates, and a cleared mode leaves no stale instructions. (The
plan-mode reference's filter only drops when disabled — we don't copy that.)

### `tool_call`

If a mode is active, run `checkToolCall()`. A block returns
`{ block: true, reason }` — **the reason string is the guardrail**; the
agent reads it and adapts. The footer is cosmetic.

## Token budget

- Extension runtime = **0 prompt tokens** (host process).
- No mode = 0 tokens, tools unchanged, empty footer.
- Mode restrictions apply only to Pi's active tools and calls delivered through
  its `tool_call` hook; not to side effects outside that path.
- Active mode = exactly one hidden message of ~100–300 tokens, prefix-cached
  after the first turn (body is byte-stable).

## Persistence format

`pi.appendEntry("pi-work-modes", { mode, toolsBefore, modelBefore,
thinkingBefore })` per switch. `mode` is the name or `null` (cleared).
`toolsBefore` is the baseline toolset, `modelBefore` is the pre-mode model as
`{ provider, id }` (serialization-safe; resolved back through
`ctx.modelRegistry` on restore), and `thinkingBefore` is the pre-mode
thinking level — so a restored session knows exactly what to put back. No
timestamp — pi timestamps entries itself. Entries written by older versions
(without `modelBefore`/`thinkingBefore`) still restore correctly.

## Testing

`test/*.test.ts` via `tsx --test` (flat dir — no `**`). Coverage:

- `schema.test.ts` — frontmatter validation, delimiter/BOM/CRLF handling, and shell policy schema.
- `load.test.ts` — merge priority bundled<global<project, trust gate, bad-file skip.
- `guard.test.ts` — tool-call policy, strict shell blocking, and explicitly best-effort shell-filter regression cases.
- `context.test.ts` — keep-one-copy filter, other modes dropped, cleared drops all.
- `restore.test.ts` — current-branch restore, clear, flags, transition failures, model/thinking apply+restore, missing-file fail-closed recovery.
- `commands.test.ts` — trusted/untrusted write destinations, validation, overwrite confirmation, filesystem errors.
- `integration.test.ts` — pack, install, load bundled flag with real Pi, and remove the artifact.

`test/helpers.ts` provides fakes for fast unit tests. They do not establish
real-runtime event ordering or interactive UI compatibility. `docs/SMOKE-TEST.md`
tracks the manual Pi checks required before release.
