# 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 rewriting.** A `prompt.submit` hook translates Chinese prompts from the composer, the SDK, print mode and remote control, and passes the English on. Prompts from other plugins, subagents, notifications and schedules pass through. 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. The stored reply stays in English.
- **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.
- **Updates while a session runs.** Claude Code reloads the mod when its files change, even mid-turn. Translation continues: an answer whose turn 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.
- **Button row.** Input, Instruction and Output buttons (keys 1–3) sit above the prompt, joined by **Cancel translation** (key 4) while a translation runs.
- **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 or output translation uses one isolated `$.model.complete`, with no tools, history, second Claude process, AI audit or repair call; an end marker guards completion framing. An optional Pi backend is available, with one completion per translated output part for long answers.
- **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 minimum for the Mods API this mod uses. Version 0.6.10 was tested on **2.1.296**, including its real terminal and desktop-surface fixtures. Earlier records cover **2.1.293** and **2.1.294**, not retested for this patch; see the [verification record](../docs/testing.md#verification-record-for-0610).
- **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.
- `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. 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.

```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 existing row above the prompt retains **Input**, **Instruction** and **Output** with keys **1**, **2** and **3**. It stays synchronized with the pane and commands. Press a digit while the composer is empty and pause briefly, or press **Ctrl+X, then Tab** to focus the row first; clicking also works. These are native button hotkeys, not custom key bindings: 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. 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 with native hotkey **4** appears only while input
or output translation is pending, in the above-prompt row and an open settings
pane. With an empty composer, press **4** and pause briefly; otherwise use
**Ctrl+X, then Tab, then 4** to focus and activate the row, or click the button.
There is **no cancellation slash command or subcommand**, and no arbitrary Ctrl
shortcut is intercepted. The usual **1/2/3** buttons keep their functions.

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, observes the host's dispatch signal and
`turn.abort`, and cancels old-session work on session refresh. Native completion
receives the abort signal; closing the worker stream also terminates the optional
Pi subprocess. A cancelled output is not retried through the fallback handler.

The evidence is deliberately specific: native-kit/Node tests exercise the button
for both directions, dispatch signals, session changes and late results. Real
Claude Code **2.1.294** fixtures verify `turn.abort` during pending **output** for
both backends, including native HTTP closure and Pi-child termination. This is
not verification of physical Escape-key routing in every host phase; 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 a masked request and an in-memory continuation, the mod calls `$.model.complete` once, and the mod then sends `finish` back to the worker, which checks completion framing and literal restoration, restores literals and saves the result. `finish` returns the result or a failure, not another prepared completion. 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`.

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, and the configured timeout. 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.
Ambiguous prose stays visible to the model, and quoted Chinese prose remains
translatable. The exact, case-sensitive technical names/labels `Markdown`,
`Mermaid`, `Downloads`, `Token` and `Tokens` still do not make an otherwise Chinese
answer English. This output-classification list is not a blanket exemption for
capitalized words or a Chinese-character ratio; real English prose and plain
`OK` output still translate. See [local language detection](../README.md#local-language-detection)
and [what is protected locally](../README.md#what-is-protected-locally).

After the single completion, 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.6.10 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.

### Rewriting the submitted prompt

```js
on('prompt.submit', async ($, e, next) => {
  // Translate before calling next; the original remains in editor history.
  return next({ ...e, text: english })
}).catch(() => ({ drop: 'Prompt NOT sent: translation unavailable' }))
```

`prompt.submit` is an event; `$.prompt.submit(...)` is a different method that submits **another** prompt, and the mod does not use it. The rewrite keeps attachments, existing `context`, the origin and the queue flags unchanged. Only prompts from the composer, the SDK or print mode, and remote control are translated; prompts from other plugins, peer agents, notifications, schedules and subagents pass through untouched.

No handler rewrites Claude's history file or submits the editor. In the terminal, known English history drafts are restored to their original Chinese using `$.prompt.read` / `$.prompt.fill`; ordinary typing/pasting and other plugins' fills are excluded. Unchanged recalled sources explicitly replay their saved translation, even with automatic cache off or an older contract, after source-literal integrity is established. Edited sources 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.

Claude 2.1.296 does not raise `prompt.edit` for Up/Down history navigation. A local 150 ms composer check runs only while a terminal draft and known originals exist; it makes no model request and is stopped on empty drafts/session end. `prompt.edit` and `prompt.fill` mark user/plugin changes so they are not mistaken for history, and a second draft/revision check rejects a stale fill. Native arrow bindings are unchanged. Recent shared history and session rows provide persisted pairs; up to 250 pairs in the session's `recall` state survive plugin reloads, including memory-only mode. Missing or ambiguous originals stay as stored, not guessed. This is terminal-only, not a desktop/headless editor feature. See [Recalling original prompts](../README.md#recalling-original-prompts).

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 only to note which rows hold reply text and which row holds a translated prompt (see [Drawing the Chinese answer](#drawing-the-chinese-answer) and [Drawing the original prompt](#drawing-the-original-prompt)), and relays every row unchanged.

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. On 2.1.292, however, a notice appended only at `turn.complete` can miss the last transcript flush in `-p` (print) mode, which the real-binary integration test caught. 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. Only a main-loop `end_turn` with a non-empty answer, no tool calls and enabled output is translated, and only when preflight finds English prose to translate after setting literals aside. Literal-only answers skip too.
3. The Chinese is drawn under the answer (next section) and then appended as a **notice before the host's final flush**.
4. The original result is returned unchanged.

`turn.complete` is a de-duplicated fallback for turns without a provider step. Its answer is only the reply's last text row, while a step's answer joins all of them, so the fallback runs only for a turn that no step handled. A preflight-skipped answer also counts as handled, so a last row that alone would read as English (such as `OK`) cannot trigger a second, partial translation. Its journal record is durable, but whether its notice reaches the native transcript depends on the host. The mod never forges transcript IDs, writes Claude Code's live JSONL directly, or modifies assistant content to work around this.

### 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. 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 English 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 stored row that holds that English. It writes the original to the `$.state` family `prompts`, keyed by the row's ID (`row:<uuid>`), and relays the row unchanged.
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 English 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.
- **As the next turn's own prompt row** (door `prompt`), when the turn ends first.

Both match when the English fills whole lines of the row's text, never part of a longer prompt. 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, 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). When a session starts or is resumed, the mod asks the worker for these links and draws the original prompts and Chinese answers again, in the desktop app too (next section). 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. A prompt that waits for the next turn is stored under the app's id and needs nothing more.

The worker looks these ids up right after a row is linked while the app is attached, asking up to eight times, half a second apart, until the engine has written the row. It also looks them up once for all linked rows: when they are restored with the app attached, or when the app first draws a row in a session, which covers a resumed session and a reload of the mod; a pass that fails is tried again by a later draw, 30 seconds on. It reads only assistant rows and lines that name a row it was asked about, 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.

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

### 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`,** one member per session: translations waiting for their prompt row (`waiting`), the current turn's last reply row (`reply`, cleared when a turn starts), the last answer taken for translation (`handled`) 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.
- **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 awaited,** 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. 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, 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; a mod cannot see that command-line flag. 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)
- [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/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, pending-only key 4, 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; 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`.

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 abort fixture verifies output cancellation in both backends. No paid
model calls are made by these fixtures. The [0.6.10 verification record](../docs/testing.md#verification-record-for-0610)
reports 1,013 passing Node tests and 48 passing native plugin tests, with none
skipped, including preflight, one-completion safety, original-history recall and
replay, literal integrity and unchanged synchronized transcript display.

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.
