# Changelog ## 3.16.6 - 2026-08-15 - **A `null` where the contract asks for nothing no longer fails the read ([#37](https://github.com/liustack/modlens/issues/37)).** The reported failure named `visual.notes`, which the validator only reports when the field is present in a shape it does not accept: leaving it out was always fine. `null` is the shape a model reaches for when it has nothing to say, and it is what the reporter's own workaround had to legalize, so that is what this fixes. An optional field holding `null` is now dropped before the check rather than passing through it, which keeps the read alive and keeps `null` out of the fields this contract declares: each one is absent or holds its declared type, as the schema always promised. A key a gateway adds on its own is dropped the same way when it is null. On a required field `null` is still a violation. The error also stopped calling everything missing, since reading `missing: visual.notes` about a field that was right there sends you looking in the wrong place. - **The openai provider can ask the gateway to enforce the contract ([#37](https://github.com/liustack/modlens/issues/37)).** `modlens config set openai.structuredOutput true` sends it as `response_format: json_schema` in the strict form those endpoints require: every property required, `additionalProperties: false`, and the ones this contract leaves optional made nullable. It is derived from the same schema the runtime checks against, so there is no second copy to keep in step, which is the part that made the reporter's own workaround expensive: they had to hand-write the whole thing to get thinking-disabled qwen through. Off by default, since a gateway without structured-output support answers 400 for the field, and a `response_format` set in `extraBody` still wins. ## 3.16.5 - 2026-08-15 - **dsh: pasting into a plain text-only model works again ([#36](https://github.com/liustack/modlens/issues/36)).** The paste takeover shipped in 3.14.0 has been dead in every default install since 3.16.0 moved the verdict server-side. The verdict refuses when any model matching the selector label declares image input, which is right for a real vision model and wrong for the one case it could not see: this plugin's own `(modlens vision)` wrapper reuses the upstream model id verbatim and declares image input, because that declaration is exactly how the wrapper unlocks admission. So selecting plain `DeepSeek-V4-Pro` matched the real text-only model and the plugin's synthetic twin of it, the twin vetoed, and the paste fell through to dsh's own gate and its `MODEL_DOES_NOT_SUPPORT_IMAGES`. The verdict now skips a twin two ways, both requiring proof rather than a name: the provider ids this instance actually registered, tracked as each wrapper lands, and a model that carries the `(modlens vision)` marker on a provider id minted by the rule this plugin uses, which is how a sibling instance in the same process is recognized. A real vision provider still vetoes even if it borrows the marker, and an id someone else already holds is never trusted as ours. The verdict had no test at all, which is how a regression this total shipped and stayed for six releases; it now has an integration suite driving the real route against a registry shaped like a live install. Thanks to @Taz-dingo for a report that arrived with the conflicting rules already quoted side by side. - **The docs cannot print a stale install command any more.** Every file the repo tracks is scanned, and any install of this package that is not pinned to an exact version has to carry `--config.minimumReleaseAge=0` as an argument of that same command. Review found the first version of that check passing five different ways of writing an unpinned install, including a command split across lines and a spec like `3.16.4+local` that only looks pinned. ## 3.16.4 - 2026-08-15 - **How to update is written down, and the explanation it replaces was wrong.** 3.10.0 claimed that pnpm's release-age gate has a 10-day window and that an explicit version or dist-tag skips it, so every install command in this repo carried `@latest` as the fix. Measured on a machine with no gate configured at all: pnpm 11 turns `minimumReleaseAge` on by default at 24 hours (`24 * 60` in its config reader; `pnpm config get` does not surface that particular default), and asking for `@latest` installed 3.9.1 while 3.16.3 was the published latest. The gate filters the candidate versions before the tag is resolved, so the tag lands on an older one, and a day of held-back wall-clock time can be several releases on a fast week. The pnpm issue behind the original claim describes a bug in pnpm 10.16.1 that was fixed, and says in its own text that `@latest` was subject to the gate. What does work is naming the version, a deliberate request rather than a resolution: pnpm 11 installs it and records that one version as an approved exception, leaving everything else behind the window. Where a stricter policy is configured it refuses instead, and `--config.minimumReleaseAge=0` lifts the gate for one command, for every package that command resolves. `harness-setup` now has an Updating section covering both install shapes, including why `update` cannot cross a major (it stays inside the recorded semver range) and how to check what actually landed. ## 3.16.3 - 2026-08-15 - **The dsh tool is registered under a name of its own, so the model can actually see it ([#34](https://github.com/liustack/modlens/issues/34)).** A host with a durable attachment store mounts its own `read_image`, and 3.13.0 handled that by catching the duplicate-registration error and renaming. That catch never fired. dsh's tool registry is layered and a scoped tool shadows a global one, so a host `read_image` in the agent-preset scope and ours registered globally are not a duplicate at all: the registration succeeded, nothing was logged, and the model kept resolving the host tool, which refuses a text-only model outright. Nor is the collision cheap to detect, so the tool now takes the name `modlens_read_image` instead of competing for `read_image`, and the model finds it through the schema that reaches it every request regardless of the name. `toolName` still pins whatever a host prefers. Thanks to @ingleav626-art, who instrumented the registry and brought back the layer that made the old fix dead code. - **A region kind outside the common list no longer fails the read ([#34](https://github.com/liustack/modlens/issues/34)).** `layout.regions[].type` was a closed enum, so a model answering `link` on any web screenshot or `search` on a portal produced a result the schema rejected, losing the entire read over a descriptive label. Region kinds are an open set: the same reporter found that extending the list to 13 values still was not enough. The field is a free string now, and the common vocabulary moved into its schema description, where it guides every provider that enforces the schema server-side without constraining any of them. ## 3.16.2 - 2026-08-14 - **The claude-cli provider starts on Windows ([#31](https://github.com/liustack/modlens/issues/31)).** npm installs every JS CLI as a trio, and both ways of reaching it failed: the bare name found none of them (ENOENT, reported as "not installed" next to a `claude --version` that worked fine), and handing spawn the `.cmd` hit Node's post-CVE refusal to run batch files without a shell (EINVAL). Wrapping it in `cmd.exe` turned out to be a trap, since a cmd command line cannot carry a raw newline and our provider arguments are whole multi-line vision prompts, so a wrapped prompt truncates at its first line break and the rest is read as a second command. modlens instead reads the shim, takes the Node entry it points at, and spawns Node on it directly: no shell, no escaping, and the child is the real provider, so a timeout's SIGTERM lands on it rather than an intermediate. The reading is conservative by construction. Every line of the shim must be accounted for, the interpreter is read rather than inferred (cmd-shim will happily generate a python shim for a file named `.js`), and a shim carrying anything this cannot reproduce faithfully, an environment assignment, cmd control syntax, an argument whose quoting it cannot prove, is declined and left to a spawn error that names the provider, the command, and the real error code. Nine rounds of independent review shaped that boundary, each round adding a shape it had to refuse rather than guess at. Thanks to @zhang66633 for a report that arrived with both failure modes already isolated. - **The install procedure knows about dsh ([#32](https://github.com/liustack/modlens/issues/32)).** INSTALL.md, the file an agent is pointed at, never mentioned dsh once, so an agent told to install modlens followed the skill procedure to its end and left the user without the `read_image` tool and without the `(modlens vision)` entries they were looking for. It now opens with a dsh branch: install the plugin, skip the skill copy, keep the same engine configuration. - **`doctor` says when an installed skill copy has fallen behind ([#33](https://github.com/liustack/modlens/issues/33)).** A skill is installed by copying it, and a copy keeps the version it was stamped with, so one machine ran 3.8.0 for eight releases and hit bugs that were long fixed. doctor now reads the pin out of every installed copy it can find and compares it against the version of the CLI reporting, which differ exactly when it matters, since a copy is frozen at install time while the CLI reporting is whatever was just launched. Still offline: two local file reads, no registry call. Thanks to @Ztyss for a report that had already verified the release stamping, leaving the update story as the real gap. ## 3.16.1 - 2026-08-14 - **OpenChamber (OpenCode's desktop UI) is detected, and Windows detection got quieter and sharper ([#30](https://github.com/liustack/modlens/issues/30)).** Three stacked Windows gaps from one runtime-confirmed report. The env-fingerprint fallback never checked the markers opencode servers inject (`OPENCODE`, `OPENCODE_PID`, `OPENCODE_BINARY`), so OpenChamber read as "none detected" and `recover-paste` never auto-ran, while the pasted bytes sat recoverable in the opencode database the whole time; the fingerprint now resolves to `opencode`, placed before Claude Code's so nested setups pick the innermost input box. The `ps` ancestry probe now runs only off Windows: MSYS machines carry a `ps` that exists but rejects `-Ao`, and a failed child's stderr printed into every `doctor` run (the docs always said Windows skips ancestry, now the code agrees). And `findOnPath` tries the PATHEXT extensions before the bare name, so the POSIX `sh` shim npm installs next to `opencode.cmd` no longer shadows the executable into a `spawnSync ENOENT`. Thanks to @IA20201 for a report with the evidence already attached: observed env markers, doctor output, and the exact database row. ## 3.16.0 - 2026-08-14 - **dsh: whether a paste is taken over is now the host's call, made from real model metadata.** The browser half used to guess with a name regex, which read every vision model it did not recognize (Qwen2.5-VL, GPT-4o, ...) as text-only and hijacked its native paste. And turning `pasteToPath` off only removed the host route while the client kept capturing pastes into a 404. The client now asks `GET /modlens/paste?model=` and the host answers from the provider registry's declared `inputModalities`, with every unknown answered conservatively: the label carries no provider id, so EVERY model whose name or id appears in it must be confirmed text-only. One image-capable match anywhere vetoes, an unreadable provider catalog vetoes (the vision twin could live there), and missing modality metadata counts as unknown, never as text-only. Verdicts are re-asked on every composer focus and every paste, the host empties its own cache on every provider-topology change (a same-named vision route mounting mid-session is seen within one round-trip), a 60-second hard age bound backstops both, and a route that vanishes mid-session costs only the pastes inside the one round-trip it takes the failure to come back, after which the client forgets its verdicts and stands down. Until a model is positively confirmed text-only, pastes stay native. The paste route's magic-byte table now matches the CLI's signature for signature (a plain `ftypmp42` video is refused instead of saved as `paste.heic`, real heic/heif brands pass), and the browser half gained its first test suite, loaded straight from `dsh/client.js`. - **A provider that ignores SIGTERM no longer outlives the CLI.** The SIGKILL escalation timer was unref'd, so in a standalone run it died with the event loop and a stubborn provider process survived its parent indefinitely, found by independent review with a live repro. The timer is now ref'd and cleared by the child's own exit (normal runs pay nothing, the stubborn case at most the 2s grace), pinned by a test that drives the real spawn path from a genuinely independent parent process, because inside the test runner its own timers had been masking exactly this. - **`config show` and error text stop leaking proxy credentials.** A proxy URL's userinfo (`http://alice:s3cr3t@proxy`) rendered verbatim in the one command whose contract is being safe to paste into an issue. It is now masked everywhere config show renders a proxy, and the shared redactor handles URL userinfo through the same WHATWG parser the runtime connects with, instead of a regex approximation: backslash authorities, slash runs, tabs inside the authority, and passwords containing `@` all get their credentials removed in normalized form, while scheme-less `//text@` prose and ordinary query `@`s stay verbatim. - **The release pipeline can no longer publish first and fail later.** The tag workflow validated the CHANGELOG section only after `npm publish`. The notes are now extracted and verified before anything irreversible runs. And the release script's `git push --follow-tags` could deliver the tag while a stale main was rejected, triggering a release from a tree the remote branch never contained. It now syncs with origin first, refuses a behind or diverged main and an already-existing remote tag, and pushes branch and tag atomically, together or not at all. - **The gates got honest.** `pnpm lint` always claimed to check `dsh` while the Biome config silently excluded it (now covered, with the plugin's own style rules); CI ran "Node 22" as latest-22.x while calling it the 22.19 floor (the matrix now runs the exact floor); and an Electron-runtime argv test pins the [#25](https://github.com/liustack/modlens/issues/25) fix against regression. Docs corrections ride along: both READMEs present the two paste flows as they actually ship, an empty `provider` is documented as the failover chain rather than a hardcoded antigravity pin, and the skill front matter carries the real 22.19 floor. ## 3.15.0 - 2026-08-14 - **dsh: the vision wrap discovers every eligible provider route ([#29](https://github.com/liustack/modlens/issues/29)).** The wrapper used to bind one hardcoded upstream, so a machine with several subscription packages (opencode-go, zai, ...) had to hand-pick a single route and lose the rest. With `upstream` unset the plugin now sweeps the llm registry and registers a `modlens-` wrapper for every route carrying wrappable text-only DeepSeek/GLM models, re-sweeping on the registry's own `llm/adapters-updated` notification so late-registering routes (llm-pi-ai mounts after settings load) are picked up without polling. A `discover` array narrows the set, `families` filters as before, setting `upstream` keeps exact legacy single-route behavior, wrappers never wrap themselves, and the deepseek-official wrap keeps its historical `deepseek-modlens` id so an upgraded machine's model selection survives. Design and behavior spec contributed by @zlycode01, who also published a reference implementation in a public fork; this is an independent upstream implementation of that spec, with the polling schedule replaced by the registry notification. ## 3.14.0 - 2026-08-14 - **dsh: pasting into a text-only model now just works — the paste becomes a file path.** The plugin grows a browser half (a hand-written bundle in dsh's client plugin protocol, zero dependencies, loaded automatically under the web profile). A capture-phase listener takes over image pastes before the composer's own intake: the bytes go to the plugin's `/modlens/paste` route on the dsh web server (loopback-bound, magic-byte checked, 25 MB cap, private 0600 temp file), and the composer receives the file path as plain text — the exact shape Pi, OpenCode, and Claude Code hand their models, and the modlens skill's primary trigger. Image admission never fires because the message carries no image attachment; verified end to end with a native text-only DeepSeek-V4-Flash, whose visible reasoning quoted the modlens skill rule and went for the path. The takeover is conditional: `(modlens vision)` variants and known vision models keep the native paste flow (thumbnails and all), and `pasteToPath: false` turns the feature off. The host route rides a scoped `ctx.inject(['webServer'], ...)`, so headless profiles never see any of it. - **The CLI survives Electron hosts ([#25](https://github.com/liustack/modlens/issues/25)).** In the packaged dsh desktop app, `process.execPath` is the Electron binary, and commander's Electron auto-detection then mis-slices argv so the script path lands as a stray positional (`too many arguments for 'analyze'`). The CLI now parses argv with explicit node semantics — it is always spawned script-first, whatever binary hosts it — and the plugin's spawns set `ELECTRON_RUN_AS_NODE` for good measure. Thanks to @hi-fangj for tracing it into commander's `_prepareUserArgs`. ## 3.13.0 - 2026-08-14 - **Proxy support actually works now ([#23](https://github.com/liustack/modlens/issues/23)).** 3.12.0's proxy path was broken on arrival, twice over: the bundled copy of undici had its internal `node:http2` references destroyed by bundling (the embedded `ProxyAgent` threw `http2.connect is not a function`), and handing any undici 8 dispatcher to the host's built-in fetch (a different undici major) fails with `UND_ERR_INVALID_ARG` regardless. undici is no longer bundled — it resolves from `node_modules`, shrinking the CLI bundle from 1.17 MB to 131 KB — and the proxied path now uses undici's own fetch so dispatcher and fetch are same-sourced, with the dispatcher closed after the response so its keep-alive pool cannot pin the process open. A new integration test drives the built CLI through a real local HTTP proxy, the exact coverage whose absence let 3.12.0 ship broken (and whose first draft repeated a classic mistake: `spawnSync` freezes the test's own fake servers, so the CLI must be spawned async); both the env-var and explicit-setting forms were also verified against a real LAN proxy. Independent review of the fix then caught the same cross-version boundary hiding in the no-proxy remote-image path — the IP-pinned download `Agent` was still handed to the host's fetch — so that path is now same-sourced too, and the Node floor rises to 22.19 (undici 8's own engine requirement, which externalizing made load-bearing). Thanks to @JooJeen for a diagnosis that had already isolated both layers. - **dsh: images nested inside tool-result content no longer wedge the session ([#24](https://github.com/liustack/modlens/issues/24)).** dsh's own native `read_image` (from `dsh-tool-fs`, reachable under the `(modlens vision)` variants since they declare image input) returns its image block nested inside `tool-result` content. Both conversion paths only scanned top-level message content, while the upstream DeepSeek adapter's rejection check recurses — so one nested image block in the history failed every later turn with `UNSUPPORTED_CONTENT`, permanently, since the durable log by design keeps the real blocks. Both paths now recurse through `tool-result` content exactly as the adapter does, converting nested images to evidence text on the wire while the log keeps its native blocks. Thanks to @alex16lai for the precise trace. ## 3.12.1 - 2026-08-14 - **claude-cli reads the envelope's `structured_output` first ([#22](https://github.com/liustack/modlens/issues/22)).** Newer claude CLI builds ship the schema-parsed object beside the `result` string, and the parser only hard-parsed the string, so an unescaped newline in the OCR text failed the whole read while the good object sat unread — intermittently, since it depended on what the model emitted. The parse order is now `structured_output`, then fence-tolerant extraction of the result string, then the error, matching the antigravity provider. Thanks to @lin-nanxing for the precise diagnosis, down to the code lines. - **A `read_image` name collision no longer kills the whole dsh plugin ([#21](https://github.com/liustack/modlens/issues/21)).** Hosts with a durable attachment store mount dsh's own native `read_image` (from `dsh-tool-fs`), the duplicate registration threw, and the whole plugin fiber failed — vision wrapper included. The registration now falls back to `modlens_read_image` on a name collision (valuable exactly there: the native tool is gated on the model declaring image input and vanishes for text-only models, so the renamed bridge is the only image path left), the name is configurable via the plugin row's `toolName`, and any other registration error degrades loudly instead of taking the plugin down. Thanks to @abyss-stars for the root-cause analysis and the interim patch. ## 3.12.0 - 2026-08-14 - **The API providers work behind a proxy ([#20](https://github.com/liustack/modlens/issues/20)).** Node's fetch ignores `HTTP_PROXY`/`HTTPS_PROXY` entirely, so machines that reach the internet through a proxy could not use `gemini-api` at all, and the failure surfaced as a bare `fetch failed`. The three inline API providers now honor the standard environment variables (`NO_PROXY` included, via undici's `EnvHttpProxyAgent`), with an explicit setting as the escape hatch: `modlens config set proxy ` for all API providers, `.proxy` to scope it to one. A connect-level failure now names the unreachable host and points at both knobs instead of saying `fetch failed`. Scope is deliberate and documented: the proxy applies to API requests only, while the remote-image download path keeps its direct, IP-pinned connection, because its SSRF guards validate the exact address being contacted and a proxy would blind them. Thanks to @soloyu for a report that arrived with the diagnosis, the fix direction, and the security boundary already thought through. ## 3.11.0 - 2026-08-14 - **A full-project audit, all ten findings fixed, then re-reviewed until clean.** An independent deep review of the whole repository (P0: none) surfaced ten conditional-but-real defects. Every fix went back through further independent review rounds, which caught real bugs in the first fixes themselves (case and Unicode boundaries, a cancellation regression); the final round accepted with no blocking findings. Each item below carries a regression test — the suite grew by 32 cases. - *Cross-project recovery, two ways in.* The OpenCode directory filter's ancestor branch built a LIKE pattern from the database's own `session.directory`, so `_`/`%` inside another project's path acted as wildcards; the comparison now uses exact SUBSTR prefixes in both directions, measured in Unicode code points (SQLite counts characters, JS counts UTF-16 units, so a path with an emoji shifted the boundary), case-sensitive on POSIX and LOWER-folded on Windows (SQLite's LIKE was ASCII case-insensitive, crossing projects on case-sensitive filesystems), with filesystem roots normalized so `/` and `E:/` match as ancestors. And a Claude/Pi transcript recording no cwd at all was accepted on slug evidence alone, though slugs collide (`/tmp/a.b` and `/tmp/a-b`); ownerless transcripts are now skipped by scanning and session lookup, while an explicit `--transcript` still reads anything you point it at. - *Image-type spoofing.* When magic-byte sniffing failed, the extension or content-type used to win, so an `.png` URL serving HTML was encoded and uploaded as an image. Every allowed type must now prove itself from its file header — heic/heif included, via ftyp-box sniffing — so there is no extension or content-type fallback left at all. - *Credential redaction, everywhere errors quote foreign text.* Subprocess stderr, the three API providers' error bodies, and discovery-probe failures now pass through a shared redactor (known keys replaced exactly, common token shapes as the second net) before reaching terminals, `meta.attempts`, model contexts, or the discovery cache. Remote-URL errors quote origin and path only, so signed query tokens (S3 presigns) never travel, and the shapes are tuned to leave prose like "token limit_exceeded" alone. - *Windows PATH probing.* `findOnPath` now walks `PATHEXT`, so a real `agy.exe`/`claude.cmd` install no longer reads as "not on PATH" (doctor and the failover chain both trusted that verdict). - *dsh evidence cache.* Failed reads are no longer memoized forever (a fixed config gets a fresh chance without restarting dsh), concurrent steps join one in-flight read instead of double-spending the engine, the cache is capped LRU-style, and cancellation is per-waiter: aborting one step stops its own wait immediately while the shared read completes into the cache, instead of killing every concurrent joiner. Pasted HEIC/HEIF now maps to its real extension, and an unknown media type refuses instead of dressing up as PNG. - *Docs told the truth about less than the code did.* The security page now states per provider who fetches a remote URL (only gemini-api downloads locally behind the SSRF guards, magic-byte check, and size cap; openai/anthropic hand the URL to the vendor). The output contract now lists `visual` as required, matching the schema that always enforced it, with a docs-contract test pinning the two together. Doctor renders CLI providers as `[ok?] installed / sign-in not verified offline` instead of a flat ok, adds a machine-readable `status` field (`ready`/`installed`/`missing`) so JSON consumers stop reading bare-binary as ready, and the install flow treats the first real read as the auth check. The skill's trigger list drops `.bmp` (never supported by the CLI) and gains `.heif`. - *Hardening around the edges.* The release script and workflow now run lint, refuse version downgrades, and verify the tag matches package.json; CLI numeric flags reject trailing garbage (`--count 3x`); error exits use `process.exitCode` so piped output survives. - **dsh install can silently land on 3.5.0 ([#18](https://github.com/liustack/modlens/issues/18)).** pnpm v11's release-age quarantine falls back to an old version when every recent one is inside the window (10 days on pnpm 11.21, measured), and versions before 3.9.0 carry no `dsh.bundle` declaration, so the plugin installs as a plain dependency and none of the tools appear. There is no CLI or env override for the gate ([pnpm#11224](https://github.com/pnpm/pnpm/issues/11224) is still open), but an explicit version or dist-tag skips it ([pnpm#9989](https://github.com/pnpm/pnpm/issues/9989), verified against pnpm 11.21: a bare add resolved 2.8.0 while `@latest` resolved 3.10.0 under the same default gate). **(Wrong, corrected in 3.16.4: the window is 24 hours, not 10 days, and `@latest` does not skip the gate.)** The install command everywhere now carries `@latest`, which does not in fact land current either; troubleshooting documents the mechanism plus a durable `minimumReleaseAgeExclude` fallback. Thanks to @stonogic086-1 for the precise diagnosis. ## 3.10.0 - 2026-08-14 - **Pasting into the dsh Web UI now works end to end, thumbnails included.** The plugin registers a wrapper provider whose model list carries two entries, `DeepSeek-V4-Flash (modlens vision)` and `DeepSeek-V4-Pro (modlens vision)`; select one and pastes are admitted, because dsh's image admission asks the selected provider for input modalities and the wrapper declares image input. Conversion happens at request time on the wire messages only: the durable session log and the UI keep the native image blocks (your paste keeps its thumbnail), each pasted attachment is read once through the modlens CLI and cached for every later step, and the wrapped request is answered by the same DeepSeek route it always used. The wrap covers DeepSeek and GLM text models only; their own vision models (deepseek-vl/ocr, janus, glm v-series) are excluded by name pattern and by declared modality, so a model that can already see is never wrapped. The `agent/pre-step` auto-read from 3.9.0 becomes opt-in (`autoRead: true`) since request-time conversion owns the paste path and preserves the native UX. Registration also stopped failing silently: the adapter now supplies the base-class methods dsh's duck-typing expects (`providerInfo`, `providerRetryPolicy`), whose absence was exactly the invisible failure, and a registration error is logged to the harness console instead of swallowed. - **Paste auto-read read a field the attachment store never had ([#17](https://github.com/liustack/modlens/issues/17)).** dsh's `attachments.readImage` returns `{ ref, data }`, but the 3.9.1 plugin read `stored.bytes` and `stored.mediaType`, so every pasted image degraded to a `Buffer.from(undefined)` TypeError block. The plugin now reads the real shape (`data`, with the media type on `ref`), and a missing byte field fails with a named error pointing at the dsh attachment shape instead of a bare TypeError, so the next developer-preview drift stays legible. Thanks to @nico2656-ui for the rc.6 type archaeology and a verified patch. - The README leads its demo gallery with the real dsh paste session (recovered from the machine that ran it), states the wrap scope, and lists both variant names verbatim under a rather more visible first-plugin banner. ## 3.9.1 - 2026-08-13 - dsh follow-ups that missed the 3.9.0 tag during a GitHub outage: the plugin row references the bare package name via a root export, so the dsh plugin list shows `modlens` instead of `modlens/dsh`; install lines use `npx -y @deepseek-ai/dsh` (the developer preview has no global binary); and both READMEs state the paste status honestly, since the dsh DeepSeek adapter declares text-only input and Web-UI image admission runs before any plugin hook, `read_image` is the working path today and paste auto-read stays wired for when images can enter. ## 3.9.0 - 2026-08-13 - **The first plug-in vision plugin for DeepSeek Harness (dsh).** The npm package is now also a dsh bundle, so a single `dsh plugin --profile add` is the whole install (the current command, with its version named, is in [INSTALL.md](INSTALL.md)). It registers a native `read_image` tool (schema in every model request, so there is no trigger heuristic at all) that spawns the modlens CLI shipped in the same package, declares the vision schema as its canonical output contract, and renders evidence text for the model. Phase 2 rides `agent/pre-step`: images pasted or dropped into the dsh Web UI are read automatically and enter the step as modlens evidence blocks, with failed reads degrading to an explanatory note instead of rejecting the step (`autoRead: false` in the plugin row turns this off). The plugin imports no dsh packages (raw JSON-Schema tool registration, node builtins only), which is also the smallest possible surface against developer-preview churn. Verified end to end on a real dsh headless profile: the DeepSeek model called `read_image` and quoted the exact transcription back. - **Grok Build joins as the fifth reusable harness.** `reuse.grok` grants the local Grok CLI login as an engine: discovery reads `~/.grok` (OAuth evidence in auth.json, model ids from models_cache.json judged by the builtin vision table), and the route drives headless `grok -p` with `--json-schema` (which accepts this project's schema unmodified; the structuredOutput field carries the conforming answer) and `--allow Read`, following the claude-cli template since headless grok has no image-attach flag. Verified live: an exact OCR read through a real SuperGrok login. The agent region order becomes antigravity, codex, opencode, grok, pi-cli, claude-cli. ## 3.8.0 - 2026-08-13 - **Reuse: per-harness grants for the logins already on your machine.** A signed-in Codex, an OpenCode vision model, or credentials held by pi can now serve as vision engines, each behind one explicit yes recorded as `reuse.` (true grants, false means never ask again, absent means never asked; `claude` absent counts as granted since `claude-cli` predates the model, and `reuse.claude false` now removes it from the chain). pi credentials with an exactly-supported api shape (`openai-completions`, `anthropic-messages`) and a printable API key run through the existing inline providers with every guard intact; OAuth logins and other shapes (Responses, Vertex) drive pi itself (`pi -p` with the `@file` attachment), and codex (`codex exec -i`) and opencode (`opencode run -f`) join as agent routes. Reused engines get no priority: one chain, two regions, speed-class placement only, and every reused answer adds a `meta.warnings` line naming whose quota it spent. Keys are fetched at call time, live only in memory, and no subprocess output ever travels into an error message. - **The failover chain leads inline everywhere.** The local chain becomes gemini-api, openai, anthropic, then antigravity-cli, then claude-cli: a configured key answers in 5-10 seconds while an agent loop takes 15-45, so with both set up the old agent-first order made every read slower than it had to be. A `config set provider` preference still moves any provider to the front for local images; for remote URLs inline-first stays a security boundary even against a preferred agent, since only the inline download path runs the private-address guards, the magic-byte check, and the size cap. - **Onboarding is one consent conversation, symmetric across harnesses.** INSTALL.md's engine step now reads doctor's Reuse section and asks one question per never-asked harness, the harness you are installing into included, so a Codex-only or Pi-only machine gets the same zero-config offer a Claude Code machine always had. The same flow lives in the skill as `references/onboard.md` for machines where the skill arrived without a config. Refusals are recorded and never re-asked; `config show` renders the tri-state so the flow can tell. - **doctor tells the whole truth.** The Auto section becomes Reuse (per-harness decision plus what discovery found), the failover chains display includes reused routes labeled `(reused)` so a machine living entirely on granted logins no longer reads as having no engine, and a fully failed chain distinguishes never-asked reusable vision from a grant that stopped working. A stock codex install without a config.toml counts as vision-capable, and a cache timestamp that fails to parse now expires instead of living forever. - **SKILL.md went progressive.** 59 lines instead of 164: the trigger contract, the launcher, and the loop stay; machine state is read from `doctor` and `config show` at run time, and the heavy flows load on demand from references (`find-image.md` for the per-harness paste-path branches, `onboard.md` for first-run setup, `configure.md` for every key). `cli.md` and `output-schema.md` moved to `docs/`: they are manuals for humans, not run-time knowledge. - The whole reuse surface passed an independent code acceptance review (run through a reused Codex, fittingly): nine findings fixed, from credential redaction and exact api-shape mapping to preference preservation and the remote security boundary, each with a regression test. 279 tests. ## 3.7.0 - 2026-08-13 - An image-extension path is now the skill's primary trigger. Transcript forensics on a Claude Code cli session explained why the skill loaded but never fired for a text-only model there: the harness silently swaps a pasted image for a usable `[Image: source: ]` line, so no failure ever pushes the model to consult its skill list, and a path in hand makes hand-rolled OCR the path of least resistance (the same model behind OpenCode, where the gateway error "model does not support image input" is loud, found and followed this skill exactly). The description now keys on what the model perceives first: any file path or URL ending in an image extension (.png, .jpg, .jpeg, .webp, .gif, .bmp, .heic) it cannot see behind is a hard trigger, with self-built OCR, PIL, and tesseract explicitly forbidden. Placeholder cues (`[Image #1]`, `[Unsupported Image]`) remain as the second tier. - First slice of auto mode (borrowing local harness vision, design in progress): a read-only discovery module probes the four supported harnesses without spending anything, and `doctor` grew an Auto section showing what it found. claude on PATH counts as borrowable (Anthropic's lineup is all multimodal), the codex model catalog is judged by its own `input_modalities`, pi's models-store is crossed with its stored credentials so only borrowable vision counts, and one `opencode models` listing is judged by a builtin vision-pattern table that harness metadata outranks. Results cache to `~/.modlens/auto-cache.json` (6h TTL, doctor always probes fresh). The `auto` config switch parses as a strict boolean and defaults to off; nothing routes through these results yet, so behavior with the switch off (or on) is unchanged from 3.6.0. ## 3.6.0 - 2026-08-13 - The skill now triggers on what a text-only model can actually see. Field testing with DeepSeek behind an Anthropic-compatible gateway showed the old description asking the model to judge "can I see images", the exact self-assessment that fails when a gateway strips images silently, so the skill loaded but never fired. The description now keys on visible evidence: placeholders like `[Image #1]` and `[Unsupported Image]` trigger with a guard check as backup, and a `[Image: source: ]` line with no visible image content is a hard trigger, because that line means the harness stored the pasted image on disk and did not deliver it. Newer Claude Code builds write pastes to `~/.claude/image-cache//` and inject that line from the cli entrypoint (all models get it, vision models also get the real image and are immune to the trigger by the no-visible-content condition; the VSCode entrypoint injects nothing). The skill reads that path directly when it is alive, falls back to `recover-paste` when it is not, and never deletes Claude Code's own cache files. - `guards.allowModels`: the guard gains an allowlist mode for the world where most models are multimodal and the text-only ones are the short list. Non-empty means only listed models run the engine and every other identified model is denied. Deny patterns win over allow matches, so a broad allow can have vision variants carved out (`allowModels: ["glm-5.*"]`, `denyModels: ["glm-*v*"]`), and the unknown-model policy is unchanged (fail open unless `denyWhenUnknown`). `config set guards.allowModels` takes a JSON array or comma list, `doctor` reports both lists and flags allowlist mode, the analyze fast gate also refuses an explicit `MODLENS_MODEL` that is off the list, and `configure.md` documents tightly anchored patterns (`deepseek-v4-*`, not `deepseek*`) so a vendor's next multimodal generation falls off the list instead of into it. Configure by what actually reaches the model, not by what it could see: a multimodal model behind an image-stripping gateway still needs modlens. ## 3.5.1 - 2026-08-13 - `file://` inputs now resolve through Node's `fileURLToPath` instead of hand-stripping the prefix (issue #16). The old unwrap left a leading slash in front of Windows drive letters, so `file:///C:/Temp/shot.png` could resolve against the current working drive as `E:\C:\Temp\shot.png`, and `decodeURI` left reserved escapes such as the `%23` in a `#` filename undecoded. A URL produced by `pathToFileURL()` now round-trips back to the original local path, and a malformed file URL fails with Node's clear error instead of silently resolving to a wrong path. Thanks to @BruceWae for the report and a validated fix branch. ## 3.5.0 - 2026-08-12 - The CLI no longer prints a `node:sqlite` ExperimentalWarning on every start. Bundling undici had hoisted its lazy `require('node:sqlite')` (for a cache store nothing here uses) into a top-level import. The build now keeps that require a runtime call. - Invocation guard (issue #15): `modlens guard` answers whether the vision engine should run at all, for people who point both text-only and vision-capable models at the same client. `guards.denyModels` in the config holds glob patterns of models with native vision. A match means deny (exit 1, machine-readable verdict), and the skill's workflow now checks it before the first read of a session. The active model is detected from three signals, strongest first: the `MODLENS_MODEL` env var, the harness's own session storage (Claude Code, Pi, and Codex transcripts, the OpenCode database, scoped by the same harness detection recover-paste uses: a transcript cannot misname the model, while a model's `--model` self-report can), then that self-report. Unknown stays fail-open unless `guards.denyWhenUnknown` is set: a wrongly blocked read would break the text-only bridge this tool exists for, a wrongly allowed one only wastes a provider call. `analyze` itself refuses before spending quota when the explicit `MODLENS_MODEL` matches a deny rule (only that: sniffing and the unknown policy stay advisory, in `modlens guard`), and `doctor` grew a Guard section showing the rules, the detected model with its signal, and a live verdict. Sniffing reads a bounded tail window of transcripts that can carry hundreds of MB of inline images, and a guard with no configured rules answers without touching detection at all. ## 3.4.0 - 2026-08-12 - Vendor-specific request fields can now be passed through to the three API providers, which is how you turn thinking off (issue #12). `modlens config set openai.extraBody '{"thinking":{"type":"disabled"}}'` stores it per provider, `--extra-body ''` overrides it for one run, and an empty value clears it. Reasoning models spend their budget re-deriving a transcription task that needs none, so on a thinking-by-default model this is the difference between a slow read and a fast one. There is deliberately no `--no-thinking` flag: every gateway spells the knob differently (`thinking.type` on the MiMo API, `reasoning.effort` on its Responses route, `chat_template_kwargs.enable_thinking` on a self-hosted vLLM, `thinkingConfig` inside `generationConfig` on Gemini), some ignore what they do not know and others reject it with a 400, so guessing on the user's behalf would fail silently about as often as it worked. `configure.md` carries the per-vendor recipes. - The passthrough deep-merges into the request body, so adding a knob to a nested block keeps what was already there (a Gemini `thinkingConfig` no longer wipes out the `responseJsonSchema` next to it). The fields that carry the image, the prompt, and the schema enforcement are reserved and rejected with a message naming the field. The two CLI providers take no request body: they warn in `meta.warnings` that the value was ignored rather than letting a run look configured when nothing was sent. ## 3.3.0 - 2026-08-07 - Automatic provider failover. A run now tries every provider that is set up on this machine, in order, and the first good result wins: a provider that errors, times out, or returns a schema-violating result hands over to the next. A local image tries `antigravity-cli`, then `gemini-api`, `openai`, `anthropic`, `claude-cli`; a remote URL tries the inline API providers first and the agent last (only the inline download path runs the private-address guards, the magic-byte check, and the size cap), and `claude-cli` never joins the remote chain since it reads local files only. The result's `meta.attempts` records every provider tried with timings and failure reasons, and `meta.warnings` carries failover notices. `doctor` prints both chains. Availability (binary on PATH, required keys present) is one shared source of truth between the doctor's readiness report and the chain. The 3.2.0 remote-URL reroute is absorbed by the remote chain order. - Behavior change: `config set provider ` is now a preference, not a pin. It moves that provider to the front of its allowed region (for a remote URL an agent still stays behind the inline providers), and the rest of the chain backs it up on failure, matching modsearch's engine setting. To pin exactly one provider with no fallback, pass `-p `, which keeps its original error when it fails. ## 3.2.0 - 2026-08-07 - A remote image URL with no explicit `-p` now runs on `gemini-api` whenever a Gemini key is configured, even if the default provider is an agent. The inline path downloads the image itself, behind the private-address guards, the magic-byte image check, and the 25 MB cap; an agent fetching the URL on its own passes through none of those. Without a Gemini key the run stays on the configured default, a local image never reroutes, and an explicit `-p` always wins. ## 3.1.1 - 2026-08-07 Fixes from a deep acceptance review (external audit, reproduced and verified here). - The isolated workdir now actually isolates. The "isolated copy" of a local image was a hardlink sharing the original's inode, so a provider writing its temp path mutated the user's file: it is now always a real 0600 copy, with a regression test asserting the original survives a provider that overwrites everything in its cwd. A remote image skipped isolation entirely and ran the agent in the caller's directory: it now gets an empty throwaway cwd, and the antigravity fallback is the tmpdir, never `process.cwd()`. `docs/security.md` states plainly that this is exposure reduction, not an OS sandbox, and that untrusted images are better served by an inline API provider. - Remote image downloads go through SSRF guards ported from modsearch. The old path called bare `fetch` on the user-supplied URL, so a URL pointing at loopback, RFC-private, link-local, or cloud-metadata addresses was downloaded and its bytes uploaded to the vision provider. Now the hostname is checked against a blocklist, every resolved address must be public, the connection is pinned to the exact validated IP via an undici dispatcher (closing DNS rebinding), and every redirect hop is re-normalized, re-validated, and re-pinned. There is deliberately no allow-private switch: for a genuinely local image the answer is a file path, and the error says so. - Schema validation has one source of truth. `missingSchemaFields` checked only that arrays existed, so `[42]` in `ocr.lines`, a string `reading_order`, and numbers in `uncertainty` all passed as evidence, and the runtime check required `visual` while the provider schema did not. The walk is now driven by `VISION_RESULT_SCHEMA` itself (types, array elements, enums, nested requireds, present-but-optional fields), and `visual` joins the schema's required list. - The launchers skip npx when node is below the CLI's 22.13 floor: an old node with a working npx used to be selected anyway, a path known to fail at run time. The diagnosis explains an unusable npx and reports `nodeMeetsFloor`, and the no-runtime next step names the actual node version. Doc wording is corrected: `doctor` spends no quota but the npx/bunx paths may download the pinned package on first use, and Bun cannot load `node:sqlite`, so OpenCode paste recovery on a bunx-resolved machine needs a real Node install. ## 3.1.0 - 2026-08-07 - Windows joins the CI matrix (Node 22 and 24), so the CLI core, config, `doctor`, harness detection, and OpenCode paste recovery run on a real Windows runner rather than being assumed. The POSIX-only cases (subprocess signal handling, permission-bit assertions, and the Claude Code and Pi JSONL home-layout fixtures) are guarded with `describe.skipIf`, and the OpenCode path normalization from #11 now runs end to end on Windows, not only as an injected-path unit test. A `.gitattributes` pins text files to LF so the Windows checkout matches the other platforms and Biome does not fail on line endings. - Two guards that assumed POSIX permissions are fixed for Windows, where files report `0o666`/`0o777` and access is ACL-based. `recover-paste --out-dir` no longer rejects an existing private directory, and `doctor` no longer flags the config file's mode. Both checks now run only where `process.getuid` exists, and the symlink guard on `--out-dir` stays in force everywhere. - The skill now launches the CLI through a bundled launcher (`skills/modlens/scripts/run.sh` for macOS/Linux, `run.ps1` for Windows) instead of a hard-coded `npx`, because a Claude Code native install has none of `node`, `npx`, `bun`, or `bunx` on PATH, so a fixed command failed for a whole class of users. Both launchers resolve the same way, forward every argument to the CLI unchanged, and share a `doctor --json` diagnosis: a compatible `modlens` already on PATH (same major version and not older than the pinned one), then the pinned-version `npx`, then `bunx --bun`, then a structured diagnosis on stderr with `nextSteps` and exit 78 when nothing can run. `doctor` is offline and chains the CLI's own provider/config doctor when a CLI is reachable. The native-artifact branch is a phase-B placeholder that reports none is published yet. The two scripts are POSIX sh and PowerShell 5.1, identical apart from their version constants and shell syntax, and a new `references/runtime.md` documents the pin, the compatibility rule, and the diagnostic fields. - `SKILL.md` is rewritten to drive the launcher: the usage section runs `run.sh` / `run.ps1`, keeps a plain-language version of the same resolution order for harnesses that forbid running scripts, and drops the bare-`npx` fallback. Its frontmatter is brought in line with the Agent Skills spec: `allowed-tools` becomes the spec's space-separated string (`Bash`) instead of a YAML list, and a `compatibility` field states the runtime requirement. - Release tooling stamps the pinned version so it cannot drift. A new `scripts/stamp.mjs` rewrites the version constant in `run.sh`, `run.ps1`, and `runtime.md` from `package.json`, `scripts/release.mjs` calls it on every bump, and `scripts/stamp.test.mjs` fails the build if the three copies ever disagree with `package.json`. The npm `files` list now ships `skills/modlens/scripts`. - Root `INSTALL.md`, written for an AI agent installing the skill on a user's behalf, is rebuilt around the launcher and the machine as found. Four ordered, idempotent steps (find the harness's skill directory, copy `skills/modlens` in, give it one vision engine, verify), each with an "if it fails" branch and Windows notes. It installs into the user-global skill directory by default, probes before configuring (the README now has the user prepare an engine first, so an already-ready provider is the common case), prefers the Gemini key for headless reliability with Antigravity as the no-signup path, and verifies through the launcher, including the exit-78 no-runtime diagnosis. - The README installation section (both languages) is reordered to match how the install actually flows: step 1 is the only human part, preparing an engine (get a free Gemini key, or install Antigravity and sign in), and step 2 hands one line to the user's AI, which follows `INSTALL.md` and reports back. The hero one-liner above the fold, three highlight bullets, and the how-it-works section are removed, keeping the highlights to evidence output, free start, and install-once-use-everywhere, and the docs table links `INSTALL.md`. The platform line is rewritten to state per-OS support honestly, and the harness and troubleshooting docs gain a Windows section. A code-review pass. Two user-facing bugs, a stack of doc corrections, and the tooling a public repo is expected to carry. - BREAKING: requires Node 22.13+. The floor was Node 18 with a special note that OpenCode paste recovery needed 22.13 for `node:sqlite`. That split is gone: 22.13 is the single minimum, `node:sqlite` is always available, and the CI matrix now runs Node 22 and 24 (dropping 18 and 20). The defensive runtime guard in the OpenCode adapter and the `describe.skipIf` in its tests stay, harmless, in case someone runs below the stated floor. README (both languages), CONTRIBUTING, AGENTS, and the harness doc drop the per-feature Node caveat. - New `evals/` scaffolding makes "every experiment leaves a reproducible artifact" a format and a tool rather than a slogan. Seed cases (dense bilingual text, a dense chart, a clean diagram, a stylized banner, and a dependency-free generated prompt-injection image) live under `evals/cases/`, and `pnpm eval` drives the built CLI over them, writing one evidence artifact per case (command, tool version and commit, provider and model, input SHA-256, raw output, expected points and scoring, latency, usage, errors and degradation) to a git-ignored `evals/results//`. It reports transcription and schema pass rates and a latency summary, and `--dry-run` validates cases without spending quota. Local and on-demand by design: it spends real quota and never runs in CI. - New `modlens doctor` command diagnoses local config and routing without spending a byte of provider quota or making a network request. It reports the Node version against the 22.13 floor, node:sqlite availability, each provider's readiness (agy/claude on PATH, and whether each API key comes from env or the config file, with a copy-paste fix for what is missing), which provider will be selected and from which layer (flag, config, or default), the detected harness and whether the verdict came from process ancestry or an environment fingerprint, and the config file's path and permission bits. Add `--json` for a machine-readable report. Troubleshooting now opens by pointing at it. - OpenCode paste recovery works on Windows again (issue #11). Matching a session compared `path.resolve(cwd)`, which is backslash-separated on Windows, against opencode's forward-slash `session.directory`, so the equality and both prefix checks missed every row and recovery returned nothing. The v2.8.0 change that turned `--session` into a directory-narrowing filter meant the old `--session` workaround stopped helping too. Both sides are normalized to forward slashes before matching now, with the LIKE wildcard escaping preserved. - A provider that ignores SIGTERM on timeout is now actually killed. The SIGKILL backstop checked `child.killed`, which turns true the moment a signal is delivered, not when the process exits, so a child that trapped SIGTERM read as already dead and was never escalated. It now tracks whether the process has exited and sends SIGKILL when it has not. - The published npm package now includes `docs/`, `CHANGELOG.md`, and `SECURITY.md`. The README links to the harness, security, and troubleshooting docs, but the `files` allow-list left them out of the tarball, so those links 404'd for anyone reading the package on npm. - CI now runs on macOS as well as Linux across the Node 18/20/22 matrix, so a macOS-only regression (path handling, `mkdtemp`, file modes) is caught before release. - `vitest` moves to 3.2.7 to match `@vitest/coverage-v8`, silencing the version-mismatch warning `pnpm coverage` printed when the two drifted. - The `node:sqlite` requirement for OpenCode paste recovery is stated correctly: Node **22.13+**, not 22.5. The module was added in 22.5 but behind `--experimental-sqlite`, and only became available without a flag in 22.13. Both READMEs, CONTRIBUTING, the harness and troubleshooting docs, the runtime error message, and the CI comment now agree. - Publishing happens in exactly one place now. `scripts/release.mjs` and the tag-triggered `release.yml` workflow both ran `npm publish` and created the GitHub Release, a race that could double-publish or leave a half-finished release. `release.mjs` keeps every guard, the version bump, commit, tag, and push, but stops at the tag: pushing it hands off to CI, which publishes with provenance and cuts the GitHub Release from the matching CHANGELOG section. - The subprocess providers (`antigravity-cli`, `claude-cli`) now run in a throwaway directory holding only the input image, not in the image's own directory. `agy` runs with `--dangerously-skip-permissions`, so an injection in an image sitting beside other files could in principle steer the agent into reading them. Each call now hardlinks (or copies) the one image into a fresh `mkdtemp` directory, runs there, and removes it afterward. An explicit `--workdir` keeps the old behaviour. - Structural schema validation now runs for every provider, not just `openai`. The shape check that caught half-filled results lived inside the OpenAI-compatible path, so a malformed result from gemini, anthropic, agy, or claude-cli could slip through if the server-side schema was not honoured. The check moved into `schema.ts`, and the analyzer runs it over every provider's result, naming the provider when a field is missing. - Remote image downloads are now capped at 25 MB and their type is confirmed from the file header rather than guessed. `fetchRemoteImageBase64` read the whole response into memory with no ceiling (a memory-exhaustion vector) and trusted a server's `content-type`, defaulting to `image/jpeg` for anything else. It now rejects an oversized `content-length` up front, enforces the cap while streaming, sniffs the magic bytes (png/jpeg/gif/webp) as the authority over a faked extension or lying header, and refuses a type outside the allow list instead of relabelling it. Local reads go through the same type check. - `recover-paste` no longer writes to a fixed, shared `/modlens-paste` by default. A `recursive` mkdir does not re-apply its mode to a directory that already exists, so on a shared machine another user could pre-create that path and read every screenshot recovered into it. Each run now mints a private `mkdtemp` directory instead. An explicit `--out-dir` is still honoured, but an existing one is rejected unless it is a real directory (not a symlink), owned by the current user, with no group or world access. Recovered files are deliberately left in place for `modlens -i` to read. - `config show` prints the effective config now, merging environment variables over the file and tagging each value file or env. Reading only the file hid keys set through `GEMINI_API_KEY` and the other bound vars, so the value modlens actually used never appeared. - Local image paths containing `#` or `?` keep their real extension. Routing them through `new URL()` read the character as a fragment or query and dropped the extension, mislabelling the type as JPEG. - The disclaimer no longer contradicts the MIT license it ships beside. It withholds warranty and endorsement without withholding the commercial-use right MIT grants, and points at the upstream engines' own terms. - Docs caught up with the code. Both READMEs gained `--provider-bin`, `--workdir`, a per-provider default-model table, a full `recover-paste` flag table, and the `meta` output fields, with `MODLENS_HARNESS` and `--out-dir` written up in troubleshooting. The anthropic recipe warns that `ANTHROPIC_BASE_URL` can silently reroute a vision request to a text-only gateway. AGENTS.md drops three claims that had gone stale. - Internals, all behavior-preserving: the duplicated JSON helpers (parse, extract, truncate) collapsed into one `util/json` module, and the 710-line `recoverPaste` split into per-harness modules. An always-true branch and a few lint findings cleared. - Tooling: Biome for formatting and linting on the repo's 4-space style, a Node 18/20/22 CI matrix that skips the `node:sqlite` tests where the module is unavailable, `@vitest/coverage-v8` with a `coverage` script, tests for the CLI assembly, and a tag-triggered release workflow that publishes with provenance. Adds the collaboration files a public repo expects: CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, issue and pull-request templates, and Dependabot. - The Chinese README's plug now invites readers to the WeChat public account rather than installing the liustack skills. - `release.mjs` matches the CHANGELOG again: version dots are escaped literally and the section ends at end of file, so the newest entry (and versions like `2.8.0`) match instead of being missed. ## 2.8.0 - 2026-08-06 - README rebuilt against how widely used projects actually write theirs: install command inside the first screen, a nav row and badges in the hero, short scannable highlights, and roughly 1,000 words instead of a long read. Harness specifics and security detail moved into `docs/harness-setup.md` and `docs/security.md`, with a Documentation table pointing at them. ## 2.7.11 - 2026-08-06 - Stops calling it OCR. A vision model reading an image is not OCR, which is a specific and different technology, and the word was borrowed for convenience across the README, the skill, and both articles. The prose now says what actually happens: every word in the image is transcribed. The `ocr` field in the output contract keeps its familiar name, with a note that a vision model does the reading. ## 2.7.10 - 2026-08-06 - README rewritten rather than patched again. The hero buried the one thing that sets this apart (you can paste) under a generic pitch, then repeated it in a feature list and again in its own section. The opening now leads with pasting, the feature list is gone as duplication, and the comparison against swapping models or running a vision MCP server sits where a reader weighing options will find it. ## 2.7.9 - 2026-08-06 - The flow diagram says something again. Replacing ASCII art with an abstract illustration removed the labels along with the alignment chore, which was a bad trade. It is now a rendered diagram with real labels, one per language, generated from HTML so nothing drifts. ## 2.7.8 - 2026-08-06 - Releases are now one command: `pnpm release ` refuses a dirty tree, a non-main branch, a duplicate tag, or a version with no CHANGELOG entry, then runs typecheck, tests, and build before anything irreversible happens, and finishes with tag, push, npm publish, and a GitHub release. Publishing by hand is how a version once reached npm with no changelog and no tag behind it. - Every previously published version now has a git tag, reconstructed from the commit that carried it. ## 2.7.7 - 2026-08-05 - README: leads with a scannable feature block (paste support, evidence rather than a description, honest uncertainty, no model swap, zero-key start, four harnesses) and states requirements. Adds a comparison against swapping in a multimodal model and against vision MCP servers, our own weaknesses included. - New `docs/troubleshooting.md`: every error this CLI prints, with cause and fix, linked from the README and the skill. - The ASCII flow diagram is now a real illustration. Its alignment had needed repair across several releases, which is a poor trade for a picture. - The Gemini CLI era research doc is marked historical so it is not read as current design. ## 2.7.6 - 2026-08-05 - `config init` now writes only the shape (`{"provider": "", "providers": {}}`) instead of all five providers with their fields pre-filled. Baked-in defaults in a config file silently outrank later changes to those defaults, and the placeholders hid the one decision that matters. The command prints what can be set instead. ## 2.7.5 - 2026-08-05 A verification pass on the 2.7.4 fixes (same external reviewer) found four that did not hold and three bugs the fixes themselves introduced. All seven are addressed here. **Fixes that did not hold** - Unreadable config files still became empty configs: the 2.7.4 edit never applied, because this file is indented differently from its sibling project. Permissions errors now surface. - Harness detection still matched a flag's value: `node --require pi app.js` read as Pi. The script behind a node shim must now look like a path to a script. - agy log evidence was scoped by file mtime alone, so a concurrent call or an older failure in the same file still misdiagnosed this run. Lines are now filtered by their own glog timestamps. - The openai schema check only looked at top-level keys, so `{"ocr":{}}` passed. Nested required fields are checked. **Bugs introduced by the 2.7.4 fixes** - `transcriptBelongsTo` returned on the first recorded cwd, so a transcript whose first line matched could still hand over another project's images. Any matching line now decides, and a transcript with cwd lines that all mismatch is rejected. - That check also read every transcript in full, then the image scan read it again. Each file is read once. - The alias table added for config lookups was written by hand and did not match the real provider aliases (`claude` resolves to `anthropic`, not `claude-cli`, and `claude-code` and `openai-compat` were missing), so settings landed on the wrong provider. The table now comes from the provider registry. - `--transcript` skipped harness validation, so `--harness bogus` silently parsed the file as Claude Code. ## 2.7.4 - 2026-08-05 Correctness and privacy pass after an external review (gpt-5.6-sol) that proved every finding with a probe. **Recovering the wrong project's images** - OpenCode directory matching passed the project path straight into SQL `LIKE`, where `_` and `%` are wildcards, so a path containing either matched other projects. Patterns are escaped now. - `--session ` dropped the directory condition entirely, and session slugs are not unique across projects. The reviewer found two colliding slugs in a real local database. A session now narrows the directory match instead of replacing it. - Claude Code and Pi directory slugs are lossy: `/tmp/project.alpha` and `/tmp/project-alpha` produce the same slug. Both harnesses record the real cwd inside the transcript, which is now checked before a transcript is trusted. **Privacy** - Recovered images landed as 0644 inside a 0755 directory, so on a shared `/tmp` any local user could read them. They are written 0600 into a 0700 directory, and re-chmodded because the filenames are content hashes and an existing file keeps its old mode. **Correctness** - A successful run could be reported as a timeout: the timer stayed armed while output drained, so a slow drain turned exit code 0 into a timeout error. It is cleared when the child exits. - A timeout sent one SIGTERM and then waited, so an engine ignoring signals hung the CLI. It now settles immediately and escalates to SIGKILL. - Output decoding kept no state across chunks, so a multi-byte character split across a chunk boundary became replacement characters. - The OpenCode "needs Node 22.5" message was swallowed by an empty catch, leaving only "no pasted images". Setup problems now travel with the error. - `--harness` was ignored when `--transcript` was given, so a copied Pi transcript was parsed as Claude Code. `--transcript ` also ignored `--cwd`. - Harness detection scanned the first eight command tokens, so a command that merely mentioned "pi" in its arguments was detected as Pi. Only the executable, plus the script path behind a node shim, is read now. - agy log evidence was accepted if the file was under two minutes old, which let a previous quota failure or a concurrent agy call misdiagnose an unrelated error. Evidence must now postdate the start of this run. - The `claude-cli` provider inherited a 30 second kill grace meant for agy's own `--print-timeout`, silently extending `--timeout`. The grace applies only to engines with an internal deadline. - The openai provider's "schema validation" accepted `{"summary":"x","ocr":null}` and anything missing layout, semantics, visual, or uncertainty. All required fields are checked. - Settings saved under a provider alias (`config set gemini.apiKey`) were invisible once the name resolved to `gemini-api`. - An unmapped image type was relabelled `.png`, so downstream tools reading the extension got the wrong type. - `ENOENT` from spawn was always reported as a missing CLI, even when the real cause was a missing working directory. - A config file that exists but cannot be read (permissions) silently became an empty config. ## 2.7.3 - 2026-08-05 - Fix: a failing `antigravity-cli` run now explains itself instead of reporting a bare exit code ([#3](https://github.com/liustack/modlens/issues/3), thanks @mtongle). Providers gained a `describeFailure` hook, and the agy provider uses it to surface agy's own error text and classify the two failures users actually hit: a locked OS keyring in headless sessions (the report's case, where agy claims to be signed out) and an exhausted weekly quota. Both messages end with the exact commands to switch to a keyless, quota-independent provider. Diagnosis only reads agy's log when this run produced an agy error envelope and the log is fresh, so stale logs cannot misdiagnose an unrelated failure. - Docs: README leads with paste support, recommends the free AI Studio key over the slower agy default, and documents that the skill configures modlens for you on request. ## 2.7.2 - 2026-08-05 - Fix: runs with the `antigravity-cli` provider hung until the timeout killed them ([#1](https://github.com/liustack/modlens/issues/1), thanks @hawkmor for the diagnosis). agy exits cleanly but leaves a language server holding the inherited stdout pipe, so the child's `close` event never fires. The provider run now settles on `exit` plus a short drain window, and releases the pipes afterwards so a lingering descendant cannot keep the CLI process alive either. ## 2.7.1 - 2026-08-04 - Docs: per-harness skill discovery paths (`~/.claude/skills/`, `~/.codex/skills/`, `~/.agents/skills/` for Pi and OpenCode), OpenCode + DeepSeek setup recipe, and the four-harness live verification matrix (Claude Code session-id recovery, OpenCode full skill loop on DeepSeek, Pi store isolation, Codex refusal). ## 2.7.0 - 2026-08-04 - `recover-paste` now identifies the harness it is running inside before touching any storage: process ancestry first (the nearest known harness among parent processes, which also resolves nested setups to the innermost tool), env fingerprints second (`CLAUDECODE`, `PI_CODING_AGENT`, `CODEX_THREAD_ID`). Detection scopes recovery to that harness's store only, so another tool's stale sessions can never hijack a paste; Codex is refused outright with path-tag guidance. `--harness ` overrides, output gains a `detected` field. - In Claude Code, recovery auto-targets the exact session from the injected `CLAUDE_CODE_SESSION_ID`, falling back to newest-image scanning when that transcript holds no images (subagent sessions). ## 2.6.1 - 2026-08-03 - Fix: opencode runs shell commands at the repo root while sessions record the directory they were launched in. Exact directory matching made recovery miss the real paste and fall through to stale Claude Code transcripts of the same project, recovering the wrong images (caught in a live session). Directories now match by prefix in both directions, and recovery is scoped to the single opencode session owning the newest image. - Recovered entries report `filename` (the original attachment name) when the harness stored one. - Skill: recovered paths are oldest to newest so analyze the last one first, match `filename` when present, run every command yourself instead of delegating to the user, and treat a `harness` value that differs from the harness you are running in as suspect. ## 2.6.0 - 2026-08-03 - `recover-paste` now supports OpenCode: pasted/attached images are read from its SQLite store (`~/.local/share/opencode/opencode.db`) via node:sqlite (Node 22.5+, lazy-loaded so older Nodes keep the JSONL harnesses). Recovery internals refactored into per-harness adapters (Claude Code, Pi, OpenCode) sharing one newest-image picker. Verified against a real opencode + deepseek session. ## 2.5.0 - 2026-08-03 - `recover-paste` now supports Pi (Armin Ronacher's coding agent) alongside Claude Code: both store pasted images as base64 in per-session JSONL files, and recovery auto-detects which harness owns the newest pasted image. Verified live against a real pi + deepseek session. Result JSON gains a `harness` field. ## 2.4.3 - 2026-08-03 - Docs: the Claude Code paste-recovery loop is now marked as verified end to end in a real DeepSeek-gateway session (placeholder spotted, file recovered by session id, image answered in full). ## 2.4.2 - 2026-08-03 - Project hygiene: CHANGELOG, GitHub Actions CI, AGENTS.md rewrite, testing guide rewrite, recover-paste and config command reference in READMEs, dead code removal, auto-externalized Node built-ins in the build. ## 2.4.1 - 2026-08-03 - Skill: path-finding is now a per-harness decision tree. Codex path tags never trigger transcript recovery, unknown harnesses are told to ask for a path instead of guessing. ## 2.4.0 - 2026-08-03 - `recover-paste --session `: exact transcript targeting. Skills relay `${CLAUDE_SESSION_ID}` (substituted by Claude Code since v2.1.9); without it, recovery falls back to newest-image-timestamp scanning. ## 2.3.2 - 2026-08-03 - Tests co-located with sources, one module one `.test.ts` (31 to 50 tests). First direct coverage for `prompt` and `imageInput`. - Skill explains why `recover-paste` takes no session id. ## 2.3.1 - 2026-08-03 - `recover-paste` locates the session by newest pasted-image timestamp instead of file mtime, immune to concurrent sessions in the same project. ## 2.3.0 - 2026-08-03 - New `recover-paste` command: recovers images pasted into Claude Code from the local session transcript (they never hit a regular temp file), prints real file paths as JSON. ## 2.2.0 - 2026-08-03 - New `claude-cli` provider: rides an existing Claude Code login, `--allowedTools Read` only, `--json-schema` enforced, haiku default. - Skill routes configuration questions to `references/configure.md`. ## 2.1.0 - 2026-08-02 - Three direct-API providers: `gemini-api` (free AI Studio key, `responseJsonSchema`), `openai` (any OpenAI-compatible multimodal endpoint), `anthropic` (forced tool call, Claude Haiku default). 3-10s per image versus 15-40s agent loops. - Layered config: `~/.modlens/config.json` via `config init/set/show` (0600, masked), env vars override the file, flags override everything. ## 2.0.0 - 2026-08-01 - Breaking: vision engine migrated from the discontinued Gemini CLI free tier to Antigravity CLI (`agy`). - Provider layer (`buildInvocation` + `parseOutput`), schema-enforced structured output via `--json-schema`, no markdown scraping. - Output contract v2: `result`/`meta` envelope; fabricated bbox and confidence fields dropped.