OpenCode's file tools read and write UTF-8. On a file that is not valid UTF-8 — a legacy Java
module in ISO-8859-1, an old JSP in Windows-1252 — the read itself destroys the bytes: ó
stored as the single byte 0xF3 is decoded as U+FFFD (�), and that byte is gone before any
encoding conversion could happen. Re-encoding afterwards cannot bring it back.
This plugin keeps the tool working in UTF-8 and owns the bytes around the write, so the file on disk comes back byte-identical outside the lines you actually edited.
| Encodings | ISO-8859-1 · Windows-1252 · ASCII · UTF-8 · UTF-8 BOM — resolved per file |
| Line endings | LF · CRLF · CR preserved. Mixed files are reported, never normalised |
| BOM | never added, never removed |
| Lossy writes | blocked, naming the exact unrepresentable code point |
| Writes | atomic — temp file, fsync, rename |
| Detection | plugin rules → Eclipse .settings → .editorconfig → BOM → bytes → fallback |
| Configuration | none required. One dependency: iconv-lite |
Restart the service (opencode service restart). There is nothing else to configure: the plugin
enables itself and works out each file's encoding per operation.
Install from a local checkout
git clone https://github.com/zademy/opencode-encoding-guard.git
cd opencode-encoding-guard && bun install && bun run buildThen create one file in the global plugins directory that points at the built checkout — OpenCode discovers files there automatically:
mkdir -p ~/.config/opencode/plugins
cat > ~/.config/opencode/plugins/encoding-guard.ts <<'EOF'
export { default } from "/absolute/path/to/opencode-encoding-guard/dist/index.js";
EOFThe plugins key also accepts absolute paths directly, if you prefer it per project:
// opencode.json
{ "plugins": ["/absolute/path/to/opencode-encoding-guard"] }The bundled dist/index.js is self-contained, so no dependency has to be installed in the config
directory. For a project-only install use .opencode/plugins/ instead.
Before (ISO-8859-1): Informaci\xF3n del tr\xE1mite
Agent edits an unrelated line with OpenCode's own tools:
After (UTF-8): Informaci\xEF\xBF\xBDn del tr\xEF\xBF\xBDmite ← data lost
The damage is done at read time, so no encoder, charset setting or post-hoc iconv pass can undo
it. The only defence is to not hand the tool a file it cannot read.
The guard owns the bytes on both sides of the tool call and keeps the tool itself inside UTF-8.
before ──▶ snapshot bytes + metadata ──▶ stage the file as exact UTF-8
│
the tool edits
│
after ──▶ decode what it wrote ──▶ restore encoding, line endings, BOM ──▶ atomic write
| Hook | Action |
|---|---|
execute.before |
Snapshot original bytes + metadata (encoding, BOM, line endings, sha256). Reject writes whose new text cannot be represented in the target encoding. For legacy files containing non-ASCII bytes, rewrite the file as exact UTF-8 so the tool reads correct text. |
execute.after |
Decode what the tool wrote, restore the original encoding, line endings and BOM, write atomically, re-read and verify. If the tool changed nothing, put the snapshot back byte for byte so Git sees no diff. |
The result is a one-line diff on a file whose accents never moved:
$ file legacy/src/Servicio.java
legacy/src/Servicio.java: ISO-8859 text
$ git diff --numstat
1 1 legacy/src/Servicio.java
| Encoding | Notes |
|---|---|
iso-8859-1 |
Latin-1, 1 byte per character |
windows-1252 |
Distinct from Latin-1: smart quotes and € live in 0x80–0x9F |
utf8 |
Default |
ascii |
US-ASCII |
ISO-8859-1 and Windows-1252 are never conflated, and the plugin never converts between them. Files with NUL bytes or UTF-16 BOMs are treated as binary and left alone.
Deterministic configuration always wins over heuristics. Highest priority first:
- Explicit plugin rule (
rulesin the config) - Eclipse
.settings/org.eclipse.core.resources.prefs— file → deepest folder → project → default .editorconfigcharset- UTF-8 BOM
- Byte analysis — ASCII / valid UTF-8 /
0x80–0x9Fpresent ⇒ Windows-1252 / otherwise ISO-8859-1 fallbackEncoding(defaultutf8)
Charsets that cannot be round-tripped safely (Shift_JIS, UTF-16, …) are ignored, not guessed.
# .settings/org.eclipse.core.resources.prefs
eclipse.preferences.core.defaultEncoding=UTF-8
/Legacy=encode.ISO-8859-1
/Legacy/src/main/webapp=encode.windows-1252
/Legacy/src/main/webapp/legacy-note.txt=UTF-8Both encode.<charset> and a bare charset value are accepted.
Two read-only tools, so an agent can check a file before touching it.
encoding_inspect — single-file report:
File: legacy/src/Servicio.java
Encoding: ISO-8859-1
Source: Eclipse project preference
BOM: No
Line endings: CRLF
Round-trip: OK
Protection: Active
encoding_scan — project summary with a per-encoding histogram, mixed-encoding detection, Eclipse
detection and a list of at-risk files. Ignores .git, node_modules, target, build, dist
and out by default.
Defaults work with no configuration at all. Create .encoding-guard.json in the project root to
change them — options passed by OpenCode are merged on top of the file:
{
"enabled": true,
"mode": "preserve", // preserve | warn
"fallbackEncoding": "utf8",
"newFileEncoding": "inherit", // inherit = rule → Eclipse → .editorconfig → fallback
"preserveLineEndings": true,
"preserveBom": true,
"unsupportedCharacters": "block", // block | warn | allow-lossy
"eclipse": { "enabled": true },
"editorconfig": { "enabled": true },
"maxFileBytes": 8388608,
"debug": false,
"ignore": [".git", "node_modules", "target", "build", "dist", "out"],
"rules": [
{ "glob": "legacy/**/*.java", "encoding": "iso-8859-1" }
]
}A malformed config file never breaks the plugin; it is ignored and the defaults are used.
The default policy is block. The agent gets a precise, actionable error and the working tree is
left untouched — the check runs before anything is staged or written:
Encoding Guard blocked an unsafe write.
File:
legacy/src/Servicio.java
Original encoding:
ISO-8859-1
Unsupported character:
U+2705 ✅
No file corruption was allowed.
Use a representable alternative ... or migrate the file encoding explicitly.
- Fail closed. If safe re-encoding cannot be guaranteed, the original bytes are restored and the failure is logged. The plugin prefers blocking over corrupting source.
- Path safety. Patch paths are resolved against the project root and rejected if they escape it
(
../../../etc/passwd). Windows separators and drive letters are handled. - Atomic writes. Content is written to a sibling temp file, flushed, then renamed over the target.
- Operation-scoped state. Metadata lives in a registry keyed by
Tool.CallID, never in a single global variable, so concurrent operations cannot restore each other's snapshots. - Binary files (NUL bytes, UTF-16) are never touched.
- Oversized files above
maxFileBytesare skipped with a warning instead of being buffered.
Fully independent. This plugin only owns the filesystem encoding boundary; context-mode owns
context management, compaction and retrieval. Neither indexes, compacts or rewrites the other's
state, and both can be enabled at the same time.
- If the OpenCode process is killed between
beforeandafter, a staged file stays UTF-8 until the next edit of that file restores it. The window is one tool call. - Files above
maxFileBytesare not protected. - Statistical encoding detection is not implemented; legacy files with no configuration rely on byte
analysis, which cannot distinguish two single-byte encodings that differ outside
0x80–0x9F. - Mixed line-ending files are reported and left alone rather than normalised.
- Only the
edit,write,patch/apply_patchandmultiedittools are intercepted. Direct writes by shell commands (sed -i, build tools) are outside the plugin's reach.
bun install
bun run check # typecheck + tests — the gate
bun run build # dist/index.js + typesThe encoding core under src/core/ does not import the OpenCode SDK and is testable on its own.
Issues and pull requests are welcome. For any encoding bug, please include the byte-level evidence
(xxd / file output, git diff --numstat) — that is what the test suite asserts on, and text
that merely looks right proves nothing.
MIT
