@liustack/modlens 3.24.0 → 3.24.1

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,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.24.1 - 2026-08-24
4
+
5
+ - **dsh bundle startup no longer logs `inactive context` before `llm` is ready ([#79](https://github.com/liustack/modlens/issues/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.
6
+
3
7
  ## 3.24.0 - 2026-08-22
4
8
 
5
9
  - 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`.
package/README.md CHANGED
@@ -33,7 +33,7 @@ Issues are welcome any time: [open one](https://github.com/liustack/modlens/issu
33
33
 
34
34
  ## Highlights
35
35
 
36
- **🥇 The most capable vision plugin for DeepSeek Harness (dsh):** install it instantly with one command: `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.24.0`. See the [setup guide](docs/harness-setup.md) for installation and update details. If the command line is not your thing but you still want to try DSH, check out <a href="https://github.com/liustack/aimanager"><b>AIManager</b></a>, the lightest desktop wrapper for DeepSeek Harness. It gets you started with zero code or configuration and installs every dependency for you with one click.
36
+ **🥇 The most capable vision plugin for DeepSeek Harness (dsh):** install it instantly with one command: `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.24.1`. See the [setup guide](docs/harness-setup.md) for installation and update details. If the command line is not your thing but you still want to try DSH, check out <a href="https://github.com/liustack/aimanager"><b>AIManager</b></a>, the lightest desktop wrapper for DeepSeek Harness. It gets you started with zero code or configuration and installs every dependency for you with one click.
37
37
 
38
38
  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)).
39
39
 
package/README.zh-CN.md CHANGED
@@ -33,7 +33,7 @@ DeepSeek 和 GLM 的主力对话模型是纯文本的,无法进行图片识别
33
33
 
34
34
  ## 亮点
35
35
 
36
- **🥇 全网最强的 DeepSeek Harness(dsh)外挂视觉识别插件:**一条命令即刻安装 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.24.0`。更多安装与更新细节参考 [配置手册](docs/harness-setup.zh-CN.md) 。如果用不惯命令行,也想想玩玩 DSH,推荐食用全网最轻量级的 DeepSeek Harness 桌面版封装 <a href="https://github.com/liustack/aimanager"><b> AIManager</b></a>,零代码零配置起手,一键帮你安装所有依赖环境。
36
+ **🥇 全网最强的 DeepSeek Harness(dsh)外挂视觉识别插件:**一条命令即刻安装 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.24.1`。更多安装与更新细节参考 [配置手册](docs/harness-setup.zh-CN.md) 。如果用不惯命令行,也想想玩玩 DSH,推荐食用全网最轻量级的 DeepSeek Harness 桌面版封装 <a href="https://github.com/liustack/aimanager"><b> AIManager</b></a>,零代码零配置起手,一键帮你安装所有依赖环境。
37
37
 
38
38
  DeepSeek Harness 粘贴识图有两种玩法。
39
39
 
@@ -71,7 +71,7 @@ agy # 浏览器完成
71
71
  **DeepSeek Harness(dsh)用户不走 skill 流程**,本包就是原生 dsh 插件:
72
72
 
73
73
  ```sh
74
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.24.0
74
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.24.1
75
75
  ```
76
76
 
77
77
  装完即有 `modlens_read_image` 工具,选「(modlens vision)」模型变体即可直接粘贴识图。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.zh-CN.md)。
package/dist/main.js CHANGED
@@ -5520,7 +5520,7 @@ function parsePositiveInt(raw, flag) {
5520
5520
  }
5521
5521
  return Number.parseInt(raw, 10);
5522
5522
  }
5523
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.24.0");
5523
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.24.1");
5524
5524
  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(
5525
5525
  "--extra-body <json>",
5526
5526
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
@@ -5631,7 +5631,7 @@ program.command("doctor").description(
5631
5631
  configPath: CONFIG_PATH,
5632
5632
  // Lets doctor name an installed skill copy that is older than
5633
5633
  // the CLI reporting on it (issue #33).
5634
- version: "3.24.0"
5634
+ version: "3.24.1"
5635
5635
  });
5636
5636
  const output = options.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report);
5637
5637
  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.24.0
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.24.1
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.
@@ -87,7 +87,7 @@ modlens ships often, and both install shapes freeze at whatever version they
87
87
  got. On dsh, re-run the install with the version named:
88
88
 
89
89
  ```sh
90
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.24.0
90
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.24.1
91
91
  ```
92
92
 
93
93
  `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.24.0
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.24.1
59
59
  ```
60
60
 
61
61
  这会注册一个 `modlens_read_image` 工具,它的 schema 随每次请求抵达模型(不靠触发启发式),运行同一个包里自带的 modlens CLI,并把结构化证据作为工具的标准 JSON 输出返回。引擎、复用授权和 guard 规则仍在 `~/.modlens/config.json` 里,与其他所有 harness 共享。dsh 还在开发者预览阶段,插件接口可能变化。这个插件刻意保持很小的接触面(原生工具注册、视觉变体所用的 llm 适配层、附件读取器,以及一个 agent 执行前钩子),其中任何一处变动,它都会大声报错而不是无声退化。
@@ -73,7 +73,7 @@ dsh 的网页用户面前没有终端,所以引擎设置有一张卡片,在*
73
73
  modlens 发布很频繁,而两种安装形态都会冻结在装进来的那个版本上。dsh 上重跑一遍安装即可,版本号要点名:
74
74
 
75
75
  ```sh
76
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.24.0
76
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.24.1
77
77
  ```
78
78
 
79
79
  `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.24.0
166
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.24.1
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.24.0'
181
+ - '@liustack/modlens@3.24.1'
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.24.0
147
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.24.1
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.24.0
153
153
 
154
154
  ```yaml
155
155
  minimumReleaseAgeExclude:
156
- - '@liustack/modlens@3.24.0'
156
+ - '@liustack/modlens@3.24.1'
157
157
  ```
158
158
 
159
159
  或者只为这一条命令解除冷静期,注意它解除的是这条命令解析到的所有包,不只 modlens:
package/dsh/index.js CHANGED
@@ -55,7 +55,20 @@ export function apply(ctx, config = {}) {
55
55
  // wrappers land, including the later sweeps, and read by the verdict.
56
56
  const ownProviders = new Set()
57
57
  if (config.visionProvider !== false) {
58
- registerVisionProvider(ctx, config, ownProviders, evidenceCache)
58
+ // Bundle loaders can call apply while this outer context is still waiting
59
+ // for its required services. Reading ctx.llm here then throws "inactive
60
+ // context" before the first discovery sweep can register any lifecycle
61
+ // work (#79). Put the whole provider registry inside an injected child
62
+ // scope: Cordis starts it only while llm is active, and tears its listeners
63
+ // and registrations down with that service. Preview hosts without inject
64
+ // keep the dependency-free plugin's former feature-detected path.
65
+ if (typeof ctx.inject === 'function') {
66
+ ctx.inject(['llm'], (scope) => {
67
+ return registerVisionProvider(scope, config, ownProviders, evidenceCache)
68
+ })
69
+ } else {
70
+ registerVisionProvider(ctx, config, ownProviders, evidenceCache)
71
+ }
59
72
  }
60
73
  // Paste-to-path: the browser half (dsh/client.js) intercepts image pastes
61
74
  // and POSTs the bytes here; the file lands in a private temp dir and the
@@ -569,10 +582,50 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
569
582
  if (Array.isArray(info?.inputModalities) && info.inputModalities.includes('image')) return false
570
583
  return true
571
584
  }
572
- if (typeof ctx.llm?.registerAdapter !== 'function' || typeof ctx.llm?.stream !== 'function') {
585
+ // Keep this activation bound to the exact service implementation that made
586
+ // it runnable. Cordis reuses the child context when llm is replaced, so
587
+ // looking the service up again after an await could otherwise move an old
588
+ // topology result into the new registry.
589
+ const llm = ctx.llm
590
+ if (typeof llm?.registerAdapter !== 'function' || typeof llm?.stream !== 'function') {
573
591
  return
574
592
  }
575
593
 
594
+ // Discovery promises are ordinary JavaScript work, not Cordis effects.
595
+ // The disposer invalidates this activation before the injected child is
596
+ // re-run, and clears the ownership facts whose actual adapter effects the
597
+ // framework tears down independently.
598
+ let active = true
599
+ const claimedProviders = new Set()
600
+ const deactivate = () => {
601
+ active = false
602
+ for (const providerId of claimedProviders) ownProviders?.delete(providerId)
603
+ claimedProviders.clear()
604
+ }
605
+ // Cordis marks a fiber UNLOADING before it runs activation disposers. A
606
+ // promise continuation already in the microtask queue can therefore see
607
+ // `active` before the disposer flips it. Creating and immediately releasing
608
+ // a zero-work effect is the framework's atomic liveness boundary: once it
609
+ // succeeds, the following synchronous registry mutation cannot race an
610
+ // unload. A lifecycle refusal cancels this activation without turning an
611
+ // expected teardown into a terminal diagnostic.
612
+ const activationCanCommit = () => {
613
+ if (!active) return false
614
+ if (typeof ctx.effect !== 'function') return true
615
+ try {
616
+ const release = ctx.effect(() => {})
617
+ if (typeof release === 'function') release()
618
+ return active
619
+ } catch (error) {
620
+ const inactive =
621
+ error?.code === 'INACTIVE_EFFECT' ||
622
+ /cannot create effect on inactive context/i.test(String(error?.message ?? error))
623
+ if (!inactive) throw error
624
+ deactivate()
625
+ return false
626
+ }
627
+ }
628
+
576
629
  // dsh snapshots providerInfo and providerRetryPolicy at registration time.
577
630
  // Keep the state and registration handle for each wrapper so an upstream
578
631
  // replacement can refresh those snapshots instead of leaving a synthetic
@@ -582,6 +635,7 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
582
635
  const policyKey = (policy) => (policy === undefined ? undefined : JSON.stringify(policy))
583
636
 
584
637
  const registerWrapper = (upstream, providerId, displayName) => {
638
+ if (!activationCanCommit()) return false
585
639
  const state = { displayName, retryPolicyKey: undefined }
586
640
  const withVision = (info) => {
587
641
  const inputModalities = Array.isArray(info?.inputModalities) ? [...info.inputModalities] : []
@@ -590,7 +644,7 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
590
644
  return { ...info, provider: providerId, inputModalities }
591
645
  }
592
646
  try {
593
- const registration = ctx.llm.registerAdapter([providerId], {
647
+ const registration = llm.registerAdapter([providerId], {
594
648
  // Duck-typing LlmAdapter: providerInfo/providerRetryPolicy are
595
649
  // base-class defaults a plain object must supply itself (their
596
650
  // absence is exactly the silent registration failure this catch
@@ -608,20 +662,20 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
608
662
  // and the registration boundary below fails closed instead; the
609
663
  // ordinary not-mounted-yet case never reaches this method, because
610
664
  // reconcile waits for the upstream before registering (#66).
611
- if (typeof ctx.llm.providerRetryPolicy !== 'function') return undefined
612
- const policy = ctx.llm.providerRetryPolicy(upstream)
665
+ if (typeof llm.providerRetryPolicy !== 'function') return undefined
666
+ const policy = llm.providerRetryPolicy(upstream)
613
667
  state.retryPolicyKey = policyKey(policy)
614
668
  return policy
615
669
  },
616
670
  async listModels(_provider, signal) {
617
- const models = await ctx.llm.listModels(upstream, signal)
671
+ const models = await llm.listModels(upstream, signal)
618
672
  return models.filter(shouldWrap).map((model) => ({
619
673
  ...withVision(model),
620
674
  name: `${model.name ?? model.id} (modlens vision)`,
621
675
  }))
622
676
  },
623
677
  async resolveModel(_provider, model, signal) {
624
- const info = await ctx.llm.resolveModelInfo(upstream, model, signal)
678
+ const info = await llm.resolveModelInfo(upstream, model, signal)
625
679
  if (!shouldWrap(info)) {
626
680
  // Refusing is right: wrapping a model that reads images itself
627
681
  // would claim a bridge it does not need, hand it text evidence
@@ -662,7 +716,7 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
662
716
  return (async function* () {
663
717
  const converted = await convertImagesToEvidence(ctx, options.messages, options.signal, self)
664
718
  const messages = restoreUpstreamSource(converted, providerId, upstream)
665
- yield* ctx.llm.stream({ ...options, provider: upstream, messages })
719
+ yield* llm.stream({ ...options, provider: upstream, messages })
666
720
  })()
667
721
  },
668
722
  evidenceCache,
@@ -672,6 +726,7 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
672
726
  // duplicate below means someone else holds that id, and skipping a
673
727
  // provider we do not own would let a real vision model's paste be
674
728
  // taken over, which is the bug the verdict exists to prevent.
729
+ claimedProviders.add(providerId)
675
730
  ownProviders?.add(providerId)
676
731
  return true
677
732
  } catch (error) {
@@ -695,6 +750,7 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
695
750
  const dropWrapper = (upstream, current) => {
696
751
  registrations.delete(upstream)
697
752
  wrapped.delete(upstream)
753
+ claimedProviders.delete(current.providerId)
698
754
  ownProviders?.delete(current.providerId)
699
755
  if (typeof current.registration === 'function') current.registration()
700
756
  }
@@ -704,9 +760,9 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
704
760
  // failed and what to do next depends on whether it failed before or after
705
761
  // the host committed.
706
762
  const routed = (providerId) => {
707
- if (typeof ctx.llm.listProviders !== 'function') return false
763
+ if (typeof llm.listProviders !== 'function') return false
708
764
  try {
709
- return ctx.llm.listProviders().some((info) => (typeof info === 'string' ? info : info?.id) === providerId)
765
+ return llm.listProviders().some((info) => (typeof info === 'string' ? info : info?.id) === providerId)
710
766
  } catch {
711
767
  return false
712
768
  }
@@ -724,7 +780,7 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
724
780
  let nextPolicyKey
725
781
  try {
726
782
  nextPolicyKey =
727
- typeof ctx.llm.providerRetryPolicy === 'function' ? policyKey(ctx.llm.providerRetryPolicy(upstream)) : undefined
783
+ typeof llm.providerRetryPolicy === 'function' ? policyKey(llm.providerRetryPolicy(upstream)) : undefined
728
784
  } catch (error) {
729
785
  dropWrapper(upstream, current)
730
786
  console.error(`[modlens] vision provider refresh removed (${current.providerId}): ${error}`)
@@ -772,9 +828,9 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
772
828
  // plumbing below could never correct it, because the name it compared
773
829
  // against was a constant.
774
830
  const upstreamName = () => {
775
- if (typeof ctx.llm.listProviders !== 'function') return upstream
831
+ if (typeof llm.listProviders !== 'function') return upstream
776
832
  try {
777
- const found = ctx.llm.listProviders().find((entry) => entry.id === upstream)
833
+ const found = llm.listProviders().find((entry) => entry.id === upstream)
778
834
  return found?.name ?? upstream
779
835
  } catch {
780
836
  return upstream
@@ -789,6 +845,7 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
789
845
  // re-examined when the holder's route disappears.
790
846
  let claimedElsewhere = false
791
847
  const reconcile = () => {
848
+ if (!activationCanCommit()) return
792
849
  if (reconciling) {
793
850
  // dropWrapper's disposer makes the host emit adapters-updated while
794
851
  // this very run is on the stack, and whatever that event announced
@@ -800,8 +857,8 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
800
857
  try {
801
858
  const current = registrations.get(upstream)
802
859
  const available =
803
- typeof ctx.llm.listProviders !== 'function' ||
804
- ctx.llm.listProviders().some((info) => (typeof info === 'string' ? info : info?.id) === upstream)
860
+ typeof llm.listProviders !== 'function' ||
861
+ llm.listProviders().some((info) => (typeof info === 'string' ? info : info?.id) === upstream)
805
862
  if (!current) {
806
863
  if (claimedElsewhere) {
807
864
  if (routed(providerId)) return
@@ -848,7 +905,7 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
848
905
  }
849
906
  reconcile()
850
907
  if (typeof ctx.on === 'function') ctx.on('llm/adapters-updated', reconcile)
851
- return
908
+ return deactivate
852
909
  }
853
910
 
854
911
  // Auto-discovery. `wrapped` guards duplicates across sweeps and the
@@ -860,16 +917,19 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
860
917
  // one promise chain so two can never interleave their probes at all.
861
918
  const discover = Array.isArray(config.discover) ? new Set(config.discover) : null
862
919
  const sweepOnce = async () => {
920
+ if (!activationCanCommit()) return
863
921
  try {
864
922
  await sweepBody()
865
923
  } catch (error) {
924
+ if (!active) return
866
925
  // A sweep failure must never become an unhandled rejection inside the
867
926
  // host process; the next topology notification simply tries again.
868
927
  console.error(`[modlens] vision provider discovery sweep failed: ${error}`)
869
928
  }
870
929
  }
871
930
  const sweepBody = async () => {
872
- if (typeof ctx.llm.listProviders !== 'function') {
931
+ if (!active) return
932
+ if (typeof llm.listProviders !== 'function') {
873
933
  // Older registry surface: fall back to the single legacy wrap once.
874
934
  if (!wrapped.has('__legacy_fallback__')) {
875
935
  wrapped.add('__legacy_fallback__')
@@ -877,15 +937,18 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
877
937
  }
878
938
  return
879
939
  }
880
- const providers = ctx.llm.listProviders()
940
+ const providers = llm.listProviders()
941
+ if (!active) return
881
942
  // Same tolerance as the pinned path: an entry may be a bare id string.
882
943
  const idOf = (info) => (typeof info === 'string' ? info : info?.id)
883
944
  const available = new Set(providers.map(idOf).filter(Boolean))
884
945
  for (const [upstream, current] of registrations) {
946
+ if (!active) return
885
947
  if (available.has(upstream)) continue
886
948
  dropWrapper(upstream, current)
887
949
  }
888
950
  for (const info of providers) {
951
+ if (!active) return
889
952
  const id = idOf(info)
890
953
  if (!id || String(id).startsWith('modlens-')) continue
891
954
  if (discover && !discover.has(id)) continue
@@ -900,13 +963,19 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
900
963
  wrapped.add(id)
901
964
  let models = []
902
965
  try {
903
- models = await ctx.llm.listModels(id)
966
+ models = await llm.listModels(id)
904
967
  } catch {
968
+ if (!activationCanCommit()) return
905
969
  // Unreachable route today; release the claim so a later topology
906
970
  // change retries it.
907
971
  wrapped.delete(id)
908
972
  continue
909
973
  }
974
+ // The promise can settle just before Cordis marks this fiber UNLOADING,
975
+ // while the activation disposer is still one microtask away. Re-enter
976
+ // the atomic lifecycle boundary before either continuing to refresh a
977
+ // later registration or committing this provider's wrapper.
978
+ if (!activationCanCommit()) return
910
979
  if (!models.some(shouldWrap)) {
911
980
  // No eligible models yet: release, the route may gain some later.
912
981
  wrapped.delete(id)
@@ -931,6 +1000,7 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
931
1000
  void sweep()
932
1001
  })
933
1002
  }
1003
+ return deactivate
934
1004
  }
935
1005
 
936
1006
  // The same pasted attachment rides every later step of its session, and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.24.0",
3
+ "version": "3.24.1",
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.24.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.24.1):
24
24
 
25
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.24.0: `modlens <args>`.
26
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.24.0 modlens <args>`.
27
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.24.0 <args>`.
25
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.24.1: `modlens <args>`.
26
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.24.1 modlens <args>`.
27
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.24.1 <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.24.0
11
+ - Pinned CLI version: 3.24.1
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.24.0'
27
+ $Pinned = '3.24.1'
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.24.0"
25
+ PINNED="3.24.1"
26
26
  # -------------------------------------------------------------------------------
27
27
 
28
28
  NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"