@liustack/modlens 3.18.0 → 3.18.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.18.2 - 2026-08-17
4
+
5
+ - **dsh: the settings card no longer sets off Safari's password manager ([#56](https://github.com/liustack/modlens/issues/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.
6
+ 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.
7
+
8
+ ## 3.18.1 - 2026-08-17
9
+
10
+ - **dsh: pasted files are collected instead of piling up forever ([#51](https://github.com/liustack/modlens/issues/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.
11
+ 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.
12
+ 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.
13
+
3
14
  ## 3.18.0 - 2026-08-16
4
15
 
5
16
  - **Windows: the CLI providers start again, and a shim is now read by recognising it rather than parsing it ([#43](https://github.com/liustack/modlens/issues/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.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center"><b>Give a text-only model sight, and just paste the image.</b></p>
8
8
 
9
- <p align="center">🥇 <b>The FIRST vision plugin for DeepSeek Harness (dsh)</b> 🥇</p>
9
+ <p align="center">🥇 <b>The most capable vision plugin for DeepSeek Harness (dsh)</b> 🥇</p>
10
10
 
11
11
  <p align="center">
12
12
  <a href="./README.zh-CN.md">简体中文</a> ·
@@ -14,7 +14,7 @@
14
14
  <a href="skills/modlens/references/configure.md">Configuration</a> ·
15
15
  <a href="docs/output-schema.md">Output contract</a> ·
16
16
  <a href="docs/security.md">Security</a> ·
17
- <a href="https://github.com/liustack/modsearch">ModSearch (web)</a>
17
+ <a href="https://github.com/liustack/modsearch"><b>🔎 ModSearch (web search)</b></a>
18
18
  </p>
19
19
 
20
20
  <p align="center">
@@ -34,7 +34,7 @@ Issues are welcome any time: [open one](https://github.com/liustack/modlens/issu
34
34
 
35
35
  ## Highlights
36
36
 
37
- **🥇 The first vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.0`, and the text-only DeepSeek model behind dsh reads images through a native `modlens_read_image` tool. Updating is the same command again. The version is named rather than `@latest` on purpose: pnpm 11 holds back releases published in the last 24 hours and resolves the tag against what survives, so `@latest` would install whatever shipped a day ago ([details](docs/harness-setup.md#keeping-it-up-to-date)).
37
+ **🥇 The most capable vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.2`, and the text-only DeepSeek model behind dsh reads images through a native `modlens_read_image` tool. Updating is the same command again. The version is named rather than `@latest` on purpose: pnpm 11 holds back releases published in the last 24 hours and resolves the tag against what survives, so `@latest` would install whatever shipped a day ago ([details](docs/harness-setup.md#keeping-it-up-to-date)).
38
38
 
39
39
  Pasting an image works two ways. **① Just paste.** On a text-only model the pasted image lands as a private temp file and its path enters the composer — the same interaction OpenCode and Pi ship — and the `modlens_read_image` tool takes it from there. **② Pick a `(modlens vision)` entry** in the model selector (it remembers your choice, so once is enough), then paste: the thumbnail stays visible in your message, closer to the Codex app feel, and the image is converted to structured evidence at request time, answered by the same underlying route. The plugin auto-discovers every provider route carrying text-only DeepSeek or GLM models and adds a wrapped entry per route (a stock install gets **`DeepSeek-V4-Flash (modlens vision)`** and **`DeepSeek-V4-Pro (modlens vision)`**; extra routes like opencode-go or zai get their own); the two families' own vision models are excluded automatically. Which paste route applies is the host's per-model call: only a model its metadata positively confirms text-only is taken over, anything unconfirmed is left alone, so vision models keep their native paste ([details](docs/harness-setup.md)).
40
40
 
package/README.zh-CN.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center"><b>为纯文本模型补上视觉能力,直接粘贴图片就能识别。</b></p>
8
8
 
9
- <p align="center">🥇 <b>全网第一个支持 DeepSeek Harness(dsh)的视觉插件</b> 🥇</p>
9
+ <p align="center">🥇 <b>全网最强的 DeepSeek Harness(dsh)视觉插件</b> 🥇</p>
10
10
 
11
11
  <p align="center">
12
12
  <a href="./README.md">English</a> ·
@@ -14,7 +14,7 @@
14
14
  <a href="skills/modlens/references/configure.zh-CN.md">配置</a> ·
15
15
  <a href="docs/output-schema.zh-CN.md">输出契约</a> ·
16
16
  <a href="docs/security.zh-CN.md">安全</a> ·
17
- <a href="https://github.com/liustack/modsearch">ModSearch(联网)</a>
17
+ <a href="https://github.com/liustack/modsearch"><b>🔎 ModSearch(联网搜索)</b></a>
18
18
  </p>
19
19
 
20
20
  <p align="center">
@@ -34,7 +34,7 @@ DeepSeek 和 GLM 的主力对话模型是纯文本的,无法进行图片识别
34
34
 
35
35
  ## 亮点
36
36
 
37
- **🥇 全网第一个支持 DeepSeek Harness(dsh)的外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.0`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `modlens_read_image` 工具读图。更新就是再跑一遍同一条命令。这里点名版本号而不用 `@latest` 是有意的:pnpm 11 会扣住最近 24 小时内发布的版本,dist-tag 只在剩下的里面解析,用 `@latest` 装到的会是一天前发布的那个([细节](docs/harness-setup.zh-CN.md#保持更新))。
37
+ **🥇 全网最强的 DeepSeek Harness(dsh)外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.2`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `modlens_read_image` 工具读图。更新就是再跑一遍同一条命令。这里点名版本号而不用 `@latest` 是有意的:pnpm 11 会扣住最近 24 小时内发布的版本,dist-tag 只在剩下的里面解析,用 `@latest` 装到的会是一天前发布的那个([细节](docs/harness-setup.zh-CN.md#保持更新))。
38
38
 
39
39
  DeepSeek Harness 粘贴识图有两种玩法。
40
40
 
@@ -69,7 +69,7 @@ agy # 浏览器完成
69
69
  **DeepSeek Harness(dsh)用户不走 skill 流程**,本包就是原生 dsh 插件:
70
70
 
71
71
  ```sh
72
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.0
72
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.2
73
73
  ```
74
74
 
75
75
  装完即有 `modlens_read_image` 工具,选「(modlens vision)」模型变体即可直接粘贴识图。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.zh-CN.md)。
package/dist/main.js CHANGED
@@ -4565,7 +4565,7 @@ function parsePositiveInt(raw, flag) {
4565
4565
  }
4566
4566
  return Number.parseInt(raw, 10);
4567
4567
  }
4568
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.18.0");
4568
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.18.2");
4569
4569
  program.command("analyze", { isDefault: true }).description("Analyze an image into structured JSON evidence (default command)").requiredOption("-i, --input <path|url>", "Input image path or https URL").option("-o, --output <path>", "Write result JSON to a file").option("-m, --model <name>", "Provider model name").option("-p, --provider <name>", `Vision provider (${listProviders().join(", ")})`).option("--prompt <text>", "Extra focus for this image").option("--timeout <ms>", "Provider timeout in milliseconds", "180000").option("--provider-bin <path>", "Provider binary path (default: agy)").option("--workdir <path>", "Working directory for the provider").option(
4570
4570
  "--extra-body <json>",
4571
4571
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
@@ -4675,7 +4675,7 @@ program.command("doctor").description(
4675
4675
  configPath: CONFIG_PATH,
4676
4676
  // Lets doctor name an installed skill copy that is older than
4677
4677
  // the CLI reporting on it (issue #33).
4678
- version: "3.18.0"
4678
+ version: "3.18.2"
4679
4679
  });
4680
4680
  const output = options.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report);
4681
4681
  process.stdout.write(`${output}
@@ -55,7 +55,7 @@ OpenCode with DeepSeek: `opencode auth login`, pick DeepSeek and paste the key (
55
55
  dsh is different from the other harnesses: modlens plugs in as a native tool, not a prompt-triggered skill. The package itself is a dsh bundle, so one command installs it into a profile:
56
56
 
57
57
  ```sh
58
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.0
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.2
59
59
  ```
60
60
 
61
61
  This registers a `modlens_read_image` tool whose schema reaches the model on every request (no trigger heuristics), runs the modlens CLI shipped inside the same package, and returns the structured evidence as the tool's canonical JSON output. Engines, reuse grants, and guard rules stay in `~/.modlens/config.json`, shared with every other harness. dsh is in developer preview and its plugin surface may change; the plugin keeps its touch small (raw tool registration, the llm adapter surface for the vision variants, the attachment reader, and one agent pre-step hook) and degrades loudly if any of them moves.
@@ -81,7 +81,7 @@ modlens ships often, and both install shapes freeze at whatever version they
81
81
  got. On dsh, re-run the install with the version named:
82
82
 
83
83
  ```sh
84
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.0
84
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.2
85
85
  ```
86
86
 
87
87
  `npm view @liustack/modlens version` prints the current one, and this page is
@@ -55,7 +55,7 @@ OpenCode 接 DeepSeek:执行 `opencode auth login`,选择 DeepSeek 并粘贴
55
55
  dsh 与其他 harness 不同:modlens 以原生工具的形式接入,而不是靠提示词触发的 skill。本包自身就是一个 dsh bundle,一条命令即可装进某个 profile:
56
56
 
57
57
  ```sh
58
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.0
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.2
59
59
  ```
60
60
 
61
61
  这会注册一个 `modlens_read_image` 工具,它的 schema 随每次请求抵达模型(不靠触发启发式),运行同一个包里自带的 modlens CLI,并把结构化证据作为工具的标准 JSON 输出返回。引擎、复用授权和 guard 规则仍在 `~/.modlens/config.json` 里,与其他所有 harness 共享。dsh 还在开发者预览阶段,插件接口可能变化。这个插件刻意保持很小的接触面(原生工具注册、视觉变体所用的 llm 适配层、附件读取器,以及一个 agent 执行前钩子),其中任何一处变动,它都会大声报错而不是无声退化。
@@ -71,7 +71,7 @@ dsh 的网页用户面前没有终端,所以引擎设置有一张卡片,在*
71
71
  modlens 发布很频繁,而两种安装形态都会冻结在装进来的那个版本上。dsh 上重跑一遍安装即可,版本号要点名:
72
72
 
73
73
  ```sh
74
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.0
74
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.2
75
75
  ```
76
76
 
77
77
  `npm view @liustack/modlens version` 可以查到当前版本号,本页的版本号则由发布流程自动写入。
@@ -163,7 +163,7 @@ simply lands on an older one. Name the exact version instead, which pnpm treats
163
163
  as a deliberate request rather than a resolution:
164
164
 
165
165
  ```sh
166
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.0
166
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.2
167
167
  ```
168
168
 
169
169
  `npm view @liustack/modlens version` prints the current one. pnpm 11 installs a named
@@ -178,7 +178,7 @@ file:
178
178
 
179
179
  ```yaml
180
180
  minimumReleaseAgeExclude:
181
- - '@liustack/modlens@3.18.0'
181
+ - '@liustack/modlens@3.18.2'
182
182
  ```
183
183
 
184
184
  Or lift the gate for a single command, which lifts it for everything that
@@ -144,7 +144,7 @@ dsh profile 装到的是旧版 modlens。`dsh.bundle` 声明从 3.9.0 起才存
144
144
  `@latest` 绕不开这一层,本页早先的说法是错的。冷静期先把候选版本过滤掉,dist-tag 才在剩下的里面解析,于是它直接落到了更旧的那个上。改成写死精确版本号,pnpm 会把它当作一次明确的指定,而不是一次解析:
145
145
 
146
146
  ```sh
147
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.0
147
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.2
148
148
  ```
149
149
 
150
150
  `npm view @liustack/modlens version` 可以查到当前版本号。pnpm 11 会装上被点名的版本,11.1.3 起还会把它作为一条已批准的例外写进该 profile 的 `pnpm-workspace.yaml`,其余所有包和 modlens 以后的版本仍然留在窗口后面。
@@ -153,7 +153,7 @@ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.0
153
153
 
154
154
  ```yaml
155
155
  minimumReleaseAgeExclude:
156
- - '@liustack/modlens@3.18.0'
156
+ - '@liustack/modlens@3.18.2'
157
157
  ```
158
158
 
159
159
  或者只为这一条命令解除冷静期,注意它解除的是这条命令解析到的所有包,不只 modlens:
package/dsh/client.js CHANGED
@@ -287,6 +287,71 @@ window.__ModuleLoader__.load({
287
287
  return payload
288
288
  }
289
289
 
290
+ /**
291
+ * How to render the API key field so the characters are hidden.
292
+ *
293
+ * A real password input makes Safari's iCloud Keychain offer to enable
294
+ * autofill for the site and then pop its bubble on every focus, for a
295
+ * field that is always empty: the key lives in the config file and the
296
+ * host never sends it here, only whether one is stored. `autocomplete`
297
+ * cannot turn that off, because WebKit ignores it on password fields on
298
+ * purpose (issue #56). Masking with text-security gets the same hidden
299
+ * characters without ever being a password field, and it also keeps a key
300
+ * meant for one machine out of a synced keychain.
301
+ *
302
+ * Feature-detected rather than assumed. Where the property is missing the
303
+ * field stays a password input: the nuisance is worth more than the
304
+ * alternative, which is somebody's API key rendered in clear text while
305
+ * they type it.
306
+ *
307
+ * This is a trade, not a free win, and the cost falls on people who are
308
+ * not in the room. A password input carries a protected state into the
309
+ * accessibility tree, and screen readers stop reading characters back
310
+ * because of it. Masking is only paint: VoiceOver and NVDA will read this
311
+ * key aloud, and ARIA has no equivalent to restore. Selection and copy
312
+ * also become possible, and an IME candidate window shows what is being
313
+ * typed above the field. Accepted here because the field is empty in
314
+ * normal use (the key lives in the config file and is never sent to the
315
+ * browser), so what a screen reader can read back is what the user is
316
+ * typing at that moment, not a stored secret.
317
+ */
318
+ /**
319
+ * Whether this browser masks a text field's characters. Only the prefixed
320
+ * property exists: there is no unprefixed `text-security`, so probing for
321
+ * one would be dead code that reads like a real path.
322
+ *
323
+ * A throwing `supports` counts as no support. The spec says the two
324
+ * argument form returns false for an unknown property rather than
325
+ * throwing, but this runs inside render, where an exception takes the
326
+ * whole settings surface down instead of costing one field.
327
+ */
328
+ function supportsTextSecurity() {
329
+ try {
330
+ return (
331
+ typeof CSS === 'object' &&
332
+ CSS !== null &&
333
+ typeof CSS.supports === 'function' &&
334
+ CSS.supports('-webkit-text-security', 'disc') === true
335
+ )
336
+ } catch {
337
+ return false
338
+ }
339
+ }
340
+
341
+ function secretFieldProps() {
342
+ if (!supportsTextSecurity()) {
343
+ return { type: 'password' }
344
+ }
345
+ return {
346
+ type: 'text',
347
+ autoComplete: 'off',
348
+ autoCorrect: 'off',
349
+ autoCapitalize: 'off',
350
+ spellCheck: false,
351
+ style: { WebkitTextSecurity: 'disc' },
352
+ }
353
+ }
354
+
290
355
  function ConfigCard(react, ui) {
291
356
  var h = react.createElement
292
357
  var Input = ui.Input
@@ -411,19 +476,21 @@ window.__ModuleLoader__.load({
411
476
  noteState[1]('')
412
477
  }
413
478
 
479
+ var inputProps = (key, placeholder) => ({
480
+ value: draft[key],
481
+ placeholder: placeholder,
482
+ onChange: (event) => {
483
+ set(key, event.target.value)
484
+ },
485
+ })
414
486
  var textField = (label, key, type, placeholder) =>
415
- fieldRow(
416
- label,
417
- h(Input, {
418
- type: type,
419
- value: draft[key],
420
- placeholder: placeholder,
421
- onChange: (event) => {
422
- set(key, event.target.value)
423
- },
424
- }),
425
- key,
426
- )
487
+ fieldRow(label, h(Input, Object.assign(inputProps(key, placeholder), { type: type })), key)
488
+ // Its own function rather than a `type` string the caller has to
489
+ // spell right. A sentinel compared with `===` fails open: one
490
+ // typo, or a later edit passing 'text', and the key renders in
491
+ // clear text with every test still green.
492
+ var secretField = (label, key, placeholder) =>
493
+ fieldRow(label, h(Input, Object.assign(inputProps(key, placeholder), secretFieldProps())), key)
427
494
 
428
495
  // Auto mode: the probes say which harnesses exist on this
429
496
  // machine. Found ones get a checkbox with their status; missing
@@ -436,7 +503,7 @@ window.__ModuleLoader__.load({
436
503
  var probe = probes.find((candidate) => candidate.harness === name)
437
504
  return probe ? probe.cliFound : false
438
505
  }).map((name) => {
439
- var probe = probes && probes.find((candidate) => candidate.harness === name)
506
+ var probe = probes?.find((candidate) => candidate.harness === name)
440
507
  return h(
441
508
  'label',
442
509
  {
@@ -531,7 +598,7 @@ window.__ModuleLoader__.load({
531
598
  ),
532
599
  'clinote',
533
600
  )
534
- : textField(t.apiKey, 'apiKey', 'password', current.hasKey ? t.stored : t.unset),
601
+ : secretField(t.apiKey, 'apiKey', current.hasKey ? t.stored : t.unset),
535
602
  draft.provider === '' || keyless ? null : textField(t.baseUrl, 'baseUrl', 'text', t.fallback),
536
603
  draft.provider === '' ? null : textField(t.model, 'model', 'text', t.fallback),
537
604
  // Where these values are coming from, said once, because the
@@ -779,7 +846,7 @@ window.__ModuleLoader__.load({
779
846
  try {
780
847
  mountCard(scope)
781
848
  } catch (error) {
782
- console.error('[modlens] settings card skipped: ' + error)
849
+ console.error(`[modlens] settings card skipped: ${error}`)
783
850
  }
784
851
  })
785
852
  .catch(() => {})
@@ -791,7 +858,7 @@ window.__ModuleLoader__.load({
791
858
  try {
792
859
  react = require('react')
793
860
  } catch (error) {
794
- console.error('[modlens] settings card skipped: ' + error)
861
+ console.error(`[modlens] settings card skipped: ${error}`)
795
862
  return
796
863
  }
797
864
  var ui = require('@deepseek-ai/dsh-client-ui-primitives')
@@ -819,7 +886,12 @@ window.__ModuleLoader__.load({
819
886
 
820
887
  exports.apply = apply
821
888
  // Exposed for the repo's tests only; not part of the plugin contract.
822
- exports.__card = { nextDraft: nextDraft, savePayload: savePayload }
889
+ exports.__card = {
890
+ nextDraft: nextDraft,
891
+ savePayload: savePayload,
892
+ secretFieldProps: secretFieldProps,
893
+ ConfigCard: ConfigCard,
894
+ }
823
895
  // `slots` is optional, so it is not required here: registerCard checks.
824
896
  exports.inject = []
825
897
  return module.exports
package/dsh/index.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // rules keep living in ~/.modlens/config.json, shared with every harness.
13
13
  import { spawn } from 'node:child_process'
14
14
  import { chmodSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
15
- import { homedir } from 'node:os'
15
+ import { homedir, tmpdir } from 'node:os'
16
16
  import { dirname, join } from 'node:path'
17
17
  import { fileURLToPath } from 'node:url'
18
18
 
@@ -67,7 +67,7 @@ export function apply(ctx, config = {}) {
67
67
  try {
68
68
  // scope carries webServer; the plugin's own ctx carries llm for the
69
69
  // takeover verdicts.
70
- registerPasteRoute(scope, ctx, ownProviders)
70
+ registerPasteRoute(scope, ctx, ownProviders, config)
71
71
  } catch (error) {
72
72
  console.error(`[modlens] paste-to-path route skipped: ${error}`)
73
73
  }
@@ -326,7 +326,7 @@ const PASTE_VERDICT_CAP = 32
326
326
  * stands down instead of swallowing pastes into a 404. Bound to the dsh web
327
327
  * server, which listens on loopback by default.
328
328
  */
329
- function registerPasteRoute(ctx, host, ownProviders) {
329
+ function registerPasteRoute(ctx, host, ownProviders, config = {}) {
330
330
  const verdicts = new Map()
331
331
  // The cache key is only the selector label, which cannot tell two
332
332
  // same-named models on different routes apart. A route mounting mid-TTL
@@ -413,11 +413,23 @@ function registerPasteRoute(ctx, host, ownProviders) {
413
413
  return
414
414
  }
415
415
  const { mkdtemp, writeFile } = await import('node:fs/promises')
416
- const { tmpdir } = await import('node:os')
417
416
  const { join } = await import('node:path')
418
- const dir = await mkdtemp(join(tmpdir(), 'modlens-dsh-paste-'))
417
+ const root = await openPasteRoot(config.pasteDir)
418
+ const dir = await mkdtemp(join(root, 'p-'))
419
419
  const file = join(dir, `paste${sniff.ext}`)
420
420
  await writeFile(file, buffer, { mode: 0o600 })
421
+ // This one cannot be deleted when the request ends: its path is what
422
+ // goes into the composer, so the file has to outlive the response and
423
+ // survive until the model reads it. Nothing was ever collecting them
424
+ // afterwards though, so they accumulated for as long as dsh was
425
+ // installed (issue #51). Sweeping the expired ones here keeps it to
426
+ // the moment a paste already costs a disk write, with no timer to
427
+ // own and nothing running when nobody is pasting.
428
+ // Fire and forget: the response must not wait on housekeeping. The
429
+ // promise is kept so a test can await the side effect instead of
430
+ // racing it, which is otherwise a coin flip decided by the scheduler.
431
+ lastPasteSweep = sweepExpiredPastes(Date.now(), root)
432
+ void lastPasteSweep
421
433
  res.writeHead(200, { 'content-type': 'application/json' })
422
434
  res.end(JSON.stringify({ path: file }))
423
435
  } catch (error) {
@@ -1255,6 +1267,198 @@ function isTrustedRequest(req) {
1255
1267
  }
1256
1268
  }
1257
1269
 
1270
+ /**
1271
+ * How long a pasted file stays reachable.
1272
+ *
1273
+ * This is a proxy for "nobody needs it any more", and a proxy is all that is
1274
+ * available: the path leaves through the composer as plain text, so nothing
1275
+ * here observes whether the draft holding it was sent, cleared, or abandoned.
1276
+ * A week errs the way that asymmetry asks for. Deleting too early breaks a
1277
+ * draft somebody is still writing and reads as a bug in the paste; deleting
1278
+ * too late costs a few kilobytes in a directory the OS already collects.
1279
+ */
1280
+ const PASTE_TTL_MS = 7 * 24 * 60 * 60 * 1000
1281
+
1282
+ /**
1283
+ * A ceiling on what unread pastes may hold, independent of age. One image may
1284
+ * be 25 MB, so a week is long enough for a burst to reach tens of gigabytes
1285
+ * before any of it expires. This only engages far past ordinary use, and it
1286
+ * removes oldest first, which is a worse rule than liveness but the only one
1287
+ * available; running a disk out of space is worse than either.
1288
+ */
1289
+ const PASTE_STORE_MAX_BYTES = 1024 * 1024 * 1024
1290
+
1291
+ /** The most recent sweep, so tests can await what production does not. */
1292
+ let lastPasteSweep = Promise.resolve()
1293
+
1294
+ /** Everything this plugin writes for pastes lives under one directory. */
1295
+ function pasteRoot(base = null) {
1296
+ return base ?? join(tmpdir(), 'modlens-dsh-paste')
1297
+ }
1298
+
1299
+ /**
1300
+ * Open the store directory, or refuse it.
1301
+ *
1302
+ * The path is predictable and the system temp directory is shared, so on a
1303
+ * multi-user machine somebody else can get there first. A symlink planted at
1304
+ * that name would point this plugin's recursive cleanup at whatever it names,
1305
+ * which is the oldest trick there is against a program that tidies up in
1306
+ * /tmp. So an existing entry has to be a real directory this user owns, and a
1307
+ * new one is created private. Anything else is refused, and the paste fails
1308
+ * loudly rather than writing into somebody else's directory.
1309
+ */
1310
+ async function openPasteRoot(base = null) {
1311
+ const { mkdir, lstat, chmod, realpath } = await import('node:fs/promises')
1312
+ const { basename, dirname } = await import('node:path')
1313
+ const root = pasteRoot(base)
1314
+ // Create first, then check. Checking first leaves a window between the
1315
+ // answer and the use, and on a shared temp directory that window is enough
1316
+ // for somebody to drop a symlink at the name and have this write into
1317
+ // whatever it points at. An exclusive mkdir either creates the directory,
1318
+ // in which case it is ours by construction, or reports that something is
1319
+ // already there, which is the case worth inspecting.
1320
+ // Canonicalise the parent first, then work inside it. A system temp
1321
+ // directory is often behind a legitimate link (macOS puts /var behind
1322
+ // /private/var), so refusing every resolved ancestor would refuse ordinary
1323
+ // machines. What must not be a link is the leaf: an exclusive mkdir
1324
+ // succeeds just as happily through one, and the directory it makes then
1325
+ // sits wherever that link points.
1326
+ const parent = dirname(root)
1327
+ await mkdir(parent, { recursive: true }).catch(() => {})
1328
+ let realParent
1329
+ try {
1330
+ realParent = await realpath(parent)
1331
+ } catch (error) {
1332
+ throw new Error(`${parent} is not usable for the paste store: ${error?.message ?? error}`)
1333
+ }
1334
+ const target = join(realParent, basename(root))
1335
+ try {
1336
+ await mkdir(target, { mode: 0o700 })
1337
+ } catch (error) {
1338
+ if (error?.code !== 'EEXIST') {
1339
+ throw error
1340
+ }
1341
+ }
1342
+ // lstat, so a link reads as a link rather than as whatever it points at.
1343
+ const info = await lstat(target)
1344
+ if (!info.isDirectory()) {
1345
+ throw new Error(`${target} exists and is not a directory`)
1346
+ }
1347
+ // POSIX ownership and mode bits are one capability boundary. Windows has
1348
+ // neither getuid nor owner/group/other mode distinctions, and reports a
1349
+ // directory as 0777 even when its ACL is private. Applying this verdict
1350
+ // there would reject every paste without proving anything about its ACL.
1351
+ const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
1352
+ if (uid !== undefined) {
1353
+ if (info.uid !== uid) {
1354
+ throw new Error(`${target} belongs to another user`)
1355
+ }
1356
+ // Narrow it even if it was created wider, so a later paste is not readable
1357
+ // by everyone on the machine. A failure here is not cosmetic: it means the
1358
+ // directory stays readable by others and this cannot fix it, so the paste
1359
+ // is refused rather than written where it can be read.
1360
+ if ((info.mode & 0o777) !== 0o700) {
1361
+ await chmod(target, 0o700)
1362
+ const after = await lstat(target)
1363
+ if ((after.mode & 0o777) !== 0o700) {
1364
+ throw new Error(`${target} could not be made private`)
1365
+ }
1366
+ }
1367
+ }
1368
+ return target
1369
+ }
1370
+
1371
+ /**
1372
+ * Remove pastes that have expired, then, if what remains is still too large,
1373
+ * the oldest until it is not.
1374
+ *
1375
+ * The sweep only ever looks inside our own directory. An earlier version
1376
+ * scanned the whole system temp directory, which made every paste pay for
1377
+ * every unrelated entry there and put the blast radius of a bug in somebody
1378
+ * else's files. Entries are read with withFileTypes so a symlink reads as a
1379
+ * link and never as the directory it points at, because a cleanup that
1380
+ * follows a link is how it becomes somebody else's deleted files.
1381
+ *
1382
+ * The root is a parameter so tests never run this against the real one, where
1383
+ * they would delete a developer's own live pastes. That matters more than it
1384
+ * sounds: the route calls this on every successful paste, so any test that
1385
+ * exercises the route runs it too.
1386
+ */
1387
+ async function sweepExpiredPastes(now = Date.now(), base = null, maxBytes = PASTE_STORE_MAX_BYTES) {
1388
+ try {
1389
+ const { readdir, stat, rm } = await import('node:fs/promises')
1390
+ const root = pasteRoot(base)
1391
+ const kept = []
1392
+ for (const entry of await readdir(root, { withFileTypes: true })) {
1393
+ if (!entry.isDirectory()) continue
1394
+ const full = join(root, entry.name)
1395
+ try {
1396
+ const info = await stat(full)
1397
+ if (now - info.mtimeMs >= PASTE_TTL_MS) {
1398
+ try {
1399
+ await rm(full, { recursive: true, force: true })
1400
+ continue
1401
+ } catch {
1402
+ // Held open, or removed only in part. Either way it still
1403
+ // occupies the disk, so it stays on the books rather than being
1404
+ // silently written off and leaving the ceiling reading low.
1405
+ }
1406
+ }
1407
+ // A directory that cannot be measured still occupies the disk, so it
1408
+ // stays on the books at the largest a single paste may be. Erring
1409
+ // high makes the ceiling clean sooner; erring low, or dropping the
1410
+ // entry, lets the store grow while the ceiling reads comfortable.
1411
+ const bytes = await directorySize(full).catch(() => PASTE_MAX_BYTES)
1412
+ kept.push({ full, mtimeMs: info.mtimeMs, bytes })
1413
+ } catch {
1414
+ // Vanished between listing and measuring. A paste must never fail
1415
+ // over housekeeping.
1416
+ }
1417
+ }
1418
+ let total = kept.reduce((sum, item) => sum + item.bytes, 0)
1419
+ if (total <= maxBytes) return
1420
+ for (const item of kept.sort((a, b) => a.mtimeMs - b.mtimeMs)) {
1421
+ if (total <= maxBytes) break
1422
+ try {
1423
+ await rm(item.full, { recursive: true, force: true })
1424
+ total -= item.bytes
1425
+ } catch {
1426
+ // Still held, or gone only in part. Measure what is actually left
1427
+ // instead of trusting the subtraction, and keep going: one
1428
+ // undeletable directory must not stop the rest from being freed.
1429
+ total -= item.bytes - (await directorySize(item.full).catch(() => item.bytes))
1430
+ }
1431
+ }
1432
+ } catch {
1433
+ // No directory yet, or no listing available. The paste itself worked.
1434
+ }
1435
+ }
1436
+
1437
+ /**
1438
+ * What a paste directory occupies, or a throw when that cannot be known.
1439
+ *
1440
+ * A file that vanished between listing and measuring really does occupy
1441
+ * nothing, so it counts as zero. Any other failure means the number would be
1442
+ * an undercount, and an undercount here is worse than no answer: the caller
1443
+ * puts an unmeasurable directory on the books at the largest a paste may be,
1444
+ * while a quietly low number lets the store pass a ceiling it has already
1445
+ * exceeded.
1446
+ */
1447
+ async function directorySize(dir) {
1448
+ const { readdir, stat } = await import('node:fs/promises')
1449
+ let bytes = 0
1450
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
1451
+ if (!entry.isFile()) continue
1452
+ try {
1453
+ bytes += (await stat(join(dir, entry.name))).size
1454
+ } catch (error) {
1455
+ if (error?.code === 'ENOENT') continue
1456
+ throw error
1457
+ }
1458
+ }
1459
+ return bytes
1460
+ }
1461
+
1258
1462
  function registerConfigRoute(ctx) {
1259
1463
  ctx.webServer.register({
1260
1464
  name: 'modlens-config',
@@ -1321,3 +1525,13 @@ function registerConfigRoute(ctx) {
1321
1525
  // and write a real file and a real environment, so they are tested against
1322
1526
  // both rather than through the HTTP route.
1323
1527
  export const __config = { engineSummary, applyEngineSettings, modlensConfigPath }
1528
+
1529
+ // The paste sweeper, reachable from the test suite the way __config is.
1530
+ export const __paste = {
1531
+ sweepExpiredPastes,
1532
+ settled: () => lastPasteSweep,
1533
+ openPasteRoot,
1534
+ pasteRoot,
1535
+ ttlMs: PASTE_TTL_MS,
1536
+ maxBytes: PASTE_STORE_MAX_BYTES,
1537
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.18.0",
3
+ "version": "3.18.2",
4
4
  "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,11 +20,11 @@ powershell -ExecutionPolicy Bypass -File <skill-dir>\scripts\run.ps1 <args>
20
20
 
21
21
  It resolves a working runtime (PATH `modlens`, then `npx`, then `bunx`) and forwards your arguments unchanged. Exit 78 means no runtime: relay the `nextSteps` from its stderr JSON instead of retrying.
22
22
 
23
- If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.18.0):
23
+ If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.18.2):
24
24
 
25
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.18.0: `modlens <args>`.
26
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.18.0 modlens <args>`.
27
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.18.0 <args>`.
25
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.18.2: `modlens <args>`.
26
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.18.2 modlens <args>`.
27
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.18.2 <args>`.
28
28
  4. Otherwise tell the user no JavaScript runtime was found and that installing Node 22.19+ (https://nodejs.org) or Bun (https://bun.sh) is the next step. Do not claim modlens itself failed.
29
29
 
30
30
  `references/runtime.md` documents the pin and the diagnostic fields.
@@ -8,7 +8,7 @@ shell syntax.
8
8
 
9
9
  ## Pinned version
10
10
 
11
- - Pinned CLI version: 3.18.0
11
+ - Pinned CLI version: 3.18.2
12
12
  - npm package: `@liustack/modlens`
13
13
  - CLI binary name: `modlens`
14
14
 
@@ -24,7 +24,7 @@ $ErrorActionPreference = 'Stop'
24
24
  # package.json version, and the release script rewrites it on every bump.
25
25
  $Package = '@liustack/modlens'
26
26
  $Bin = 'modlens'
27
- $Pinned = '3.18.0'
27
+ $Pinned = '3.18.2'
28
28
  # -------------------------------------------------------------------------------
29
29
 
30
30
  $NativeNote = 'no native artifact is published for this tool yet; phase A ships npm launch paths only'
@@ -22,7 +22,7 @@ set -eu
22
22
  # package.json version, and the release script rewrites it on every bump.
23
23
  PKG="@liustack/modlens"
24
24
  BIN="modlens"
25
- PINNED="3.18.0"
25
+ PINNED="3.18.2"
26
26
  # -------------------------------------------------------------------------------
27
27
 
28
28
  NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"