@liustack/modlens 3.18.2 → 3.18.4
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 +14 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/dist/main.js +56 -23
- package/docs/harness-setup.md +2 -2
- package/docs/harness-setup.zh-CN.md +2 -2
- package/docs/troubleshooting.md +2 -2
- package/docs/troubleshooting.zh-CN.md +2 -2
- package/dsh/index.js +154 -27
- package/dsh/spawnHidden.d.ts +13 -0
- package/dsh/spawnHidden.js +20 -0
- package/package.json +1 -1
- package/skills/modlens/SKILL.md +4 -4
- package/skills/modlens/references/runtime.md +1 -1
- package/skills/modlens/scripts/run.ps1 +1 -1
- package/skills/modlens/scripts/run.sh +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.18.4 - 2026-08-18
|
|
4
|
+
|
|
5
|
+
- **Windows: cleaning up after a read can no longer take the whole process down with it ([#58](https://github.com/liustack/modlens/issues/58)).** Node 24.0.0 through 24.13.0 carry an upstream bug where `fs.rmSync` aborts the process outright (exit `0xC0000409`) instead of throwing, when the path handed to it holds non-ASCII characters (nodejs/node#58759, fixed upstream in 24.13.1). The throwaway directory each isolated CLI read runs in lives under the system temp directory, so a Windows machine whose temp path holds non-ASCII characters, a non-ASCII user name being the common way, hit that abort on the cleanup of every read, and an abort is not something a `try`/`catch` can stop. Removal now goes through the asynchronous `fs.rm`, which never reaches the affected code, keeps the one delayed retry for a directory the provider still holds open (#50), and is awaited, so a read returns only after its directory is gone or formally given up on. Thanks to @Vaking02 for the exit code and the failing path, which is what made the crash findable upstream.
|
|
6
|
+
- **A schema mismatch on the openai route now names the setting that fixes it ([#59](https://github.com/liustack/modlens/issues/59)).** The error used to say "switch to gemini-api / anthropic for enforced schemas", which is advice to abandon an endpoint that one config line would have fixed, and on the Kimi Code endpoint it cost a reader a debugging round of blaming the model. The message now says what actually applies: a cut-off answer points at the token limit, a gateway that stopped for its own reason is quoted rather than diagnosed, a `response_format` of your own in `extraBody` is named as the thing deciding the shape, plain `structuredOutput: false` gets the one-liner that turns enforcement on, and only when the schema was genuinely sent and still missed does the switch-providers advice remain. Both failure branches, JSON that will not parse and JSON that parses into the wrong shape, read the same rule the request itself uses, so the advice and the request can no longer disagree. The issue also carries @StarChen-Cycler's verified recipe for the Kimi Code coding endpoint, worth finding if you run one.
|
|
7
|
+
- **Windows: reads from a console-less host no longer flash a black console window per child ([#60](https://github.com/liustack/modlens/issues/60)).** A child process started from a host without a console of its own gets one allocated, and Windows shows its window. The desktop app is exactly such a host, so every read popped a console window for the provider child, and the probes and the config-file opener could do the same. Node's `windowsHide` suppresses it and defaults to off, and it was passed nowhere. Every child, in the core and in the dsh plugin, is now started through a wrapper that writes the option after the caller's, and a contract test holds the boundary: no shipped file other than the two wrappers may reach `child_process` at all, so the next child someone adds cannot forget an option it never sees. Thanks to @DoiiarX for the report and the call-site list.
|
|
8
|
+
|
|
9
|
+
## 3.18.3 - 2026-08-17
|
|
10
|
+
|
|
11
|
+
- **dsh: a `(modlens vision)` route now inherits the retry policy its upstream already had ([#57](https://github.com/liustack/modlens/issues/57)).** A user set `maxRetries: 50` on their route and watched the vision group give up after 2, the harness default. The wrapper registers an adapter of its own, and the method dsh reads that policy from was returning nothing, which asks for the default on a synthetic route that ultimately calls the real one. It delegates now.
|
|
12
|
+
This was the second time the wrapper had been found dropping something the upstream had, after [#49](https://github.com/liustack/modlens/issues/49) and its reasoning state, and both were reported by users rather than found here. So the fix went through every method the host calls on an adapter instead of the one that got reported, and four more came out of that. dsh captures a route's display name and retry policy when it registers, so editing your config mid-session left the wrapper on the old values; the wrappers refresh now. A revoked upstream left its wrapper behind as a route to nowhere. The duplicate-registration check matched any error containing the word "already", which is wider than the one error it was meant to catch. And a wrapper overwrote the upstream's declared input modalities instead of adding to them.
|
|
13
|
+
One deliberate behaviour change: when the upstream cannot list its models, that failure now travels instead of becoming an empty catalogue. dsh files it per provider and the model selector shows a load warning with a retry, which is both survivable and more honest than a route that appears to have no models. Thanks to @ZzAltMan, whose report came with the session log showing the wrapper's own retry event, which is what made the cause unambiguous.
|
|
14
|
+
- **A wrapped route is named after the route it wraps.** Pointing the single-route configuration at anything other than DeepSeek still produced a model group labelled DeepSeek, and the refresh added above could never correct it, because the name it compared against was a constant.
|
|
15
|
+
- **A route that turns out to read images itself explains itself.** The wrapper declines to bridge a model with native image input, which is right: bridging would claim work it does not do and hand the model text where it could have had the picture. But the refusal named an internal scope and offered no way out, while a session already holding that entry fails every turn. It now says what changed and which entry to pick instead, and only in that case; a model that has simply left the configured families is a different situation and keeps its own message.
|
|
16
|
+
|
|
3
17
|
## 3.18.2 - 2026-08-17
|
|
4
18
|
|
|
5
19
|
- **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.
|
package/README.md
CHANGED
|
@@ -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 most capable vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.
|
|
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.4`, 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
|
@@ -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.
|
|
37
|
+
**🥇 全网最强的 DeepSeek Harness(dsh)外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.4`,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.
|
|
72
|
+
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.4
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
装完即有 `modlens_read_image` 工具,选「(modlens vision)」模型变体即可直接粘贴识图。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.zh-CN.md)。
|
package/dist/main.js
CHANGED
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as path from "path";
|
|
5
|
-
import * as childProcess from "child_process";
|
|
6
|
-
import { execFileSync, spawn } from "child_process";
|
|
7
5
|
import * as os from "os";
|
|
8
6
|
import { fileURLToPath } from "url";
|
|
9
7
|
import { fetch as fetch$1, Agent, ProxyAgent, EnvHttpProxyAgent } from "undici";
|
|
10
8
|
import * as dns from "dns/promises";
|
|
11
9
|
import { isIP } from "net";
|
|
10
|
+
import { execFileSync, spawn } from "child_process";
|
|
12
11
|
import { createRequire } from "module";
|
|
13
12
|
import * as crypto from "crypto";
|
|
14
13
|
import * as readline from "readline";
|
|
@@ -1421,6 +1420,27 @@ const kimiCliProvider = {
|
|
|
1421
1420
|
// it gets a throwaway directory holding only the image.
|
|
1422
1421
|
isolateWorkdir: true
|
|
1423
1422
|
};
|
|
1423
|
+
function derivedSchemaSent(settings) {
|
|
1424
|
+
return Boolean(settings?.structuredOutput) && settings?.extraBody?.response_format === void 0;
|
|
1425
|
+
}
|
|
1426
|
+
function requestShapeAdvice(settings) {
|
|
1427
|
+
if (settings?.extraBody?.response_format !== void 0) {
|
|
1428
|
+
return "The response_format in your extraBody replaces the schema modlens derives, so yours decides the shape here. Check it against the contract, or drop it to hand enforcement back to modlens.";
|
|
1429
|
+
}
|
|
1430
|
+
if (!derivedSchemaSent(settings)) {
|
|
1431
|
+
return "The gateway was never asked to enforce the shape, so this is the model free-handing it. Ask the gateway instead: modlens config set openai.structuredOutput true.";
|
|
1432
|
+
}
|
|
1433
|
+
return "The gateway was asked to enforce the shape and answered with this anyway. Retry, or switch to gemini-api / anthropic for enforced schemas.";
|
|
1434
|
+
}
|
|
1435
|
+
function unusableOutputAdvice(finishReason, settings, quoteReason, whenFinished) {
|
|
1436
|
+
if (finishReason === "length") {
|
|
1437
|
+
return `The answer was cut off (finish_reason=length), so this is a length limit rather than a shape problem. Raise it, e.g. modlens config set openai.extraBody '{"max_tokens":4096}'.`;
|
|
1438
|
+
}
|
|
1439
|
+
if (finishReason !== void 0 && finishReason !== "stop") {
|
|
1440
|
+
return `The gateway ended the answer with finish_reason=${quoteReason(finishReason)}, so it may be incomplete for a reason of its own. Check what that reason means for this endpoint before changing the request.`;
|
|
1441
|
+
}
|
|
1442
|
+
return `${whenFinished} ${requestShapeAdvice(settings)}`;
|
|
1443
|
+
}
|
|
1424
1444
|
async function executeOpenaiCompat(options) {
|
|
1425
1445
|
assertNoRetiredEndpointBinding("openai", options.settings ?? {});
|
|
1426
1446
|
const apiKey = options.settings?.apiKey;
|
|
@@ -1458,7 +1478,7 @@ ${JSON_TEMPLATE_INSTRUCTION}`;
|
|
|
1458
1478
|
// caller supplied wins outright rather than being
|
|
1459
1479
|
// merged into ours, since the two describe the same
|
|
1460
1480
|
// thing and a blend of them describes neither.
|
|
1461
|
-
...options.settings
|
|
1481
|
+
...derivedSchemaSent(options.settings) ? { response_format: visionResponseFormat() } : {},
|
|
1462
1482
|
messages: [
|
|
1463
1483
|
{
|
|
1464
1484
|
role: "user",
|
|
@@ -1490,8 +1510,12 @@ ${JSON_TEMPLATE_INSTRUCTION}`;
|
|
|
1490
1510
|
}
|
|
1491
1511
|
const rawResult = extractJson(text);
|
|
1492
1512
|
if (rawResult === null) {
|
|
1493
|
-
const
|
|
1494
|
-
|
|
1513
|
+
const advice = unusableOutputAdvice(
|
|
1514
|
+
payload.choices?.[0]?.finish_reason,
|
|
1515
|
+
options.settings,
|
|
1516
|
+
(reason) => quote(reason, (clipped) => truncate(clipped, 80)),
|
|
1517
|
+
"The answer ended normally but no complete JSON object could be read from it."
|
|
1518
|
+
);
|
|
1495
1519
|
throw new Error(
|
|
1496
1520
|
`OpenAI-compatible API returned non-JSON output. ${advice} Output ended with: ${quote(text, tail)}`
|
|
1497
1521
|
);
|
|
@@ -1499,8 +1523,14 @@ ${JSON_TEMPLATE_INSTRUCTION}`;
|
|
|
1499
1523
|
const result = normalizeVisionResult(rawResult);
|
|
1500
1524
|
const missing = missingSchemaFields(result);
|
|
1501
1525
|
if (missing.length > 0) {
|
|
1526
|
+
const advice = unusableOutputAdvice(
|
|
1527
|
+
payload.choices?.[0]?.finish_reason,
|
|
1528
|
+
options.settings,
|
|
1529
|
+
(reason) => quote(reason, (clipped) => truncate(clipped, 80)),
|
|
1530
|
+
"The answer ended normally and parsed, but not into the contract."
|
|
1531
|
+
);
|
|
1502
1532
|
throw new Error(
|
|
1503
|
-
`OpenAI-compatible API returned JSON that does not match the vision schema (wrong or missing: ${missing.join(", ")}).
|
|
1533
|
+
`OpenAI-compatible API returned JSON that does not match the vision schema (wrong or missing: ${missing.join(", ")}). ${advice} Got: ${quote(text)}`
|
|
1504
1534
|
);
|
|
1505
1535
|
}
|
|
1506
1536
|
return {
|
|
@@ -2019,6 +2049,12 @@ function providerChain(kind, config2, env = process.env) {
|
|
|
2019
2049
|
}
|
|
2020
2050
|
return names.filter((name) => providerAvailable(name, config2, env)).map((name) => resolveProvider(name));
|
|
2021
2051
|
}
|
|
2052
|
+
function spawnHidden(command, args, options) {
|
|
2053
|
+
return spawn(command, args, { ...options, windowsHide: true });
|
|
2054
|
+
}
|
|
2055
|
+
function execFileSyncHidden(file, args, options) {
|
|
2056
|
+
return execFileSync(file, args, { ...options, windowsHide: true });
|
|
2057
|
+
}
|
|
2022
2058
|
function existenceOf(target) {
|
|
2023
2059
|
try {
|
|
2024
2060
|
fs.statSync(target);
|
|
@@ -2332,7 +2368,7 @@ const DEFAULT_TTL_MS = 6 * 60 * 60 * 1e3;
|
|
|
2332
2368
|
const CLI_TIMEOUT_MS = 1e4;
|
|
2333
2369
|
function defaultRunCli(bin, args, timeoutMs) {
|
|
2334
2370
|
const plan = resolveSpawnPlan(bin, args);
|
|
2335
|
-
return
|
|
2371
|
+
return execFileSyncHidden(plan.command, plan.args, {
|
|
2336
2372
|
encoding: "utf-8",
|
|
2337
2373
|
timeout: timeoutMs,
|
|
2338
2374
|
stdio: "pipe",
|
|
@@ -2920,7 +2956,7 @@ function fetchPiKey(piPath, modelId, provider, timeoutMs) {
|
|
|
2920
2956
|
"--provider",
|
|
2921
2957
|
provider
|
|
2922
2958
|
]);
|
|
2923
|
-
const key =
|
|
2959
|
+
const key = execFileSyncHidden(plan.command, plan.args, {
|
|
2924
2960
|
encoding: "utf-8",
|
|
2925
2961
|
stdio: ["ignore", "pipe", "pipe"],
|
|
2926
2962
|
timeout: timeoutMs,
|
|
@@ -3184,7 +3220,7 @@ async function runProvider(provider, model, options, resolvedInput, timeoutMs, c
|
|
|
3184
3220
|
);
|
|
3185
3221
|
parsed = parseOutput(commandResult.stdout);
|
|
3186
3222
|
} finally {
|
|
3187
|
-
isolation?.cleanup();
|
|
3223
|
+
await isolation?.cleanup();
|
|
3188
3224
|
}
|
|
3189
3225
|
} else {
|
|
3190
3226
|
throw new Error(
|
|
@@ -3225,19 +3261,16 @@ function validateInputFile(filePath) {
|
|
|
3225
3261
|
throw new Error(`Input is not a file: ${filePath}`);
|
|
3226
3262
|
}
|
|
3227
3263
|
}
|
|
3228
|
-
function removeWorkdir(workdir) {
|
|
3264
|
+
async function removeWorkdir(workdir) {
|
|
3229
3265
|
try {
|
|
3230
|
-
fs.
|
|
3231
|
-
|
|
3266
|
+
await fs.promises.rm(workdir, {
|
|
3267
|
+
recursive: true,
|
|
3268
|
+
force: true,
|
|
3269
|
+
maxRetries: 1,
|
|
3270
|
+
retryDelay: 500
|
|
3271
|
+
});
|
|
3232
3272
|
} catch {
|
|
3233
3273
|
}
|
|
3234
|
-
const retry = setTimeout(() => {
|
|
3235
|
-
try {
|
|
3236
|
-
fs.rmSync(workdir, { recursive: true, force: true });
|
|
3237
|
-
} catch {
|
|
3238
|
-
}
|
|
3239
|
-
}, 500);
|
|
3240
|
-
retry.unref();
|
|
3241
3274
|
}
|
|
3242
3275
|
function isolateImage(source) {
|
|
3243
3276
|
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "modlens-work-"));
|
|
@@ -3268,7 +3301,7 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
|
|
|
3268
3301
|
invocation.cwd
|
|
3269
3302
|
);
|
|
3270
3303
|
const spawnEnv = plan.env ?? childEnv;
|
|
3271
|
-
const child =
|
|
3304
|
+
const child = spawnHidden(plan.command, plan.args, {
|
|
3272
3305
|
cwd: invocation.cwd,
|
|
3273
3306
|
stdio: ["ignore", "pipe", "pipe"],
|
|
3274
3307
|
// A provider may mark its own child, which is how kimi-cli tells a
|
|
@@ -3418,7 +3451,7 @@ function detectHarnessDetailed() {
|
|
|
3418
3451
|
}
|
|
3419
3452
|
if (process.platform !== "win32") {
|
|
3420
3453
|
try {
|
|
3421
|
-
const ps =
|
|
3454
|
+
const ps = execFileSyncHidden("ps", ["-Ao", "pid=,ppid=,command="], {
|
|
3422
3455
|
encoding: "utf-8",
|
|
3423
3456
|
maxBuffer: 16 * 1024 * 1024,
|
|
3424
3457
|
stdio: ["ignore", "pipe", "pipe"]
|
|
@@ -4565,7 +4598,7 @@ function parsePositiveInt(raw, flag) {
|
|
|
4565
4598
|
}
|
|
4566
4599
|
return Number.parseInt(raw, 10);
|
|
4567
4600
|
}
|
|
4568
|
-
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.18.
|
|
4601
|
+
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.18.4");
|
|
4569
4602
|
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
4603
|
"--extra-body <json>",
|
|
4571
4604
|
`JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
|
|
@@ -4675,7 +4708,7 @@ program.command("doctor").description(
|
|
|
4675
4708
|
configPath: CONFIG_PATH,
|
|
4676
4709
|
// Lets doctor name an installed skill copy that is older than
|
|
4677
4710
|
// the CLI reporting on it (issue #33).
|
|
4678
|
-
version: "3.18.
|
|
4711
|
+
version: "3.18.4"
|
|
4679
4712
|
});
|
|
4680
4713
|
const output = options.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report);
|
|
4681
4714
|
process.stdout.write(`${output}
|
package/docs/harness-setup.md
CHANGED
|
@@ -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.
|
|
58
|
+
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.4
|
|
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.
|
|
84
|
+
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.4
|
|
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.
|
|
58
|
+
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.4
|
|
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.
|
|
74
|
+
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.4
|
|
75
75
|
```
|
|
76
76
|
|
|
77
77
|
`npm view @liustack/modlens version` 可以查到当前版本号,本页的版本号则由发布流程自动写入。
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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.
|
|
166
|
+
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.4
|
|
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.
|
|
181
|
+
- '@liustack/modlens@3.18.4'
|
|
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.
|
|
147
|
+
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.4
|
|
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.2
|
|
|
153
153
|
|
|
154
154
|
```yaml
|
|
155
155
|
minimumReleaseAgeExclude:
|
|
156
|
-
- '@liustack/modlens@3.18.
|
|
156
|
+
- '@liustack/modlens@3.18.4'
|
|
157
157
|
```
|
|
158
158
|
|
|
159
159
|
或者只为这一条命令解除冷静期,注意它解除的是这条命令解析到的所有包,不只 modlens:
|
package/dsh/index.js
CHANGED
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
// Loaded via the cordis.patch.yml row `@liustack/modlens/dsh` (see the
|
|
11
11
|
// package.json `dsh.bundle` manifest). Providers, reuse grants, and guard
|
|
12
12
|
// rules keep living in ~/.modlens/config.json, shared with every harness.
|
|
13
|
-
import { spawn } from 'node:child_process'
|
|
14
13
|
import { chmodSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
15
14
|
import { homedir, tmpdir } from 'node:os'
|
|
16
15
|
import { dirname, join } from 'node:path'
|
|
17
16
|
import { fileURLToPath } from 'node:url'
|
|
17
|
+
import { spawnHidden } from './spawnHidden.js'
|
|
18
18
|
|
|
19
19
|
const CLI_PATH = fileURLToPath(new URL('../dist/main.js', import.meta.url))
|
|
20
20
|
// Kept in lockstep with src/schema.ts by a repo test; the plugin file cannot
|
|
@@ -533,39 +533,71 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
533
533
|
return
|
|
534
534
|
}
|
|
535
535
|
|
|
536
|
+
// dsh snapshots providerInfo and providerRetryPolicy at registration time.
|
|
537
|
+
// Keep the state and registration handle for each wrapper so an upstream
|
|
538
|
+
// replacement can refresh those snapshots instead of leaving a synthetic
|
|
539
|
+
// route on yesterday's name or recovery policy.
|
|
540
|
+
const registrations = new Map()
|
|
541
|
+
const wrapped = new Set(['deepseek-modlens'])
|
|
542
|
+
const policyKey = (policy) => (policy === undefined ? undefined : JSON.stringify(policy))
|
|
543
|
+
|
|
536
544
|
const registerWrapper = (upstream, providerId, displayName) => {
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
inputModalities
|
|
541
|
-
|
|
545
|
+
const state = { displayName, retryPolicyKey: undefined }
|
|
546
|
+
const withVision = (info) => {
|
|
547
|
+
const inputModalities = Array.isArray(info?.inputModalities) ? [...info.inputModalities] : []
|
|
548
|
+
if (!inputModalities.includes('text')) inputModalities.unshift('text')
|
|
549
|
+
if (!inputModalities.includes('image')) inputModalities.push('image')
|
|
550
|
+
return { ...info, provider: providerId, inputModalities }
|
|
551
|
+
}
|
|
542
552
|
try {
|
|
543
|
-
ctx.llm.registerAdapter([providerId], {
|
|
553
|
+
const registration = ctx.llm.registerAdapter([providerId], {
|
|
544
554
|
// Duck-typing LlmAdapter: providerInfo/providerRetryPolicy are
|
|
545
555
|
// base-class defaults a plain object must supply itself (their
|
|
546
556
|
// absence is exactly the silent registration failure this catch
|
|
547
557
|
// used to swallow).
|
|
548
558
|
providerInfo(provider) {
|
|
549
|
-
return { id: provider, name: displayName }
|
|
559
|
+
return { id: provider, name: state.displayName }
|
|
550
560
|
},
|
|
551
561
|
providerRetryPolicy() {
|
|
552
|
-
|
|
562
|
+
// dsh captures this synchronously when the wrapper route registers.
|
|
563
|
+
// Returning the base default here gives the synthetic route a retry
|
|
564
|
+
// budget unrelated to the real route it ultimately calls (#57).
|
|
565
|
+
// Older preview builds exposed registration before this runtime
|
|
566
|
+
// query, so keep their former default only when the query itself is
|
|
567
|
+
// absent. A current runtime that cannot resolve `upstream` throws,
|
|
568
|
+
// and the registration boundary below fails closed instead.
|
|
569
|
+
if (typeof ctx.llm.providerRetryPolicy !== 'function') return undefined
|
|
570
|
+
const policy = ctx.llm.providerRetryPolicy(upstream)
|
|
571
|
+
state.retryPolicyKey = policyKey(policy)
|
|
572
|
+
return policy
|
|
553
573
|
},
|
|
554
574
|
async listModels(_provider, signal) {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
}))
|
|
561
|
-
} catch {
|
|
562
|
-
return []
|
|
563
|
-
}
|
|
575
|
+
const models = await ctx.llm.listModels(upstream, signal)
|
|
576
|
+
return models.filter(shouldWrap).map((model) => ({
|
|
577
|
+
...withVision(model),
|
|
578
|
+
name: `${model.name ?? model.id} (modlens vision)`,
|
|
579
|
+
}))
|
|
564
580
|
},
|
|
565
581
|
async resolveModel(_provider, model, signal) {
|
|
566
582
|
const info = await ctx.llm.resolveModelInfo(upstream, model, signal)
|
|
567
583
|
if (!shouldWrap(info)) {
|
|
568
|
-
|
|
584
|
+
// Refusing is right: wrapping a model that reads images itself
|
|
585
|
+
// would claim a bridge it does not need, hand it text evidence
|
|
586
|
+
// instead of the picture, and lose whatever its own vision does
|
|
587
|
+
// better. What was wrong is that the refusal explained nothing.
|
|
588
|
+
// A session that already picked this entry fails every turn, and
|
|
589
|
+
// the catalogue is advisory so nothing clears the stale choice,
|
|
590
|
+
// leaving the user to read internal vocabulary and guess.
|
|
591
|
+
//
|
|
592
|
+
// Only the image case gets the specific wording. The same check
|
|
593
|
+
// also fails when a model leaves the configured families, which
|
|
594
|
+
// is a different situation and keeps the general message.
|
|
595
|
+
const declaresImage = Array.isArray(info?.inputModalities) && info.inputModalities.includes('image')
|
|
596
|
+
throw new Error(
|
|
597
|
+
declaresImage
|
|
598
|
+
? `model "${model}" declares native image input, so its "(modlens vision)" entry no longer applies. Select the same model from the provider group without "(modlens vision)".`
|
|
599
|
+
: `model "${model}" is outside the modlens vision wrap scope`,
|
|
600
|
+
)
|
|
569
601
|
}
|
|
570
602
|
return { ...withVision(info), id: model }
|
|
571
603
|
},
|
|
@@ -583,6 +615,7 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
583
615
|
},
|
|
584
616
|
evidenceCache: new Map(),
|
|
585
617
|
})
|
|
618
|
+
registrations.set(upstream, { providerId, registration, state })
|
|
586
619
|
// Trusted as ours only on a registration this call actually made. A
|
|
587
620
|
// duplicate below means someone else holds that id, and skipping a
|
|
588
621
|
// provider we do not own would let a real vision model's paste be
|
|
@@ -592,7 +625,10 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
592
625
|
} catch (error) {
|
|
593
626
|
// A duplicate means a concurrent or earlier registration already won:
|
|
594
627
|
// that is success for the claim, not a reason to retry forever.
|
|
595
|
-
|
|
628
|
+
const duplicate =
|
|
629
|
+
error?.code === 'DUPLICATE_ADAPTER' ||
|
|
630
|
+
/\balready registered\b|\bduplicate (adapter|provider)\b/i.test(String(error))
|
|
631
|
+
if (duplicate) {
|
|
596
632
|
console.error(`[modlens] vision provider ${providerId} already registered, keeping the existing one`)
|
|
597
633
|
return true
|
|
598
634
|
}
|
|
@@ -604,8 +640,85 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
604
640
|
}
|
|
605
641
|
}
|
|
606
642
|
|
|
643
|
+
const dropWrapper = (upstream, current) => {
|
|
644
|
+
registrations.delete(upstream)
|
|
645
|
+
wrapped.delete(upstream)
|
|
646
|
+
ownProviders?.delete(current.providerId)
|
|
647
|
+
if (typeof current.registration === 'function') current.registration()
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// A caveat for whoever reads the catch below: commitRoutes mutates the
|
|
651
|
+
// registry and only then emits, so a listener throwing during that emit
|
|
652
|
+
// means the replace already SUCCEEDED. Treating the throw as failure and
|
|
653
|
+
// disposing would drop a healthy registration. No shipped listener can
|
|
654
|
+
// throw there today, which is why this is a comment rather than a guard.
|
|
655
|
+
const refreshWrapper = (upstream, displayName) => {
|
|
656
|
+
const current = registrations.get(upstream)
|
|
657
|
+
if (!current || typeof current.registration?.replace !== 'function') return
|
|
658
|
+
let nextPolicyKey
|
|
659
|
+
try {
|
|
660
|
+
nextPolicyKey =
|
|
661
|
+
typeof ctx.llm.providerRetryPolicy === 'function' ? policyKey(ctx.llm.providerRetryPolicy(upstream)) : undefined
|
|
662
|
+
} catch (error) {
|
|
663
|
+
dropWrapper(upstream, current)
|
|
664
|
+
console.error(`[modlens] vision provider refresh removed (${current.providerId}): ${error}`)
|
|
665
|
+
return
|
|
666
|
+
}
|
|
667
|
+
if (current.state.displayName === displayName && current.state.retryPolicyKey === nextPolicyKey) return
|
|
668
|
+
const previousName = current.state.displayName
|
|
669
|
+
current.state.displayName = displayName
|
|
670
|
+
try {
|
|
671
|
+
// Re-read both adapter methods at the same atomic boundary dsh's own
|
|
672
|
+
// adapters use when their registration-captured facts change.
|
|
673
|
+
current.registration.replace([current.providerId])
|
|
674
|
+
} catch (error) {
|
|
675
|
+
current.state.displayName = previousName
|
|
676
|
+
dropWrapper(upstream, current)
|
|
677
|
+
console.error(`[modlens] vision provider refresh failed (${current.providerId}): ${error}`)
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
607
681
|
if (config.upstream) {
|
|
608
|
-
|
|
682
|
+
const upstream = config.upstream
|
|
683
|
+
const providerId = config.providerId || 'deepseek-modlens'
|
|
684
|
+
// Named after the route it actually wraps. This used to say DeepSeek
|
|
685
|
+
// whatever `upstream` was, so anyone pointing it at another route got a
|
|
686
|
+
// model group labelled for a provider they were not using. The refresh
|
|
687
|
+
// plumbing below could never correct it, because the name it compared
|
|
688
|
+
// against was a constant.
|
|
689
|
+
const upstreamName = () => {
|
|
690
|
+
if (typeof ctx.llm.listProviders !== 'function') return upstream
|
|
691
|
+
try {
|
|
692
|
+
const found = ctx.llm.listProviders().find((entry) => entry.id === upstream)
|
|
693
|
+
return found?.name ?? upstream
|
|
694
|
+
} catch {
|
|
695
|
+
return upstream
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
let reconciling = false
|
|
699
|
+
const reconcile = () => {
|
|
700
|
+
if (reconciling) return
|
|
701
|
+
reconciling = true
|
|
702
|
+
try {
|
|
703
|
+
const current = registrations.get(upstream)
|
|
704
|
+
const available =
|
|
705
|
+
typeof ctx.llm.listProviders !== 'function' ||
|
|
706
|
+
ctx.llm.listProviders().some((info) => (typeof info === 'string' ? info : info?.id) === upstream)
|
|
707
|
+
if (!current) {
|
|
708
|
+
registerWrapper(upstream, providerId, `${upstreamName()} (modlens vision)`)
|
|
709
|
+
return
|
|
710
|
+
}
|
|
711
|
+
if (!available) {
|
|
712
|
+
dropWrapper(upstream, current)
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
refreshWrapper(upstream, `${upstreamName()} (modlens vision)`)
|
|
716
|
+
} finally {
|
|
717
|
+
reconciling = false
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
reconcile()
|
|
721
|
+
if (typeof ctx.on === 'function') ctx.on('llm/adapters-updated', reconcile)
|
|
609
722
|
return
|
|
610
723
|
}
|
|
611
724
|
|
|
@@ -617,7 +730,6 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
617
730
|
// skip it while this one is still probing), and sweeps are serialized on
|
|
618
731
|
// one promise chain so two can never interleave their probes at all.
|
|
619
732
|
const discover = Array.isArray(config.discover) ? new Set(config.discover) : null
|
|
620
|
-
const wrapped = new Set(['deepseek-modlens'])
|
|
621
733
|
const sweepOnce = async () => {
|
|
622
734
|
try {
|
|
623
735
|
await sweepBody()
|
|
@@ -636,10 +748,22 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
636
748
|
}
|
|
637
749
|
return
|
|
638
750
|
}
|
|
639
|
-
|
|
751
|
+
const providers = ctx.llm.listProviders()
|
|
752
|
+
const available = new Set(providers.map((info) => info?.id).filter(Boolean))
|
|
753
|
+
for (const [upstream, current] of registrations) {
|
|
754
|
+
if (available.has(upstream)) continue
|
|
755
|
+
dropWrapper(upstream, current)
|
|
756
|
+
}
|
|
757
|
+
for (const info of providers) {
|
|
640
758
|
const id = info?.id
|
|
641
|
-
if (!id ||
|
|
759
|
+
if (!id || String(id).startsWith('modlens-')) continue
|
|
642
760
|
if (discover && !discover.has(id)) continue
|
|
761
|
+
const base = info.name ?? id
|
|
762
|
+
if (registrations.has(id)) {
|
|
763
|
+
refreshWrapper(id, `${base} (modlens vision)`)
|
|
764
|
+
continue
|
|
765
|
+
}
|
|
766
|
+
if (wrapped.has(id)) continue
|
|
643
767
|
// Claim before the await: the probe may suspend, and the sweep a
|
|
644
768
|
// registration triggers must not probe the same id concurrently.
|
|
645
769
|
wrapped.add(id)
|
|
@@ -658,7 +782,6 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
658
782
|
continue
|
|
659
783
|
}
|
|
660
784
|
const providerId = id === 'deepseek-official' ? 'deepseek-modlens' : `modlens-${id}`
|
|
661
|
-
const base = info.name ?? id
|
|
662
785
|
if (!registerWrapper(id, providerId, `${base} (modlens vision)`)) {
|
|
663
786
|
wrapped.delete(id)
|
|
664
787
|
}
|
|
@@ -900,7 +1023,7 @@ async function readImageBlock(ctx, block, signal) {
|
|
|
900
1023
|
|
|
901
1024
|
function run(command, args, signal) {
|
|
902
1025
|
return new Promise((resolve, reject) => {
|
|
903
|
-
const child =
|
|
1026
|
+
const child = spawnHidden(command, args, {
|
|
904
1027
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
905
1028
|
signal,
|
|
906
1029
|
// In the packaged desktop app process.execPath is the Electron binary;
|
|
@@ -1226,7 +1349,11 @@ function openConfigFile() {
|
|
|
1226
1349
|
: process.platform === 'win32'
|
|
1227
1350
|
? ['cmd', ['/c', 'start', '', file]]
|
|
1228
1351
|
: ['xdg-open', [file]]
|
|
1229
|
-
|
|
1352
|
+
// Hiding applies to the `cmd` that runs `start`, not to the editor it hands
|
|
1353
|
+
// off to: `start` opens the file through its association in a process of its
|
|
1354
|
+
// own. Windows ignores CREATE_NO_WINDOW next to DETACHED_PROCESS, so what
|
|
1355
|
+
// this leaves is SW_HIDE on the middleman.
|
|
1356
|
+
spawnHidden(command, args, { detached: true, stdio: 'ignore' }).unref()
|
|
1230
1357
|
}
|
|
1231
1358
|
|
|
1232
1359
|
/** localhost, ::1, or anything in 127/8, matching dsh's own /api fence. */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ChildProcess, SpawnOptions } from 'child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Starts a child with its console window hidden, whatever the caller asked for
|
|
5
|
+
* (issue #60). `windowsHide` is accepted and ignored rather than refused: the
|
|
6
|
+
* plugin is plain JavaScript, so refusing it in the types would stop nothing,
|
|
7
|
+
* and the wrapper overwrites it either way.
|
|
8
|
+
*/
|
|
9
|
+
export declare function spawnHidden(
|
|
10
|
+
command: string,
|
|
11
|
+
args: readonly string[],
|
|
12
|
+
options: SpawnOptions,
|
|
13
|
+
): ChildProcess;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The only place this plugin starts a child process.
|
|
2
|
+
//
|
|
3
|
+
// The desktop app has no console of its own, so on Windows every child it
|
|
4
|
+
// starts would be given one and shown its window: a black box per read
|
|
5
|
+
// (issue #60). `windowsHide` suppresses that, defaults to false in Node, and is
|
|
6
|
+
// ignored elsewhere.
|
|
7
|
+
//
|
|
8
|
+
// It lives in a file of its own, apart from its callers, so the rule can be
|
|
9
|
+
// checked by looking at which files reach `child_process` at all rather than at
|
|
10
|
+
// what each call passes. A call site cannot forget an option it never writes,
|
|
11
|
+
// and writing the option after the caller's leaves nothing to override it.
|
|
12
|
+
//
|
|
13
|
+
// The core has its own copy in src/util/spawnHidden.ts. The duplication is on
|
|
14
|
+
// purpose: this plugin ships as a unit and must not import from the CLI it
|
|
15
|
+
// drives.
|
|
16
|
+
import { spawn } from 'node:child_process'
|
|
17
|
+
|
|
18
|
+
export function spawnHidden(command, args, options) {
|
|
19
|
+
return spawn(command, args, { ...options, windowsHide: true })
|
|
20
|
+
}
|
package/package.json
CHANGED
package/skills/modlens/SKILL.md
CHANGED
|
@@ -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.
|
|
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.4):
|
|
24
24
|
|
|
25
|
-
1. A `modlens` on `PATH` whose major version is 3 and is at least 3.18.
|
|
26
|
-
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.18.
|
|
27
|
-
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.18.
|
|
25
|
+
1. A `modlens` on `PATH` whose major version is 3 and is at least 3.18.4: `modlens <args>`.
|
|
26
|
+
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.18.4 modlens <args>`.
|
|
27
|
+
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.18.4 <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.
|
|
@@ -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.
|
|
27
|
+
$Pinned = '3.18.4'
|
|
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.
|
|
25
|
+
PINNED="3.18.4"
|
|
26
26
|
# -------------------------------------------------------------------------------
|
|
27
27
|
|
|
28
28
|
NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"
|