@liustack/modlens 3.13.0 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,210 @@
1
+ ---
2
+ summary: '故障排查:modlens 可能打印的每一条报错、成因与解法'
3
+ read_when:
4
+ - 运行失败了,报错信息看不明白
5
+ - recover-paste 什么都没找到,或找到了错的图片
6
+ - 判断一次失败属于配置问题、额度问题还是 bug
7
+ ---
8
+
9
+ # 故障排查
10
+
11
+ [English](troubleshooting.md) | 中文
12
+
13
+ 先跑 `modlens doctor`:它会检查你的 Node 版本、哪些 provider 已就绪、将选中哪一个及其原因,以及检测到的 harness,全程不消耗额度,也不发网络请求。大多数配置问题在你继续往下读之前就能被它查出来。
14
+
15
+ 下面每条消息都是 modlens 实际会打印的。拿你看到的字眼在本文里搜索即可。
16
+
17
+ ## Antigravity CLI 读不到已保存的登录令牌
18
+
19
+ ```
20
+ Antigravity CLI cannot read its stored login token.
21
+
22
+ On Linux this usually means the OS keyring is locked, which is normal for headless
23
+ sessions (agents, cron, systemd, SSH without a desktop login) ...
24
+ ```
25
+
26
+ agy 把令牌存在操作系统钥匙串里。钥匙串被锁定时,agy 会把自己报告为未登录,并尝试浏览器登录,而没有显示器时这个流程无法完成。三条出路:
27
+
28
+ - 解锁钥匙串,或在桌面会话里运行 modlens。
29
+ - 用 `agy` 重新登录。
30
+ - 换一个不需要交互式登录的 provider:
31
+
32
+ ```bash
33
+ modlens config set gemini-api.apiKey <key> # free key: https://aistudio.google.com
34
+ modlens config set provider gemini-api
35
+ ```
36
+
37
+ ## 额度用尽
38
+
39
+ ```
40
+ Individual quota reached. ... Resets in 94h19m9s.
41
+
42
+ agy's free tier is one weekly bucket shared by the desktop app, the CLI, and the SDK ...
43
+ ```
44
+
45
+ 等重置,或换到 `gemini-api`,它有自己独立的预算。并行的 subagent 会飞快耗干这个共享额度池,用得猛的一天就能把它用完。
46
+
47
+ ## 找不到 provider CLI
48
+
49
+ ```
50
+ Provider CLI not found: agy. Install it and sign in first.
51
+ ```
52
+
53
+ 二进制不在 PATH 上,或者 `--provider-bin` 指错了地方。
54
+
55
+ ```
56
+ Working directory does not exist: /some/path
57
+ ```
58
+
59
+ 成因不同,但操作系统返回的是同一个底层错误码:`--workdir` 指向了一个不存在的目录。二进制本身没问题。
60
+
61
+ ## recover-paste 什么都没找到
62
+
63
+ ```
64
+ No pasted images found in any session storage for this directory (looked in: ...)
65
+ ```
66
+
67
+ 按可能性从高到低:
68
+
69
+ - **你在错误的目录里。**恢复只限于对话所在的项目。传 `--cwd /path/to/project`。
70
+ - **根本没有粘贴过。**拖进来的文件和手打的路径本来就是真实文件,没有什么可恢复的:直接用那个路径。
71
+ - **某个配置问题挡住了一个 harness。**被挡的原因会出现在同一条消息的 `Blocked:` 之后,例如 OpenCode 需要 Node 22.13+ 才能用 `node:sqlite`。
72
+
73
+ ## recover-paste 返回了另一个项目的图片
74
+
75
+ 这种情况现在不应该再出现了,真出现就是值得上报的 bug。恢复检查的是 transcript 里记录的工作目录,不只是目录名,因为目录 slug 会撞车(`/tmp/a.b` 和 `/tmp/a-b` 生成同一个 slug)。提 issue 时带上输出里的 `harness` 和 `transcript` 字段。
76
+
77
+ ## 项目对了,图片恢复错了
78
+
79
+ 输出按从旧到新排列,所以**最后**一条才是最近一次粘贴。harness 存了文件名时条目会带 `filename`:用户提到名字时按它来匹配。`--count 3` 能多给几个候选。
80
+
81
+ ## recover-paste:覆盖检测结果与输出位置
82
+
83
+ `recover-paste` 会自动检测自己运行在哪个 harness 里(先看进程祖先,再看环境特征),并且只读那个 harness 的存储。两个旋钮可以覆盖它:
84
+
85
+ - **`MODLENS_HARNESS`** 不用命令行参数就能强制指定存储范围:`claude-code`、`pi`、`opencode`、`codex`,或 `none`(扫描所有存储,不限范围)。检测最先读它,所以它优先于进程祖先和环境特征。`--harness` 对单次运行做同样的事。
86
+ - **`--out-dir`** 决定恢复出的图片落在哪。默认每次运行都新建一个不可预测的 `<tmpdir>/modlens-paste-*` 目录(0700,内含 0600 文件),没人能预先创建一个共享路径来截获字节。系统临时目录不合适时可以指到别处。显式传入的 `--out-dir` 若已存在,必须是真实目录(不是符号链接)、归你所有、组和其他用户无任何权限,否则会被拒绝。Windows 上会跳过所有权和权限检查,因为该平台没有 POSIX 权限位(见下方 Windows 一节)。符号链接检查仍然生效。
87
+
88
+ ## 这是一个 Codex 会话
89
+
90
+ ```
91
+ This is a Codex session: pasted images already exist as temp files, and each image
92
+ tag in the message carries its path.
93
+ ```
94
+
95
+ 一切符合设计。Codex 会把粘贴的图片写到磁盘,并把路径放进消息里,所以直接从 tag 里取路径来读,不需要恢复任何东西。
96
+
97
+ ## openai provider 的结果被拒绝
98
+
99
+ ```
100
+ OpenAI-compatible API returned JSON that does not match the vision schema
101
+ (missing: ocr, ocr.full_text, ...)
102
+ ```
103
+
104
+ 那个端点返回了残缺的结果。只有 agy、gemini-api、anthropic 和 claude-cli 在服务端强制执行 schema,较弱的网关可能只产出半个结果。重试一次,然后换 provider:
105
+
106
+ ```bash
107
+ modlens -i <image> -p gemini-api
108
+ ```
109
+
110
+ ## guard 给出了 deny,或一次读取被拒绝
111
+
112
+ ```
113
+ 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.
114
+ ```
115
+
116
+ 这是配置在按预期工作:配置文件里的 `guards.denyModels` 列出了自带视觉的模型,当前模型匹配到了其中一条,引擎因此拒绝为一张该模型自己就能读的图片花掉一次 provider 调用。`modlens doctor` 有一个 Guard 小节,展示规则、检测到的模型、来自哪个信号(`MODLENS_MODEL` 环境变量、会话存储或 `--model` 自报),以及判定结果。
117
+
118
+ 如果检测错了,`MODLENS_MODEL=<actual-model> modlens guard` 覆盖一切,`MODLENS_MODEL=none` 把模型标为未知(判定随 `denyWhenUnknown` 走,默认 allow)。彻底关掉 guard:`modlens config set guards.denyModels ''`。
119
+
120
+ 一个已知盲区:存储检测读的是这个项目记录的最新一条 assistant 轮次,所以同一个项目目录里同时跑着不同模型的两个会话可能互相遮蔽(Claude Code 和 Codex 通过注入的会话 id 锁定确切会话,Pi 和 OpenCode 做不到)。中招时用 `MODLENS_MODEL` 覆盖。
121
+
122
+ 注意上面那种硬拒绝只在显式的 `MODLENS_MODEL` 值真正匹配到 `denyModels` 时才触发。存储检测和 `denyWhenUnknown` 策略从不阻断 `analyze`,它们只通过 `modlens guard` 发声,而 guard 的 deny 是给 agent 的建议,不是上了锁的门。
123
+
124
+ ## dsh 提示 `declares no dsh.bundle — installed as a plain dependency`
125
+
126
+ dsh profile 装到的是旧版 modlens。`dsh.bundle` 声明从 3.9.0 起才存在,而 pnpm v11 的发布冷静期机制(`minimumReleaseAge`,隔离刚发布的版本,pnpm 11.21 上实测窗口为 10 天)在所有较新版本都在窗口内时,会静默回退到更旧的版本。那个旧版本没有 bundle 声明,dsh 于是正确地把它当作普通依赖,一个工具都不会出现。
127
+
128
+ 解法:把版本写死。pnpm 只在解析版本范围时应用冷静期,显式版本或 dist-tag 会跳过它([pnpm#9989](https://github.com/pnpm/pnpm/issues/9989),pnpm 11.21 上实测),安装命令带 `@latest` 就是这个原因:
129
+
130
+ ```sh
131
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@latest
132
+ ```
133
+
134
+ dsh 的 reconcile 会注意到新版本上的 bundle 声明并激活它,随后重启 dsh。用 `npx -y @deepseek-ai/dsh plugin --profile <name> list` 验证,显示的版本应该是 3.9.0 或更新。
135
+
136
+ 如果将来的 pnpm 关掉了这条跳过通道,更持久的替代方案是在 `~/.dsh/profiles/<name>/pnpm-workspace.yaml` 里加一条一次性排除(写裸包名,不写 `name@version`,这样以后的新版本也能沿用):
137
+
138
+ ```yaml
139
+ minimumReleaseAgeExclude:
140
+ - '@liustack/modlens'
141
+ ```
142
+
143
+ 然后执行 `npx -y @deepseek-ai/dsh plugin --profile <name> update @liustack/modlens`。两种方式的代价都摆在明面上:显式 `@latest`(或这条排除)让 modlens 退出 pnpm 的供应链冷静期,新版本会立即装上。
144
+
145
+ ## fetch failed 或连接失败
146
+
147
+ ```
148
+ Could not connect to generativelanguage.googleapis.com (UND_ERR_CONNECT_TIMEOUT). The request never reached the network. ...
149
+ ```
150
+
151
+ API 请求根本没离开这台机器。在要靠代理才能上网的网络里这是预期表现:Node 的 fetch 默认无视代理环境变量。你明确要求走代理后 modlens 才会遵循,两种写法任选:
152
+
153
+ ```bash
154
+ HTTPS_PROXY=http://127.0.0.1:7890 modlens -i shot.png -p gemini-api # env (NO_PROXY honored too)
155
+ modlens config set proxy http://127.0.0.1:7890 # persistent, all API providers
156
+ modlens config set openai.proxy http://127.0.0.1:7890 # one provider only
157
+ ```
158
+
159
+ 代理只作用于 API provider 的请求。远程图片的下载路径有意保持直连并钉死 IP:它的 SSRF 防护校验的正是实际连接的那个地址,加了代理这些防护就失明了。在必须走代理的机器上,优先用本地文件,或让故障转移链把远程 URL 交给会在上游自行抓取的 provider。
160
+
161
+ ## 配置文件问题
162
+
163
+ ```
164
+ Cannot read /Users/you/.modlens/config.json: EACCES ... Fix the file or its permissions.
165
+ ```
166
+
167
+ 文件存在但读不了。文件缺失是正常的,所以这是真问题,不能无视。
168
+
169
+ ```
170
+ Failed to parse ... Fix or delete the file.
171
+ ```
172
+
173
+ JSON 无效。`modlens config init --force` 会写入一份干净的配置,旧内容会丢失。
174
+
175
+ ## 超时
176
+
177
+ ```
178
+ antigravity-cli provider timed out after 210000 ms.
179
+ ```
180
+
181
+ 带 `--timeout 300000` 重试一次。信息密集的图片在 agy 上花 15-40 秒属于正常,`-m gemini-3.1-pro-high` 还会更慢。无视 SIGTERM 的引擎会被升级为 SIGKILL,所以超时无论如何都会迅速返回。
182
+
183
+ ## 推理模型上每次读取都很慢
184
+
185
+ 默认思考的模型会在开始转录之前先把预算花在思考上,而视觉读取并不需要思考。没有统一的 `--no-thinking` 参数,因为每家厂商给这个开关起的名字都不一样,所以直接传厂商自己的字段:
186
+
187
+ ```bash
188
+ modlens config set openai.extraBody '{"thinking":{"type":"disabled"}}'
189
+ modlens -i shot.png --extra-body '{"reasoning_effort":"low"}' # one run only
190
+ ```
191
+
192
+ 各家厂商的具体写法、哪些模型完全关不掉,以及怎么确认字段真的生效,见[配置手册](../skills/modlens/references/configure.zh-CN.md#关闭思考)。
193
+
194
+ ```
195
+ extraBody cannot override "messages" for the openai provider
196
+ ```
197
+
198
+ 这个字段承载着图片、prompt 和 schema 强制逻辑。把它删掉,只保留厂商的开关字段。网关返回 400 并点名你设置的某个字段,说明那个端点用的是另一种写法。在 `antigravity-cli` 或 `claude-cli` 上运行时,`meta.warnings` 会说明该值被忽略了,因为 CLI provider 没有请求体。
199
+
200
+ ## Windows
201
+
202
+ ModLens 可以在 Windows 上运行。三个值得了解的平台差异:
203
+
204
+ - **没有 POSIX 权限检查。**Windows 文件没有所有者、组、其他用户的权限位(读出来是 `0o666`/`0o777`,实际访问由 ACL 控制),所以 `doctor` 不评判配置文件的权限模式,`recover-paste --out-dir` 也不会因所有权或组和其他用户的权限而拒绝目录。`--out-dir` 的符号链接检查仍然生效。
205
+ - **Harness 检测依赖环境特征。**没有 `ps` 可以读进程树,检测只能依靠各 harness 设置的环境变量。猜错时用 `--harness <name>` 或 `MODLENS_HARNESS` 强制指定。
206
+ - **粘贴恢复。**OpenCode 的恢复在 Windows 上已覆盖(issue #11)。Claude Code 和 Pi 的 JSONL 路径依赖 `os.homedir()` 和各 harness 在那里的磁盘 slug。恢复扑空时,用 `--transcript` 直接指向文件,或把图片拖进终端。
207
+
208
+ ## 还是没解决
209
+
210
+ 提 issue 时附上完整命令和完整报错:https://github.com/liustack/modlens/issues
package/dsh/client.js ADDED
@@ -0,0 +1,126 @@
1
+ // Browser half of the modlens dsh plugin: paste-to-path.
2
+ //
3
+ // A capture-phase paste listener runs before the composer's own handler.
4
+ // When the clipboard carries image files, the default intake (attachment ->
5
+ // host image admission -> "model does not support images" for text-only
6
+ // models) is suppressed; the bytes go to the plugin's host route
7
+ // (POST /modlens/paste), land as a private temp file, and the returned path
8
+ // is inserted into the composer as plain text. A text-only model then sees
9
+ // exactly what Pi, OpenCode, and Claude Code hand their models: a file path,
10
+ // which is also the modlens skill's and read_image tool's primary trigger.
11
+ //
12
+ // Hand-written in the lazy-CJS bundle protocol (window.__ModuleLoader__.load
13
+ // with a factory returning cordis-plugin exports), so no build step and no
14
+ // imports from dsh client packages — the same zero-dependency stance as the
15
+ // host half.
16
+ window.__ModuleLoader__.load({
17
+ id: '@liustack/modlens',
18
+ factory: () => {
19
+ var module = { exports: {} }
20
+ var exports = module.exports
21
+
22
+ function imageFilesOf(event) {
23
+ var items = event.clipboardData && event.clipboardData.items
24
+ if (!items) return []
25
+ var files = []
26
+ for (var i = 0; i < items.length; i++) {
27
+ var item = items[i]
28
+ if (item.kind !== 'file') continue
29
+ var file = item.getAsFile()
30
+ if (file && /^image\//.test(file.type)) files.push(file)
31
+ }
32
+ return files
33
+ }
34
+
35
+ function insertText(target, text) {
36
+ var el =
37
+ target && (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT')
38
+ ? target
39
+ : document.activeElement
40
+ if (!el || (el.tagName !== 'TEXTAREA' && el.tagName !== 'INPUT')) return
41
+ el.focus()
42
+ // execCommand fires the input event React's controlled textarea needs;
43
+ // the prototype-setter dance is the fallback for engines dropping it.
44
+ var inserted = false
45
+ try {
46
+ inserted = document.execCommand('insertText', false, text)
47
+ } catch {
48
+ inserted = false
49
+ }
50
+ if (!inserted) {
51
+ var proto =
52
+ el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype
53
+ var setter = Object.getOwnPropertyDescriptor(proto, 'value').set
54
+ setter.call(el, el.value + text)
55
+ el.dispatchEvent(new Event('input', { bubbles: true }))
56
+ }
57
+ }
58
+
59
+ function uploadOne(file) {
60
+ return file.arrayBuffer().then((buffer) =>
61
+ fetch('/modlens/paste', { method: 'POST', body: buffer }).then((res) => {
62
+ if (!res.ok) {
63
+ return res
64
+ .json()
65
+ .catch(() => ({}))
66
+ .then((body) => {
67
+ throw new Error(body.error || `paste upload failed (${res.status})`)
68
+ })
69
+ }
70
+ return res.json()
71
+ }),
72
+ )
73
+ }
74
+
75
+ // The takeover is for text-only models: the (modlens vision) variants
76
+ // convert pastes at request time with the thumbnail preserved, and real
77
+ // vision models read images natively — both keep the original paste UX.
78
+ // The model selector button's accessible label is the only client-side
79
+ // source of the current model; when it cannot be found, taking over is
80
+ // the safe default (text-only is the common case this exists for).
81
+ var VISION_HINT = /\(modlens vision\)|deepseek-(vl|ocr)|janus|glm-[\d.]*v\b|vision|image/i
82
+
83
+ function currentModelLabel() {
84
+ var buttons = document.querySelectorAll('button[aria-label]')
85
+ for (var i = 0; i < buttons.length; i++) {
86
+ var label = buttons[i].getAttribute('aria-label') || ''
87
+ if (/选择模型|select model|current model/i.test(label)) return label
88
+ }
89
+ return ''
90
+ }
91
+
92
+ function onPaste(event) {
93
+ var files = imageFilesOf(event)
94
+ if (files.length === 0) return
95
+ if (VISION_HINT.test(currentModelLabel())) return
96
+ // Take the paste before the composer's intake starts an attachment (and
97
+ // with it the host-side image admission a text-only model fails).
98
+ event.preventDefault()
99
+ event.stopImmediatePropagation()
100
+ var target = event.target
101
+ Promise.all(files.map(uploadOne))
102
+ .then((results) => {
103
+ var text = results
104
+ .map((r) => r.path)
105
+ .filter(Boolean)
106
+ .join(' ')
107
+ if (text) insertText(target, `${text} `)
108
+ })
109
+ .catch((error) => {
110
+ console.error(`[modlens] paste-to-path failed: ${error && error.message ? error.message : error}`)
111
+ })
112
+ }
113
+
114
+ function apply(ctx) {
115
+ document.addEventListener('paste', onPaste, true)
116
+ // cordis effect: unregister on plugin disposal (HMR, profile reload).
117
+ if (typeof ctx.effect === 'function') {
118
+ ctx.effect(() => () => document.removeEventListener('paste', onPaste, true), 'modlens: paste-to-path listener')
119
+ }
120
+ }
121
+
122
+ exports.apply = apply
123
+ exports.inject = []
124
+ return module.exports
125
+ },
126
+ })