--- summary: 'Harness setup: how images reach the model in Codex, Claude Code, Pi, and OpenCode' read_when: - Setting modlens up inside a specific coding agent - A pasted image is not reaching the model - Understanding what recover-paste does per harness --- # Harness setup English | [中文](harness-setup.zh-CN.md) Where a pasted image ends up differs per harness, and modlens takes a different route in each. `recover-paste` detects which harness it runs inside (process ancestry, then environment fingerprints) and reads only that harness's storage. ## Codex Pasted images become real temp files, and the message carries a tag like ``. The skill reads the path out of the tag. `recover-paste` detects Codex and refuses, pointing back at the tag. One catch with text-only models: once `models.json` declares `input_modalities: ["text"]`, the Codex TUI blocks Ctrl+V paste outright. Drag the file into the terminal, type its path, or use `codex exec -i image.png "..."`. ## Claude Code, Pi, OpenCode None of them hands the model a usable temp-file path the way Codex does (newer Claude Code builds do write pastes to their own `~/.claude/image-cache/`, injected as a path line only in the terminal entrypoint), but all three persist the user message locally before any gateway strips it: | Harness | Storage | Notes | | :-- | :-- | :-- | | Claude Code | `~/.claude/projects//.jsonl` | images as base64. The injected `CLAUDE_CODE_SESSION_ID` targets the exact session | | Pi | `~/.pi/agent/sessions/----/*.jsonl` | same shape as Claude Code | | OpenCode | `~/.local/share/opencode/opencode.db` | SQLite, images as data URLs (read via `node:sqlite`) | Running a text-only model behind `ANTHROPIC_BASE_URL` in Claude Code, a pasted image arrives as a pathless `[Unsupported Image]` placeholder (on lenient gateways) or breaks the request outright ([#62009](https://github.com/anthropics/claude-code/issues/62009)). The bytes are not gone, and that is what `recover-paste` retrieves. ## Skill locations | Harness | Reads skills from | | :-- | :-- | | Claude Code | `~/.claude/skills/` | | Codex | `~/.codex/skills/` | | Pi, OpenCode | `~/.agents/skills/` | Symlinks work in all of them, so linking the skill folder once keeps every agent on the latest version. ## Platform support macOS and Linux are fully supported and verified in CI on Node 22 and 24. Windows runs the same CI matrix. Detection there skips the process-ancestry pass, since there is no `ps`, and falls back to the environment fingerprints above, so a harness that sets none of them reads as undetected (force it with `--harness` or `MODLENS_HARNESS`). OpenCode paste recovery is covered on Windows, including the path-separator normalization from [#11](https://github.com/liustack/modlens/issues/11): opencode records `session.directory` with forward slashes while `path.resolve` returns backslashes there, and both sides are normalized before matching. The JSONL stores (Claude Code, Pi) key off `os.homedir()` and each harness's own on-disk slug, and are exercised on POSIX. External engines (Antigravity CLI, the Claude CLI) run only where they ship a Windows build. ## Gateway setups OpenCode with DeepSeek: `opencode auth login`, pick DeepSeek and paste the key (it lands in `~/.local/share/opencode/auth.json`), then set the default model in `~/.config/opencode/opencode.jsonc` to `deepseek/deepseek-v4-flash`. Pi reads its key from `~/.pi/agent/auth.json`. ## DeepSeek Harness (dsh) dsh is different from the other harnesses: modlens plugs in as a native tool, not a prompt-triggered skill. The package itself is a dsh bundle, so one command installs it into a profile: ```sh npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.5 ``` This registers a `modlens_read_image` tool whose schema reaches the model on every request (no trigger heuristics), runs the modlens CLI shipped inside the same package, and returns the structured evidence as the tool's canonical JSON output. Engines, reuse grants, and guard rules stay in `~/.modlens/config.json`, shared with every other harness. dsh is in developer preview and its plugin surface may change; the plugin keeps its touch small (raw tool registration, the llm adapter surface for the vision variants, the attachment reader, and one agent pre-step hook) and degrades loudly if any of them moves. ### Keeping it up to date modlens ships often, and both install shapes freeze at whatever version they got. On dsh, re-run the install with the version named: ```sh npx -y @deepseek-ai/dsh plugin --profile add @liustack/modlens@3.16.5 ``` `npm view @liustack/modlens version` prints the current one, and this page is stamped with it at release time. Two things in that command are deliberate. `add`, not `update`, because `update` stays inside the semver range already recorded, and a plain install records a caret range, so a profile that once landed on 2.7.1 updates to 2.8.0 and never crosses into 3.x. And a named version rather than `@latest`, because pnpm 11 holds back anything published in the last 24 hours (`minimumReleaseAge`, on by default) and resolves the tag against what survives that filter: `@latest` lands on an older release instead of skipping the gate. What it costs is a day of wall-clock time rather than one version, which on a fast-moving week is several releases. A named version is a deliberate request, so pnpm installs it, and since 11.1.3 records that one version as an approved exception in the profile's `pnpm-workspace.yaml`, leaving everything else behind the window. Restart dsh, then confirm what actually landed: ```sh npx -y @deepseek-ai/dsh plugin --profile list ``` The [troubleshooting page](troubleshooting.md#dsh-says-declares-no-dshbundle--installed-as-a-plain-dependency) covers the stricter case, where you configured `minimumReleaseAge` yourself and pnpm refuses rather than approves. On the skill harnesses a skill is a copied folder, and the copy keeps its install-time version, so re-run the install to overwrite it in place. `modlens doctor` reads the pin out of every copy it can find and flags the ones behind the CLI doing the reporting, which makes the drift visible before it costs anyone a debugging session. ### Paste-to-path (web profile) Pasting an image into the dsh Web UI under a **text-only model** used to die at image admission. The plugin now ships a browser half (loaded automatically by dsh's client plugin system) that takes over the paste in exactly that case: the image bytes go to the plugin's `/modlens/paste` route on the dsh web server (loopback, magic-byte checked, 25 MB cap), land as a private temp file, and the composer receives the file path as plain text — the same shape Pi, OpenCode, and Claude Code hand their models, and the modlens skill's and `modlens_read_image` tool's primary trigger. Admission never fires because the message carries no image attachment. The takeover is conditional, and the decision is the host's: the browser half asks the plugin's route whether the currently selected model is text-only, and the host answers from the provider registry's declared model metadata (`inputModalities`), not from a name heuristic. A `(modlens vision)` variant or any model that declares image input keeps its native paste flow (variants convert at request time with the thumbnail preserved; vision models read images themselves), and so does any model the host cannot resolve. Pastes stay native until the host has confirmed a takeover is right. A model whose metadata declares no input modalities counts as unresolved: absent metadata is never read as "confirmed text-only". Verdicts also age out (60s), so a route whose models changed mid-session is re-asked, not trusted forever. `pasteToPath: false` in the plugin row turns the whole feature off: the browser half stands down when the policy endpoint 404s. If the route vanishes mid-session after a verdict already confirmed it, the pastes made in the brief window before the failed upload comes back (one local round-trip) are lost. The client then forgets its verdicts and every later paste goes native.