Docs文档

Changelog

3.24.2 - 2026-08-24

  • dsh: repeated modlens_read_image calls reuse one image read (#81). A small host model can emit the same path-tool call repeatedly inside its thinking loop, and every call started a new CLI process because #68 cached attachment conversion but not the explicit tool. The tool now keeps a per-plugin LRU cache keyed by the CLI-normalized source identity and focus prompt. Identical concurrent calls join the same pending read. A changed local file or focus creates a fresh read, remote results expire after 60 seconds, and failures cool for 60 seconds before retrying. Returned evidence is cloned so one consumer cannot mutate the shared result, and the model-facing description explicitly says to call once and reuse the evidence. This prevents repeated tool decisions from becoming repeated vision-provider work. It cannot make a small model stop hallucinating calls or recover reliably from every tool failure, so duplicate call events may remain visible and hosts should still cap tool rounds where available.

3.24.1 - 2026-08-24

  • dsh bundle startup no longer logs inactive context before llm is ready (#79). Bundle loaders can invoke the plugin while required services are still inactive. The first auto-discovery sweep read ctx.llm immediately, logged one to three errors, then relied on later topology events to recover. Discovery now runs inside Cordis’s injected llm lifecycle. Each activation binds its own service, invalidates pending probes before teardown, and releases its wrapper ownership. A Cordis effect boundary also stops Promise continuations that were already queued from registering or refreshing adapters after the scope starts unloading. Existing and late providers are still discovered exactly once, and preview hosts without injection retain their previous feature-detected path.

3.24.0 - 2026-08-22

  • A single gemini-api, openai, or anthropic provider can now use multiple API keys. Both providers.<name>.apiKey and the corresponding environment variable accept a comma-separated list, trim each item, and ignore empty entries. Requests use the configured order and rotate only after authentication, rate-limit, or quota failures. Network, 5xx, and response-parsing failures skip the remaining keys and preserve the existing cross-provider failover. Quota cooldown is recorded per key, and a provider moves to the back only when every configured key is cooling. Legacy provider-level cooldown state still loads. Every sibling key is registered for redaction, doctor reports the key count, and the dsh settings card explains the syntax. Single-key and keyless attempts keep their previous JSON shape, while multi-key attempts add a zero-based keyIndex.
  • Quota cooldown is new. A quota-spent key cools for 45 minutes by default, a monthly HTTP 432/433 for 24 hours, and an engine-reported Resets in 94h19m9s clause wins when present. A pure 429 rate-limit without quota wording is not recorded. State lives in ~/.modlens/state.json. A cooling key is still tried last. Success of one key clears that key only. modlens state clear forgets every cooldown. modlens config set cooldown on|off turns the store off entirely (on by default).
  • GEMINI_BASE_URL now binds to gemini-api.baseUrl the same way OPENAI_BASE_URL and ANTHROPIC_BASE_URL bind, and only while the file does not name gemini-api.

3.23.1 - 2026-08-21

  • dsh 0.1.1: (modlens vision) routes work again (#73). dsh 0.1.1 dispatches every call — and its replay path — through a new required adapter method, prepareCall(provider, model, signal), and threw registration.adapter.prepareCall is not a function on every turn of a wrapped route. Real adapters inherit a base-class default that binds resolveModel and stream into one generation; the wrapper registers a plain object, so it now carries the same pair itself, exactly mirroring the upstream default. Hosts that never call prepareCall (dsh 0.1.0) ignore the extra method. The regression test drives the wrapper through the 0.1.1 dispatch shape and failed with the reported TypeError before the fix. Thanks to @5ME for a report that had already located both call sites.

3.23.0 - 2026-08-21

  • dsh: a vision model never gets a (modlens vision) twin, even from a catalog that forgot to say it sees (#71 surfaced the mechanism). DeepSeek launched deepseek-v4-flash-vision-exp today, and the official dsh catalog declares its image modality from day one, so the stock route was never at risk. But third-party catalogs and hand-written models lists copy an id without its modalities, and the wrapper’s name gate only knew the older spellings (deepseek-vl, deepseek-ocr, janus, glm-…v): an id whose own name says vision would have been wrapped as text-only, stripping its native sight. The name gate now reads the word vision in the bare model id. The judgment also classifies by the bare id deliberately: a gateway namespace that happens to contain the word cannot veto the text model behind it, and a leading ~ alias marker cannot hide one.
  • dsh: OpenRouter’s GLM models get their wrapper variants (#71). OpenRouter spells GLM as z-ai/glm-5.2:free, and family matching only looked at the full id, so startsWith('glm') never fired: a text-only family member — exactly what the wrapper exists for — was invisible behind its vendor prefix. Family matching now also tries the bare model id behind the namespace and alias marker; a custom families list keeps its full-id matching untouched. Vision-named and image-declaring models stay excluded on both spellings.
  • The reuse vision table knows DeepSeek’s first vision endpoint. Same shape as 3.22.0’s #70: harness listings that carry no modality data are judged by the builtin table, and the table predates today’s launch. deepseek-*vision* now marks deepseek-v4-flash-vision-exp (and successors named the same way) image-capable, verified against the official docs, models.dev, and OpenRouter’s catalog.

3.22.2 - 2026-08-21

  • The scope contract is written down where maintainers look. AGENTS.md and CONTRIBUTING.md now state it in full: an image the user hands over is read once, and that read leaves text later turns can quote. The image is in the conversation because the user pasted it, dropped a path, or gave a URL — capture is a different job. Both files list what stays out and why: a camera, screenshot capture, or hotkeys; CDP or holding a browser session; computer-use; a pixel toolbox; bounding boxes or confidence scores, dropped from the schema on purpose. Docs only, no behavior change — verified byte-identical CLI output against the previous tree.
  • README: paste once, later questions about the same image need no second paste. One interaction sentence added to both READMEs; the position statement is unchanged. And per the promise recorded on #48, both READMEs now link dsh-screenshot as the separate plugin for hotkey screen capture into DeepSeek Harness — the plugin entered the live dsh-market catalog on 2026-08-18, which was the agreed condition for the cross-link.

3.22.1 - 2026-08-20

  • dsh: the settings card speaks the language dsh is set to, not the language the page was built in. dsh 0.1.0-rc.7 freezes <html lang="zh-CN"> into its built index.html and never rewrites it, so the card, keyed on the page language, stayed Chinese under a dsh switched to English. The card now asks dsh’s own locale service first and subscribes to it through useSyncExternalStore, so a card sitting open follows a live language switch. The service rides a scoped inject of its own: naming it beside slots would keep the card off any host that ships no locale service, since ctx.inject waits for every service it names, and where the service is absent the old page-then-browser chain decides, unchanged. The one path that ignored the language either way is closed too: a failed load or save with no server detail used to hard-code English into the footer status region, and now falls back in the card’s language, while whatever the server did say still travels untranslated, because that is the diagnosis rather than the card’s copy. Verified in a browser against rc.7 with locale.preference: en: the card that rendered Chinese before renders English, matching the rest of the settings page.

3.22.0 - 2026-08-20

  • OpenCode Go’s vision models are now borrowable through reuse.opencode (#70). The opencode models listing carries no modality data, so the built-in vision table judges the ids, and that table’s snapshot predated the qwen 3.7 generation and knew no mimo at all: discovery filtered out qwen3.7-plus and mimo-v2.5, and the opencode-cli route never registered. The table now recognizes qwen3.7-plus, qwen3.7-flash, qwen3.8-max, mimo-v2.5 with its -free tier, and mimo-v2-omni, each verified image-capable against OpenCode’s own catalog on models.dev. mimo-v2.5-pro and qwen3.7-max stay excluded because that catalog declares them text-only, which is why the mimo entries are exact tiers rather than a wildcard. Go-plan users also have a no-release-needed route: the endpoint speaks the OpenAI protocol, so openai.baseUrl https://opencode.ai/zen/go/v1 plugs it into the openai slot directly. Thanks to @hatanokokosa for the report.

3.21.2 - 2026-08-20

  • README: dsh-market now leads the ecosystem partners list. Both the English and Chinese READMEs list the visual plugin market first, ahead of DeepSeek Harness Desktop. Docs only, no code change.

3.21.1 - 2026-08-19

  • config use openai constructor can no longer destroy a key, and hostile hand edits meet one discipline everywhere ([3.21.0 acceptance]). An independent acceptance run against 3.21.0 found real data loss: the label constructor walked the prototype chain, came back as Object’s constructor, and emptied the active slot under a success message, destroying an unsaved key with --discard. Seven adversarial review rounds then hardened every surface the same hostile input can reach: bundle labels, provider names (config set constructor.apiKey used to write the key onto a global object and print success), and the registry all read own properties only; malformed roots, entries, bundles, and field types fail with a fix-or-remove sentence naming the path, at set, save, use, guards.*, and a new execute boundary in reads and doctor; and config show, built to be pasted into issues, now runs every value through redaction against every known key with no length assumption, while no property name in the view ever carries user data, the structural guarantee a dedicated regression walks and asserts. Two display designs that keyed rows by user data each grew a credential leak during review; the shipped view keeps labels, slot names, and unknown spellings inside redacted values, where they cannot leak or collide.
  • dsh: a failed image read no longer bills the whole session, and never rewrites history while the outcome stands (#68). A session that pasted an image the engine happened to fail on paid full price for every request after it: the failure placeholder embedded that attempt’s own error text, the cache evicted failures on settle so every step re-ran the engine and rewrote the same message, and the provider’s prefix cache missed from that message on. Failure placeholders are now constants per stage, with the attempt’s detail in the harness log and no attacker-shaped media type on the wire; failures hold for a 60-second cooldown on the monotonic clock, so a broken engine is probed once per cooldown per attachment and a recovered one moves the text exactly once; the cache key ignores attachment key order; cached blocks are frozen; and one evidence cache serves every wrapper route and auto-read, which used to bypass caching entirely. Eviction went through three designs under adversarial review, each broken by a measured reproduction, and what ships pins every open walk’s keys as exact refcounts: within the cap, wire bytes move only when an outcome changes. Thanks to @nanami-0713 for a report that named the mechanism, the reproduction, and the cost curve, and for respecting the no-PR policy while still making the fix easy to verify.

3.21.0 - 2026-08-18

  • Switching OpenAI-compatible gateways no longer costs a key (#67). The openai slot is the one slot users point at many different gateways, and switching meant overwriting providers.openai field by field, with the previous endpoint’s key lost unless backed up by hand. modlens config save openai <label> snapshots the slot whole under a name; modlens config use openai <label> swaps a saved copy back in whole, never merged. A slot that no label holds refuses to be overwritten without --discard, so the silent key loss this exists to remove now takes saying so in as many words. The saved section is inert data: resolution, guards, the failover chain, and the environment bindings never read it, the effective view lists labels with keys masked, and older CLI versions carry it through their writes untouched. The issue’s own proposal, named provider slots with a type field, was declined with reasons on the thread: it opens the closed provider registry every downstream consumer assumes, and reopens the mistyped-name trap 3.19.0 closed. Per-call routing and a dsh passthrough are likewise declined; endpoint choice stays user-owned configuration.

3.20.0 - 2026-08-18

  • Windows: default-layout pnpm shims work (#43 closes for pnpm too). pnpm with the isolated linker and extendNodePath, which is its default configuration, prepends a four-line NODE_PATH block to every shim, and the recogniser did not know the shape, so those shims were declined and still landed on the spawn EINVAL that 3.18.0 removed for npm. The block is now a recognised prefix of both pnpm shapes: the plan sets NODE_PATH to the hoisted directory when the child has none and prepends when it does, with cmd’s own DEFINED semantics, and the directory value goes only into the child’s environment, exactly where the shim itself puts it. Fixtures are verbatim generator output for the branch, multi-directory, and native forms. Found by the same review that shipped 3.19.0, which had recorded this as the known gap.

3.19.0 - 2026-08-18

An adversarial review swept everything written since 3.17.0, three reviewers by area plus release-artifact checks. One crash, a config trap, and a set of lifecycle and Windows-lookup defects came out of it, all fixed here with tests that failed before the fix. One behaviour change is deliberate and marked breaking below.

  • openai: a finish_reason: null no longer crashes the diagnosis it was part of. null is the streaming-chunk spelling of the field, and compat gateways that reuse one response model send it in non-streaming answers too. It fell past the length and stop checks into the quoted-reason branch, where redaction called .split on it, so both unusable-output paths surfaced a TypeError in place of the advice they exist to give. Anything that is not a string now reads as the field being absent. The non-JSON branch had carried this since 3.17.1.
  • config: config set folds and validates the provider name. config set OpenAI.apiKey was saved verbatim, reported as saved, and never read, since reads look sections up by exact lowercase key; the environment quietly kept answering for the provider the user thought they had just configured, and the effective view showed two rows for one provider. The name is now folded the way -p folds it, aliases stay the storage key they always were, and a name no provider answers to is refused with the valid ones listed.
  • analyzer: an isolation that fails to set up removes its directory. A copy failure between creating the throwaway workdir and returning it leaked one directory per failover attempt, since the run’s cleanup only knows directories that were returned.
  • dsh: the pinned-upstream lifecycle gains the protections the discovery sweep already had. A duplicate registration retried and logged on every topology event instead of being remembered as another holder’s claim, and is now retried only once their route disappears. The wrapper’s own drop emits a topology event mid-reconcile, which the re-entrancy guard used to swallow, so an upstream that unmounted and remounted in one breath stayed unregistered until an unrelated event; the guard now queues a rerun. A refresh whose replace throws after the host already committed kept disposing a healthy registration; the catch now asks the registry which side of the commit the failure landed on. And the sweep’s availability set reads bare-string provider listings the way the pinned path always did.
  • Windows: the bare-node lookup is as picky as cmd, and shim-supplied targets stay on this machine. A directory that happened to carry the name counted as the found executable, so spawn was handed a directory while cmd skipped it and ran the node one entry later. Quoted PATH entries were probed with their quotes on, which deleted them from the search: a different node ran, silently, or the shim was declined where cmd works. Both now read the way the Windows search reads them. A shim whose text supplies a plain-absolute target may no longer name UNC or device paths, and an execution line glued to %* is declined, since cmd concatenates there and a split argv would not be that spawn.
  • dsh (BREAKING): a pinned upstream’s default provider id now encodes the upstream, modlens-<upstream>. The flat default, deepseek-modlens whatever the upstream, was also the id auto-discovery mints for deepseek-official, so history recorded under a pinned foreign upstream was indistinguishable from DeepSeek history, and a later switch to auto-discovery could hand that route’s private replay state to the DeepSeek adapter, the exact transfer the 3.17.2 boundary exists to prevent. It also meant pinned foreign setups never got that boundary’s relabelling at all, so their reasoning continuity was silently dropped every turn. A pinned deepseek-official keeps its name, and an explicit providerId is honoured as before. If a session or default-model setting on a pinned foreign setup named deepseek-modlens, reselect the model once; those routes gain the reasoning continuity they were losing.

Known and recorded rather than fixed here: default-configuration pnpm shims carry a NODE_PATH preamble the recogniser does not know yet, so they still land on the spawn error 3.18.0 removed for npm; recognition for that template is the next piece of work.

3.18.6 - 2026-08-18

  • dsh: startup no longer logs a scary failure while waiting for a late upstream (#66). A pinned upstream that mounts after this plugin is ordinary startup order: llm-pi-ai, for one, mounts its providers once settings load. But the wrapper registered against the absence anyway, and that attempt cannot succeed, because dsh snapshots the retry policy synchronously during registration and the upstream lookup throws NO_ADAPTER. Every reconcile before the mount burned one doomed attempt and logged vision provider registration skipped ... LlmError, which reads fatal while the next topology event quietly healed it, so the plugin looked broken in exactly the logs people check when it matters. The reconcile now waits: nothing is attempted until the upstream is actually mounted, the first real registration snapshots the upstream’s own retry policy rather than burning a placeholder round, and while waiting a single calm line names the upstream being waited on, so a mistyped upstream that never arrives still leaves a breadcrumb saying exactly what was expected. Thanks to @abyss-stars, whose report named the race, the synchronous snapshot inside registration, and the self-heal, and drew the boundary against #57 and #29 before anyone had to ask.

3.18.5 - 2026-08-18

  • dsh: the settings card is back on harness 0.1.0-rc.7 (#61, #65). rc.7 changed the settings page twice over, and both changes land on plugin cards. The slot cards register into became keyed, so the old registration throws, which is the console error both reports carry. And a card now renders only when its key matches a settings namespace the host serves, where the old page rendered every registered card, so adding the key the error asks for fixes only the error: the card still never appears, and no console message points at why. The browser half now registers with both the id the old list slot required and the key the new keyed slot requires, one file serving both harness versions, and the host half registers an empty pass-through modlens namespace so the key has something to match. That namespace exists to make the card dispatchable and holds nothing: modlens configuration stays in ~/.modlens/config.json behind the loopback route, where the CLI and every other harness read it, and the schema is duck-typed rather than imported from a harness package, so the plugin stays unpinned from harness versions. Verified in a browser against rc.7 both ways, card absent before and rendering with live values after. Thanks to @zeng111234, whose report named the missing key and read the keyed-slot contract, and to @blackboard1809 and @kaiwenyao for pinning the scope with a two-plugin reproduction.

3.18.4 - 2026-08-18

  • Windows: cleaning up after a read can no longer take the whole process down with it (#58). Node 24.0.0 through 24.13.0 carry an upstream bug where fs.rmSync aborts the process outright (exit 0xC0000409) instead of throwing, when the path handed to it holds non-ASCII characters (nodejs/node#58759, fixed upstream in 24.13.1). The throwaway directory each isolated CLI read runs in lives under the system temp directory, so a Windows machine whose temp path holds non-ASCII characters, a non-ASCII user name being the common way, hit that abort on the cleanup of every read, and an abort is not something a try/catch can stop. Removal now goes through the asynchronous fs.rm, which never reaches the affected code, keeps the one delayed retry for a directory the provider still holds open (#50), and is awaited, so a read returns only after its directory is gone or formally given up on. Thanks to @Vaking02 for the exit code and the failing path, which is what made the crash findable upstream.
  • A schema mismatch on the openai route now names the setting that fixes it (#59). The error used to say “switch to gemini-api / anthropic for enforced schemas”, which is advice to abandon an endpoint that one config line would have fixed, and on the Kimi Code endpoint it cost a reader a debugging round of blaming the model. The message now says what actually applies: a cut-off answer points at the token limit, a gateway that stopped for its own reason is quoted rather than diagnosed, a response_format of your own in extraBody is named as the thing deciding the shape, plain structuredOutput: false gets the one-liner that turns enforcement on, and only when the schema was genuinely sent and still missed does the switch-providers advice remain. Both failure branches, JSON that will not parse and JSON that parses into the wrong shape, read the same rule the request itself uses, so the advice and the request can no longer disagree. The issue also carries @StarChen-Cycler’s verified recipe for the Kimi Code coding endpoint, worth finding if you run one.
  • Windows: reads from a console-less host no longer flash a black console window per child (#60). A child process started from a host without a console of its own gets one allocated, and Windows shows its window. The desktop app is exactly such a host, so every read popped a console window for the provider child, and the probes and the config-file opener could do the same. Node’s windowsHide suppresses it and defaults to off, and it was passed nowhere. Every child, in the core and in the dsh plugin, is now started through a wrapper that writes the option after the caller’s, and a contract test holds the boundary: no shipped file other than the two wrappers may reach child_process at all, so the next child someone adds cannot forget an option it never sees. Thanks to @DoiiarX for the report and the call-site list.

3.18.3 - 2026-08-17

  • dsh: a (modlens vision) route now inherits the retry policy its upstream already had (#57). A user set maxRetries: 50 on their route and watched the vision group give up after 2, the harness default. The wrapper registers an adapter of its own, and the method dsh reads that policy from was returning nothing, which asks for the default on a synthetic route that ultimately calls the real one. It delegates now. This was the second time the wrapper had been found dropping something the upstream had, after #49 and its reasoning state, and both were reported by users rather than found here. So the fix went through every method the host calls on an adapter instead of the one that got reported, and four more came out of that. dsh captures a route’s display name and retry policy when it registers, so editing your config mid-session left the wrapper on the old values; the wrappers refresh now. A revoked upstream left its wrapper behind as a route to nowhere. The duplicate-registration check matched any error containing the word “already”, which is wider than the one error it was meant to catch. And a wrapper overwrote the upstream’s declared input modalities instead of adding to them. One deliberate behaviour change: when the upstream cannot list its models, that failure now travels instead of becoming an empty catalogue. dsh files it per provider and the model selector shows a load warning with a retry, which is both survivable and more honest than a route that appears to have no models. Thanks to @ZzAltMan, whose report came with the session log showing the wrapper’s own retry event, which is what made the cause unambiguous.
  • A wrapped route is named after the route it wraps. Pointing the single-route configuration at anything other than DeepSeek still produced a model group labelled DeepSeek, and the refresh added above could never correct it, because the name it compared against was a constant.
  • A route that turns out to read images itself explains itself. The wrapper declines to bridge a model with native image input, which is right: bridging would claim work it does not do and hand the model text where it could have had the picture. But the refusal named an internal scope and offered no way out, while a session already holding that entry fails every turn. It now says what changed and which entry to pick instead, and only in that case; a model that has simply left the configured families is a different situation and keeps its own message.

3.18.2 - 2026-08-17

  • dsh: the settings card no longer sets off Safari’s password manager (#56). Safari’s iCloud Keychain offers to enable autofill for any site carrying a password input, then shows its bubble whenever that field is focused. Here it did so for a field that is always empty: API keys live in ~/.modlens/config.json and the host route reports only whether one is stored, never the key itself. autocomplete="off" cannot turn it off either, because WebKit ignores it on password fields on purpose. The field is masked with -webkit-text-security now, which hides the characters without ever being a password field, and it keeps a key meant for one machine out of a synced keychain. Where a browser lacks that property the field stays a password input: the nuisance is worth more than an API key rendered in clear text while somebody types it. Two things worth knowing rather than discovering. A password input carries a protected state into the accessibility tree and screen readers stop reading characters back because of it, while masking is only paint, so VoiceOver and NVDA will read this field aloud and ARIA has nothing that restores the difference. It is accepted here because the field is empty in normal use, so what can be read back is what you are typing at that moment rather than a stored secret. And dsh’s own model settings still contain password inputs, so Safari’s site-level offer to enable autofill can still appear from those; what this removes is the bubble on the modlens field. Thanks to @RSJguoke, whose report named the WebKit behaviour and the reason autocomplete cannot help.

3.18.1 - 2026-08-17

  • dsh: pasted files are collected instead of piling up forever (#51). The paste-to-path route cannot delete its file when the request ends: the path it returns is what goes into the composer, so the file has to outlive the response and survive until the model reads it. Nothing collected them afterwards, so every paste left a directory behind for as long as dsh stayed installed. The attachment route already cleaned up after itself, which is why only this half leaked, and the reporter spotted exactly that asymmetry. Pastes now live under one directory of ours rather than loose in the system temp directory, and expired ones are swept at the next paste: no timer to own, nothing running while nobody is pasting, and the work lands at a moment that already costs a disk write. Two limits, because one is not enough. The clock is a week, and it is a proxy rather than a proof, since the path leaves through the composer as plain text and nothing here observes whether the draft holding it was sent or abandoned; it errs long because deleting too early breaks a draft somebody is still writing, while deleting too late costs kilobytes in a directory the OS already collects. The ceiling is a gigabyte, because one image may be 25 MB and a week is long enough for a burst to reach many of them before any expires; it removes oldest first, which is a worse rule than liveness and the only one available. The store itself is treated as untrusted ground, since its path is predictable and the system temp directory is shared. The parent is resolved, the directory is created inside the resolved path, and then it is checked rather than assumed: a link, a file, or another user’s directory is refused, and one that cannot be made private is refused rather than used, because what it holds is your images. A directory whose size cannot be measured is booked at the largest a paste may be, since a quietly low number would let the store pass a ceiling it had already exceeded. Thanks to @xiyoucheng, whose report named the asymmetry with the attachment route and made the cause obvious.

3.18.0 - 2026-08-16

  • Windows: the CLI providers start again, and a shim is now read by recognising it rather than parsing it (#43). npm installs a compiled CLI as a .cmd that runs the .exe beside it, and handing that .cmd to spawn is the EINVAL that broke the claude-cli fallback and the opencode reuse probe. The first attempt at this classified each line of the batch file against a whitelist and reasoned about the ones it recognised. Review found eight defects in it, every one the same shape: a line cannot be judged on its own, because whether it runs at all is a property of the file. The last was an IF EXIST whose condition is false, where cmd runs nothing and the reader still produced a plan that launched the provider. Trading a loud failure for a silent wrong action is worse than the bug being fixed, so that version was withdrawn from 3.17.1 rather than shipped. What ships instead recognises the exact shapes the two real generators emit, each with its own fixed recipe, and declines everything else. npm’s Node shim is two templates rather than one, because the PATHEXT edit changes meaning between them: through cmd-shim 9.0.1 it sits inside SETLOCAL while the execution line begins with endLocal, so it is undone before the lookup and before the child starts, and honouring it would reproduce a bug npm fixed in 9.0.2; from 9.0.2 it rides the execution line after endLocal, where it applies to either arm. pnpm writes it inside the ELSE block and never calls endlocal, so it applies to that arm alone. npm’s native template calls no ENDLOCAL at all, so its child inherits the dp0 the prologue set, and the plan carries it. A bare node is now resolved the way cmd resolves it, which Windows CI settled rather than reasoning: the working directory before PATH unless NoDefaultCurrentDirectoryInExePath says otherwise, relative PATH entries anchored to that directory, PATHEXT extensions before the bare name, and a hit that is itself a batch file declined. There is no process.execPath fallback, which would run a different Node than the shell whenever a portable install or an overridden PATH is involved. The existence test never takes its operand from the file: each recipe tests the one candidate it is about to run, built from the shim’s own directory, and the file has to name exactly that. A path captured out of an untrusted file and handed to the filesystem is a file-existence oracle, and a UNC or device path turns a check into network I/O and authentication that reading a local shim never authorised. Relative, drive-relative, root-relative, UNC and device paths are refused before the file is opened. Existence is tri-state, because existsSync folds a permission error into “absent” and absent decides which program runs. What this costs. Shims outside the recognised set are now declined and left to the original EINVAL rather than being run a way that might differ from your shell. Current npm and pnpm installs are covered. Not covered: cmd-shim 1.0.1 through 2.1.0 and 3.0.2, @zkochan/cmd-shim 2.x through 5.1.0, newer pnpm shims carrying progArgs, a pnpm shim whose pinned nodeExecPath is itself a .cmd or .bat, an npm shebang using a separated argument value such as --require ./preload.cjs, and a shim whose own relative target text contains a space. A directory with a space in its name is fine: that space lives in %dp0% and expands at run time. If one of those is yours, the issue tracker is the place, and a fork is a reasonable answer too, since Windows fixes here move at the speed of CI rather than of a developer with the machine in front of them.
  • PATH is read the way each platform reads it. Building a child environment by spreading process.env produces a plain object, and that object is case-sensitive even on Windows, where the key is usually spelled Path. Every CLI could then read as missing. One shared rule now covers the outer command lookup, the lookup inside a shim recipe, and the environment a plan hands the child: exact name on POSIX, folded name on Windows, with the spelling Node itself would pass.

3.17.3 - 2026-08-16

  • Windows: a successful read is no longer thrown away by its own cleanup (#50). The reporter saw kimi-cli run its full 45 seconds, finish the turn, and then fail with EPERM on a path that turned out to be modlens’s own throwaway directory. It is not a kimi permission problem and no amount of granting it access would have helped: that directory is the provider’s working directory, and Windows will not delete a directory a live process still holds open as its current directory. Something still held it when kimi’s output arrived, which is ordinary: modlens stops waiting once the direct child exits, so a descendant, a language server, or a short-lived handle can outlive the output. rmSync threw, the throw came out of the finally that runs cleanup, and it replaced a perfectly good result with a failed attempt. Removal is best effort now, with one retry for the handle that is released a moment later, and a directory that still cannot go is left in the system temp directory for the OS to collect. Every subprocess provider was exposed to this on Windows, not just kimi-cli; kimi only made it reliable enough to notice. Thanks to @StarChen-Cycler, whose report pinned the failing path to the workdir itself, which is what made the cause findable.

3.17.2 - 2026-08-16

  • dsh: reasoning blocks come back on the (modlens vision) models (#49). Sessions routed through a wrapper model lost the model’s reasoning blocks and wrote the chain of thought straight into the answer text, measured by the reporter at roughly 57% of turns against a 1% baseline on the identical upstream route, and fixed the instant the session switched back. Nothing was wrong with the messages, which is why passing them through unchanged looked correct: dsh removes an assistant message’s adapter-private replay state whenever the provider recorded on that message belongs to a different adapter instance than the one about to run, and a wrapper is a different instance by construction. That state is what carries reasoning continuity, so every turn the wrapper had produced arrived upstream without it, and the longer the conversation the more turns were affected. The copy sent over the wire now names upstream as the source of the turns upstream actually produced. The durable session log keeps the wrapper id, so the UI and the model selector still show the route you picked. This applies only where the wrapper id proves which upstream made those turns, which is what auto-discovery’s modlens-<provider> naming does; a hand-configured upstream under some other id could be repointed between runs, and relabelling there would hand one adapter another adapter’s private state, so it is left alone. Thanks to @mmyhj, who measured it across three sessions before and after the switch rather than reporting an impression.

3.17.1 - 2026-08-16

  • A model that closes its JSON early no longer fails the whole read (#45). On the openai route, qwen3-vl intermittently ends the object after semantics, keeps writing the fields it still owed, and adds a stray quote. Extraction sliced from the first { to the last }, so it swallowed the fragment and failed, and the read died as non-JSON output, which reads exactly like a truncation. Extraction now walks the braces with string literals and escapes accounted for and keeps the largest top-level balanced span that parses, so the recoverable object comes back and the schema check names the fields that are genuinely missing. The old first-brace-to-last-brace slice still runs first, so nothing it already read correctly changes. When nothing parses, the error stops guessing why: finish_reason=length points at max_tokens, a stop points at openai.structuredOutput, and anything else (content_filter, a gateway’s own reason) is reported as the gateway’s own early ending rather than described as normal. Errors about how output ended now show the end of it rather than the opening. Thanks to @abyss-stars, who captured the raw response through a proxy and came back to verify which half was still broken.
  • A private endpoint can no longer reach your terminal through a gateway error. Every error quoting model or gateway output is now redacted against both the configured key and the configured baseUrl, and redacted before it is clipped rather than after: a clip landing inside a hostname left nothing for an exact match to find. This covers the HTTP error body, the model content, the schema mismatch, and a finish_reason the gateway invented. The body previously masked only the key, so a private gateway hostname reached terminals, CI logs, and failover warnings.

3.17.0 - 2026-08-16

  • Breaking: a provider takes its settings from one place, whole (#42). GEMINI_API_KEY, OPENAI_API_KEY, OPENAI_BASE_URL, ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL used to override ~/.modlens/config.json field by field. The reported failure was a configured Kimi endpoint answering 401, and the reason is worth stating precisely: a baseUrl and an apiKey are one credential, so taking the endpoint from the file and the key from an ambient variable built a pairing that existed in neither place, and nothing in the error named the environment as the source. Merging the other way round keeps that shape, which is why this is not a change of precedence: name a provider in the config file and the file is its source, name nothing and the environment is. A container or CI job that only exports variables keeps working exactly as before, both halves still matching. What changes is the mixture. If the file configures a provider and you were completing it from the environment, put the rest in the file: a key variable becomes modlens config set <provider>.apiKey at the hidden prompt, an endpoint variable becomes modlens config set <provider>.baseUrl "$THE_VARIABLE" ($env:THE_VARIABLE in PowerShell), and until you do, a run that names such a provider refuses with that exact command rather than quietly using the vendor’s own endpoint. Naming a provider is about the key existing, not what it holds: an entry emptied down to {} still takes that provider off its variables, since the alternative is a file you edited quietly handing the provider back to the environment. modlens config show and modlens doctor label each provider with the source actually in effect. MODLENS_MODEL, MODLENS_HARNESS and the HTTPS_PROXY family are untouched: none is a credential, and none can be split from a matching half. This also defuses the documented ANTHROPIC_BASE_URL trap, where a shell that routed Claude Code through a text-only gateway silently sent vision requests there too. Thanks to @StarChen-Cycler for a report that arrived with the mechanism, a deterministic repro in both directions, and a patch.
  • The openai route still requires baseUrl, deliberately. Defaulting it to official OpenAI was the obvious convenience and the wrong call: anyone whose endpoint came from OPENAI_BASE_URL would have had their third-party key, and the image beside it, sent to OpenAI instead. That is the same fault this release removes, pointed the other way. The error now names the setting, so the fix is one modlens config set openai.baseUrl <url> away. The same trap existed for anthropic, which does have a default endpoint: a run that names anthropic while the file configures it without a baseUrl and ANTHROPIC_BASE_URL is still set now refuses and prints the exact command to keep the endpoint it was using, rather than delivering that gateway’s key to Anthropic. All three conditions have to hold, so a setup configured entirely through those variables is not affected.
  • A kimi-cli provider rides a Kimi Code login (#44). Kimi Code subscribers read images without a key, the way claude-cli rides a Claude login: modlens config set provider kimi-cli, with kimi-cli.model taking one of kimi’s own <provider>/<model> aliases. It runs only when you name it, with -p kimi-cli or config set provider kimi-cli: it spends a Kimi Code subscription, and having the CLI installed is not agreement to spend it. (claude-cli joins the chain automatically only because it predates that model.) It never serves a remote URL either, since the CLI reads local files. Requests that go over an API keep using the openai route: there is no bespoke Kimi API provider. Worth knowing if you ever debug it: modlens runs kimi with skill discovery pointed at a fresh empty directory, because otherwise kimi can find the modlens skill in the shared skill directories and read the image by running modlens, which is modlens calling itself. Thanks to @StarChen-Cycler for the request and the groundwork.

3.16.7 - 2026-08-15

  • dsh: the vision engine has a settings card in the web UI (#39). Engine, API key, endpoint, model, and which local sign-ins a read may borrow, in Settings under Plugins, for the dsh users who never open a terminal. Expanding it probes the machine and lists the harnesses actually found, so auto mode is a choice between real options. The shared ~/.modlens/config.json stays the one place those values live: the card reads and writes a loopback route that owns the file, so nothing is duplicated into a second store and every other harness sees the same edit. The host never sends a stored key to the browser, only whether one is stored, and a blank key field means leave the stored one alone. A cross-origin or non-loopback write is refused the way dsh fences its own API, since a page in the same browser could otherwise repoint someone’s engine at an endpoint of its choosing. Not pinning an engine stays its own state rather than being collapsed into one, and a save carries only what it is about: toggling a grant never moves the pin or rewrites engine settings.
  • A key can be entered without it touching argv, shell history, or the chat. modlens config set gemini-api.apiKey with the value omitted prompts for it with the echo hidden, and also accepts one piped line. The conversational path is not blocked, because it cannot be: most users hand their key to whatever agent is in front of them, and the onboarding still takes exactly the key they hand over. This is the clean path for the ones who would rather not, and the docs say which boundary each form actually holds.

3.16.6 - 2026-08-15

  • A null where the contract asks for nothing no longer fails the read (#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). 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). 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). 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). 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). 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). 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). 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). 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=<selector label> 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 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). 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-<provider> 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). 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). 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). 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). 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). 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). 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 <url> for all API providers, <provider>.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). 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 is still open), but an explicit version or dist-tag skips it (pnpm#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). 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 <name> add is the whole install (the current command, with its version named, is in 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.<claude|codex|opencode|pi> (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: <path>] 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: <path>] 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/<session>/ 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 '<json>' 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 <name> 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 <name>, 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/<date>/. 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 <tmpdir>/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 <version|patch|minor|major> 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 <id> 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 <db> 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, 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, 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 <name|none> 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 <id>: 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.