# Translator for Claude Code

English | [简体中文](README.zh-CN.md)

[![npm](https://img.shields.io/npm/v/@orbit-labs-ai/translator?logo=npm&label=npm)](https://www.npmjs.com/package/@orbit-labs-ai/translator)

This directory is the Claude Code adapter of [Translator](../README.md), packaged as a Claude Code mod named **`translator`**. The [main guide](../README.md) covers installation, every setting and privacy. This page explains how the mod behaves inside Claude Code and the design decisions behind it.

## Features

- **Prompt translation.** A `prompt.submit` hook translates the Chinese prompts you write, from the composer, the SDK, print mode, remote control, scheduled tasks and a plugin sending them as your own words, then lets the text you typed enter: a `session.append` hook stores the row in English, which is all the model reads, while Claude Code's own prompt history keeps your Chinese. This prompt hook passes other plugins' prompts, subagent messages, notifications and slash-command lines through; dedicated task hooks are described below. A failed translation drops the prompt with `Prompt NOT sent: …`.
- **The original under your prompt.** Your prompt row shows the English the model received, with your Chinese original drawn under it, also for prompts you send while Claude is working, in the terminal and in the Claude Code desktop app. It is a drawing only and never enters a request.
- **Chinese answers.** With Output on, the Chinese translation of each final answer is drawn under the answer, in the terminal and in the desktop app. Ordinary answers translate after their turn; text accompanying `AskUserQuestion` or `ExitPlanMode` translates while the choice is pending. Question text and options get a separate display-only translation above the unchanged native question. The stored reply, selection values and permission decisions are unchanged.
- **Subagent tasks and reports.** Input also translates Chinese task prompts at `agent.spawn`, including nested/background agents and teammates, and parent-to-child follow-up messages at `session.send`. Output translates completed child reports for display only: child transcript rows, matching Agent result rows and matching background notifications can show the translation. The original child result returned to the parent is never translated or replaced.
- **Redrawn after a resume.** Each drawing is linked to its transcript row in the session's local journal, so a restarted or resumed session draws it again, as does a conversation continued under a new session ID (`/branch`, `--fork-session`, `← for agents`, the desktop app's Rewind or Fork).
- **Updates while a session runs.** Claude Code reloads the mod when its files change, even mid-turn. Translation continues: an answer whose turn or translation spans the reload is translated once and drawn.
- **Settings pane.** `/translator` opens a pane where every row has a hotkey: `1` `2` `3` toggle Input, Instruction and Output, `m` and `t` step the model and thinking, `i` edits the model ID, and Esc closes it.
- **Listed like your own skills.** Through the installer's `skills/` link, Claude Code also reads the `SKILL.md` beside the mod, so `/translator` is in the slash menu before any hook runs, including the desktop app's menu for a new session. The mod still answers the command itself, and the model is never offered it.
- **Button row.** Input, Instruction and Output buttons sit above the prompt, joined by **Cancel translation** while a translation runs. They have no keys, so a digit typed into the prompt box never presses them.
- **Quiet status.** A short status at the end of the hint line under the prompt, such as `Translator · Input + Output` or `Translator · Input: translating…`.
- **Native translation.** Each fresh input translation or answer part uses an isolated `$.model.complete` and at most one retry for API/transport, empty/incomplete-response or timeout failures, with no tools, history, second Claude process, AI audit or repair call; a long answer's parts go out four at a time. An end marker guards completion framing, and a reply cut short before it, or one an overloaded or unavailable model could not give, is asked once more of another model family. An optional Pi backend retries response failures once on the same model, or uses another family after a refusal. Cancellation and local configuration/storage/literal faults do not retry.
- **Shared preflight.** Before either direction calls a model, the shared scanner identifies certain literals, including bare Chinese filenames, paths, identifiers/settings and mentions, and checks the remaining prose. Literal-only input/output skips with no translation call or log. Quoted Chinese prose remains translatable; plain `OK` output translates.
- **Records.** Each translation also produces a transcript notice that the model never reads and a line in the local journal. Claude Code's existing synchronized prompt-row/original display is unchanged.

## Requirements

- **Claude Code 2.1.292 or newer**, the installer's existing minimum. Version **0.7.2** is verified against CLI **2.1.311** on **2026-10-28**, using isolated local fixtures rather than paid models; see the [0.7.2 record](../docs/testing.md#verification-record-for-072). Claude runtime behavior and settings are unchanged. The new `/translator usage` command and auxiliary receipt contract are Pi-only. The 0.7.0 CLI **2.1.302** and desktop-engine **2.1.299** results (app **2.42304.0**) remain historical; the desktop engine is not newly retested. Optional `agent.spawn` and `session.send` registrations are guarded; missing support does not disable ordinary translation, but those task-input paths need a host that offers the events. The older minimum is not newly verified; [earlier verification records](../docs/testing.md#verification-record-for-0616) remain historical.
- **Node.js 22.19 or newer.**
- The **complete `translator` directory**, not just this folder: the worker, `bridge.mjs`, imports `../shared/`.
- Pi is **not** required. The default native backend translates through Claude Code's own isolated `$.model.complete` API, without copying credentials or starting another Claude process.

## Installing

From the `translator` directory, run `./install.sh --agent claude`, or just `./install.sh` to let it choose. The installer links the mod into Claude Code's user `skills/` directory:

```text
<claude-config>/skills/translator  ->  ~/.agents/hooks/translator/claude-code
~/.agents/hooks/translator          ->  the translator directory in your clone
```

`<claude-config>` is `~/.claude` unless you pass `--claude-dir` or set `CLAUDE_CONFIG_DIR`. To use the mod in another Claude Code account, run the installer again with that account's directory; each account gets its own link to the same source.

Then start a new Claude Code session and check:

- `/translator status` shows the three switches plus translator model and thinking; `/translator` opens the native settings pane.
- Typing `/` lists `/translator`, read from the mod's `SKILL.md` as one of your own skills.
- `claude plugin details translator` reports `translator@skills-dir`. Its "Hooks (0)" counts classic settings hooks only; `claude plugin validate` lists the mod's events.

Nothing else needs to change: no settings hooks, instructions, permission rules, model settings or key bindings.

To load the mod for a single session during development:

```sh
claude --plugin-dir /path/to/orbit-tools/Hooks/translator/claude-code
```

Use one loading method at a time: do not add `--plugin-dir` in an account that already discovers the `skills/` link. Loaded this way, the folder is not one of your skills, and the manifest's `"skills": []` keeps Claude Code from reading `SKILL.md` as the plugin's own skill: the mod registers `/translator` itself. The mod runs the worker in its own directory, `claude-code/bridge.mjs`, so a development copy never runs an installed copy's worker. If the worker lives somewhere unusual, set the `bridgePath` plugin option to its absolute path; an old, non-empty `bridgePath` that points to the former top-level `bridge.mjs` must be updated.

## Using it

**`/translator` is the only translation slash command.** With no arguments, it opens a native `Pane` with two groups. **Switches** lists **Input**, **Instruction** and **Output**, each with its state and a one-line hint. **Translation model** lists **Model**, **Thinking** and a **Model ID** field. Every row has a hotkey, so the pane needs no Tab navigation:

| Key | Action |
|---|---|
| `1` `2` `3` | Toggle Input, Instruction, Output |
| `m` | Next model: follow the session, `haiku`, `sonnet`, `opus` (Pi backend: follow Pi's configured default) |
| `t` | Next thinking level: `low` → `max`, then back to `low` |
| `i` | Move to the Model ID field; type an alias, a full ID or `provider/model` (Pi backend), then Enter |
| `4` | Cancel a running translation (shown only while one runs) |
| ↑ ↓ · Enter · Esc | Move between rows · press the row · close |

Changes apply immediately to subsequent translations, and quick repeated presses step from the saved value. If the pane cannot be placed, the command reports that and directs you to the subcommands.

Installed through the `skills/` link, `/translator` is the skill `SKILL.md` lists, answered by the mod. Claude Code runs it as it runs your other skills: typed in the terminal while Claude is working, it runs when the turn ends. The button row works at any time. A `--plugin-dir` copy registers `/translator` itself, as a command that runs at once.

```text
/translator
/translator status
/translator help
/translator input [on|off|toggle|status]
/translator instruction [on|off|toggle|status]
/translator output [on|off|toggle|status]
/translator model [follow|MODEL]
/translator thinking [low|medium|high|xhigh|max]
```

The three switch subcommands toggle when no value is given; their `status` action is read-only. `model` and `thinking` without a value only report settings. Use a native alias/full ID for `MODEL`, or `provider/model` with `claudeBackend: "pi"`. Invalid syntax is rejected before storage. Saving a model does not probe its availability: the backend checks availability and allowlists when translating, and an unavailable model blocks input rather than falling back to the original.

- **Translator only.** Model and thinking apply to both translation directions, never to the main conversation's model or thinking. `model follow` explicitly overrides a file pin: native mode follows the current Claude model, including later `/model` switches; the Pi backend uses **Pi's configured default**, not Claude's session model. Thinking stays independent (default `medium`); `follow` leaves it unchanged. No setting action calls a model or edits the shared config. In-flight translations retain their settings snapshot. Valid input cache entries are still reused after changing translator model or thinking; disable the cache to force fresh translations.
- **Button row.** The row above the prompt holds **Input**, **Instruction** and **Output**, synchronized with the pane and commands. Its buttons have no hotkeys: Claude Code presses a band button for a digit typed into an empty prompt box after a short pause, so `1`, `2` or `3` at the start of a prompt used to switch a feature off for the session. Click a button, or press **Ctrl+X, then Tab** to reach the row and Enter; the settings pane keeps its keys. No global Ctrl shortcut or existing binding is replaced. When the row is collapsed or hidden, or a survey takes its place, use the direct settings subcommands.
- **What appears.**
  - Your prompt row shows the English the model received, with your original Chinese drawn directly under it, labelled `Translator · Input · original · display only`. It is a drawing only: the stored row and the model's context hold the English.
  - Each translated prompt also produces a `[Translator · Input · …]` notice with the original and the English. Claude Code shows these notices in the transcript view (Ctrl+O), not in the default view.
  - A prompt that cannot be translated is dropped with a `Prompt NOT sent: …` message.
  - With answer translation on, the Chinese appears directly under each final answer, labelled `Translator · Output · display only`. The same text is kept in a `[Translator · Output · display only]` notice.
  - A short status sits at the end of the dim hint line under the prompt: the switches that are on (`Translator · Input + Output`, or `Translator · off`), or `Translator · Input: translating…` while a translation runs. It replaces the pinned status line, which Claude Code draws with a warning glyph and the plugin name.
  - A failed answer translation shows a short toast; the answer itself is unchanged.

The [main guide](../README.md) covers the cache, configuration, storage locations, the language instruction, the `translator` command and the no-persistence option.

All five settings are overrides stored per **session ID**, not global preferences.
Unspecified fields inherit file defaults; a new session starts with those
defaults, and resuming the same ID restores its saved overrides. The worker loads
Claude-specific defaults with `loadConfig(undefined, 'claude')`: built-in defaults,
then legacy top-level fields, then `hosts.claude`, then the session's settings,
with `TRANSLATOR_DISABLE=1` still last. `hosts.pi` does not affect Claude. Existing
flat configuration files remain valid. The standalone `translator --host claude`
controls those defaults; `/translator` continues to change only this session. With
`TRANSLATOR_NO_PERSIST=1`, all five stay in memory instead. The environment
input-disable lock cannot be bypassed through the pane, commands or buttons.
Within one mod instance, all controls serialize read/modify/write and refresh per
session. Overlapping changes are retained; failed updates do not block later ones,
and session changes invalidate stale pane/button actions and status refreshes.
This is not cross-process store locking.

### Cancelling translation

A **Cancel translation** button appears while input or output translation runs,
in the above-prompt row and in an open settings pane, where its key is **4**. It
shows once a model call starts, or after a moment, so a switched-off direction or
a cached prompt never flashes it. Click it, or press **Ctrl+X, then Tab** to reach
the row. There is **no cancellation slash command or subcommand**, and no
arbitrary Ctrl shortcut is intercepted.

Cancellation during translation blocks submission of both the original and
translated input. For output it keeps the completed original answer and publishes
no late result from that cancelled translation. Once a completed result begins
publication, the button disappears: host state/notice writes cannot be rolled back,
and cancellation is not an undo action. Native abort still guards input submission
after recording. Cancellation does not change settings or disable future translations. The mod owns a
controller for each translation and cancels old-session work on session refresh.
A prompt's translation also stops with its `prompt.submit` dispatch, which an
interrupt (Esc, the desktop app's Stop or Send now) abandons; a prompt typed
while a turn runs is then handed down untranslated, so it stays queued and the
engine submits it again (2.1.295) or keeps it (2.1.300), while Cancel and a
session change drop it. Where a surface draws the session, an answer's
translation of completed answers runs independently (selection steps start before
the choice is answered) and stops only for Cancel: an interrupt, a session
change, or a plugin's `$.turn.abort` of a later turn, leaves it alone, and the mod
no longer hooks `turn.abort`. In a run with no surface (`-p`, the SDK), a
session-start-owned, event-driven output runner keeps the translation outside
the step's abort lifetime. The step normally waits so notices are saved before
exit; ordinary interruption releases that wait without cancelling the job.
The host may still delay its next prompt until the side completion finishes.
Explicit Cancel aborts native completion or terminates the optional Pi subprocess. A cancelled output is not
retried through the fallback handler.

Native-kit/Node tests exercise the button for both directions, dispatch signals,
session changes and late results, and check that an answer translated after its
turn outlives an interrupt and is drawn when done. A real Claude Code fixture
checks explicit cancellation for both backends. Additional real stream-json
runs with no attached surface interrupt a pending answer translation, observe
its completed journal exactly once, and verify subsequent model context is unchanged. Physical Escape-key routing is not tested;
the settings pane's close-on-Escape behavior is a UI dismissal, not a translation
cancel binding.

## How it works

### Translation backends

- **`claudeBackend: "native"`** (the default). The worker prepares masked requests, one for a prompt and one per part of a long answer, and an in-memory continuation; the mod calls `$.model.complete` for each, four at a time, and then sends `finish` back to the worker with the replies. The worker checks completion framing and literal restoration, restores literals, joins the parts and saves the result. `finish` returns the result or a failure. The one exception is a reply without its end marker, which 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), or one the model could not give (an API/transport error, an empty or marker-only reply, or a configured request timeout): `finish` then prepares those requests once more for another model family (Sonnet, or Haiku when the translator already is a Sonnet model), and the mod makes that round of completions. Its answers are final; the mod refuses a third round. The record names the model that translated as `model` and the first one as `fallbackFrom`. A request past its `timeoutMs` comes back `aborted` while the translation is still live; the mod reports it as a timeout, and the worker retries once before reporting a final `Translation timed out`. The mod reads `$.session.model()` for every request, so translation follows the current model, including `/model` switches, unless a file `claudeModel` or session `/translator model` pin overrides it. An explicit session `follow` clears the effective file pin without editing that file. If neither is available, input is blocked. A mod loaded before the session-model update (0.4.0) sends no model, and the worker blocks its input with a message to restart Claude Code. `$.model.complete` has no tools and no conversation history. **Do not replace it with `$.model.fork`**, which would inherit the transcript.
- **`claudeBackend: "pi"`.** Uses the isolated Pi subprocess backend instead. Only this mode needs Pi. Its file `provider` / `model` defaults can be overridden with a session `/translator model provider/model` pin; `follow` clears both fields and uses Pi's configured default. The session thinking override is passed as `--thinking`. A reply Pi reports as a refusal is asked once more of the other model family by name (`sonnet`, or `haiku` after a Sonnet model) at the provider that answered, and the journal names that model.

The maximum is two plugin-level completion attempts per part, including a rejected-token-cap correction. This is not an HTTP-attempt limit: Claude internally retries some transport failures; the real fixture observed three failed HTTP requests in the first completion and one successful fallback request. Explicit cancellation, local configuration/allowlist/storage errors and literal-restoration failures never retry.

Native completions run at the effective translator `thinkingLevel` effort (file default `medium`, or `/translator thinking`; never the main session's own effort), with a 32,000-token limit shared with thinking (the model's own limit instead when `$.model.complete` refuses 32,000 and names a lower one), and no deadline unless `timeoutMs` is set. An answer goes to the model that answered its step (`TurnStepInput.model`, a fallback model when the engine fell back) unless a translator model is pinned. The pane/status reports the requested thinking; the host handles model support and omits effort for models that take none. `$.session.model()` returns the resolved ID of the main loop's model (`opus` becomes `claude-opus-5-5`, and `opus[1m]` becomes `claude-opus-5-5[1m]`), which `$.model.complete` accepts unchanged; this was checked on 2.1.292 against a local fixture. Because the completion result does not say why a reply stopped, every request carries a random end marker, and a reply without it is rejected as incomplete or truncated. The worker removes the marker before saving or submitting the translation. API, worker or validation failures block input, failed replies are never cached, and continuations are never written to disk.

### Literal-aware preflight and response safety

Language decisions run **only before translation**, before model/mode reads or
worker startup, for both backends. One shared scanner identifies certain code,
math, links, filenames (including bare `报告.md`), paths and literal-like
identifiers/settings/mentions. Those spans are excluded from language detection
and masked in the request when translation is needed. Input with no remaining
Chinese prose passes through; output with no English prose to translate skips.
Literal-only text skips in both directions with no translation request or log.
Input stays conservative, including quoted Chinese prose. Output additionally
masks ASCII resources against Chinese edges, contextual technical names/labels
and a single Titlecase name directly between Han prose (spaces allowed).
Already-Chinese source units are protected and restored byte for byte, not sent
again. A leading preface of at most 12 English words before an otherwise Chinese
body with at least 80 unmasked Han characters skips; substantial English, English
conclusions and standalone `OK` still translate. English units stay batched,
not one call per line. See [local language detection](../README.md#local-language-detection)
and [what is protected locally](../README.md#what-is-protected-locally).

After the completion (or its one fallback), the worker requires a completed/nonempty response,
a terminal marker in native mode and exact placeholder counts, plus protected
code-block order for input, before restoring source bytes. A failure blocks input
or leaves the original answer intact. There is no post-translation language or
semantic check and no AI audit or repair call, including for long output parts
through the Pi backend. Literal content cannot trigger language rejection.
An accepted result can still be inaccurate, untranslated or in the wrong language;
these structural safeguards do not guarantee semantic quality.

The input cache is keyed by exact source plus the scanner/prompt contract. The
0.7.0 `literal-whitelist-v9` contract bump isolates older versioned entries. Nonempty cache candidates,
including unversioned legacy records, must retain source-literal bytes and
occurrence counts only; their order, language and semantics are not rechecked.
A literal-changing candidate triggers a fresh translation.

The sandbox imports `hooks/literals.js`, generated byte-for-byte from the pure
shared scanner because imports outside the plugin are forbidden. Use
`npm run sync:shared` after source changes and `npm run check:shared` to
verify consistency without writing. The Node worker uses the shared source.

### Listing `/translator`

`/translator` is declared twice, so that a list of commands made while no hook runs holds it too:

- **The mod's command.** `session.start` registers it with `$.command.register` (`COMMAND_SPEC` in `hooks/settings.js`), to run at once when typed mid-turn.
- **`SKILL.md`**, with the same name, description and argument hint (a test keeps them equal) and `disable-model-invocation: true`, so the model is never offered it. Claude Code reads it through the `skills/` link as one of your own skills. Loaded as a plugin (`--plugin-dir`), Claude Code would read it as the plugin's own skill, `/translator:translator` aliased `/translator`, which would take the command's name; the manifest's `"skills": []` prevents that.

The desktop app needs the file: it lists a new session's slash commands from a short stream-json run of Claude Code with `disableAllHooks`, where `session.start` never runs. Where the skill is listed, Claude Code refuses the mod's registration (`it is the user's /translator`), and `session.start` carries on with the settings and the rows it restores. A `command.run` hook on `translator` answers every run, so the engine never runs the skill, whose text would go to the model; its `.catch` answers a failure with `Translator: settings unavailable (…)` for the same reason. The text matters only where Claude Code reads your skills but does not run the mod, as with hooks disabled (`disableAllHooks`): it asks the model to say that Translator is not running. Safe and bare modes skip your skills too.

### Translating the submitted prompt

```js
on('prompt.submit', async ($, e, next) => {
  // Translate first, then let the typed text enter: Claude Code saves it in its
  // prompt history. Its stored row becomes the English (session.append below).
  return next(e)
}).catch(() => ({ drop: 'Prompt NOT sent: translation unavailable' }))
on('session.append', { door: 'prompt' }, ($, e, next) => next(withEnglish(e)))
```

`prompt.submit` is an event; `$.prompt.submit(...)` is a different method that submits **another** prompt, and the mod does not use it. The prompt keeps its attachments, `context`, origin and queue flags. Prompts the person wrote are translated: from the composer, the SDK or print mode, remote control, scheduled tasks and `/loop`, and a plugin's prompt submitted `asUser`. Prompts from other plugins, peer agents, notifications and subagents pass through untouched, and so do the lines the engine runs as a command, skill or template (`e.command` on 2.1.300, or a `/name` that `$.command.list()` lists): Claude Code never applies a rewrite of a command's line. A line that only starts with a slash, such as a path or a typo, is translated. Context the desktop app adds as whole blocks at a prompt's start or end (`system-reminder`, `widget_context_hint` and the like) is left out of the translation.

Once the translation is ready, the prompt waits for its row (see [Drawing the original prompt](#drawing-the-original-prompt)) and `next(e)` passes the **typed** text on, so Claude Code records your Chinese in its own prompt history. The `session.append` hooks for prompt rows (door `prompt`, and rows named `queued_command`) then swap each waiting original the row holds for its English before the row is stored: a text block that is the original, or originals filling whole lines (a `queued_command` frames its prompt; the engine can join several), longest first. `next({ ...e, message })` rewrites content that is "stored and sent after", so the model and the transcript read only English. While an idle prompt's own submission runs, the row stored then is that prompt's even if a hook below changed its text; its original is swapped wherever the row holds it. A queued prompt that a hook below rewrote is matched by the text that entered. An SDK message whose words span several text blocks becomes one block of English. A prompt with an image folded into a running turn is the exception: the engine keeps no rewrite of a row that carries media, so its English goes down from `prompt.submit` and Claude Code's history keeps that English until the next start restores it (below). Should the row an idle prompt's own submission stores take no English, or a stored row still show an original, the copy that stored it shows a toast that the model may read it as typed, and no drawing. A `classic.UserPromptSubmit` hook hands settings `UserPromptSubmit` hooks the English as well.

The prompt history therefore needs no help: Up/Down and Ctrl+R show what you typed. Lines that hold a translation's English, saved by earlier releases or for such an image prompt, are restored in the file itself: at `session.start`, once per copy of the module, the worker gives each line whose text is exactly the English of a prompt its session translated within ten minutes of it that prompt's original, under the lock Claude Code writes the file under (`history.jsonl.lock`). The engine still puts a sent prompt's stored English back in the box after Esc cancels it and after a rewind, with no hook first; one composer check each time the box goes from empty to a draft swaps in the known original with one `$.prompt.read` and one `$.prompt.fill`, so the English may show for a frame. It never touches text you typed (`prompt.edit` marks it), and it is terminal-only. The pairs come from this session's translations and, for lines not restored yet, from your newest translated prompts in the input cache. An unchanged recalled original replays its saved translation, even with automatic cache off or an older contract, after source-literal integrity is established; edited prompts follow normal translation. The current Input switch and language-instruction setting still apply. In the native transcript, your message holds the submitted translation; the original is drawn under it, display only, and kept in a notice and the local journal. See [Prompt history keeps your Chinese](../README.md#prompt-history-keeps-your-chinese).

Claude Code skips a mod hook that throws without `.catch`, which would let the original prompt through. This mod's input handler therefore drops the prompt explicitly when translation, the worker, local recording or the preview fails **before** `next`. It never answers a failure by adding a translation as `additionalContext`, because that would still leave the Chinese original in the model's context.

### Records the model never reads

The generated `SessionAppendArgs` declaration distinguishes two kinds of rows:

```ts
message: {
  type: 'user' | 'system';
  content: ApiContentBlock[];
}
// user: the model reads it
// system: a notice the model never reads; exactly one text block
```

So the safe append is:

```js
await $.session.append({
  message: { type: 'system', content: [{ type: 'text', text: bilingualRecord }] }
})
```

This `system` row is a **transcript notice**, not a system prompt for the model. The engine gives it an ID and stores it as an informational row in the native JSONL. A `user` row would be hidden like other non-typed input but still **sent to the model**, so the mod never appends one. The `session.append` event, which every stored row passes through, can rewrite rows and so change context; the mod hooks it to note which rows hold reply text, relaying them unchanged, and to store the person's prompt rows in English (see [Translating the submitted prompt](#translating-the-submitted-prompt), [Drawing the Chinese answer](#drawing-the-chinese-answer) and [Drawing the original prompt](#drawing-the-original-prompt)).

The notice holds the full original and translated text, the submitted instruction when there is one, and a request ID and path that match the private local journal. How notices are shown is up to the host: 2.1.292 stores them as `info` rows, which the terminal draws only in the transcript view (Ctrl+O) or with `--verbose`. Only native notices, never the truncated lines of `$.ui.log`, serve as translation records; the Chinese answer is drawn separately.

### Translating final answers

`turn.complete.answer` is the final **visible** text, not hidden thinking, but only its last text row. The normal path therefore wraps `turn.step` in an async generator:

1. `yield* next(e)` forwards every original chunk and obtains the complete result.
2. A main-loop step with a non-empty answer and no tool calls that ends the turn is an answer. A step whose tools are **only** `AskUserQuestion` and/or `ExitPlanMode` is eligible too; ordinary tool chatter, or a step mixing a choice with operational tools, still skips. A step cut at the output cap (`max_tokens`, `model_context_window_exceeded`), which the engine continues, carries its text and rows into the next step, and the two are translated as one; a refusal, which the engine continues too, is dropped. Each step is taken once, by its turn and index, so a turn a Stop hook continues gets its next answer translated even when the text repeats. Preflight then skips answers with no English prose to translate.
3. Where a surface draws the session (`$.session.surfaces()` names the terminal or the desktop app), the step queues the answer and returns its result unchanged at once: the turn ends, queued prompts and notifications go on, and an interrupt finds nothing to cancel. The translation starts at `turn.complete`, whose dispatch an interrupt does not end, and a session change does not stop it. Translations run one at a time per session. When it is ready, the Chinese is drawn under the answer (next section) and appended as a notice. A one-shot `-p` run or an SDK host with no surface would exit first, so there the step waits on the session-owned output runner; interruption releases the wait without cancelling the job.
4. Selection steps start their pending jobs from a public `$.clock.after` callback, before `turn.complete`, so a choice waiting for the person cannot suppress translation. The dialog never waits for a display translation on an attached surface. Pending-job updates use `$.state.set` with `ifVersion` and retry a stale snapshot, so parallel child completions and choices cannot overwrite each other's queued work.
5. The answer taken is kept in the session's `$.state` (`pending`) until it is translated, so a copy of the mod loaded by a reload, which ends the old copy's work, translates it again: at `session.start`, or at `turn.complete` for a reload during the turn's last step.

`turn.complete` is a de-duplicated fallback for turns without a provider step this copy saw, and flushes a cut answer whose turn ended without its continuation. A preflight-skipped answer also counts as taken, so a last row that alone would read as English (such as `OK`) cannot trigger a second, partial translation. The mod never forges transcript IDs, writes Claude Code's live JSONL directly, or modifies assistant content.

### Subagent input and display-only output

With Input on, English task instructions also receive a **Chinese display-only preview**, even with Output off. Their submitted text stays byte-for-byte English, without a language suffix. Chinese instructions are translated to English before delivery; mixed tasks separately translate the English prose for display while protecting existing Chinese sections. Task previews do not use the answer's brief-English-preface exemption. They can require a separate completion, obey Input/the environment lock, and never enter the input cache or human prompt recall. Original task, submitted English and Chinese preview remain separate in journals/state. A failed display translation warns and retains valid English; cancellation still prevents dispatch. Tool launch rows show the preview; identifiable follow-ups also have a native notice and a latest-child-task annotation at completed Agent results. Your own history retains the original text in either language.
`agent.spawn` changes only the task's `prompt`, using the same Input/Instruction,
model, thinking, cache and fail-closed worker as ordinary input. It leaves the
agent type, description, identity, background flag, directory, tools and
permissions alone. A task translation failure or cancellation returns `deny`
before spawn. `session.send` translates a follow-up only when the recipient is
exactly one known local child of the sending loop (`$.agent.list()` and
`parentId`, or saved exact spawn-result identities for children no longer listed);
this covers actual `SendMessage` resumption, including after session restart.
Names with multiple matches, peer/external messages and child-to-parent reports
remain unchanged. These hooks do not rewrite arbitrary skill text, system
prompts, history, attachments or tool results.

Workflow-agent prompts that the host declares non-rewritable cannot be safely
translated: when Input is on and such a prompt needs translation, the spawn is
refused with an explanation rather than silently sending the original. A remote
workflow child whose engine exposes no local completion has no local output
translation. Missing optional hooks are a compatibility boundary, not a claim
that those tasks translated.

For output, a child's `turn.step` chunks and result pass through unchanged. Only
its successful, non-aborted `turn.complete.answer` is translated: this is the
**delivered report**, including any host safety warning, not raw provider text
before the host reviews it. `next(e)` and its returned `text` remain untouched.
No `tool_result`, `session.receive`, Agent return value, or notification body is
rewritten, and no translated answer is submitted as another prompt.

Reply rows, de-duplication and leases include the child identity and turn.
Parallel children, repeated endings and resumed child turns cannot take each
other's rows. The final report is kept in the display-only `tasks` state family;
it can be drawn under a matching completed Agent/Task result or background
notification, identified by agent ID **and** matching report text. Aborted,
failed, unrelated and summary-only rows do not receive a guessed translation.
The native system notice remains available when a surface cannot identify a row.
Journals restore task/selection display identities without inventing transcript
UUIDs; memory-only mode does not restore them after exit. These records share the
existing plain-text storage and provider privacy boundaries in the main guide.

### Drawing the Chinese answer

Because the notice is hidden in the default view, the mod also draws the translation under the answer itself:

1. A `session.append` hook on door `response` notes the ID of each text row the main loop stores, keeping the current turn's last one in the session's `$.state` too (see [Updates while a session runs](#updates-while-a-session-runs)), and relays every row unchanged.
2. A successful translation is written to the mod's session `$.state` family `answers`, keyed only by the last stored text row's UUID (`row:<uuid>`). Text hashes are never used: repeated endings such as `Done.` cannot identify a whole answer. Without a reliable UUID, including synthetic fallback turns, only the native notice is used. The family is declared in `types/index.d.ts`, which `plugin.json` names.
3. A `ui.render` hook on `AssistantMessage` reads that family while drawing, so the write redraws the row: first the engine's own drawing, then a dim `Translator · Output · display only` line and the translation as Markdown.

A reply made of several text rows shows the translation under its last row; a summary row never shows one. Only the drawing changes: the stored reply, the transcript and the model's context keep the English answer. `AskUserQuestion` annotations use plain `Text` above exactly one native dialog reference: older desktop engines reject Markdown around that dialog. Option labels and values are never rewritten. `ExitPlanMode` is not a render site; only its accompanying answer is translated, never the permission prompt or plan approval controls. Turning answer translation off stops new translations; answers already drawn keep theirs. The row's ID is also linked to the translation in the session's journal, so a resumed session draws it again (next section).

Every error keeps the completed original answer. Each answer is attempted once per turn, so a failing notice cannot cause a second translation through the fallback.

### Drawing the original prompt

The model receives the submitted translation, not a separate copy of your original, but you should still see what you wrote:

1. Before passing the prompt on, `prompt.submit` keeps the translation waiting for its row, in this copy of the module and in the session's `$.state` family `waiting`.
2. A `session.append` hook sees the row that holds the original, stores it in English (see [Translating the submitted prompt](#translating-the-submitted-prompt)) and writes the original to the `$.state` family `prompts`, keyed by the row's ID (`row:<uuid>`).
3. A `ui.render` hook on `UserMessage` draws the engine's own row, then a dim `Translator · Input · original · display only` line and the Chinese original.

The row can be stored well after `prompt.submit` returns, which only tells that the prompt entered or was queued. A prompt sent while a turn runs is stored in one of two ways:

- **Folded into the running turn,** as a `queued_command` row whose text frames the prompt with lines of the engine's own. This happens with Ctrl+X Ctrl+S or Ctrl+Enter, and with Enter at a tool boundary; on 2.1.294 the row comes by door `delivery`, and the mod matches it by name whatever the door. Its `attachment.prompt` keeps what you typed, as the queue does; its rendering, which the model reads, holds the English.
- **As the next turn's own prompt row** (door `prompt`), when the turn ends first.

Both match when the original fills whole lines of the row's text, never part of a longer prompt; identical English from different originals no longer confuses them, since rows are matched by what you typed. A translation waits until its row is stored, at most 20 per session, and is forgotten if a plugin below drops the prompt. As a safety net for a row stored in a shape not recognized here, a prompt row from the person that shows exactly the English of a waiting translation gets its original drawn too.

The original is never part of the stored row, the transcript's model-facing text or any request.

**Saved with the session.** `$.state` lasts only while Claude Code runs, and each session has its own, so each drawn row is also linked to its translation in the session's journal (`claude-sessions/<session-id>.jsonl`: a `linkedRow` record next to the bilingual record; an answer's link is written with its translation). The engine empties every plugin's `$.state` when a session ends, so the mod loads a session's settings and asks the worker for its links on every visit: at `session.start`, at `classic.SessionStart` once `$.session.id()` names the session it carries (in-process `/resume` and `/branch` run it before the ID switches), or at the first prompt-hint or desktop draw of a session not loaded yet; a restore that fails is retried by a later draw, 30 seconds on. Row IDs survive forks, so the worker also returns the rows of the session a conversation continues, which its transcript names (`forkedFrom` on a `/branch`'s rows, a copied row's session ID, the parent's `continued-in` record after `← for agents`), newest first within 3 MiB. A fork whose transcript names none (`--fork-session`, the desktop app's Rewind, whose transcript is written only at its first prompt) is tied to its parent by the rows it draws: rows drawn without a drawing are sent to the worker, which looks for the session of the project whose journal linked one of them, three asks at most. The session found lends its rows and, when the new session has no Translator settings of its own, its settings. Memory-only mode (`TRANSLATOR_NO_PERSIST=1`) keeps no links, so nothing is redrawn after a restart.

### In the desktop app

The Code tab of the Claude desktop app runs the session headless and asks it to draw each row the app shows, as the `desktop` surface. The app draws rows from its own copy of the conversation and names them with ids of its own, so the drawings above need three additions there:

- **The prompt row gets the English.** The app draws a prompt from what you sent, so its row would show your Chinese with the same original again under it. When a row shows the original, the mod hands it the prompt as sent instead, the English the model read with any instruction, as the terminal's row shows it. A row that already shows the English, as in a session the app reopens from its transcript, is left alone.
- **Answers are found under the app's ids.** The app names a text block of an answer `<API message id>-t<n>`, an id the mod never sees: it keys a translation by the row's UUID. The worker reads the message ID from the session's own transcript, which it finds by the session ID under Claude Code's `projects/` directory and only reads, and the translation is also drawn under the app's id. How the app counts `n` is not documented, so both counts are used, the message's text blocks and all its blocks, and the translation is drawn only under the block whose text is exactly that of the row it was linked to.
- **Prompts sent mid-turn are found under the id they were sent with.** A prompt folded into a running turn is stored as a `queued_command` row with an ID of its own, while the app keeps the id it sent the prompt with; the row records it as `source_uuid`. The worker reads it from the transcript, and the original is drawn under that id too. 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 then the row is drawn by its text: a row showing exactly the English (or the original) of a prompt folded in this way gets its original at once. Identical English from different originals is ambiguous and draws none, as in Pi. The id is asked for again whenever a step ends. A prompt that waits for the next turn is stored under the app's id and needs nothing more; prompts the engine joins during one final step are translated once under the last one's id, whose bubble draws all their originals while the earlier ones draw none.

Linked rows wait on a short list (the last 500) until the worker finds these ids: asked right after a row is linked, up to eight times half a second apart, again whenever a main-loop turn starts or step ends, and when the app draws (at most every 30 seconds). Restored rows join the list too, which covers a resumed session, a fork and a reload of the mod, and rows linked while the app was detached are found for it to draw when it attaches again. A row still missing after ten asks, such as another session's row lent to a fork, is dropped. The worker reads only assistant rows and lines that name a row it was asked about, joining a long line's pieces once, so a long transcript costs little. They are display records like the others, keyed by the id of the row that shows them and never by text. The app's own painting of these rows, the Claude mobile app and the VS Code extension are not tested. Official Mods docs now list VS Code rendering from extension 2.1.299, but that is not visual acceptance evidence here. Remote Control from mobile/web runs hooks on the local host; official docs promise drawings in that local terminal, not in the phone/web client.

**Updating while the app runs.** According to Claude Code's mod reference, sessions the desktop app runs watch a mod's folder only when `CLAUDE_CODE_PLUGIN_DIR_WATCH=1` is set for them. After an update, run `/reload-plugins` in a running session, or start a new one.

**Before the first message.** The app starts Claude Code for a session in a project folder only when you send that session's first message, and it lists a new session's slash commands from a short run of Claude Code with every hook off. No mod runs before the first message, but `SKILL.md` puts `/translator` in that list. Pick it on a new session's empty screen, alone or with a setting such as `input off`, and send it as the first message: the session starts and the mod answers it without a model turn or a translation. `/translator` opens the settings pane, a setting applies to the prompts that follow, and the Input, Instruction and Output row appears. Nothing of the session runs before that message, so the row cannot appear on the empty screen. The app keeps its command list for a while: after an update, `/translator` can take a few minutes, or a restart of the app, to show up. To set every new session's defaults independently of Pi, use `translator --host claude input on`, `translator --host claude output on`, `translator --host claude model sonnet`, or `translator --host claude thinking low` in your shell. `instruction` and `cache` work too. `model follow` explicitly masks a common pin; `inherit` / `reset` restore inheritance. These commands edit `hosts.claude` in `config.json`; `/translator` remains session-only. See [default commands](../README.md#default-settings-for-new-sessions).

### Updates while a session runs

Agents load the mod straight from your clone or install, so an update, a `git pull` or a branch switch changes the files under running sessions. Claude Code then reloads the module: when the current turn ends, or sooner when a command or tool the plugin registered is about to run, such as `/translator`. A reload is a fresh copy of the module with empty variables. The old copy finishes the hooks it is running, and the new copy takes every later event. The worker, `bridge.mjs` with `../shared/`, starts afresh for every request, so it always runs the files on disk.

Translation carries on across that switch:

- **What both copies need lives in the session's `$.state`,** keyed by session and, for child reply rows and handled turns, by agent/turn too: translations waiting for their prompt row (`waiting`), the current turn's last reply row (`reply`, cleared when a main-loop turn starts), the answer steps taken for translation (`handled`), answers taken but not translated yet (`pending`, resumed by the new copy) and, with `TRANSLATOR_NO_PERSIST=1`, the session settings (`memory`). An answer whose step ran in the old copy, while the new copy stored its reply rows and received `turn.complete`, is translated once and drawn under its row.
- **One copy translates each answer.** Before translating an answer, a copy takes the answer's lease: a worker process that holds a lock file for it, named by a hash of the session and the answer, in the system's temporary directory, until the copy releases it or unloads and the host ends that process. A new copy's job for an answer the old copy is still translating waits for the lease, then skips the answer if the old copy translated it, or translates it if the old copy stopped first.
- **Each copy runs its own worker,** the `bridge.mjs` in the plugin's directory (`$.plugin.root`), unless the `bridgePath` option names another.
- **The worker accepts what older copies send.** If Claude Code refuses a reload (`reload failed, the previous version stays loaded`), the previous copy keeps translating through the updated worker. A session setting saved by another version is ignored rather than an error.
- **Links to the journal are written before a main-loop step ends,** off the way to the model, so a copy that stops once its hook returns does not lose the record that redraws a row after a resume.

Reads of `$.state` within one dispatch all see the moment of its first read. The `turn.step` hook therefore reads nothing before the step ends, by which time its reply rows are stored.

## Limits and trust

- Mods are on by default in supported versions. `--safe-mode`, `--bare`, `disableAllHooks`, an untrusted workspace or a managed policy can disable the plugin, and it cannot enforce translation when the host refuses to load it. With hooks disabled, `/translator` comes from `SKILL.md` alone, and the model replies that Translator is not running; safe and bare modes skip your skills too, so `/translator` is not available there. In a new installation, check `/translator status` before relying on it. The old early-access flag `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS` is not needed.
- The mod may start its fixed Node worker, read and write its local translation records, restore originals in Claude Code's prompt history file as described above, and use Claude Code's isolated completion API (or Pi's provider, when explicitly configured). This is not a sandbox. Prompts reach the worker as JSON on standard input, never through shell interpolation or process arguments, and the main conversation is never passed to it.
- With `--no-session-persistence`, also set `TRANSLATOR_NO_PERSIST=1` so the mod writes no history, journals or settings store and leaves Claude Code's prompt history as it is; a mod cannot see that command-line flag. Only the answer leases, which hold no text, still use the system's temporary directory. Headless hosts decide for themselves how to show notices; local records and the isolation of the main context still apply.

## API references

- [Mods overview](https://code.claude.com/docs/en/plugins/mods/overview)
- [Mods events and methods](https://code.claude.com/docs/en/plugins/mods/reference)
- [Event contracts](https://code.claude.com/docs/en/plugins/mods/events) and [API contracts](https://code.claude.com/docs/en/plugins/mods/api)
- [Get the types for your build](https://code.claude.com/docs/en/plugins/mods/create#get-the-types-for-your-build)
- [UI and button keyboard rules](https://code.claude.com/docs/en/plugins/mods/interface)
- [Test a mod](https://code.claude.com/docs/en/plugins/mods/test)
- [Classic hooks](https://code.claude.com/docs/en/hooks#userpromptsubmit)

Public declarations can lag behind the installed binary. The mod is written against the declarations Claude Code generates locally in `.claude-plugin/types/claude-code/index.d.ts`, and is checked with real requests against local fixtures; the [verification record](../docs/testing.md) lists the versions each release was tested on. The generated declarations are ignored by Git and are not part of the source; Claude Code regenerates them.

## Verifying and removing

From the `translator` directory:

```sh
npm run check:shared
claude plugin validate ./claude-code
claude plugin test ./claude-code
node --test test/bridge.test.mjs test/claude-safety-regression.test.mjs test/claude-integration.test.mjs test/claude-cancellation.test.mjs test/claude-live-session.test.mjs test/claude-tui.test.mjs test/claude-desktop.test.mjs test/claude-tasks.test.mjs test/claude-skill.test.mjs test/corpus-hosts.test.mjs
```

None of these call a hosted model. The last command runs the real Claude Code binary against an HTTP fixture with both backends: the Pi backend talks to a fake Pi process, and the native backend uses Claude Code's real completion API with Pi deliberately unavailable. It checks the notices, the English the model receives, cache reuse, failure blocking and isolation after resuming. In native mode the plugin is discovered through a skills link that the installer creates in a temporary home, so the test always exercises this clone, never another installed copy. The mod test kit covers the unified command, native five-option pane, synchronized button row without hotkeys, the pending-only Cancel button, the prompt-hint status and the drawing of translated answers. The corpus test sends the hardest mixed-language prompts through the real binary. The kit's `$.session.append` limitation is covered by real-binary notice/resume tests rather than a fabricated stub. See the [test matrix](../docs/testing.md).

Two more real-binary tests cover updates and prompts sent mid-turn. `claude-live-session.test.mjs` keeps one headless session open (`--input-format stream-json`, with `CLAUDE_CODE_PLUGIN_DIR_WATCH=1`) on a copy of the mod and no `~/.agents` link. It checks that prompts sent mid-turn are linked to the rows they are stored in, and that an edit to the mod reloads it between turns while translation continues. `claude-tui.test.mjs` drives the real terminal UI through tmux: Ctrl+X Ctrl+S and Enter during a turn, a reload forced mid-turn by `/translator`, and `--resume` in a new process. It is skipped, saying so, when tmux is missing.

`claude-desktop.test.mjs` drives a headless session the way the desktop app does: it attaches as the `desktop` surface and asks for rows with the props and ids the app uses. It checks that a prompt row showing the Chinese is handed the English, with the original drawn once under it; that the Chinese answer is drawn under `<message id>-t<n>`, also for a message that thinks first, and not under another block; that a prompt sent mid-turn gets its original under the id it was sent with, and the English when the app draws it from its Chinese copy; that a prompt folded into a long turn shows its original while the turn's next step still runs; and that all of these come back after `--resume`. To test another Claude Code binary, such as the one the app ships, put a `claude` that runs it first on `PATH`.

`claude-tasks.test.mjs` runs two parallel real child agents against localhost,
once foreground and once background, with an SDK permission responder limited to
those synthetic, tool-less children. It holds an actual `AskUserQuestion` pending,
requires translations before the answer is selected, inspects parent requests
for unchanged child results and no display translations, exercises remote
`AskUserQuestion`/`ToolResult`/notification rendering, and restores the question's
display after resume. It additionally checks exact English task delivery with a mixed-script filename, Chinese task previews at `ToolUse` before/after resume, no duplicate child translation and original English/Chinese human history. It does not disable host safety or patch engine code. Native
and Node tests additionally cover `ExitPlanMode` steps, nested/teammate task input,
parent-child follow-up routing, failure/cancellation, optional event rejection,
parallel identity, reload and repeated child turns. Real-engine cases now also
cover `SendMessage` to completed children before/after process restart, pending
`AskUserQuestion` and `ExitPlanMode` with and without interruption, and retries
for API errors, transport loss, timeout, empty/marker-only and truncated replies.
Remote cloud workflows and manual app painting remain unverified boundaries.

`claude-skill.test.mjs` links the mod into a skills folder, as the installer does, and lists commands the way the desktop app does for a new session, with every hook off: `/translator` is there once, from `SKILL.md`, with the mod's name, description and hint. Sent as a new session's first message, `/translator` opens the settings pane and the button row shows the file's settings, with no model request; a setting then changes the row; and an ordinary prompt's request offers the model another skill but never `/translator`; the session's debug log names no skipped hook of the mod. Loaded with `--plugin-dir`, the same copy lists only the mod's own `/translator`. A Node check keeps `SKILL.md` equal to `COMMAND_SPEC` and the manifest's `"skills": []` in place.

The real fixtures also check multi-text-block message UUIDs against display
state, both directions' pinned/follow model and thinking requests, unchanged main
model/config, settings restored on resume and defaults in a new session. Native
and Node regressions cover malformed settings, environment locks, synchronized
pane/command/button updates, persistent and memory-only queues, failed updates,
stale session callbacks and repeated answer endings. Cancellation tests cover
pending worker/model/finish phases and discarded late results; the separate
real-host fixtures distinguish explicit output cancellation from ordinary headless interruption in both backends. No paid
model calls are made by these fixtures. The [0.7.2 verification record](../docs/testing.md#verification-record-for-072)
records the full suite on CLI 2.1.311, including **61 native plugin tests** with
zero failures/skips. The missing-icon warning and marketplace advice remain
non-gating; no marketplace acceptance or registry publication is implied.
The [0.7.0 verification record](../docs/testing.md#verification-record-for-070)
retains the earlier CLI and bundled desktop-engine evidence; that engine and
manual GUI acceptance were not rerun for 0.7.2.

To remove the mod, delete the `translator` link under each account's `skills/` directory and restart Claude Code. Keep the shared `translator` directory if Pi still uses it. Local records and caches are not deleted automatically.

## License

[MIT](../../../LICENSE) © 2026 Orbit Labs contributors.
