# Architecture

This document describes how Translator is built: the source layout, the
dependency rules, what the installer creates, and how each agent translates a
prompt or an answer. The translation contract below is implemented in 0.6.10;
see its [verification record](testing.md#verification-record-for-0610).
Agent entry points and host-specific code live in parallel `pi/` and `claude-code/`
directories, and no Pi hook is left at the package root. See the
[user guide](../README.md) for installation and settings.

## 1. Source tree

```text
translator/
├── README.md                         # English user guide, install, settings, privacy
├── README.zh-CN.md                   # Complete Simplified Chinese counterpart
├── CHANGELOG.md                      # Release notes, newest first
├── CHANGELOG.zh-CN.md                # Complete Simplified Chinese counterpart
├── LICENSE                           # Copy of the root MIT license, shipped on npm
├── package.json                      # Commands, Pi resource manifest, npm package
│                                     # (bin: translator; files allowlist)
├── .gitignore                        # Personal config, history, generated types
├── config.example.json               # Committed default configuration
├── config.json                       # LOCAL, ignored; never overwritten on install
├── install.sh                        # Executable one-command installer entry point
├── cli.mjs                           # Public standalone translation CLI (Pi backend)
├── control.mjs                       # Public translator config command
├── import-history.mjs                # Public, idempotent old-record importer
│
├── shared/                           # No host lifecycle or UI registrations
│   ├── core.mjs                      # Rules, config validation, masking, translation
│   │                                 # preparation/finalization, deadlines, failures,
│   │                                 # long answers in parts
│   ├── literals.mjs                  # Canonical pure literal scanner and prose gates
│   ├── features.mjs                  # Names, wording, defaults, thinking levels, on/off parsing
│   ├── history.mjs                   # Versioned input cache and durable JSONL helpers
│   ├── file-update.mjs               # Canonical locks, checked writes, safe CLI detection
│   └── preview.mjs                   # Text previews and terminal-control sanitizing
│
├── pi/                               # ALL Pi-specific integration implementation
│   ├── README.md                     # English Pi registration, lifecycle, data boundaries
│   ├── README.zh-CN.md               # Complete Simplified Chinese counterpart
│   ├── index.ts                      # Extension entry; card renderer and event wiring
│   ├── card.mjs                      # Translation card: only the Chinese, as Markdown
│   ├── input.mjs                     # input: replace/block; user lifecycle: delivered originals
│   ├── output.mjs                    # turn_end: display-only final-answer translation
│   ├── controls.mjs                  # Unified command, lifecycle, validated branch settings
│   ├── settings.mjs                  # Native five-option panel, model search, thinking feedback
│   ├── widget.mjs                    # Below-editor toggle bar + native mouse hit testing
│   ├── history.mjs                   # Pi active-branch / in-memory history adapter
│   ├── trace.mjs                     # Pi-session sidecar write-ahead logs
│   ├── model.mjs                     # Translator model + clamped thinking level
│   └── backend.mjs                   # Optional tool-less Pi CLI subprocess backend
│
├── claude-code/                      # ALL Claude-specific integration implementation
│   ├── README.md                     # English Mods API and compatibility details
│   ├── README.zh-CN.md               # Complete Simplified Chinese counterpart
│   ├── bridge.mjs                    # Node JSON worker: config, cache, literals, logs
│   ├── tsconfig.json                 # Uses declarations generated by installed Claude
│   ├── .claude-plugin/
│   │   ├── plugin.json               # Plugin identity/version, bridgePath option, types
│   │   └── types/                    # GENERATED, ignored; not shipped
│   ├── types/
│   │   └── index.d.ts                # $.state contract: drawings and per-session bookkeeping
│   ├── hooks/
│   │   ├── hooks.json                # Module registration
│   │   ├── literals.js               # GENERATED scanner mirror; shipped, not hand-edited
│   │   ├── features.js               # GENERATED feature-name mirror; shipped, not hand-edited
│   │   ├── recall.js                 # Pure original/history matching and replay selection
│   │   ├── settings.js               # Pure command/settings validation shared with bridge
│   │   └── register.js               # Sandboxed Mods events, controls, native completion
│   └── tests/
│       └── translation.test.ts       # Claude-native test-kit cases
│
├── scripts/
│   ├── install.mjs                   # Detect → select → preflight → plan → apply/rollback
│   ├── sync-shared.mjs               # Generate / check the in-plugin shared copies
│   └── eval-corpus.mjs               # Opt-in live corpus run against a real model (paid)
├── docs/
│   ├── architecture.md               # This directory and protocol contract
│   └── testing.md                    # Coverage matrix, reproduction, limitations
├── test/                             # Node tests; no hosted model calls
│   ├── core.test.mjs                 # Shared input behavior and prompt guardrails
│   ├── audit-regressions.test.mjs    # Fence boundaries, damaged cache, stdin imports
│   ├── file-update.test.mjs          # File locks, snapshots, atomic-write helpers
│   ├── pi-lifecycle-regression.test.mjs # Real SDK invalidation and explicit cancellation
│   ├── pi-queue-regression.test.mjs   # Real SDK delayed-tool queues and original display timing
│   ├── claude-safety-regression.test.mjs # UUID association, settings queues, cancellation
│   ├── claude-cancellation.test.mjs  # Real native turn.abort, HTTP closure and Pi-child exit
│   ├── literals.test.mjs             # Preflight partitions, round trips, gates, restoration safety
│   ├── hard-prompts.test.mjs         # Hard corpus through core, Pi handlers, worker, Claude mod
│   ├── corpus-hosts.test.mjs         # Hard corpus sample through the real Pi SDK and claude CLI
│   ├── fixtures/hard-prompts.mjs     # The corpus: sources, expected literals, references
│   ├── fixtures/reference-translator.mjs # Deterministic reference translator for the corpus
│   ├── literal-edge-cases.test.mjs   # Delimiter/prose edge cases and safe cache replacement
│   ├── features.test.mjs             # Cache, modes, importer and bridge integration
│   ├── bridge.test.mjs               # Native two-phase worker / failure boundaries
│   ├── backend.test.mjs              # Subprocess isolation, output limits, cancellation
│   ├── cli.test.mjs                  # stdout/stderr/exit-code protocol
│   ├── control.test.mjs              # All global controls and damaged-cache recovery
│   ├── entrypoints.test.mjs          # Relocation and symlinked entry points
│   ├── install.test.mjs              # Installer matrix, migration, conflicts, rollback
│   ├── layout.test.mjs               # Directory/manifest/config-location invariants
│   ├── package.test.mjs              # npm data directory, npx refusal, tarball contents
│   ├── pi-input.test.mjs             # Pi submission, images, failure blocking
│   ├── pi-output.test.mjs            # Pi final-answer lifecycle and failure isolation
│   ├── output-parts.test.mjs         # Long answers: cuts, parts at once, deadlines, stops
│   ├── pi-card.test.mjs              # Card content, details, sanitizing; real Pi Markdown
│   ├── pi-integration.test.mjs       # Real Pi SDK + installer paths + HTTP fixture
│   ├── pi-controls.test.mjs          # Toggle bar, mode sync, lifecycle, failure boundaries
│   ├── pi-settings.test.mjs          # Native settings panel layout, keys, help and model search
│   ├── pi-widget-integration.test.mjs # Real SDK + fullscreen renderer + raw SGR mouse
│   ├── claude-integration.test.mjs   # Real Claude + native/Pi backends + resume
│   ├── claude-live-session.test.mjs  # Real long-lived Claude session: mid-turn prompts, hot reload
│   ├── claude-tui.test.mjs           # Real Claude terminal via tmux: Ctrl+X Ctrl+S, mid-turn reload, resume
│   ├── preview.test.mjs              # Full bilingual display and terminal safety
│   ├── trace.test.mjs                # Write-ahead ordering and permissions
│   └── fixtures/fake-pi.mjs          # Offline, isolation-checking Pi replacement
│
├── history/input.jsonl               # LOCAL, ignored; successful input translations
└── claude-sessions/*.jsonl           # LOCAL, ignored; bilingual Claude sidecars
```

The three root `.mjs` commands are public user-facing utilities, **not agent hook
entry points**. They retain their existing paths so command symlinks and scripts
continue working. Agent registration belongs only under the agent's directory.

Every tool and adapter README has an English `README.md` and a complete
`README.zh-CN.md`, maintained together under the repository's
[documentation policy](../../../CONTRIBUTING.md#language-and-documentation-policy).
Supporting architecture and test records remain English. The root
[MIT license](../../../LICENSE) covers this tool and its documentation.

## 2. Dependency rules

- `shared/` must not import agent adapters, register hooks, or use a host SDK.
- Pi's entry point uses host-supplied Pi SDK/TUI packages. No separate copies are
  bundled or installed by this project. Local implementation dependencies load
  through the synchronous `require` provided by Pi's jiti entry loader (with
  module caching disabled), not Node's `createRequire` or static `.mjs` imports.
  jiti's asynchronous `.mjs` path uses native ESM imports, whose cache survives
  `/reload`; the synchronous path reevaluates the complete local dependency graph.
  Keep host package imports separate so their shared runtime identity is retained.
- Claude's sandboxed mod uses the Mods API, not Node imports. Node filesystem and
  crypto work stays in `claude-code/bridge.mjs` and `shared/`.
- `shared/literals.mjs` is the canonical pure scanner: no Node or host APIs. Pi
  and the Node core import it directly. Claude Mods forbid imports outside the
  plugin root, so `hooks/register.js` imports `hooks/literals.js` inside that root.
  `scripts/sync-shared.mjs` generates this mirror: a generated-file header
  followed by the byte-identical source body. From the package root, run
  `npm run sync:shared` to regenerate it and `npm run check:shared` for a
  read-only comparison. Ship the mirror; never edit it independently.
- `shared/features.mjs` is the single source for the tool name, the three
  feature names (`input`, `instruction`, `output`), their labels (**Input**,
  **Instruction**, **Output**), descriptions, status summary and notice titles,
  plus the default settings, the thinking levels and on/off/toggle parsing.
  Pi and the Node core import it directly; Claude imports the `hooks/features.js`
  mirror, which the same sync script generates. Never hard-code a feature label,
  default or thinking level in an adapter.
- Both agents use the same scanner, core validation and cache contract. Moving
  code must not create a second prompt definition or a second literal-protection
  algorithm. Scanner-rule changes must update `LITERAL_RULES_VERSION` and the
  generated mirror so versioned input caches cannot silently use older rules.
- `pi/backend.mjs` is an **optional** subprocess implementation used by the public
  CLI or explicit `claudeBackend: "pi"`. Importing it does not require or launch Pi.
- `scripts/install.mjs` has no model/client dependencies. Detection runs only
  `--version`; installation does not execute a translation or access credentials.

## 3. Installed filesystem layout

Default installation:

```text
~/.agents/hooks/
└── translator -> /absolute/path/to/checkout/Hooks/translator

~/.local/bin/
└── translator -> ~/.agents/hooks/translator/control.mjs

~/.pi/agent/
└── settings.json
    extensions includes: /absolute/HOME/.agents/hooks/translator/pi/index.ts

~/.claude/skills/
└── translator -> ~/.agents/hooks/translator/claude-code
```

Only the **selected** agent's registration is created. The shared source link and
command are installed once. The checkout necessarily contains both adapters, but
an unselected agent is neither installed nor registered.

`--pi-dir` / `PI_CODING_AGENT_DIR` replace the Pi config directory;
`--claude-dir` / `CLAUDE_CONFIG_DIR` replace the Claude config directory. A custom
Claude account can share its `skills/` directory through a symlink. Existing
symlinked Pi settings are updated at their target, without replacing the symlink.
No global `settings.json` is written for Claude; its user plugin directory is the
registration mechanism.

In a clone, the default translation config remains at the **package root**, not
inside `shared/` or an agent directory. `CONFIG_URL` is resolved relative to the
source module's URL, not the process working directory. `TRANSLATOR_CONFIG` changes
both the config and adjacent shared-cache / Claude-sidecar locations. Pi sidecars
remain adjacent to Pi's native session files. Source relocation does not
implicitly relocate or delete private logs.

An npm install (`npm install -g @orbit-labs-ai/translator`, then
`translator install`) creates the same entries, with the stable source link
pointing into npm's global package directory. Because npm replaces that directory
on every update, a copy whose real path lies under `node_modules` keeps its
default config, shared cache and Claude journals in `~/.agents/translator/`
instead (`defaultConfigPath` in `shared/core.mjs`, used by every entry point and
the installer). The installer refuses to run from npm's temporary `_npx` cache,
whose links would later break. The published tarball is limited by the `files`
allowlist: runtime code, documentation and the license, never tests, generated
declarations or local data.

## 4. Translation paths

Both adapters and the shared core use `needsTranslation` from the same preflight
scanner. It applies Unicode script tests to the **residual**: text outside the
certain literal spans identified by `splitLiterals`, not all visible characters.
The same identified spans are masked before either an input or output completion.
There is no separate, more permissive post-translation language classifier.
Anything uncertain remains visible to the model instead of being hidden inside
a guessed literal:

- Input is translated when Han remains in the residual. Quoted Chinese prose
  stays in the residual: an English prompt quoting Chinese takes the translation
  path, although the model may keep an exact quoted value. Input whose Chinese
  is only in recognized code, filenames, paths or literal-like names skips,
  including a bare `报告.md` with no English prose alongside it.
- Output is translated when the residual reads as English: it holds an English
  prose word, or it has no Han but still has a Latin word such as plain `OK`.
  Acronyms, camelCase, flags, versions, dotfiles and code-like words do not make
  an otherwise Chinese answer English. The exact, case-sensitive names/labels
  `Markdown`, `Mermaid`, `Downloads`, `Token` and `Tokens`, including surrounding
  punctuation/emphasis, are also recognized for that output decision. This
  small list is not blanket Titlecase handling or a Han-ratio shortcut; it does
  not itself mask those plain words. Plain `OK` or `Mermaid` output still
  translates, while scanner-recognized literal-only output does not. Other
  plain-word names (`Orbit`) and slash-joined prose (`clone/fork/download`) still
  count as English prose.
- Empty and literal-only text skips in both directions. Examples include
  `报告.md`, `./文档/报告.md`, `用户表_v2`, `LABEL=中文` and `@张三`.
  Raw negative fast paths (no Han for input, no Latin for output) avoid
  unnecessary scanning without changing the preflight contract. Bounded token
  matching and linear edge trimming avoid unbounded backtracking; performance
  regression verification for the changed scanner is pending.

Language decisions run **only before translation**, before Pi config/history/
trace work and before Claude model/mode reads or worker startup, for both Claude
backends. A preflight skip creates no translation call, log, preview or language
suffix. Translation must also be enabled for its direction. Hidden thinking is
never part of visible-output preflight; the original answer remains unchanged.

### Shared scanner and restoration safety

The pure `splitLiterals` scanner identifies conservative, non-overlapping spans
for both residual detection and masking. The 0.6.10 scanner/prompt contract bump
isolates older versioned cache entries. Protected categories are:

- Explicit syntax, which may hold Han: backtick/tilde fences (blockquoted and
  unclosed too), inline backticks with exact runs, `pre`/`code`/`script`/`style`
  blocks, delimited math (`$...$` single-line and not before a digit, `$$...$$`,
  `\(...\)`, `\[...\]`), wikilinks, Markdown link destinations, reference
  definitions and identifiers, footnote identifiers, and quoted paths or
  filenames with spaces or Han.
- Resources with unambiguous edges: ASCII URLs (`scheme://`, `www.`, `mailto:`),
  emails, explicit paths (`/`, `~/`, `./`, `../`, drive letters, UNC, `@…/`),
  and certain bare filenames/relative paths, including `报告.md`, `README.md`
  and `docs/报告.md`. A `/` right after Han starts an ASCII path only when a
  second `/` follows, so `项目/Orbit` remains prose.
- Certain literal-like identifiers, settings and mentions, such as `用户表_v2`,
  `LABEL=中文` and `@张三`. These are masked for actual translation too, not just
  excluded from a language decision.

Boundaries must leave uncertain prose visible: Han glued to Latin letters
(`请查看data.csv`, `…/docs然后`), Han tied to a name by `：` or `、`, a link/path
ending in Han without a file extension or closing separator, a mention that runs
into a sentence, an email with a Chinese local part, or prose joined only by
slashes or `#`. Quoted Chinese prose is not a protected literal merely because
it has quotes. JSON, ordinary HTML tags/attributes, arbitrary command values,
indented text and undelimited formulas are not protected as a whole; recognized
literal spans within them may still be masked. Model instructions to preserve
unmasked exact values are not a local byte-integrity guarantee.

`shared/core.mjs` replaces identified bytes with short placeholders
(`__PT_` + 8 random hex digits + index, regenerated until absent from the source).
Each fresh input translation or output part needing translation gets **one
completion**. After it, `finishTranslation` checks only response and restoration
safety:

- **Completion**: the response must be completed and nonempty. Native Claude
  additionally requires its unique terminal marker because its API does not
  report a stop reason.
- **Placeholders**: each exactly once, with no missing, damaged, duplicated or
  unrecognized marker. Input keeps the relative order of protected fenced code
  blocks; inline literals may move. Display-only output may move any literal.
- **Restoration**: replace accepted markers with the exact original bytes.

A restoration failure raises an error and terminates that
translation. There is no AI audit, repair prompt or second completion. Response,
restoration, API and timeout failures block input or keep the original output;
cancellation still prevents late publication. Accepted results may be recorded
and input results cached, but no language or semantic check is run on a draft,
restored text or joined answer. Remaining Chinese in input or absent Han in
output is not a rejection reason. Literal content cannot trigger language
rejection. These safeguards protect framing and source literals, not translation
quality: a result may still be untranslated, inaccurate or semantically wrong.

### Long answers

A request takes longer the more it has to write, and `timeoutMs` bounds each
request; Pi also awaits its `turn_end` handlers before it ends the turn. So
`translateOutput` sends an answer whose masked text is longer than
`OUTPUT_PART_LENGTH` (2,000 characters) in parts:

- `outputParts` cuts the trimmed masked text between blocks (blank lines) and,
  inside a block longer than the limit, between lines. A placeholder holds no
  line break, so no cut falls inside a masked literal. Each cut keeps its
  whitespace, the next line's indentation included: no part starts or ends with
  whitespace, so trimming a draft loses nothing. Cuts fall nearest equal shares
  of the text, and no part is longer than the limit unless a single line is.
- Each part carries the placeholders it holds and goes out with the output
  system prompt, at most four at a time. Preflight inspects the source part before
  any completion; a part with no English prose, including a literal-only part,
  is kept without a request. Each part needing translation gets one completion
  with its own deadline and completed/nonempty response and exact-placeholder
  requirements. A failure stops the translation rather than requesting a repair.
- Each part's literals are restored once by `finishTranslation`, then the
  completed parts are joined with the original cuts. Neither a draft part nor
  the joined answer is checked for language or semantic quality, and there is
  no joined-answer audit call. Usage is added up field by field.
- The first failure aborts the parts still running, starts no other, and names
  its part (`… (part 2 of 4)`). A caller's cancellation aborts them all and is
  reported as `cancelled`.

An answer within the limit, or one that cannot be cut, keeps its single
request. Claude Code's native path prepares one request per answer and does
not split it.

### Input cache contract

`shared/history.mjs` hashes `LITERAL_RULES_VERSION` and the input `SYSTEM_PROMPT`
into `CONTRACT`; the exact original input plus that contract forms the cache key.
Changing either component isolates older versioned entries. Main/translator model,
translator thinking, backend choice and the optional language suffix are
intentionally outside the cache key. A session model/thinking change therefore
still permits exact-input reuse; it is not a request to retranslate with the new
model. Disable the cache when a fresh translation is needed.

Before reuse, `canReuseTranslation` checks that the candidate is nonempty and
retains the exact bytes and occurrence counts of source literals identified by
the scanner. Order is not rechecked. This is source-literal integrity only: no
language detection, semantic audit or target-language requirement is applied to
cached text, even when it equals the source or contains untranslated prose.
This applies to unversioned legacy Pi records as well as current cache entries;
importing a legacy record does not exempt it from literal-integrity checks.
A literal-changing cached result triggers a fresh translation rather than
silently renaming resources. An accepted fresh result can replace it; append-only
history retains the old record while later lookups use the replacement. Readers
ignore torn lines and non-object JSON values without rewriting the journal or
discarding valid neighbors. Cache acceptance is not a semantic quality guarantee.

### Unified session settings

Each adapter registers only `/translator`. No arguments open a native panel
with five options: input, instruction, output, translator model and thinking.
Direct syntax is `status|help`, `input|instruction|output [on|off|toggle|status]`,
`model [follow|MODEL]` and `thinking [low|medium|high|xhigh|max]`. Switch subcommands
without a value toggle; model/thinking without a value only report settings.
There is no cancel subcommand or cancellation slash command.

These are branch/session overrides over validated file defaults, not edits to
the shared config or the main model/thinking. Explicit `follow` masks a file pin;
missing overrides retain file defaults. Both translation directions use the
resolved settings, while requests already in flight keep their own snapshots.
Thinking defaults to `medium` independently of the main conversation. The
forced-input-disable environment setting wins over all UI/command overrides.
Existing toggle rows and shortcuts share the same control path as the panels.
No setting operation makes a model call.

### Pi

Before registration, `pi/index.ts` checks `TRANSLATOR_PI_BYPASS === "1"`.
An automated launcher sets it only for its Pi child to opt out of the whole
extension: no controls, renderers, input/output handlers, configuration/history
reads or translation records. The original prompt and answer remain unchanged,
without the language suffix; saved branch overrides cannot re-enable translation.
Other extensions and Pi's trust/tool/model policy are not disabled. This explicit
process opt-out is independent of `TRANSLATOR_DISABLE` (input only), and neither
JSON mode nor prompt wording implies it. The launcher and Translator must both
support the marker; old launchers or old installed packages do not gain it from
a configuration change alone.

1. `input` skips when no Chinese remains in the residual, for extension-generated
   inputs, or for explicitly disabled translation.
2. Write the original to the sidecar before any model call. Check the active
   branch/shared exact cache when enabled.
3. Request only the masked current prompt through `ctx.modelRegistry.streamSimple`,
   on the effective branch/file `provider` / `model` pin or the current session
   model when following. Use the translator's branch/file `thinkingLevel`, clamped
   to the model's supported levels, never the session's own thinking level.
4. Check completed/nonempty response and literal marker counts/block order.
   Restore exact bytes; do not recheck language or call a reviewer/repair model.
5. Save the result in the write-ahead journal and enabled cache before returning
   `transform` with the English and original images. Do not append a successful
   custom entry or show a preview yet. Failure returns `handled` and immediately
   appends/displays its original and error; there will be no delivered user row.
6. `turn_end` independently translates only complete, final public assistant text.
   An answer without English prose to translate, including literal-only text,
   skips before config/history reads or trace creation; remaining English prose
   makes it eligible for translation. It never replaces the answer, submits
   another turn, or forwards thinking/tools. A long answer goes out in
   [parts](#long-answers), one completion per part needing translation; the
   handler counts the answer once and clears its status when it ends.

Successful input display has a separate delivery lifetime. A live in-memory
queue matches exact submitted text and image bytes at public `message_start`,
then stages that same user object at `message_end`. Pi persists it **after** the
extension's end hook. The next ordered `message_start`, `before_provider_request`
or `agent_end` therefore flushes the staged item only if that exact object is a
new user row after its captured branch leaf, with unchanged content and no later
message. The display-only custom entry records `userMessageId` and `deliveredAt`
after that row, before another row can bury it. RPC/stderr previews follow the
same timing. There is no timer, message injection, context edit or extra turn.
Post-delivery display failure warns that the prompt was sent, not "NOT sent".

Pi exposes no input-to-queue ID or queue-removal event to extensions. Identical
originals consume separate occurrences; repeated lifecycle objects cannot produce
duplicate cards. Different originals with indistinguishable pending submissions
or a conflicting bypass (including disabled/extension input) are suppressed, not
matched by guessed FIFO across steering/follow-up queues. Host image normalization
or later input transformations can also omit a card. Input and final settlement
prune waiting records when the host reports no queued messages; navigation,
reload and session replacement invalidate them. Pending displays are never
reconstructed from journals or cache. Previously delivered entries persist in
order, including legacy cards whose historical placement is not rewritten. A
missing/ambiguous display does not remove its write-ahead result or enabled cache.

Input and output each own AbortControllers, combined with `ctx.signal` when
available. Ctrl+Alt+C cancels all pending controllers in both directions, including
while the settings panel owns focus. Input returns `handled` rather than sending
either original or translation; output retains the completed original answer.
Cancellation does not change settings. Each handler verifies a generation, live
context and session identity before accepting/recording results or clearing UI
status. `session_before_tree` invalidates both directions before the host changes
the leaf, so a slow earlier `session_tree` handler cannot allow a late write on
the new branch; the post-event also invalidates. Shutdown/reload disposes them.
Guarded cleanup cannot turn an input failure into a fail-open submission or let
stale work clear another session's status.

Idle input preprocessing lacks a reliable ordinary SDK/RPC abort path; the
regression documents this gap separately from shortcut cancellation. Bare SDK
disposal blocks late results but may leave a network request running until
completion or timeout. Focused evidence uses Pi 1.1.0's real SDK and registered
shortcut handler, not physical Escape-key delivery. No private host API or
host-core patch is used.

The `session_start` handler mounts `translation-controls` with
`ctx.ui.setWidget(..., { placement: "belowEditor" })` only in TUI mode. The entry
point injects Pi's terminal-width helpers into `widget.mjs`; rendering and mouse
hit testing use the same wrapped button bounds. Fullscreen left-button presses
are claimed so the native renderer can synthesize a click, but only a completed
click toggles a mode. Dragging, modifiers, padding and stale/disposed targets do
not toggle. The widget never requests focus or installs a raw terminal listener.
Regular mode shows keyboard hints; RPC retains the text status without a widget.

Buttons, commands, panel edits and Ctrl+Alt+T/L/Y append the same non-context
`translation-mode` field patches. Old boolean records remain valid. Model patches
always contain both `provider` and `model`, with `null`/`null` for explicit follow;
malformed patches are ignored atomically. New pins must resolve in the registry.
The controller refreshes snapshots after changes, model selection, tree navigation,
session start, input and agent completion, not on every streamed render. Input
forced off by the environment is visibly locked; unreadable config disables stale
controls until refresh succeeds.

`settings.mjs` composes injected host `SettingsList`, `SelectList` and `Input`
components through `ctx.ui.custom`. Its default nine-line, five-option panel uses
ON/OFF badges, inline selected/resolved model and requested/effective thinking,
and one keyboard hint. `?` toggles the selected setting's explanation without
changing state; inside the searchable available-model/follow picker it remains
ordinary search text. Thinking arrows appear only for a difference, preserving
raw native cycling values. Unsupported thinking is clamped; non-reasoning models
show `off`. Locks and unavailable persisted pins stay visible in the compact view;
`/translator status` retains the full details. Rendering is capped at 80 columns,
and mouse coordinates account for the compact header and width.
The panel and existing below-editor bar stay synchronized. TUI mode gets the
panel; RPC/JSON/print get status/help for the no-argument command instead. Panel
callbacks belong to one interaction and branch/session generation. Tree navigation
closes the panel; shutdown also disposes the bar, so stale callbacks cannot write
to replacement sessions. No timers, watchers, editor replacement or custom footer
are needed.

### Claude native (default; Pi not required)

The sandbox cannot directly import Node's filesystem/crypto modules. Preparation
and completion validation are therefore split across the existing Node worker:

1. Mod applies the local language gate before model/mode reads or worker startup.
   For output, mark a skipped step as handled so `turn.complete` cannot translate
   a last row that alone reads as English (`OK`). Non-bypassed `input`
   / `output` goes to the worker on **JSON stdin**, with the current model from
   `$.session.model()` and the session settings, read per request.
2. Worker loads file defaults and applies validated session overrides via the
   same pure `hooks/settings.js` helper used by the controls, then reapplies the
   input environment lock. `modes` retains the existing three boolean fields and
   adds `translatorModel` and `thinkingLevel`. In native mode a pin overrides
   `claudeModel`; `follow` sets it to `null`. Preparation and safe-cache checks
   need no model call for a bypass or valid hit. Otherwise the worker prepares
   masked text, records `started`, and returns `status: "prepared"` with:
   - `request`: effective `claudeModel` or the session model (input is blocked if
     neither is available), one masked user prompt, translation system text, time limit,
     translator `thinkingLevel` as requested effort, and output cap;
   - `continuation`: the core's serializable protection state, original source,
     config snapshot, session/request IDs, contract version, expiry and end marker.
3. Mod calls **`$.model.complete` once**, not `$.model.fork`, through Claude's own client.
   No transcript/tools are inherited, and no Claude process is spawned. Owned
   cancellation and host dispatch signals are propagated; a session switch
   blocks submission. A cancelled native response is never sent to `finish`.
4. Mod sends `finish`, the same session ID, continuation and completion response
   back to the worker. Continuations remain in memory/stdin, not ticket files.
5. Worker checks the contract, session, expiry, completion marker, nonempty
   response and literal marker counts/block order. It restores literals, stores
   an accepted input cache entry and saves the final record. `finish` returns a
   final result or failure, never another `prepared` request. No language or
   semantic check or additional completion follows. Storage paths are recomputed;
   the worker never writes to a `traceFile` supplied by a continuation.
6. Mod appends a native **system notice** (display-only), then forwards only the
   translated input with unchanged attachments/context/origin/queue flags. Failure
   drops the input. Output translation never changes the answer: the mod draws the
   Chinese under the answer's row (a `ui.render` hook on `AssistantMessage` reading
   the session `$.state` family `answers`) and appends a notice. The terminal shows
   such `info` notices only in its transcript view, which is why the drawing exists.

`model.complete` does not expose a provider stop reason. Each request therefore
requires a random terminal marker. Missing/duplicate markers fail closed rather
than accepting max-token truncation. The marker is removed before display,
submission, literal restoration or caching. A complete response can still be
semantically wrong: this framing is not a translation-quality proof.

Display state is keyed only by the last stored response UUID, never by a text
hash. A synthetic fallback without a stored UUID keeps the native notice only;
repeated last rows cannot attach a whole answer's translation to another answer.

The original prompt is drawn the same way. Before calling `next` with the English,
`prompt.submit` keeps the translation waiting for its row (per session, in the
module and in the `$.state` family `waiting`). `next` resolves when the prompt
entered or was queued, so the row may be stored much later: a prompt sent during
a turn is folded into it as a `queued_command` row (door `delivery` on 2.1.294),
or stored as the next turn's prompt row (door `prompt`). A `session.append` hook on
door `prompt`, and one on any row named `queued_command`, matches a waiting
translation whose English fills whole lines of the row's text, writes
`{ original, translated, submitted }` to the `$.state` family `prompts` under `row:<uuid>`, and
relays the row unchanged; `ui.render` on `UserMessage` draws the engine's row and
then the original, dimly labelled, display only. A person's prompt row showing
exactly the English of a still-waiting translation is drawn too, as a safety net
for a row shape not recognized. Neither drawing changes a stored row or a request.
This existing synchronized Claude display is unchanged; Pi's delayed-display fix
makes its delivered user row and original appear together too.

`$.state` lasts for the running process only. Each drawn row is therefore linked
to its translation in the session journal (worker op `link`, awaited: a `linkedRow`
record beside the bilingual record), and `session.start` / `classic.SessionStart`
call worker op `rows`, which pairs links with translated records and returns up
to 500 display rows to redraw, plus up to 500 recent successful shared-input
records for original-history recall. Recall records are not filtered by cache
contract age; explicit replay checks their source literals. Memory-only mode
reads neither journal links nor shared history.

Claude Code Desktop is a remote surface (`e.surface` `desktop`): it runs the
session headless, attaches over the SDK stream (`ui_attach`) and asks the session
to draw each row it shows (`ui_render`). It sends the props it holds and draws the
engine's node with the props the hook chain hands core. Two things differ from the
terminal, both seen on the app's engine (2.1.293) and in its logs:

- **It draws a prompt from its own copy of what was sent.** Its `UserMessage` props
  carry the Chinese, so without a rewrite the row and the drawn original are the
  same text. When a row's text holds the original on whole lines and not the English,
  the hook replaces that span with the prompt as sent (`submitted` in the record;
  `rows` returns it from the journal) before calling `next`. A row that holds the
  English (every terminal row, a resumed app session) is left alone.
- **It names rows by ids of its own.** A prompt row keeps the uuid the app sent the
  prompt with, so it matches. An answer's text block is
  `<API message id>-t<n>`, and a prompt folded into a running turn keeps the app's
  uuid while its `queued_command` row gets a new one, recorded as the attachment's
  `source_uuid`. Neither the API message id nor `source_uuid` reaches a hook, so the
  worker reads them from the session's transcript (op `ids`): found by session ID
  under `$CLAUDE_CONFIG_DIR/projects/*/` (the newest copy, links followed), read
  only, never a path from the mod. It streams the file and parses only assistant
  rows, which the counts need, and lines naming a row it was asked about. For each
  linked row it returns those ids, or reports the row missing while the transcript
  does not hold it. The app's way of counting `n` is not documented, so an answer
  gets both counts, its message's text blocks and all its blocks, each with the
  row's text; the `AssistantMessage` hook draws a record that has a `text` only
  under a block whose trimmed text equals it, so the other count cannot name
  another block of the same message.

The mod writes the row's display record under each such id (`row:<id>`, still an
id of the row that shows it, never a text key) right after linking an answer or a
folded prompt while a remote surface is attached (`$.session.surfaces()`, or one
has drawn in this copy), asking up to eight times, 500 ms apart, while the row is
missing. Once per session in this copy it does the same for all linked rows,
prompt rows included: as `rows` restores them with a remote surface attached, or
when one first draws, which also covers a resume and a reload. A pass that fails is
tried again by a later draw 30 seconds on. Memory-only mode has no journal, so
only rows linked live get the app's ids. The desktop app's sessions are headless
and, per Claude Code's mod reference, watch a mod's folder only under
`CLAUDE_CODE_PLUGIN_DIR_WATCH=1`, so an update reaches them through
`/reload-plugins` or a new session. How the app paints the result, and the mobile
app and VS Code, are not verified.

Claude Code reloads the module when its files change: when the turn ends, or
sooner when a command or tool the plugin registered is about to run. The old copy
finishes the hooks it is running while a fresh copy, with empty variables, takes
every later event; the worker runs from disk for every request either way. What
both copies need is kept in the session's `$.state`, one `session:<id>` member
each: `waiting` prompts, the turn's last `reply` row (cleared at `turn.start`), the
answer `handled` last (so a step in the old copy and `turn.complete` in the new one
translate it once) and, under `TRANSLATOR_NO_PERSIST=1`, the session settings
(`memory`). Reads of `$.state` in one dispatch see the moment of its first read,
so `turn.step` reads nothing before the step ends. The mod runs the worker beside
it (`$.plugin.root`), unless `bridgePath` names another, and the worker accepts
requests from older copies: a refused reload leaves the previous copy translating,
and a session setting saved by another version is ignored.

The native settings `Pane` is keyboard-first: each setting is a row with a plain
hotkey `Button` (`1`/`2`/`3` toggle the switches, `m` and `t` step the model and
thinking, `i` focuses the model-ID `Input`), so it needs no Tab ring. Steps are
computed inside the serialized queue from the committed value. The short status is
a `tail` on the `PromptHint` line, not a pinned `$.ui.status` line.
Its five settings and the original AbovePrompt buttons (keys 1/2/3) share serialized
per-session reads/updates/refreshes with failure recovery and stale-session checks;
this is not cross-process locking. Overrides live under `modes:<session-id>` in
`$.store`, or memory with `TRANSLATOR_NO_PERSIST=1`. The native model chooser
offers follow, common aliases and a full-ID input; validation is syntactic, with
availability/allowlists left to the backend's completion. The status/pane reports
requested thinking, not a locally computed Claude capability clamp.

Pending input/output tasks add a conditional Cancel translation button, native
hotkey 4, to the above-prompt band and open pane. The keyboard path is an empty
composer or Ctrl+X, Tab, then 4; no arbitrary Ctrl shortcut is intercepted.
The mod owns per-task controllers, observes dispatch `next.signal` and
`turn.abort`, and cancels old-session tasks on refresh. Translation worker calls
use `$.process.spawn` streams; cancellation closes them with `stream.return()`.
The bridge's SIGTERM handler aborts its optional Pi child. Config-only calls use
`$.process.run` and do not initiate translations. Cancelled input is dropped;
cancelled output cannot draw/append a late translation or retry via the fallback.
Once a complete result is ready for publication, its task leaves the cancellable
set before any asynchronous state/notice write. Those irreversible host writes
are not advertised as cancellable or rolled back. The host dispatch signal still
guards input submission after recording; cancellation is not an undo action.

Native-kit/Node tests cover button actions in both directions and host-signal/
session invalidation. Real Claude 2.1.294 fixtures verify `turn.abort` for pending
output with native HTTP closure and optional Pi-child termination. This is not a
physical Escape-key claim: the focused pane may consume Escape to close itself.

### Claude terminal history

Claude's transcript/prompt submission remains translated, but editable history
should show originals. `hooks/recall.js` maps exact saved translated/submitted
strings to original records, rejecting ambiguous mappings. Since Up/Down do not
raise `prompt.edit` on 2.1.296, a terminal-only `clock.every(150)` checks the
composer through `prompt.read` while a draft and known originals exist. It fills
only recognized recalled drafts through `prompt.fill`; user edits/pastes and
other plugins' fills mark their resulting text as ineligible. Pending fills
recheck text and a revision counter before forwarding, with session identity
checked before/after. Empty drafts and session end cancel the timer. No keyboard
binding, host history-file write, model call or transcript rewrite is involved.

Current-session successful submissions, restored display rows and recent shared
history populate the index. Up to 250 pairs in the per-session `recall` state
survive module reload, including memory-only mode. A selected unchanged original
sends its saved record as an explicit `replay` to the worker. The worker first
applies the current Input gate, checks exact source and source-literal integrity,
then records/reuses the translation without a completion, even with automatic
cache off or an older contract. It applies the current language suffix once.
Edits clear selection and follow the regular translation path. Unknown,
ambiguous or absent originals are never guessed; desktop/headless sessions do
not run composer restoration.

### Claude's optional Pi backend

With `claudeBackend: "pi"`, the worker completes `input` / `output` in one call via
`pi/backend.mjs`. The same session validator maps `translatorModel` to an atomic
`provider`/`model` pair and passes the effective thinking level. Explicit follow
clears the pair, selecting Pi's configured default rather than Claude's session
model. Pi runs with tools, extensions, MCP, skills, context files, prompts, themes and
session persistence disabled. An explicit `--append-system-prompt ""` replaces
Pi 1.0.4's discovered APPEND_SYSTEM list; `--no-context-files` alone does not do
that. The source is stdin, never shell interpolation or process arguments. Only
this explicitly chosen mode requires Pi on a Claude host. A long answer runs one
Pi process per [part](#long-answers), at most four at a time, within that one
worker call, which the mod stops after 125 seconds.

## 5. Installer transaction

1. Parse and validate options; check Node/platform.
2. Discover executables/version information. No agents → error before any writes.
3. Select the sole agent automatically; with two, ask or use explicit flags.
   Check only selected agents' minimum versions.
4. Preflight **all** selected registrations, settings schemas, link conflicts,
   source files, translation config and writable parents before applying anything.
5. Build a plan of symlink/file operations. `--dry-run` prints that plan and exits.
6. Create only missing directories/links/config. Lock canonical write targets and
   Pi's logical settings path, re-read the planned baseline, and back up original
   bytes. After preparing the temporary replacement, recheck content/fingerprint
   and lock ownership immediately before committing. New files use atomic
   no-replace creation; existing files retain permissions and symlinks.
7. On an application error, restore previous settings under the same locks, unlink
   this run's files/links, and remove newly created empty directories. One undo
   owns restoration and backup cleanup. If restoration fails, retain the original
   backup and report an incomplete rollback with its recovery path.

The installer is not a general dotfile migration tool: no `--force`, recursive
removal, credentials discovery, agent installation or shell-profile rewriting.
`shared/file-update.mjs` also serializes global control read/modify/write through
canonical paths, including symlink aliases. Locks use the same directory name as
Pi 1.0.4's proper-lockfile `realpath: false` settings lock. An owner marker protects
active locks from stale-lock removal; unknown locks are never stolen. Acquisition
waits up to five seconds; a crash can require manual removal after verifying that
no writer is active. Pi may report ELOCKED/ENOTEMPTY on contention; retry when idle.

This coordinates cooperating writers, not arbitrary editors: the final comparison
and rename are not OS CAS. Unknown logical symlink aliases and Pi's first-create
read-before-lock path remain host boundaries. Conflicting installer snapshots
fail instead of silently merging. Avoid concurrent settings edits during install.

## 6. Migration map and extension policy

| Previous location | Current location |
|---|---|
| `pi.ts` | `pi/index.ts` |
| `pi-input.mjs`, `pi-output.mjs`, `pi-controls.mjs` | `pi/input.mjs`, `pi/output.mjs`, `pi/controls.mjs` |
| `trace.mjs` | `pi/trace.mjs` |
| Pi `sessionHistory` in root `history.mjs` | `pi/history.mjs` |
| `core.mjs`, `history.mjs`, `preview.mjs` | `shared/` with the same filenames |
| Generic mode helpers inside Pi controls, later `shared/modes.mjs` | `shared/features.mjs` |
| Pi subprocess inside `cli.mjs` | `pi/backend.mjs` (CLI re-exports preserved) |
| `bridge.mjs` | `claude-code/bridge.mjs` |
| `Hooks/prompt-translate/` (0.6.0) | `Hooks/translator/` |
| Stable link `~/.agents/hooks/prompt-translate`, npm data `~/.agents/prompt-translate/` | `~/.agents/hooks/translator`, `~/.agents/translator/` |
| Claude plugin `local-translation` (`skills/local-translation`) | `translator` (`skills/translator`) |
| Command link `~/.local/bin/agent-translate`; npm bin `prompt-translate` | `~/.local/bin/translator`; npm bin `translator` (`translator install`) |
| `AGENT_TRANSLATE_*` environment variables | `TRANSLATOR_*` |
| `scripts/sync-literals.mjs` (`sync:literals`) | `scripts/sync-shared.mjs` (`sync:shared`) |

The installer migrates known old Pi paths in the **selected personal settings
file**. It does not edit arbitrary project configs or external callers. Update
custom Claude `bridgePath` overrides manually. The 0.6.1 rename is not migrated
automatically: the README's upgrade steps remove the 0.6.0 links and
registrations and move ignored data by hand. Stored Pi entry types
(`prompt-translation`, `output-translation`, `translation-mode`) keep their
names so that existing sessions still render, import and restore. Keep no root-level Pi hook shim:
compatibility is handled at registration time, not by scattering adapters again.

Future agents must receive their own sibling directory. Share policy/data helpers
in `shared/`, not imports from another agent's event or UI layer. Any directory
change must update the manifest, installer, documentation and real-host tests.
