Docs文档

Troubleshooting

Start with modlens doctor: it checks your Node version, which providers are ready (including how many API keys each one has), which one will be selected and why, the cooldown switch and any cooling keys, and the detected harness, all without spending quota or making a network request. It catches most setup problems before you read any further. A spent key rotates to the next one, then cools, so the next run tries a healthy key first.

Every message below is one modlens actually prints. Search this file for the words you saw.

Antigravity CLI cannot read its stored login token

Antigravity CLI cannot read its stored login token.

On Linux this usually means the OS keyring is locked, which is normal for headless
sessions (agents, cron, systemd, SSH without a desktop login) ...

agy keeps its token in the OS keyring. When the keyring is locked, agy reports itself as signed out and tries a browser sign-in that cannot finish without a display. Three ways forward:

  • Unlock the keyring, or run modlens from a desktop session.
  • Sign in again with agy.
  • Switch to a provider that needs no interactive login:
modlens config set gemini-api.apiKey <key>   # free key: https://aistudio.google.com
modlens config set provider gemini-api

Quota exhausted

Individual quota reached. ... Resets in 94h19m9s.

agy's free tier is one weekly bucket shared by the desktop app, the CLI, and the SDK ...

Wait for the reset, or move to gemini-api, which has its own budget. Parallel subagents drain the shared bucket fast, so a heavy day can end it.

Provider CLI not found

Provider CLI not found: agy (spawn ENOENT). Install it and sign in first.

The binary is not on PATH, or --provider-bin points somewhere wrong. A different spawn-level failure (... could not start \claude`: spawn EACCES) keeps its real error code so the cause is nameable. On Windows the npm-installed CLIs are .cmdshims; modlens resolves them through PATHEXT and runs their real Node entry directly, so neither the bare name (ENOENT) nor the.cmd` (EINVAL) trips it up.

Working directory does not exist: /some/path

Different cause, same underlying error code from the OS: --workdir points at a directory that is not there. The binary is fine.

recover-paste found nothing

No pasted images found in any session storage for this directory (looked in: ...)

In order of likelihood:

  • You are in the wrong directory. Recovery is scoped to the project the conversation is happening in. Pass --cwd /path/to/project.
  • Nothing was pasted. Dragged files and typed paths are already real files, so there is nothing to recover: use the path directly.
  • A setup problem is blocking one harness. Anything blocking appears after Blocked: in the same message, for example OpenCode needing Node 22.13+ for node:sqlite.

recover-paste returned an image from another project

This should not happen any more, and if it does it is a bug worth reporting. Recovery checks the working directory recorded inside the transcript, not just the directory name, because directory slugs collide (/tmp/a.b and /tmp/a-b produce the same one). Include the harness and transcript fields from the output in the issue.

Recovered the wrong image from the right project

The output lists images oldest to newest, so the last entry is the most recent paste. Entries carry filename when the harness stored one: match on that when the user mentioned a name. --count 3 gives you more to choose from.

recover-paste: overriding detection and output location

recover-paste auto-detects which harness it runs inside (process ancestry first, then environment fingerprints) and reads only that harness’s storage. Two knobs override it:

  • MODLENS_HARNESS forces the storage scope without a flag: claude-code, pi, opencode, codex, or none (scan every store, no scoping). Detection reads it first, so it wins over ancestry and env fingerprints. --harness does the same for a single run.
  • --out-dir sets where recovered images land. By default each run mints a fresh, unpredictable <tmpdir>/modlens-paste-* directory (0700, holding 0600 files), so nobody can pre-create a shared path to intercept the bytes. Point it elsewhere when the system temp dir is not where you want them. An explicit --out-dir that already exists is rejected unless it is a real directory (not a symlink), owned by you, with no group or world access. On Windows those ownership and permission checks are skipped, since the platform has no POSIX bits (see the Windows section below). The symlink guard still applies.

This is a Codex session

This is a Codex session: pasted images already exist as temp files, and each image
tag in the message carries its path.

Working as intended. Codex writes pasted images to disk and puts the path in the message, so read the path out of the tag instead of recovering anything.

The openai provider rejected a result

OpenAI-compatible API returned JSON that does not match the vision schema
(wrong or missing: visual.notes, ...)

That endpoint returned something the contract does not accept. Note the wording: a field named here can be absent, or present with the wrong shape. For an optional field like visual.notes, only the second is possible, since leaving it out is accepted. A null there is dropped rather than refused, so what remains is a genuinely wrong type.

Most OpenAI-compatible gateways enforce nothing server-side, so the contract travels as a filled-in JSON template in the prompt and a weaker model can answer with half of it, especially with thinking turned off. Ask the gateway to enforce it instead:

modlens config set openai.structuredOutput true

That sends the contract as response_format: json_schema in strict form, derived from the same schema modlens checks against, so there is nothing to keep in sync by hand. It is off by default because a gateway that does not support the field answers 400 for it. If that happens:

modlens config set openai.structuredOutput false

A response_format you set yourself in extraBody wins over the derived one.

Failing that, retry once, then switch:

modlens -i <image> -p gemini-api

The guard said deny, or a read was refused

Invocation guard denied this read: active model "gemini-3.1-pro" matches guards.denyModels pattern "gemini-3*". A model with native vision should read the image itself. To override, unset MODLENS_MODEL or edit guards in /Users/you/.modlens/config.json.

Working as configured: guards.denyModels in the config file lists vision-capable models, and the active model matched one, so the engine refused to spend a provider call on an image that model can read itself. modlens doctor has a Guard section showing the rules, which model was detected, from which signal (the MODLENS_MODEL env var, session storage, or a --model self-report), and the verdict.

If the detection is wrong, MODLENS_MODEL=<actual-model> modlens guard overrides everything, and MODLENS_MODEL=none marks the model as unknown (the verdict then follows denyWhenUnknown, default allow). To turn the guard off entirely: modlens config set guards.denyModels ''.

One known blind spot: storage detection reads the newest assistant turn recorded for this project, so two sessions running different models in the same project directory at the same time can shadow each other (Claude Code and Codex pin the exact session through their injected session ids, Pi and OpenCode cannot). When that bites, MODLENS_MODEL is the override.

Note that the hard refusal above only fires on an actual denyModels match against the explicit MODLENS_MODEL value. Storage detection and the denyWhenUnknown policy never block analyze, they only speak through modlens guard, whose deny is advice to the agent rather than a locked door.

dsh says “declares no dsh.bundle — installed as a plain dependency”

The dsh profile installed an old modlens version. The dsh.bundle declaration exists since 3.9.0, and pnpm 11 holds back releases published in the last 24 hours (minimumReleaseAge, on by default since 11.0; pnpm config get does not surface this particular default, so it prints nothing for it). When every version carrying the declaration was inside that window, pnpm silently resolved to an older one, which has no declaration, so dsh correctly treated it as a plain dependency and none of the tools appeared.

@latest does not avoid this, which earlier versions of this page got wrong. The gate filters the candidate versions before the tag is resolved, so the tag simply lands on an older one. Name the exact version instead, which pnpm treats as a deliberate request rather than a resolution:

npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.24.2

npm view @liustack/modlens version prints the current one. pnpm 11 installs a named version, and since 11.1.3 also records it as an approved exception in the profile’s pnpm-workspace.yaml, leaving every other package and every future modlens release behind the window.

If you set minimumReleaseAge yourself, pnpm treats the policy as strict and refuses instead, naming the version and the cutoff (ERR_PNPM_NO_MATURE_MATCHING_VERSION). Approve that one version in the same file:

minimumReleaseAgeExclude:
  - '@liustack/modlens@3.24.2'

Or lift the gate for a single command, which lifts it for everything that command resolves, not only modlens:

npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@latest --config.minimumReleaseAge=0

dsh’s reconcile notices the bundle declaration on the new version and activates it; restart dsh afterwards. Verify with npx -y @deepseek-ai/dsh plugin --profile <name> list.

dsh: the model cannot see the read_image tool

The plugin registers its tool as modlens_read_image, not read_image. dsh’s tool registry is layered, and a scoped tool shadows a global one: a host read_image mounted in the agent-preset scope and a plugin’s registered globally are not a duplicate at all, so the registration succeeds silently and the model still resolves the host’s, which refuses a text-only model outright (#34). Under our own name there is nothing to shadow it, and the model finds the tool through its schema, which reaches it on every request regardless of the name.

If the model still cannot see it, check the harness log for [modlens] ... registration skipped. toolName in the plugin row pins a different name, but pick one nothing else uses: any name a scoped tool already holds will be shadowed exactly the way read_image is, which puts you back in this section.

- id: modlens
  config:
    toolName: vision_read_image

fetch failed, or could not connect

Could not connect to generativelanguage.googleapis.com (UND_ERR_CONNECT_TIMEOUT). The request never reached the network. ...

The API request never left the machine. On networks that reach the internet through a proxy this is expected: Node’s fetch ignores the proxy environment variables by default. modlens honors them once you ask it to route that way, in either form:

HTTPS_PROXY=http://127.0.0.1:7890 modlens -i shot.png -p gemini-api   # env (NO_PROXY honored too)
modlens config set proxy http://127.0.0.1:7890                        # persistent, all API providers
modlens config set openai.proxy http://127.0.0.1:7890                 # one provider only

The proxy applies to API provider requests only. The remote-image download path keeps its direct, IP-pinned connection on purpose: its SSRF guards validate the exact address being contacted, and a proxy would blind them. On a proxied machine, prefer local files or let the failover chain hand remote URLs to a provider that fetches them upstream.

Config file problems

Cannot read /Users/you/.modlens/config.json: EACCES ... Fix the file or its permissions.

The file exists but is unreadable. A missing file is fine, so this is a real problem rather than something to ignore.

Failed to parse ... Fix or delete the file.

Invalid JSON. modlens config init --force writes a clean one, losing the old contents.

Timeouts

antigravity-cli provider timed out after 210000 ms.

Retry once with --timeout 300000. Dense images on agy legitimately take 15-40 seconds, and -m gemini-3.1-pro-high is slower still. Engines that ignore SIGTERM are escalated to SIGKILL, so a timeout returns promptly regardless.

Every read is slow on a reasoning model

A model that thinks by default spends its budget before it starts transcribing, which a vision read does not need. There is no --no-thinking flag because each vendor names the switch differently, so pass the vendor’s own field:

modlens config set openai.extraBody '{"thinking":{"type":"disabled"}}'
modlens -i shot.png --extra-body '{"reasoning_effort":"low"}'    # one run only

The per-vendor spellings, which models cannot turn it off at all, and how to tell whether the field actually landed are in Configuration.

extraBody cannot override "messages" for the openai provider

That field carries the image, the prompt, or the schema enforcement. Remove it and keep the vendor knobs. A 400 from the gateway naming a field you set means that endpoint uses a different spelling, and a run on antigravity-cli or claude-cli says in meta.warnings that it ignored the value, since a CLI provider has no request body.

Windows

ModLens runs on Windows. Three platform differences are worth knowing:

  • No POSIX permission checks. Windows files carry no owner, group, or world bits (they read back as 0o666/0o777, with access governed by ACLs), so doctor does not judge the config file’s mode and recover-paste --out-dir does not reject a directory on ownership or group/world access. The symlink guard on --out-dir still applies.
  • Harness detection uses environment fingerprints. There is no ps to read the process tree, so detection relies on the environment variables each harness sets. If a run guesses wrong, force it with --harness <name> or MODLENS_HARNESS.
  • Paste recovery. OpenCode recovery is covered on Windows (issue #11). The Claude Code and Pi JSONL paths depend on os.homedir() and each harness’s on-disk slug there. If recovery comes up empty, pass --transcript at the file, or drag the image into the terminal.

Still stuck

Include the exact command and the full error in an issue: https://github.com/liustack/modlens/issues