dsh-remote-agent 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,104 @@
9
9
 
10
10
  ---
11
11
 
12
+ ## [0.8.0] - 2026-10-17
13
+
14
+ ### 功能:#27 完整版 —— 模型清单自述 + 面板下拉
15
+
16
+ v0.7.0 只做了"自述 + 派发前拦截",面板仍是自由文本 —— 当时的判断是
17
+ "in-process 后端没有稳定的清单 API(DSH 内部的 `model/list` 走 app-server
18
+ 协议面,fiber 服务摸不到)"。**那个判断已经过时**:`ctx.llm` 现在直接暴露
19
+ `listProviders()` 与 `listModels(provider)`,这就是稳定服务面。
20
+
21
+ 所以本版把最后一环补上,链路四段:
22
+
23
+ 1. **副机探测**(`readAvailableModels`):`ctx.llm.listProviders()` →
24
+ 逐个 `listModels(provider)`。单个 provider 失败 `continue`(不打断其它),
25
+ 全部失败返回 `null`(面板退回自由文本)。上限:8 provider / 每 provider
26
+ 24 模型 / 总 64 —— 不让 RESOURCE 帧膨胀。
27
+
28
+ 2. **RESOURCE 帧带缓存**:探测是 async 而 interval 回调是同步的,所以用
29
+ 后台串行链刷新(启动探一次 + 每 10 分钟重探),帧里带**上一次的结果**。
30
+ 用串行链而不是 `void refresh()` 是为了防探测堆积(探测慢时每 30s 一次帧
31
+ 会越积越多)。探测失败保留旧值 —— 一次网络抖动不该让面板下拉消失。
32
+
33
+ 3. **hub 摄入**(`registry-ingest`):字段级清洗 + **独立封顶**
34
+ (`MAX_NODE_MODELS`,不信任对端遵守了自己的上限)。
35
+ `models: null` **保留旧值**(与 `defaultModel` 的 null 清空语义刻意不同)。
36
+
37
+ 4. **视图 + 面板**:`registry.list()` → `nodeView()` 逐条投影(不 spread)
38
+ → 面板 `<select>`:
39
+ - 第一项"跟随副机默认(provider/model)"
40
+ - 中间是各模型,多节点时带 `· 节点名` 后缀
41
+ - 最后一项"自定义…"(展开回文本框)
42
+ - **没有清单时退回文本框**(向后兼容旧副机)
43
+
44
+ **安全边界**:只上报 provider 键名 + model id + 显示名。
45
+ 绝不上报 apiKeyEnv 的值、baseURL、任何凭证。`listProviders()` 只返回
46
+ 已注册(有 adapter)的 route —— 天然满足"不泄露这台机器配了哪些渠道",
47
+ 只有真能用的才会出现。
48
+
49
+ 测试:`test/model-list-dropdown.test.js` 13 条(四段链路逐段钉住 +
50
+ "上报字段恰好是 provider/id/name"的硬断言 + 单个 provider 失败不打断 +
51
+ null/[] 语义区分 + 面板有 select/自定义/多节点后缀)。
52
+ doc-drift 新增 75(链路四段)、76(安全边界)两条锚点。
53
+
54
+ ### 修复:原子写 EPERM 立项修(第 5 次 CI 失败)
55
+
56
+ `atomicWriteJson` 的 rename 在 Windows 上被 EPERM 拦(目标正被另一个句柄
57
+ 读着)。历次调整:6 次 × 10ms(总 210ms)→ 10 次封顶 200ms(总 ~1.3s),
58
+ run 39012045381 **仍失败**。
59
+
60
+ 第 5 次失败时承认:这不是"抖动",是"窗口太短"。失败用例是
61
+ 「原子写:并发写 + 并发读」—— 多个 rename 互相争抢同一个目标,而 CI
62
+ runner 高峰期的磁盘延迟能到秒级。
63
+
64
+ 三层修法:
65
+
66
+ - **总窗口拉到 ~6s**(16 次,封顶 500ms)—— 有界,正常路径第 1 次就成功
67
+ - **最后一次尝试前做 unlink 兜底**(删目标再 rename,绕开"目标存在且被锁"
68
+ 的 EPERM)
69
+ - **兜底只在第 16 次触发** —— 它破坏"任何时刻读到完整文件"的严格原子性
70
+ (删除与 rename 之间读方看到 ENOENT),所以必须当最后手段而不是常规路径;
71
+ 触发点用 `RENAME_RETRIES - 1` 计算,不写死数字
72
+
73
+ 安全性:读方(`readAll()` / `list()`)本来就容忍 ENOENT —— 首次写之前
74
+ 文件不存在是正常状态(`readAll` 的 ENOENT 分支返回空库且 `degraded: false`)。
75
+ 所以兜底造成的瞬时缺失落在既有容忍范围内,不会让读方看到半截 JSON。
76
+
77
+ 测试:`test/atomic-write-eperm.test.js` 6 条(窗口 ≥5s / 兜底存在 /
78
+ 触发点是最后一次 / readAll 容忍 ENOENT / 非 transient 立即抛 /
79
+ 目录占住时干净失败)。
80
+
81
+ ### 修复:队列残余风险的两处静默失效(#26-4 / #26-5)
82
+
83
+ 2026-10-17 实测:副机 `maxConcurrent=2` 被两条卡住的 exec 占满后,后续任务
84
+ 全部排队。排查时暴露两个让问题**不可见、不可控**的缺陷 —— 都是"没有报错、
85
+ 行为看起来正常"的静默失效型。
86
+
87
+ - **#26-4 面板"运行中"分组**:此前面板只有一个数字(`running: 2`),
88
+ 用户看不到**是哪两条、在跑什么、跑了多久**。现在任务列表按状态分组,
89
+ 运行中(含 queued / pending_approval)置顶,每条显示 `已跑 N`
90
+ (由 `startedAt` 与当前时间算差)—— 这是判断"是不是卡住了"的唯一线索。
91
+ 其余任务归入"已完成 / 已结束",保持原顺序。
92
+
93
+ - **#26-5 agent 取消真打断**:`InProcessRunner.run` 等 agent 停稳用的是
94
+ `Promise.race([agent.whenIdle(), timeoutPromise])` —— **取消信号不在里面**。
95
+ 而 `cancel()` 做的是 `agent.cancel()` + `controller.abort()`:abort 只是
96
+ 置了个标志,没有任何人在 await 它;DSH 的 agent 循环也不监听外部
97
+ AbortSignal。后果:hub 发 `task/cancel` → `remote_cancel` 返回
98
+ `requested: true` → 任务**一直 running 到 agent 自己跑完** → 最终落成
99
+ **completed**(不是 aborted)。用户以为取消成功,实际白跑一遍还烧了额度。
100
+
101
+ 修法:把 abort 做成可 race 的 promise(触发即 resolve),并让返回值的
102
+ `ok` 排除 `aborted`(`ok: !timedOut && !aborted`)。两个语义细节同时钉住:
103
+ 超时触发的 abort **不**改写成 aborted(超时 ≠ 用户取消,下游据此外报不同
104
+ 错误码);`finally` 里 `handle.dispose()` 收掉 agent,避免取消后泄漏。
105
+
106
+ 测试:`test/agent-cancel-abort.test.js` 8 条(race 含 abortPromise / ok 排除
107
+ aborted / 超时不误标 / cancel 双动作 / finally dispose / engine 映射顺序);
108
+ doc-drift 新增 73、74 两条锚点。
109
+
12
110
  ## [0.7.0] - 2026-10-17
13
111
 
14
112
  ### 功能:模型自述 + headless 指定模型派发前拦截(提案 #27)
package/lib/client.js CHANGED
@@ -74,6 +74,10 @@ window.__ModuleLoader__.load({
74
74
  '.dra-card-l{font-size:11px;color:var(--dsw-alias-label-secondary)}',
75
75
  '.dra-sec{margin:14px 0 6px;font-size:11px;letter-spacing:.04em;',
76
76
  'text-transform:uppercase;color:var(--dsw-alias-label-secondary)}',
77
+ // #26-4:任务列表的分组小标题("运行中(2)" / "已完成 / 已结束")。
78
+ // 比 .dra-sec 轻一档 —— 它是二级标题,不该和区块标题抢视觉重量。
79
+ '.dra-sub{margin:10px 0 4px;font-size:11px;font-weight:600;',
80
+ 'color:var(--dsw-alias-label-secondary);opacity:.85}',
77
81
  '.dra-row{display:flex;align-items:center;gap:8px;padding:7px 9px;border-radius:6px;',
78
82
  'border:1px solid transparent}',
79
83
  '.dra-row:hover{background:var(--dsw-alias-bg-layer-1)}',
@@ -1423,12 +1427,98 @@ window.__ModuleLoader__.load({
1423
1427
  value: fv.workspace,
1424
1428
  onChange: function (e) { setForm(function (f) { const n = Object.assign({}, f); n.workspace = e.target.value; return n }) },
1425
1429
  }),
1426
- h('input', {
1427
- className: 'dra-in',
1428
- placeholder: '模型(可选,如 deepseek-reasoner 或 provider/model)',
1429
- value: fv.model,
1430
- onChange: function (e) { setForm(function (f) { const n = Object.assign({}, f); n.model = e.target.value; return n }) },
1431
- }),
1430
+ // ── 模型选择(提案 #27)─────────────────────────────────────────
1431
+ //
1432
+ // 此前是**自由文本**:用户必须自己记住副机的 provider 名和 model 名,
1433
+ // 填错了要等任务跑到副机才失败(浪费一轮往返 + 烧一个 taskId)。
1434
+ //
1435
+ // 现在副机随 RESOURCE 帧上报可用模型清单(只报 provider 键名 +
1436
+ // model id + 显示名,不碰凭证),面板据此渲染下拉:
1437
+ //
1438
+ // · 有清单 → <select>,第一项是"跟随副机默认(xxx)",
1439
+ // 最后一项是"自定义…"(展开回文本框)
1440
+ // · 没清单(旧副机 / 探测未完成 / null)→ 保持原样:文本框
1441
+ // · 清单是 [](副机明确说一个都没有)→ 显示提示,仍是文本框
1442
+ //
1443
+ // 目标节点 = 显式选的 node,或(自动路由时)第一个在线节点的并集。
1444
+ // 多节点时取**并集**并带节点名后缀,避免同名 model 混淆。
1445
+ (function () {
1446
+ const chosen = fv.node.trim()
1447
+ const targets = chosen !== ''
1448
+ ? state.nodes.filter(function (n) { return n.nodeId === chosen || n.hostname === chosen })
1449
+ : state.nodes.filter(function (n) { return n.online })
1450
+ // 收集并集(同一 provider/model 只留一条,name 取第一个非空)
1451
+ const seen = {}
1452
+ const opts = []
1453
+ targets.forEach(function (n) {
1454
+ (Array.isArray(n.models) ? n.models : []).forEach(function (m) {
1455
+ const key = m.provider + '\u0000' + m.id
1456
+ if (seen[key]) return
1457
+ seen[key] = true
1458
+ opts.push({ provider: m.provider, id: m.id, name: m.name || m.id, nodeId: n.nodeId })
1459
+ })
1460
+ })
1461
+ const hasList = targets.some(function (n) { return Array.isArray(n.models) })
1462
+ // 副机默认(仅单节点目标时有意义;多节点时用第一台的)
1463
+ const def = targets.length > 0 ? targets[0].defaultModel : null
1464
+ const defLabel = def && def.model
1465
+ ? '跟随副机默认(' + (def.provider ? def.provider + '/' : '') + def.model + ')'
1466
+ : '跟随副机默认'
1467
+
1468
+ if (!hasList || opts.length === 0) {
1469
+ // 没有清单:保持文本框(向后兼容旧副机)
1470
+ return h('div', null,
1471
+ h('input', {
1472
+ className: 'dra-in',
1473
+ placeholder: '模型(可选,如 deepseek-reasoner 或 provider/model)',
1474
+ value: fv.model,
1475
+ onChange: function (e) { setForm(function (f) { const n = Object.assign({}, f); n.model = e.target.value; return n }) },
1476
+ }),
1477
+ hasList
1478
+ ? h('div', { className: 'dra-dim', style: { marginTop: '2px' } },
1479
+ '副机上报的模型清单为空 —— 需手动填写,或先在副机配置模型。')
1480
+ : null,
1481
+ )
1482
+ }
1483
+
1484
+ // 有清单:下拉。值是 `provider/model` 或空串(跟随默认)或 `__custom__`
1485
+ const isCustom = fv.model !== '' && !opts.some(function (o) {
1486
+ return (o.provider ? o.provider + '/' : '') + o.id === fv.model
1487
+ })
1488
+ const multi = targets.length > 1
1489
+ return h('div', null,
1490
+ h('select', {
1491
+ className: 'dra-in',
1492
+ value: isCustom ? '__custom__' : fv.model,
1493
+ onChange: function (e) {
1494
+ const v = e.target.value
1495
+ setForm(function (f) {
1496
+ const n = Object.assign({}, f)
1497
+ n.model = v === '__custom__' ? (n.model === '__custom__' ? '' : n.model) : v
1498
+ return n
1499
+ })
1500
+ },
1501
+ },
1502
+ h('option', { value: '' }, defLabel),
1503
+ opts.map(function (o) {
1504
+ const val = (o.provider ? o.provider + '/' : '') + o.id
1505
+ const label = (o.name || o.id) + (o.provider ? '(' + o.provider + ')' : '')
1506
+ + (multi ? ' · ' + o.nodeId : '')
1507
+ return h('option', { key: val + o.nodeId, value: val }, label)
1508
+ }),
1509
+ h('option', { value: '__custom__' }, '自定义…'),
1510
+ ),
1511
+ isCustom
1512
+ ? h('input', {
1513
+ className: 'dra-in',
1514
+ style: { marginTop: '4px' },
1515
+ placeholder: '自定义模型(provider/model)',
1516
+ value: fv.model,
1517
+ onChange: function (e) { setForm(function (f) { const n = Object.assign({}, f); n.model = e.target.value; return n }) },
1518
+ })
1519
+ : null,
1520
+ )
1521
+ })(),
1432
1522
  h('input', {
1433
1523
  className: 'dra-in',
1434
1524
  placeholder: '期望产出 glob,逗号分隔(可选)',
@@ -1456,27 +1546,71 @@ window.__ModuleLoader__.load({
1456
1546
  )
1457
1547
 
1458
1548
  // ── 任务列表 ──
1549
+ //
1550
+ // ═══════════════════════════════════════════════════════════════════
1551
+ // #26-4:按状态分组,**运行中置顶且显示已跑多久**
1552
+ // ═══════════════════════════════════════════════════════════════════
1553
+ //
1554
+ // 事故(2026-10-17 实测):副机的 `maxConcurrent=2` 被两条卡住的
1555
+ // exec 占满,后续任务全部排队。但面板只显示**一个数字**
1556
+ // (`running: 2`),用户看不到是哪两条、在跑什么、跑了多久 ——
1557
+ // 只知道"卡了",没有任何可操作的东西。
1558
+ //
1559
+ // 那次的代价:排查花了很久,因为"卡住的任务"在面板上完全不可见。
1560
+ //
1561
+ // 所以这里把任务列表改成三段:
1562
+ // · 运行中 —— 置顶,显示**已跑时长**(`startedAt` 与现在的时间差)
1563
+ // · 待审批 —— 次之(等用户动作的也要显眼)
1564
+ // · 其它 —— 按 updatedAt 倒序
1565
+ //
1566
+ // 只在有运行中任务时才显示"运行中"小标题,避免空分组占位。
1567
+ const ACTIVE_STATUSES = ['running', 'queued', 'pending_approval']
1568
+ const activeTasks = state.tasks.filter(function (t) { return ACTIVE_STATUSES.indexOf(t.status) >= 0 })
1569
+ const restTasks = state.tasks.filter(function (t) { return ACTIVE_STATUSES.indexOf(t.status) < 0 })
1570
+
1571
+ /** 单条任务的行(分组内复用)。 */
1572
+ const renderTaskRow = function (t) {
1573
+ const isSel = t.taskId === selected
1574
+ // 运行中的任务显示"已跑 N 秒"—— 这是判断"是不是卡住了"的唯一线索。
1575
+ const liveSec = t.status === 'running' && t.startedAt
1576
+ ? Math.max(0, Math.round((Date.now() - t.startedAt) / 1000))
1577
+ : 0
1578
+ return h('div', { key: t.taskId },
1579
+ h('div', {
1580
+ className: 'dra-row' + (isSel ? ' sel' : ''),
1581
+ onClick: function () { setSelected(isSel ? null : t.taskId) },
1582
+ style: { cursor: 'pointer' },
1583
+ },
1584
+ h(StatusBadge, { status: t.status }),
1585
+ h('span', { className: 'dra-ell', style: { flex: '1' }, title: t.goal }, t.goal || t.taskId),
1586
+ h('span', { className: 'dra-dim' }, t.nodeId),
1587
+ liveSec > 0
1588
+ ? h('span', { className: 'dra-dim', title: '已运行时长' }, '已跑 ' + fmtDur(liveSec * 1000))
1589
+ : (t.durationMs ? h('span', { className: 'dra-dim' }, fmtDur(t.durationMs)) : null),
1590
+ ),
1591
+ isSel ? h(TaskDetail, {
1592
+ task: t, detail: detail, caps: caps, onCancel: cancel, onPull: pull,
1593
+ onApprove: approve, onReject: reject,
1594
+ }) : null,
1595
+ )
1596
+ }
1597
+
1459
1598
  const taskRows = state.tasks.length === 0
1460
1599
  ? h('div', { className: 'dra-empty' }, '还没有任务。')
1461
- : state.tasks.map(function (t) {
1462
- const isSel = t.taskId === selected
1463
- return h('div', { key: t.taskId },
1464
- h('div', {
1465
- className: 'dra-row' + (isSel ? ' sel' : ''),
1466
- onClick: function () { setSelected(isSel ? null : t.taskId) },
1467
- style: { cursor: 'pointer' },
1468
- },
1469
- h(StatusBadge, { status: t.status }),
1470
- h('span', { className: 'dra-ell', style: { flex: '1' }, title: t.goal }, t.goal || t.taskId),
1471
- h('span', { className: 'dra-dim' }, t.nodeId),
1472
- t.durationMs ? h('span', { className: 'dra-dim' }, fmtDur(t.durationMs)) : null,
1473
- ),
1474
- isSel ? h(TaskDetail, {
1475
- task: t, detail: detail, caps: caps, onCancel: cancel, onPull: pull,
1476
- onApprove: approve, onReject: reject,
1477
- }) : null,
1478
- )
1479
- })
1600
+ : h('div', null,
1601
+ activeTasks.length > 0
1602
+ ? h('div', null,
1603
+ h('div', { className: 'dra-sub' }, '运行中(' + activeTasks.length + ')'),
1604
+ activeTasks.map(renderTaskRow),
1605
+ )
1606
+ : null,
1607
+ restTasks.length > 0
1608
+ ? h('div', null,
1609
+ activeTasks.length > 0 ? h('div', { className: 'dra-sub' }, '已完成 / 已结束') : null,
1610
+ restTasks.map(renderTaskRow),
1611
+ )
1612
+ : null,
1613
+ )
1480
1614
 
1481
1615
  const taskSection = h('div', null,
1482
1616
  h('div', { className: 'dra-sec' }, '任务'),
@@ -869,6 +869,10 @@ class NodeRegistryCore {
869
869
  // null = 副机未上报或未配置默认模型。
870
870
  runnerKind: typeof r.runnerKind === 'string' ? r.runnerKind : null,
871
871
  defaultModel: r.defaultModel && typeof r.defaultModel === 'object' ? r.defaultModel : null,
872
+ // 可用模型清单(提案 #27 面板下拉的数据源)。null = 副机未上报
873
+ // (旧版本 / 探测还没完成 / 全部失败)—— 消费方据此退回自由文本。
874
+ // 空数组是**权威事实**("确实一个都没有"),与 null 语义不同。
875
+ models: Array.isArray(r.models) ? r.models : null,
872
876
  // 仓库清单(提案 #13):路由提示。remote_nodes 给名字列表供选型,
873
877
  // repo 路由细节在 pickNodeByRepo。
874
878
  repos: Array.isArray(r.repos) ? r.repos.map((x) => x.name) : [],
@@ -14,6 +14,15 @@ import { ERROR_CODE, HUB_TO_NODE, NODE_TO_HUB, TASK_STATUS, TERMINAL_STATUS } fr
14
14
  import { E2E_MARKER } from '../e2e.js'
15
15
  import { errText, numOr } from './hub-util.js'
16
16
 
17
+ /**
18
+ * 单个节点可上报的模型数量上限(提案 #27)。
19
+ *
20
+ * 与副机侧的 MAX_MODELS 同量级 —— hub 侧必须**独立封顶**:不能信任对端
21
+ * 遵守了自己的上限(旧版副机、或刻意构造的帧都可能更大)。这是"单帧
22
+ * 故障隔离"不变式的一部分:畸形帧不能把注册表撑爆。
23
+ */
24
+ const MAX_NODE_MODELS = 64
25
+
17
26
  export function withIngest(Base) {
18
27
  return class extends Base {
19
28
  // ─────────────────────────── 上行帧处理 ───────────────────────────
@@ -188,6 +197,33 @@ export function withIngest(Base) {
188
197
  // 显式 null 是合法上报(副机没配默认模型)—— 清掉旧值避免误导
189
198
  if (d.defaultModel === null) rec.defaultModel = null
190
199
  }
200
+
201
+ // 可用模型清单(提案 #27 面板下拉的数据源)。
202
+ //
203
+ // 与 defaultModel 一样是"副机自述",但**整帧替换**的语义要更小心:
204
+ // · 副机上报 `models: null`(探测失败 / 还没探完)→ **保留旧值**,
205
+ // 不要清空。否则一次网络抖动会让面板下拉退回自由文本,
206
+ // 用户刚记住的选项突然消失。
207
+ // · 副机上报 `models: {models: [...]}` → 替换(含空数组:那是
208
+ // "确实一个都没有"的权威事实)。
209
+ //
210
+ // 字段级清洗 + 上限:一个畸形帧不能伤害注册表(单帧故障隔离不变式)。
211
+ const rawModels = d.models && typeof d.models === 'object' && !Array.isArray(d.models) ? d.models : null
212
+ if (rawModels) {
213
+ const list = Array.isArray(rawModels.models) ? rawModels.models : []
214
+ const cleaned = []
215
+ for (const m of list.slice(0, MAX_NODE_MODELS)) {
216
+ if (!m || typeof m !== 'object') continue
217
+ const id = typeof m.id === 'string' ? m.id.trim() : ''
218
+ if (id === '') continue
219
+ cleaned.push({
220
+ provider: typeof m.provider === 'string' ? m.provider.slice(0, 64) : '',
221
+ id: id.slice(0, 128),
222
+ name: typeof m.name === 'string' && m.name !== '' ? m.name.slice(0, 128) : id.slice(0, 128),
223
+ })
224
+ }
225
+ rec.models = cleaned
226
+ }
191
227
  return
192
228
  }
193
229
 
@@ -108,10 +108,28 @@ export const MAX_TIMEOUT_MS = 24 * 60 * 60 * 1000
108
108
  * (run 38894088627 实录)。生产读者的读窗口同样是几毫秒级,加宽到
109
109
  * ~1.3s 只影响"目标文件被别人持续锁死"这种异常场景,正常路径仍然
110
110
  * 前两次短退避就成功。仍是有界的:超时即抛,让上层报错而不是无限转圈。
111
+ *
112
+ * ── 2026-10-17 第二次加宽(run 39012045381,第 5 次 EPERM)──────────────
113
+ *
114
+ * 1.3s 仍不够。那个 run 的失败形态是:**测试自身在并发写同一个文件**
115
+ * (`原子写:并发写 + 并发读` 用例),多个 rename 互相争抢目标 ——
116
+ * 而 Windows 的 rename 覆盖"正被另一个 rename 短暂锁住"的目标时给 EPERM。
117
+ * CI runner 的磁盘在高峰期延迟抖动可以到秒级。
118
+ *
119
+ * 所以:
120
+ * · 总窗口拉到 **~6s**(16 次,封顶 500ms)—— 仍是有界的,且只在
121
+ * "目标被持续锁死"的异常路径上才会走满;正常路径第 1 次就成功。
122
+ * · 最后一次尝试前做一次 **unlink 兜底**:Windows 上"删掉目标再 rename"
123
+ * 能绕开"目标存在且被锁"的 EPERM(POSIX 上 rename 本来就覆盖,
124
+ * 这一步只在 Windows 且前面的重试全失败时才走,不影响原子性语义 ——
125
+ * 删除与 rename 之间读方会看到 ENOENT,所以**只在前 15 次全失败后**
126
+ * 才做,把它当最后手段而不是常规路径)。
111
127
  */
112
- const RENAME_RETRIES = 10
128
+ const RENAME_RETRIES = 16
113
129
  const RENAME_BACKOFF_MS = 10
114
- const RENAME_BACKOFF_MAX_MS = 200
130
+ const RENAME_BACKOFF_MAX_MS = 500
131
+ /** 第几次尝试开始走 unlink 兜底(0 基;= RENAME_RETRIES - 1 即最后一次)。 */
132
+ const RENAME_UNLINK_FALLBACK_AT = RENAME_RETRIES - 1
115
133
 
116
134
  /**
117
135
  * 可写字段白名单。
@@ -446,6 +464,16 @@ export async function atomicWriteJson(file, value) {
446
464
  } catch (err) {
447
465
  const transient = err?.code === 'EPERM' || err?.code === 'EBUSY' || err?.code === 'EACCES'
448
466
  if (!transient || attempt >= RENAME_RETRIES) throw err
467
+ // 最后一次尝试前走 unlink 兜底(见 RENAME_UNLINK_FALLBACK_AT 的说明)。
468
+ // 只在"前面所有退避都失败"时执行 —— 那个时点目标文件本来就被
469
+ // 别人长期锁着,删掉它换来一次成功的 rename 比继续等更划算。
470
+ if (attempt === RENAME_UNLINK_FALLBACK_AT) {
471
+ try {
472
+ await fs.promises.rm(file, { force: true })
473
+ } catch {
474
+ /* 删不掉就继续等下一轮 —— 失败会由最后的 throw 反映 */
475
+ }
476
+ }
449
477
  await new Promise((r) => setTimeout(r, Math.min(RENAME_BACKOFF_MAX_MS, RENAME_BACKOFF_MS * 2 ** attempt)))
450
478
  }
451
479
  }
package/lib/hub/tools.js CHANGED
@@ -443,6 +443,21 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
443
443
  model: { type: 'string' },
444
444
  },
445
445
  },
446
+ // 可用模型清单(提案 #27 面板下拉的数据源)。
447
+ // null = 副机未上报(旧版 / 探测未完成),由 dropNullForSchema
448
+ // 剥掉;[] = 副机明确说"一个都没有"(保留,语义不同)。
449
+ models: {
450
+ type: 'array',
451
+ items: {
452
+ type: 'object',
453
+ properties: {
454
+ provider: { type: 'string' },
455
+ id: { type: 'string' },
456
+ name: { type: 'string' },
457
+ },
458
+ required: ['id'],
459
+ },
460
+ },
446
461
  },
447
462
  required: ['nodeId', 'online'],
448
463
  },
package/lib/hub/views.js CHANGED
@@ -164,6 +164,29 @@ export function nodeView(n) {
164
164
  at: Number.isFinite(n.resource.at) ? n.resource.at : null,
165
165
  }
166
166
  : null,
167
+ // 执行后端 + 默认模型(提案 #27):面板节点行显示 + 派发前校验依据。
168
+ runnerKind: typeof n.runnerKind === 'string' ? n.runnerKind : null,
169
+ defaultModel: (n.defaultModel && typeof n.defaultModel === 'object')
170
+ ? {
171
+ provider: typeof n.defaultModel.provider === 'string' ? n.defaultModel.provider : '',
172
+ model: typeof n.defaultModel.model === 'string' ? n.defaultModel.model : '',
173
+ }
174
+ : null,
175
+ // 可用模型清单(提案 #27 面板下拉的数据源)。
176
+ //
177
+ // **逐条投影**,绝不 spread —— 帧里的对象可能被构造,多带字段就等于让
178
+ // 副机决定面板渲染什么。`null` 与 `[]` 语义不同:
179
+ // · null = 副机没上报(旧版 / 探测未完成)→ 面板退回自由文本
180
+ // · [] = 副机明确说"一个都没有" → 面板显示空下拉 + 提示
181
+ models: Array.isArray(n.models)
182
+ ? n.models
183
+ .map((m) => ({
184
+ provider: typeof m?.provider === 'string' ? m.provider : '',
185
+ id: typeof m?.id === 'string' ? m.id : '',
186
+ name: typeof m?.name === 'string' && m.name !== '' ? m.name : (typeof m?.id === 'string' ? m.id : ''),
187
+ }))
188
+ .filter((m) => m.id !== '')
189
+ : null,
167
190
  }
168
191
  }
169
192
 
package/lib/node/index.js CHANGED
@@ -308,10 +308,6 @@ export function resolveToken(config, stateDir = '') {
308
308
  * 的 provider/model/reasoning 选择)。**只报 provider 键名与 model id,
309
309
  * 不碰任何凭证**。取不到(服务缺失 / 抛错 / 字段形态不对)一律 null ——
310
310
  * 上报是尽力而为的遥测,绝不能影响资源帧本身。
311
- *
312
- * 完整的"可用模型清单"第一版不做:in-process 后端没有稳定的清单 API
313
- * (DSH 内部的 model/list 走 app-server 协议面,fiber 服务摸不到),
314
- * 探测 llm-pi-ai 内部结构太脆弱。等 DSH 把清单暴露成稳定 API 再补。
315
311
  */
316
312
  export function readDefaultModel(ctx) {
317
313
  try {
@@ -327,6 +323,92 @@ export function readDefaultModel(ctx) {
327
323
  }
328
324
  }
329
325
 
326
+ /** 模型清单上限:够面板下拉用,又不让 RESOURCE 帧膨胀。 */
327
+ const MAX_MODELS = 64
328
+ /** 单个 provider 的模型上限(防止某个聚合 provider 一次吐几百个)。 */
329
+ const MAX_MODELS_PER_PROVIDER = 24
330
+ /** provider 数量上限。 */
331
+ const MAX_MODEL_PROVIDERS = 8
332
+
333
+ /**
334
+ * 收集副机**可用的模型清单**(提案 #27 的面板下拉数据源)。
335
+ *
336
+ * ═══════════════════════════════════════════════════════════════════════════
337
+ * 为什么现在能做(2026-10-17 更新)
338
+ * ═══════════════════════════════════════════════════════════════════════════
339
+ *
340
+ * v0.7.0 的注释曾写"in-process 后端没有稳定的清单 API(DSH 内部的
341
+ * `model/list` 走 app-server 协议面,fiber 服务摸不到)" —— **那个判断
342
+ * 已经过时**。`ctx.llm` 服务现在直接暴露:
343
+ *
344
+ * listProviders(): LlmProviderInfo[] // {id, name}
345
+ * async listModels(provider): Promise<LlmModelInfo[]> // {provider, id, name, ...}
346
+ *
347
+ * 所以面板可以真的渲染下拉,而不是让用户猜 provider/model 名、填错了等
348
+ * 任务跑到副机才失败。
349
+ *
350
+ * ── 安全边界(硬要求)──────────────────────────────────────────────────
351
+ *
352
+ * **只上报 provider 键名 + model id + 显示名**。
353
+ * 绝不上报:apiKeyEnv 的值、baseURL、任何凭证、未注册的 provider。
354
+ * `listProviders()` 只返回**已注册(有 adapter)** 的 route —— 天然满足
355
+ * "不泄露这台机器配了哪些渠道",只有真能用的才会出现。
356
+ *
357
+ * ── 尽力而为 ────────────────────────────────────────────────────────────
358
+ *
359
+ * 每个 provider 的 `listModels` 都可能抛(网络探测、鉴权失败、adapter 不
360
+ * 支持)。单个 provider 失败**不影响其它**;全部失败则返回 null(面板退回
361
+ * 自由文本)。整段包在 try/catch 里:遥测帧绝不能因为模型探测而丢。
362
+ *
363
+ * @returns {Promise<{models: Array<{provider:string,id:string,name:string}>, defaultModel: object|null}|null>}
364
+ */
365
+ export async function readAvailableModels(ctx) {
366
+ let llm
367
+ try {
368
+ llm = typeof ctx?.get === 'function' ? ctx.get('llm') : undefined
369
+ } catch {
370
+ return null
371
+ }
372
+ if (!llm || typeof llm.listProviders !== 'function' || typeof llm.listModels !== 'function') {
373
+ return null
374
+ }
375
+
376
+ let providers
377
+ try {
378
+ providers = llm.listProviders()
379
+ } catch {
380
+ return null
381
+ }
382
+ if (!Array.isArray(providers) || providers.length === 0) return null
383
+
384
+ const models = []
385
+ // 逐个 provider 探测;单个失败静默跳过(不打断其它)。
386
+ for (const p of providers.slice(0, MAX_MODEL_PROVIDERS)) {
387
+ const providerId = p && typeof p.id === 'string' ? p.id.trim() : ''
388
+ if (providerId === '') continue
389
+ let list
390
+ try {
391
+ // 逐个探测是刻意的:并发打满会把副机的出站连接占住,
392
+ // 而这里只是低频遥测(RESOURCE 帧 30s 一次)。
393
+ list = await llm.listModels(providerId)
394
+ } catch {
395
+ continue
396
+ }
397
+ if (!Array.isArray(list)) continue
398
+ for (const m of list.slice(0, MAX_MODELS_PER_PROVIDER)) {
399
+ if (!m || typeof m.id !== 'string' || m.id === '') continue
400
+ const id = m.id.slice(0, 128)
401
+ const name = typeof m.name === 'string' && m.name !== '' ? m.name.slice(0, 128) : id
402
+ models.push({ provider: providerId.slice(0, 64), id, name })
403
+ if (models.length >= MAX_MODELS) break
404
+ }
405
+ if (models.length >= MAX_MODELS) break
406
+ }
407
+
408
+ if (models.length === 0) return null
409
+ return { models, defaultModel: readDefaultModel(ctx) }
410
+ }
411
+
330
412
  /** 组 hello:hub 与 relay 都靠它认这台机器。 */
331
413
  export function buildNodeHello(config, { roots, nodeId } = {}) { const c = asConfig(config)
332
414
  // 不变式:产出的 hello **必须**能过 protocol.validateHello。所以显式 override 也
@@ -584,6 +666,44 @@ function startWorker(ctx, config) {
584
666
  const resourceIntervalMs = Number.isFinite(cfg.resourceIntervalMs)
585
667
  ? Math.max(0, cfg.resourceIntervalMs)
586
668
  : RESOURCE_INTERVAL_MS
669
+
670
+ /**
671
+ * 模型清单缓存(提案 #27)。
672
+ *
673
+ * `readAvailableModels` 是 async(`llm.listModels()` 可能走网络探测),
674
+ * 而 RESOURCE 帧的 `setInterval` 回调是**同步**的 —— 不能在回调里 await。
675
+ *
676
+ * 所以用一个后台刷新循环:启动时探一次,之后每 `MODELS_REFRESH_MS` 重探。
677
+ * 帧里带的是**上一次的结果**(可能为 null = 还没探完/全部失败)。
678
+ *
679
+ * 为什么不用 `void refresh()` 直接塞进 interval:那会在探测慢时堆积
680
+ * 并发探测(每 30s 一次帧 × 探测要 3s = 越积越多)。用串行链保证
681
+ * 任何时刻最多一个探测在跑(与 repos 上报同款手法)。
682
+ */
683
+ const MODELS_REFRESH_MS = 10 * 60 * 1000
684
+ let cachedModels = null
685
+ let modelsTail = Promise.resolve()
686
+ let modelsQueued = false
687
+ const refreshModels = () => {
688
+ if (modelsQueued) return
689
+ modelsQueued = true
690
+ modelsTail = modelsTail.then(async () => {
691
+ modelsQueued = false
692
+ try {
693
+ const r = await readAvailableModels(ctx)
694
+ if (r) cachedModels = r
695
+ } catch {
696
+ /* 探测失败保留上一次的结果 —— 面板不该因为一次网络抖动退回空列表 */
697
+ }
698
+ })
699
+ }
700
+ // 启动后立刻探一次(不阻塞装配:fire-and-forget,结果进了缓存下一帧就带出去)
701
+ refreshModels()
702
+ const modelsTimer = resourceIntervalMs > 0
703
+ ? setInterval(refreshModels, MODELS_REFRESH_MS)
704
+ : undefined
705
+ if (modelsTimer?.unref) modelsTimer.unref()
706
+
587
707
  const resourceTimer = resourceIntervalMs > 0
588
708
  ? setInterval(() => {
589
709
  // 队列健康快照(提案 #26-3)随资源帧搭车:engine 在下方第 7 节才
@@ -593,12 +713,17 @@ function startWorker(ctx, config) {
593
713
  // 提案 #27:执行后端与默认模型随帧自述 —— hub 派发前要知道
594
714
  // "这台副机用哪个后端、默认模型是什么"(面板显示 + 派发前校验)。
595
715
  // currentSelection 每帧重算(用户可在副机改默认),取不到就 null。
716
+ //
717
+ // `models` 是**可用模型清单**(面板下拉的数据源),来自缓存 ——
718
+ // 探测是 async 的,不能在同步 interval 里 await(见 refreshModels)。
719
+ // 只报 provider 键名 + model id + 显示名,不碰任何凭证。
596
720
  send(NODE_TO_HUB.RESOURCE, {
597
721
  nodeId,
598
722
  ...sampler.sample(),
599
723
  queue,
600
724
  runnerKind: runner.kind,
601
725
  defaultModel: readDefaultModel(ctx),
726
+ models: cachedModels,
602
727
  })
603
728
  }, resourceIntervalMs)
604
729
  : undefined
@@ -477,6 +477,7 @@ export class InProcessRunner {
477
477
 
478
478
  let timer
479
479
  let timedOut = false
480
+ let aborted = false
480
481
  const timeoutPromise = new Promise((resolve) => {
481
482
  if (!(timeoutMs > 0)) return
482
483
  timer = setTimeout(() => {
@@ -487,6 +488,61 @@ export class InProcessRunner {
487
488
  if (timer.unref) timer.unref()
488
489
  })
489
490
 
491
+ /**
492
+ * 取消信号也必须参与 `Promise.race`(提案 #26-5)。
493
+ *
494
+ * ═══════════════════════════════════════════════════════════════════════
495
+ * 原实现:cancel 不真打断
496
+ * ═══════════════════════════════════════════════════════════════════════
497
+ *
498
+ * `race` 里只有两个竞争者 —— `agent.whenIdle()` 与 `timeoutPromise`:
499
+ *
500
+ * const raced = await Promise.race([
501
+ * agent.whenIdle().then(() => ({ timedOut: false })),
502
+ * timeoutPromise,
503
+ * ])
504
+ *
505
+ * 而 `InProcessRunner.cancel()` 做的是:
506
+ * `agent.cancel({ kind: 'user' })` + `controller.abort()`
507
+ *
508
+ * **`controller.abort()` 只是置了个信号,没有任何人在 await 它** ——
509
+ * `whenIdle()` 不因 signal 而停(DSH 的 agent 循环不监听外部 AbortSignal),
510
+ * 于是:
511
+ *
512
+ * 用户 cancel → race 还在等 whenIdle → agent 继续跑到自然结束 →
513
+ * 按 `ok: !timedOut` 落成 **completed**(不是 aborted)。
514
+ *
515
+ * 实测症状:hub 发 `task/cancel`,`remote_cancel` 返回 `requested: true`,
516
+ * 但任务**一直 running 到 agent 自己跑完**,最终状态 completed ——
517
+ * 用户以为取消成功,实际白跑一遍还烧了额度。
518
+ *
519
+ * ── 修法 ──────────────────────────────────────────────────────────────
520
+ *
521
+ * 把 abort 也做成可 race 的 promise:一旦触发立即 resolve,race 立刻返回,
522
+ * `finally` 里 `handle.dispose()` 收掉 agent。
523
+ *
524
+ * ⚠ 两个语义细节:
525
+ * 1. `timedOut` 为真时 abort 是**超时的副作用**,不该把状态改写成
526
+ * aborted(超时 ≠ 用户取消,下游据此外报不同的错误码)。
527
+ * 2. resolve 之后 agent 可能还跑一小会儿(dispose 是异步的),所以
528
+ * 状态判定必须用 `aborted` 而不是 `!timedOut`。
529
+ */
530
+ const abortPromise = new Promise((resolve) => {
531
+ if (controller.signal.aborted) {
532
+ aborted = true
533
+ resolve({ aborted: true })
534
+ return
535
+ }
536
+ controller.signal.addEventListener(
537
+ 'abort',
538
+ () => {
539
+ if (!timedOut) aborted = true
540
+ resolve({ aborted: !timedOut })
541
+ },
542
+ { once: true },
543
+ )
544
+ })
545
+
490
546
  const sessionIdFor = `session-remote-${String(taskId).replace(/[^A-Za-z0-9-]/g, '').slice(0, 48)}`
491
547
  let handle
492
548
  let disposeListener
@@ -699,9 +755,18 @@ export class InProcessRunner {
699
755
  const raced = await Promise.race([
700
756
  agent.whenIdle().then(() => ({ timedOut: false })),
701
757
  timeoutPromise,
758
+ // #26-5:取消必须能赢 race。少了这一项,cancel 只会置 signal,
759
+ // agent 继续跑到自然结束,状态落成 completed。
760
+ abortPromise,
702
761
  ])
703
762
  if (raced.timedOut === true) timedOut = true
704
- dlog('whenIdle resolved', { timedOut, liveTextLen: liveText.join('').length, elapsedMs: Date.now() - startedAt })
763
+ if (raced.aborted === true) aborted = true
764
+ dlog('whenIdle resolved', {
765
+ timedOut,
766
+ aborted,
767
+ liveTextLen: liveText.join('').length,
768
+ elapsedMs: Date.now() - startedAt,
769
+ })
705
770
 
706
771
  // 会话日志优先;日志取不到就用实时累积
707
772
  const fromLog = readSessionOutcome(session)
@@ -714,9 +779,13 @@ export class InProcessRunner {
714
779
  })
715
780
 
716
781
  return {
717
- ok: !timedOut,
718
- exitCode: timedOut ? null : 0,
782
+ // #26-5:取消不是成功。`ok` 必须把 aborted 排除掉,否则 engine 会把
783
+ // 取消落成 completed(engine 的映射是 `result.aborted ? ABORTED : ...`,
784
+ // 但那是 exec 侧;agent 侧靠这里的 ok 判定)。
785
+ ok: !timedOut && !aborted,
786
+ exitCode: timedOut || aborted ? null : 0,
719
787
  timedOut,
788
+ aborted,
720
789
  final: finalText,
721
790
  stderr: '',
722
791
  toolCalls: fromLog.toolCalls,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-agent",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "把另一台电脑变成你 DSH 的远程 Agent 同事:副机本地持久跑任务、独立 Agent 循环,只回传 handoff 摘要与产物清单,成品按需拉取。",
6
6
  "license": "MIT",
@@ -799,6 +799,93 @@ check('65', 'eslint 严格档:lint script + 配置文件 + CI job 三件套齐
799
799
  'scripts/check-doc-drift.mjs 断言 65(`\\s` 必须带反斜杠,否则脚本崩)')
800
800
  }
801
801
 
802
+ /* ── 73~74. 队列残余风险的两条防线(#26-4 / #26-5,2026-10-17)──
803
+ *
804
+ * 背景:副机的 `maxConcurrent=2` 被两条卡住的 exec 占满后,后续任务全部排队。
805
+ * 排查时发现两个让问题"不可见/不可控"的缺陷:
806
+ *
807
+ * #26-4 面板只显示 `running: 2` 这个数字 —— 看不到是哪两条、在跑什么、
808
+ * 跑了多久。用户只知道"卡了",没有任何可操作的东西。
809
+ * #26-5 `cancel` 只置 AbortSignal,没进 `Promise.race` —— agent 循环
810
+ * 不监听它,任务继续跑到自然结束,最终落成 **completed**(不是
811
+ * aborted)。用户以为取消成功,实际白跑一遍还烧了额度。
812
+ *
813
+ * 这两条都是"静默失效"型:没有报错、没有日志、行为看起来"正常"。
814
+ * 所以用断言把修复钉住。
815
+ */
816
+ {
817
+ const panelClient = read('lib/client.js')
818
+ check('73', '#26-4 面板任务列表按状态分组,运行中置顶且显示已跑时长',
819
+ /ACTIVE_STATUSES/.test(panelClient)
820
+ && /activeTasks/.test(panelClient)
821
+ && /运行中(/.test(panelClient)
822
+ && /已跑 /.test(panelClient),
823
+ 'lib/client.js(ACTIVE_STATUSES + 运行中分组 + 已跑时长)')
824
+
825
+ const runnerSrc = read('lib/node/runner.js')
826
+ check('74', '#26-5 agent 取消真打断:abortPromise 参与 race,ok 排除 aborted',
827
+ /const abortPromise/.test(runnerSrc)
828
+ && /abortPromise,/.test(runnerSrc)
829
+ && /ok:\s*!timedOut\s*&&\s*!aborted/.test(runnerSrc)
830
+ && /if\s*\(!timedOut\)\s*aborted\s*=\s*true/.test(runnerSrc),
831
+ 'lib/node/runner.js(abortPromise 进 race + ok 排除 aborted + 超时不误标)')
832
+ }
833
+
834
+ /* ── 75. #27 完整链路:模型清单自述 → 面板下拉(2026-10-17)──
835
+ *
836
+ * v0.7.0 只做了"自述 + 派发前拦截",面板仍是自由文本 —— 当时的判断是
837
+ * "DSH 没有稳定的清单 API"。**那个判断已过时**:`ctx.llm.listProviders()`
838
+ * 与 `listModels(provider)` 现在就是稳定服务面。
839
+ *
840
+ * 链路四段,任一断掉面板就退回自由文本:
841
+ * 1. 副机 readAvailableModels(探测 + 缓存 + 上限)
842
+ * 2. RESOURCE 帧带缓存值(interval 回调是同步的,不能 await)
843
+ * 3. hub 字段级清洗 + 独立封顶
844
+ * 4. registry.list → nodeView → 面板 <select>
845
+ *
846
+ * 安全边界:只报 provider/id/name,绝不带 apiKeyEnv / baseURL / 凭证。
847
+ */
848
+ {
849
+ const nodeIdx = read('lib/node/index.js')
850
+ const ingestSrc = read('lib/hub/registry-ingest.js')
851
+ const viewsSrc = read('lib/hub/views.js')
852
+ const clientSrc = read('lib/client.js')
853
+
854
+ check('75', '#27 模型清单链路:副机探测 + 帧自述 + hub 清洗 + 视图投影 + 面板下拉都在',
855
+ /export async function readAvailableModels/.test(nodeIdx)
856
+ && /llm\.listProviders\(\)/.test(nodeIdx)
857
+ && /llm\.listModels\(/.test(nodeIdx)
858
+ && /models:\s*cachedModels/.test(nodeIdx)
859
+ && /MAX_NODE_MODELS/.test(ingestSrc)
860
+ && /models: Array\.isArray\(r\.models\)/.test(read('lib/hub/node-registry.js'))
861
+ && /models: Array\.isArray\(n\.models\)/.test(viewsSrc)
862
+ && /跟随副机默认/.test(clientSrc)
863
+ && /__custom__/.test(clientSrc),
864
+ 'lib/node/index.js + registry-ingest.js + node-registry.js + views.js + client.js')
865
+
866
+ check('76', '#27 安全:模型自述只含 provider/id/name,不碰凭证',
867
+ (() => {
868
+ const code = nodeIdx.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
869
+ const i = code.indexOf('export async function readAvailableModels')
870
+ if (i < 0) return false
871
+ const seg = code.slice(i, i + 2600)
872
+ // 上报对象里不得出现凭证相关字段
873
+ if (/apiKeyEnv/.test(seg) || /baseURL/.test(seg)) return false
874
+ const push = seg.match(/models\.push\(\{([^}]*)\}\)/)
875
+ if (!push) return false
876
+ const inner = push[1]
877
+ const keys = new Set()
878
+ for (const m of inner.matchAll(/(\w+)\s*:/g)) keys.add(m[1])
879
+ for (const part of inner.split(',')) {
880
+ const t = part.trim()
881
+ if (/^[A-Za-z_$][\w$]*$/.test(t)) keys.add(t)
882
+ }
883
+ const got = [...keys].sort().join(',')
884
+ return got === 'id,name,provider'
885
+ })(),
886
+ 'lib/node/index.js readAvailableModels(上报字段恰好 provider/id/name)')
887
+ }
888
+
802
889
  /* ── 输出 ── */
803
890
  const failed = results.filter((r) => !r.ok)
804
891
  if (process.argv.includes('--json')) {