# 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, task or answer. Version **0.7.2** adds [Pi auxiliary usage receipts](#pi-auxiliary-usage-receipts)
through public custom entries, separately from native accounting, and validates
subagent batches before any translation call. It needs no Pi core patch; see the
[0.7.2 verification record](testing.md#verification-record-for-072) for the
unmodified official Pi 1.4.0 checks and persistence limits. The **0.7.0** release added independent host defaults,
Pi/Claude subagent boundaries, pending-selection display, and asymmetric
input/output filtering (`literal-whitelist-v9`), including Chinese display previews
for English task instructions without rewriting their English submission. See the
[0.7.0 verification record](testing.md#verification-record-for-070) for tested
hosts and evidence limits. Earlier history, cancellation, reload and long-answer
contracts remain except where the current behavior below explicitly extends them.
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
│   ├── recall.mjs                    # Original/translation pairs for prompt history and replay
│   ├── 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); RPC/headless preview
│   ├── input.mjs                     # input: replace/block; user lifecycle: delivered originals
│   ├── output.mjs                    # turn_end and completed tasks: display-only translation
│   ├── subagents.mjs                 # Atomic task preparation; immutable child results/cards
│   ├── 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 cache adapter; editor and editor-history originals
│   ├── trace.mjs                     # Pi-session sidecar write-ahead logs
│   ├── model.mjs                     # Translator model + clamped thinking level
│   ├── usage.mjs                     # Per-attempt numeric auxiliary receipts; no native accounting
│   └── 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, leases, history
│   ├── SKILL.md                      # Lists /translator with no hook running; the mod answers it
│   ├── tsconfig.json                 # Uses declarations generated by installed Claude
│   ├── .claude-plugin/
│   │   ├── plugin.json               # Plugin identity/version, bridgePath option, types, no skills
│   │   └── 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                 # GENERATED mirror of shared/recall.mjs; shipped, not hand-edited
│   │   ├── 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              # Common/host defaults, commands and atomic updates
│   ├── defaults.test.mjs             # Host isolation, legacy config, precedence and reset
│   ├── output-filtering.test.mjs     # Tolerant output spans, Chinese masking, bounded preface
│   ├── pi-subagents.test.mjs         # Task schemas, atomicity, operators, cancellation and cards
│   ├── pi-subagents-host.test.mjs    # Real Pi 1.3.0 SDK with offline subagent tool fixture
│   ├── pi-subagents-process.test.mjs # Real CLI + unmodified example, actual local-provider children
│   ├── task-display.test.mjs         # Task previews, multilingual paths, history isolation
│   ├── claude-tasks.test.mjs         # Real Agent/SendMessage, background delivery and choices
│   ├── 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
│   ├── pi-usage.test.mjs             # Numeric receipts, ownership, failure and deduplication
│   ├── pi-usage-host.test.mjs        # Official Pi 1.4.0 persistence and native/context isolation
│   ├── pi-history.test.mjs           # Pi editor history: refilled English rows mapped to originals
│   ├── pi-tui.test.mjs               # Real Pi terminal via tmux: /tree and /fork put originals back
│   ├── 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-desktop.test.mjs       # Real headless session drawn as the desktop app draws it
│   ├── claude-recall.test.mjs        # Prompt-history pairs, ambiguity, replay; mirror check
│   ├── claude-tui.test.mjs           # Real Claude terminal via tmux: Ctrl+X Ctrl+S, mid-turn reload, resume, old history
│   ├── claude-skill.test.mjs         # Real Claude via a skills link: hook-less menu, /translator sent first
│   ├── 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
literal spans identified by `splitLiterals`, not all visible characters.
Input uses conservative boundaries; output deliberately adds tolerant resource
and contextual-name spans. Detection and request masking use those same spans.
`outputSegments` further protects already-Chinese source units. There is no
post-translation language classifier or semantic audit:

- 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 masks ASCII paths/filenames even against Chinese edges, contextual
  technical identifiers and the exact labels `Markdown`, `Mermaid`, `Downloads`,
  `Token`, `Tokens`. A single Titlecase name directly between Han prose (spaces
  allowed) is protected too. Standalone `OK`, `Thanks`, `Mermaid` and ordinary
  English remain eligible. Source units split at unprotected line/sentence
  boundaries; units without English prose become protected spans restored byte
  for byte. English units stay batched, not one completion per line. A mixed
  sentence with substantive English remains eligible. The only whole-answer
  tolerance is a leading preface of at most 12 English words before an otherwise
  Chinese body with at least 80 unmasked Han characters. An English conclusion
  or substantial English section still translates without resending the Chinese
  body. This intentionally favors display usability over translating every short
  English introduction; it is not an arbitrary Chinese-majority exemption.
- 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.

Task previews use the same output mask/gate with `task: true`: no answer-preface
exemption, and `{previous}` remains an opaque chain operator. Existing Chinese
sections stay out of the request. Explicit paths with a terminal extension
protect mixed-script segments (`./src/组件Panel.vue`, `C:\项目\My文档.md`);
output also protects hard-ended multilingual relative paths adjoining Chinese.
Bare ambiguous glued instructions still remain prose.

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 input residual detection and masking; output extends the same scanner as
above. The 0.7.0 `literal-whitelist-v9` contract bump isolates older versioned
cache entries without deleting logs. The conservative base 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.

Input 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 a set `timeoutMs` (the
default `null` sets none) bounds each request. So an answer whose masked text is
longer than `OUTPUT_PART_LENGTH` (2,000 characters) is translated in parts: by
`translateOutput` for Pi, the Pi backend and the command-line tool, and through
`prepareTranslation` with `split` for Claude Code's native path:

- `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 gets at most two completion attempts,
  each with its own configured deadline. API/transport errors, empty/incomplete
  replies and timeouts retry once on the same model. Local configuration,
  persistence and literal-restoration faults and explicit cancellation never retry.
  Refusal fallback shares that budget: in `translateOutput` and
  `translatePrompt`, a reply the provider reports as a refusal
  (`rawStopReason: "refusal"`) is asked once of the caller's `fallback`. Pi passes
  the model `translationModel(ctx, config, true)` picks; Claude Code's Pi backend
  and the command-line tool pass `piCompletion().fallback` (`pi/backend.mjs`),
  which asks for the `fallbackFamily` name at the provider that answered, for Pi
  to resolve, and records the model that answered. Claude Code's native path has
  its own fallback round (below).
- 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 final failure (after any allowed retry) 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, byte for byte. Claude Code's native path prepares the same parts when the
mod asks with `parts: true` and sends them four at a time; a mod copy from before
parts gets one request per answer.

### 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.

### Independent host defaults

`loadConfig(path = configPath(), host)` accepts `pi` / `claude`; omitting `host`
retains standalone/common behavior. Existing flat files remain valid. Precedence:
built-ins → common fields → `hosts[host]` → branch/session patches → input-disable
environment lock. Each host layer is partial, validates known types/ranges and
atomic Pi provider/model pairs, and preserves unknown data. `null` model pins
explicitly follow, whereas an absent field inherits.

`updateConfig` performs a locked canonical read/modify/write, preserving symlinks,
permissions, unknown fields and other hosts. `control.mjs` exposes all five
settings plus cache under `--host pi|claude`; `inherit` and `reset` remove selected
overrides rather than copying current defaults. `reset all` removes all known
fields, including backend/timeout, but keeps unknown fields. Slash commands still
write only session/branch overrides. A new session starts from its host defaults;
resume restores its own overrides. Settings operations make no model calls.

### 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.

Claude Code also lists `/translator` from `claude-code/SKILL.md`, which needs no
hook: the desktop app builds a new session's slash menu from a short stream-json
run with `disableAllHooks`, before the session's own process exists. Through the
`skills/` link the file is the user's own `/translator` (`disable-model-invocation:
true`), so `$.command.register` is refused and caught, and the mod's `command.run`
hook, whose `.catch` answers too, takes every run: the engine never hands the
skill's text to the model. The manifest's `"skills": []` keeps plugin loading
(`--plugin-dir`) from reading the file as `/translator:translator`, aliased
`/translator`; there the mod's registration stands. The skill's text tells a
session that reads skills but runs no hooks (`disableAllHooks`) that Translator
is not running; `--safe-mode` and `--bare` skip skills as well.

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, the skill and prompt-template lines `pi.getCommands()` lists (Pi expands
   them after this hook: `/skill:name` up to the first space, a template by its
   first word), a nested run (below), or explicitly disabled translation. Any
   other slash line is translated, and so is a `!` line Pi sends to the model; a
   `!` line Enter runs goes to the shell before this hook.
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. A
   reply the provider reports as a refusal (`rawStopReason: "refusal"`, which
   pi-ai reports as an error) is asked once of `translationModel(ctx, config,
   true)`: the newest available model of `fallbackFamily` from the same provider.
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, naming the reason; there will be no
   delivered user row. A queued prompt whose translation the run's own abort
   (`ctx.signal`) cancelled goes back to the TUI editor, after what Pi restores
   there.
6. `turn_end` independently translates only complete, final public assistant text
   (stop reason `stop`, or `length` for an answer cut at the output limit).
   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. Pi awaits
   `turn_end` handlers before it reads its queues or ends the run, so in the TUI
   the handler returns once `ctx.signal` aborts or `hasPendingMessages()` is true
   (checked every 100 ms): the translation keeps its own controller, and its entry
   follows with `answerStart`, the answer's first line, when later message rows
   came first. RPC also releases the wait without cancelling completed output;
   print/JSON waits before exiting even when the producing operation aborts. Navigation, a session switch, `/reload`
   or shutdown first records a `cancelled` card for each running job. A config
   error warns once and adds no card.

`subagents.mjs` hooks Pi 1.3.0's documented mutable `tool_call.event.input` for the
`subagent` tool's single, parallel and chain schemas. It prepares every task
before mutating the dispatch payload; input-conversion/recording failure or
cancellation blocks the entire batch. An English task keeps its exact submission,
while Input also builds its Chinese preview with the output translator, even
when Output is off. Mixed-task previews protect already-Chinese sections. The
record keeps `original`, English `translated`/`submitted`, and Chinese
`displayText` separate; display-only failures record `displayError` and retain
valid English. Only real Chinese-to-English translations enter the input cache.
Only task strings needing English conversion change. `{previous}` is protected and the original previous
result remains the chain handoff. A malformed/custom shape fails closed with
Input on rather than partially translating. After successful execution,
notification-only `tool_execution_end` selects completed public child answers
and invokes the output pipeline with tool-call/task identities. Results and
provider-visible context stay untouched. Labelled `subagent-input-translation`
and `output-translation` custom entries persist task displays;
generation/session guards, deduplication,
owned cancellation and explicit prepared-task handoffs prevent stale
or duplicate display. See the [Pi contract](../pi/README.md#subagents). Tests use
both a real SDK with a synthetic tool and an actual Pi CLI running the unmodified
official example: real child processes, isolated agents and a localhost provider.

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".
While translated steering or follow-up input waits, the first lines of the last
three originals are shown above the editor (`setWidget("translation-queue", …,
{ placement: "aboveEditor" })`); delivery, settlement and invalidation update it.

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 (while `ctx.isIdle()` is
not false: a run that is starting has not delivered its prompt yet) 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. Input combines its controller with
`ctx.signal` when available; output does so only without a UI, so Esc ends Pi's
wait for an answer's translation, not the translation. 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.

Pi's editor history keeps typed text, but Pi refills it from the stored user
rows, which hold the English: at startup, `pi -c`, after `/tree` and on a session
switch, through `InteractiveMode.renderSessionEntries(…, { populateHistory: true })`.
`originalsInHistory` wraps that method and `Editor.prototype.addToHistory` once
per process, on the classes Pi shares with extensions (a `Symbol.for` marker
prevents a second wrap; the pairs live on `globalThis`, so a `/reload` keeps one
wrapper). During a refill only, it swaps in the original of an exact, unambiguous
translated or submitted text from `shared/recall.mjs`, so English the person
submits stays as typed. When Esc or Alt+Up puts Pi's queue back in the editor (a
terminal-input listener checks after each key, and settlement once more), the
English of each waiting translation there gives way to its original. `/tree` to
a translated prompt and `/fork` from one set the editor to its stored row, after
`session_tree` and after `session_start` (reason `fork`): `originalsInEditor` arms
that row's card at `session_before_tree` (`preparation.targetId`) and
`session_before_fork` (`entryId`), and a tick after the later event replaces the
editor text with the original while it is still exactly that English. Pairs come from the session's
`prompt-translation` entries at `session_start`, from the target session file at
`session_before_switch`, which precedes the refill, and from each delivered
prompt. Another editor class or module instance keeps Pi's behaviour.

`handoff.mjs` publishes `TRANSLATOR_PI_HANDOFF` only for supported prepared
subagent calls. It contains parent PID plus SHA-256 hashes and UTF-16 lengths of
task segments, never source text. A direct JSON child checks its actual initial
prompt (raw or the example's `Task: ` wrapper), including exact repeated
`{previous}` substitution, before delegating input/final-report display to the
parent. Chain templates require non-whitespace fixed text outside `{previous}`;
with Input on, the parent blocks unanchored tasks before dispatch, while Input off
leaves the task unchanged without ownership. Children reject inherited unanchored
tickets too. Other sessions translate normally; a PID alone suppresses nothing.
Nested subagent handlers remain active. Metadata is captured once across reload,
and pending assignments are removed at tool completion/invalidation. No files
are created, including with no-session persistence. Modified launchers or later
argument rewrites that do not match cannot claim this coordination.

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.

### Pi auxiliary usage receipts

`usage.mjs` wraps only actual isolated completion invocations from input, output
and subagent preparation. It copies returned numeric Pi Usage before response,
literal or chain-operator checks and before fallback; concurrent parts and retry
attempts have separate UUIDs and model identities. It uses only public
`pi.appendEntry("orbit.translator.usage.v1", data)` custom entries, never native
usage writes or direct session-file writes. `nativeAccounting` is always false.
The complete [receipt contract](../pi/README.md#auxiliary-usage-accounting) is also
consumed by Metrics. Old successful-translation aggregates are not backfilled.

The entry wires a shared accounting instance into all three handlers and seals
unresolved attempts before branch/session invalidation. Cancellation within the
same owner can still return numeric usage; unresolved attempts at invalidation
become immutable `interrupted` / null receipts on the old branch. `/translator
usage` reads all session entries, not only the active branch, deduplicates request
IDs, and reports auxiliary totals separately from native totals. Persistence
errors keep best-effort memory totals and never cause another provider call.
There is no usage sidecar: Pi 1.4.0's first-conversation-message flush boundary and
no-session behavior apply, so a blocked first input's receipts are not yet durable.

### 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, a skipped step still counts as taken, 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 `parts: true`, the model
   (`$.session.model()`; for an answer, the step's `TurnStepInput.model`) and the
   session settings, read per request.
2. Worker loads common plus `hosts.claude` 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`; a saved value the configuration no
   longer accepts is ignored, field by field. 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:
   - `requests`: one per asked part, a prompt being one part and an answer
     longer than `OUTPUT_PART_LENGTH` several ([Long answers](#long-answers)).
     Each names the effective `claudeModel` or the session model (input is
     blocked if neither is available) and carries one masked user prompt,
     translation system text with the part's own end marker, a time limit only
     when `timeoutMs` is set, translator `thinkingLevel` as requested effort, and
     a 32,000-token cap. `request` repeats the first for a mod copy that asks one
     at a time;
   - `continuation`: protocol `version: 2`, the core's serializable preparation
     (parts and protection state), config snapshot, session/request IDs,
     contract version, each asked part's end marker, the parts still `pending`,
     the replies kept so far, and the answer's `row` when the mod named one.
3. Mod calls **`$.model.complete`** for each request, four at a time (one more
   round at most, see step 5), 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 cap the model refuses before sending ("can produce in
   one reply (N)") is asked again with N, consuming the same two-call budget. A request its own deadline ended
   returns `aborted` while the task is live; the mod passes it on as `timeout`.
   A cancelled native response is never sent to `finish`.
4. Mod sends `finish`, the same session ID, continuation and the responses, in
   request order, back to the worker. Continuations remain in memory/stdin, not
   ticket files.
5. Worker checks the protocol version, contract and session, then each pending
   part: its completion marker, a nonempty response and literal marker
   counts/block order. It restores literals, joins the parts, stores an accepted
   input cache entry and saves the final record, with a `linkedRow` record for
   the answer's row (`linked: true` tells the mod not to link it again).
   `finish` returns a final result or failure, with one exception: a reply
   without its end marker was cut short (by the output limit, or by the model's
   safeguards, which since 2026-10-14 stop some Opus 5.5 translations partway, a
   stop `$.model.complete` does not report), and a reply the model could not
   give (`api-error`, `empty-reply`, marker-only text or `timeout`) is
   treated alike. Cancellation/local errors and damaged literals do not retry. `finish` then returns `prepared` once more for those parts only: the
   same masked prompts for another model family (`sonnet`, or `haiku` when the
   translator already is a Sonnet model) with fresh end markers, and a
   continuation that keeps the parts already translated and whose trace names
   the fallback model and `fallbackFrom`. The mod makes that round and sends its
   `finish`; its answers are final, the worker never prepares a third round, and
   the mod refuses one. `usage` sums every reply, and the record names the
   fallback model as `model` even when only some parts needed it. A version 1
   continuation is finished as a one-part version 2 only if its scanner/prompt
   contract is current. A contract mismatch (including an old in-flight request
   across the 0.7.0 scanner change) fails closed. Any other failure names its
   part (`… (part 2 of 4)`) when there are several: a timeout is `Translation
   timed out`, an API error carries its status and type. No language or semantic
   check or other 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). For input it then
   lets the typed text enter, with unchanged attachments/context/origin/queue
   flags, and stores its row in English (below); 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. A mod copy
from before parts sends no `parts`, gets one request per answer and finishes
with a single `response`, which the worker still accepts.

`prompt.submit` translates the person's prompts: origin `composer`, `sdk`,
`bridge` (remote control) or `scheduled-trigger`, and a plugin's prompt sent
`asUser`. Other plugins, peers, notifications and subagents pass through, as do
`e.isMeta`, `e.command` (2.1.300) and a line whose `/name` `$.command.list()`
lists (2.1.295 raises prompt-type skills without `e.command`): Claude Code never
applies a rewrite of a command's line. Any other slash line, a path or a typo,
is the person's prompt. Context blocks the desktop app adds
around a prompt (`system-reminder`, `widget_context_hint`, `session-mentions`
and the like) are peeled from its start and end before the gate and the
translation.

`turn.step` wraps every main-loop provider step. A step with text and a stop
reason is an answer when it has no tool calls or its tools are exclusively
`AskUserQuestion` / `ExitPlanMode`. Other tool chatter and selection mixed with
operational tools skip. `refusal` is not an answer; the engine continues it. `max_tokens` and `model_context_window_exceeded` mark a cut the engine
also continues: the text and reply rows carry into the next step and are
translated with it, or by `turn.complete` when the turn ends without it (reason
`answer` or `error`). Each step is claimed once, by turn ID and step index, in
the module and in `$.state` (`handled`): a reload's two copies translate it once,
and a turn a Stop hook continues still gets its next answer translated.
`turn.complete`'s own fallback (no step) claims a turn only when no step of it
was claimed.

Where `$.session.surfaces()` lists a surface, a claimed answer joins `$.state`
(`pending`, the last 20, unowned) while the step returns its result at once:
queued prompts and notifications go on, and an interrupt has nothing to cancel.
`turn.complete`, whose dispatch an interrupt does not end (a job started inside the
step's dispatch dies with it), takes the jobs no live copy owns and translates
them in the background, one at a time per session; `session.start` does the same
after a reload, which ends the old copy's work. A session change stops only
prompt translations: an answer's goes on, its drawing lands in whatever session is
open (a `/branch` shows the same rows) and its journal link redraws it later, but
its notice and warnings appear only in its own session. With no surface (`-p`, an SDK host that attaches none) the process would
exit first, so the step awaits a session-start-owned output runner. Its event-driven
`$.clock.after` callback owns the model/process calls; interruption releases the
step's wait without cancelling those calls. Creating a timer inside `turn.step`
is insufficient: the host still ties that timer to the step's cancellation.
Normal headless runs keep waiting so native notices flush before exit. No polling
runs while idle; pending jobs and leases preserve reload/deduplication behavior.

Main-answer display state uses the last stored response UUID, never a text hash.
A synthetic main-answer fallback without a stored UUID keeps the native notice
only. Task/tool displays instead use explicit identities with kind, ID,
direction and turn; journal restore must not collapse them under an absent UUID.

### Claude subagents and pending choices

`agent.spawn` rewrites only the task prompt before launch, using the same input
settings and fail-closed worker. `session.send` rewrites a follow-up only when
`$.agent.list()` identifies one local recipient as the sender's child via
`parentId`, or a persisted exact identity returned by a completed spawn (the live
list may no longer contain that child). The host normalizes `e.to` to its string spelling; ambiguous names,
peers, external sessions and reports sent back to the parent are not rewritten.
Non-rewritable workflow tasks deny when translation is needed; absent optional
agent hooks cannot make the whole mod fail to load but provide no task guarantee.

English and mixed-language tasks additionally use the bridge's `task-display`
operation. It selects output masking with `task: true` but gates it on Input,
not Output, and sets `inputDisplay` on a display-only output trace. It never
feeds the input cache or replaces the submitted task. `translateTask` returns
only the separately retained English submission. Rendering/journal restoration
uses the effective Input display identity so a preview supersedes its original
annotation without leaking into human history. Parent-to-child follow-ups have
a native notice and a latest-task annotation at completed Agent results. A
failed display translation warns and retains valid English; cancellation still
blocks undispatched work.

Child output comes from delivered, successful `turn.complete` reports, never a
raw pre-review provider step. Child rows, parent Agent tool results and background
notifications may draw translations; the delivered reports, native result bytes,
permissions and parent/child model context are unchanged. Unique task/display
identities survive parallel completion, reload and resume. Task originals share
the input cache but are excluded from human history restoration and recall by
provenance (`task`/`agent`/display metadata or task source).

Selection steps start their pending display jobs through `$.clock.after` before
`turn.complete`, so a dialog waiting for the person cannot starve translation.
Queue writes use `$.state.set` with `ifVersion` and retry stale snapshots rather
than losing concurrent child/choice work. `AskUserQuestion` annotations use the
official dialog contract: the returned tree contains the native `next(e)`
reference exactly once, with the annotation above it. Plain `Text`, not Markdown,
keeps the annotation acceptable to the older bundled desktop engine. Translation
text never changes option values, selection controls or approval behavior.
`ToolUse`/`ToolResult` task render hooks preserve `next` behavior on drawing errors.
See [Claude task and selection details](../claude-code/README.md#subagent-input-and-display-only-output).

### Claude prompt rows and history

The person's prompt enters as typed. `prompt.submit` translates it, keeps
`{ text, original, translated, requestId }` waiting for its row (per session, in
the module and in the `$.state` family `waiting`, the last 20), and calls
`next(e)` unchanged, so Claude Code's own prompt history (`history.jsonl`)
records the typed text. `session.append` hooks on door `prompt` and on rows named
`queued_command` (door `delivery` on 2.1.294) rewrite the row before it is stored
(`next({ ...e, message })`; content rewrites are "stored and sent after"): a text
block that is exactly a waiting original becomes its English, and originals that
fill whole lines of a block (a `queued_command` frames its prompt; the engine can
join several queued prompts into one row) are swapped longest first. While an
idle prompt's own `next` runs, the mod knows which entry is being stored
(`submitting`): if no block matched, that entry's original is swapped wherever
the row holds it, or the row's first block of the person's own text is replaced,
so a hook below that changed the text cannot let the Chinese through. A queued
prompt that a hook below rewrote is re-keyed to the text that entered. An SDK
message whose words span several text blocks, which the engine joined into the
prompt with newlines, becomes one block of English after its other blocks. The
copy that stores a row decides the warning: an idle prompt's own row that took no
English, or a stored row that still shows an original (the engine keeps no
rewrite of some rows), raises a toast and gets no drawing. The model and the
transcript read English; a `queued_command` keeps the typed text in
`attachment.prompt`, as the queue shows it, and the English in its rendering.

The engine keeps no rewrite of a `queued_command` row that carries media, so for
a prompt with an image (not a document) typed while a turn runs, `prompt.submit`
passes the English down itself, keeping the context blocks around it, and the
waiting entry records that the row holds the English (`stored`); Claude Code's
history then keeps that English, which the box maps back until the next start
restores the line (below). A
`classic.UserPromptSubmit` hook gives settings `UserPromptSubmit` hooks the
English the same way. An interrupt (Stop, Send now) that abandons a prompt typed
while a turn runs before it went down hands it down untranslated instead of
dropping it: 2.1.295 keeps it queued and submits it again, 2.1.300 keeps it
itself. Cancel and a session change still drop it.

The rewritten row is keyed to its original in the `$.state` family `prompts`
under `row:<uuid>` (originals joined when the engine joined several), and
`ui.render` on `UserMessage` draws the engine's row and then the original, dimly
labelled, display only. A person's row with no record under its id that shows
exactly the English or the original of a waiting entry (or, on a remote surface,
of a folded entry not mapped yet) is drawn too, unless different originals
match, as Pi's content-key match is.

Up/Down and Ctrl+R show what was typed. Lines of Claude Code's `history.jsonl`
that hold a translation's English (saved by releases before 0.6.16, or for an
image prompt as above) are restored once per module copy at `session.start`, by
worker op `history`: a line newer than the last pass (kept in
`claude-sessions/.history-<hash of the file's real path>`) whose `display` is
exactly the `submitted` or `translated` text of one original that the journal of
its `sessionId` translated within ten minutes of its `timestamp` gets that
original, and every other line keeps its bytes. The new file is written beside
the old one and renamed over it while the worker holds Claude Code's own lock
(proper-lockfile's `history.jsonl.lock` directory, taken with `mkdir` and waited
for up to two seconds) and the file's size, mtime and inode are still those it
read; otherwise it is read again, at most three times. A file with more than one
hard link is left alone, and memory-only mode skips the pass. The engine still
refills the box with a sent prompt's stored English after Esc cancels it and
after a rewind, without a hook. On the terminal `PromptHint` render that turns
the box from empty to a draft, the mod reads it once (`prompt.read`) and, when
`shared/recall.mjs` maps the text to exactly one original, fills that original
(`prompt.fill`, replace): two host calls, so the engine's English may show for a
frame. The mod's `prompt.fill` hook lets that fill through without counting it as
an edit; a person's edit (`prompt.edit`) or another plugin's fill marks its text
as theirs. No timer, keyboard binding, model call or transcript rewrite is
involved. Pairs come from the session's translations, its restored display rows,
the newest prompts of the input cache (`recall`) and the per-session `recall`
state (the last 250, kept across a reload, memory-only mode included). Submitting an unchanged recalled original that the
person did not type sends its record as an explicit `replay`: the worker applies
the current Input gate, checks exact source and source-literal integrity, and
records/reuses the translation without a completion, even with automatic cache
off or an older contract, applying the current language suffix once. Edits
follow the regular translation path; unknown or ambiguous originals are never
guessed. Task/display/agent records are excluded before creating human recall
pairs or restoring history lines, including task entries in the shared cache;
legacy human records without task provenance remain supported.

### Claude journal links, restore and forks

`$.state` lasts for the running process only, and each session has its own. Each
drawn row is therefore linked to its translation in the session journal (a
`linkedRow` record beside the bilingual record): a prompt's by worker op `link`,
written off the way to the model and awaited before a main-loop step ends and at
`session.end`; an answer's by the worker with its result. A session's settings
and rows are loaded on every visit, since the engine empties every plugin's
`$.state` when a session ends (`session.end` forgets what this copy loaded): at
`session.start`, at
`classic.SessionStart` (sources `clear`, `resume`, `fork`) once `$.session.id()`
names the session it carries, since in-process `/resume` and `/branch` run it
before the ID switches, or at the first `PromptHint` or remote draw of a session
not loaded yet. Worker op `rows` pairs links with translated records and returns
the display rows within 3 MiB, newest first (500 at most for a mod of 0.6.15,
which sends every row to `ids`), and `recall`: the newest translated prompts of
the input cache's 8 MiB window, 500 or 512 KiB at most, so another session's
history, which earlier releases saved in English, maps to its originals. Both
fill the recall pairs. A restore that fails is retried by a later draw, 30
seconds on. Memory-only mode reads no journal links or cache.

Row UUIDs survive forks, so `rows` adds the rows of the sessions a conversation
continues, as its transcript names them at the session's own first row: that
row's `forkedFrom.sessionId` (a `/branch`), else the session of the rows copied
before it (the desktop app's Fork; a copy of a copy names the copy), else the
parent's `continuedInSessionId` record after `← for agents`. The lineage is
followed up to eight sessions; the nearest ancestor's rows win over farther ones
and the session's own over all, and an ancestor's journal that cannot be read
lends nothing. A source `fork`
(`--fork-session`, including `← for agents` and the desktop app's Rewind) whose
transcript names nothing (it is written only at the fork's first new message)
comes back `orphan`. The mod then sends the rows it draws without a drawing,
person prompts and answers, to `rows` as `seen`, those drawn together in one ask
and three asks at most. The worker reads the journals of the project's sessions,
newest first (50 sessions or 64 MiB at most), for a `linkedRow` of one of them:
that session is the parent. Time never stands in for lineage, so a fork of an
older session is tied to it and no unrelated session lends its settings. The
named or found session is returned as `parent`; it lends its rows, and the
session takes its settings when it has none of its own, memory-only mode
included, where a parent the transcript names is still returned.

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 reads the file a chunk at a time, joins a
  long line's pieces once, 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.

Linked rows wait in the module (the last 500 per session) until their ids are
found, and the mod then writes each row's display record under every such id
(`row:<id>`, still an id of the row that shows it, never a text key). It asks
right after linking an answer or a folded prompt, up to eight times 500 ms apart,
whenever a main-loop turn starts (a Rewind's kept rows reach the fork's transcript
then) or step ends, and when a remote surface draws (at most every 30
seconds); a request made while one runs follows it. It asks only while a remote
surface is attached (`$.session.surfaces()`, or one has drawn in this copy), so
rows linked while the app was detached are found when it attaches again. Restored
rows join the list, which covers a resume, a fork and a reload. A row still
missing after ten asks, such as another session's row lent to a fork, is dropped.
The engine writes a folded row to the transcript only when it next saves the
session, which a long step can put off until the turn ends, so until its ids are
found a folded prompt is drawn by its text, as above. 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. The app recalls prompts from its own message
list, which no mod reaches: after it reloads a conversation, prompts sent while
Claude was idle recall the English.

The app starts a project session's process only once its first message is sent
(its start request requires one) and draws a remote surface's band or pane only
for a running process, so a new session's empty screen has no button row.
`/translator`, listed by `SKILL.md`, can be that first message: the session starts
and the mod answers it without a model turn.

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 a main-loop
`turn.start`), the answer steps `handled` (so a step in the old copy and
`turn.complete` in the new one translate it once), answers `pending`
translation, the `recall` pairs and, under `TRANSLATOR_NO_PERSIST=1`, the session
settings (`memory`). Before translating an answer, a copy takes its lease (worker
op `lease`, keyed by the session and `turnId:step`): a worker process creates
`translator-leases/<hash>.lock` in the system's temporary directory exclusively,
writes `held` and lives until the copy releases it or unloads, when the host
ends it; a lock whose process is gone is removed. A second copy's lease waits
while the holder lives and answers `done` once a translated result has written
`<hash>.done`, so that copy skips the answer; files older than a day are deleted.
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 compatible requests from older copies: a refused reload leaves
the previous copy loaded, but a changed scanner/prompt continuation contract
fails closed. An invalid 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. The
AbovePrompt buttons carry no hotkeys: the engine presses a band button for a
digit typed into an empty prompt box after a short pause, which let `1`, `2` or
`3` at the start of a prompt switch a feature off for the session.
Its five settings and the AbovePrompt buttons 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.

A running translation adds a Cancel translation button to the above-prompt band
(Ctrl+X, Tab reaches it) and to an open pane (key 4). A task shows once its first
model call is due, or after 250 ms, so a bypass, a cache hit or a switched-off
direction never flashes it. No arbitrary Ctrl shortcut is intercepted. The mod
owns per-task controllers: a prompt's also follows its dispatch `next.signal`,
which an interrupt aborts, while completed output follows only its own cancellation
controller, including jobs owned by the headless session-start runner. There is no `turn.abort`
hook. Old-session prompt tasks are cancelled on refresh. The worker's reply limit
grows with the request (4 MiB plus eight times its input), since a long answer's
parts come back several times over. 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, host-signal and
session invalidation, and an answer translated after its turn that outlives an
interrupt. Real Claude fixtures separately verify explicit Cancel closing the
native HTTP exchange or optional Pi child, and ordinary stream-json interruption
preserving completed-output translation with no attached surface. This is not a physical Escape-key claim: the focused pane may
consume Escape to close itself.

### 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. The mod sets that call no time limit of its own; cancelling the
translation closes it.

## 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.
