dsh-hitl 0.1.2 → 0.2.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-hitl · Human-in-the-loop for any tool
1
+ # dsh-hitl · Human-in-the-loop for DeepSeek Harness
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/dsh-hitl)](https://www.npmjs.com/package/dsh-hitl) [![license](https://img.shields.io/npm/l/dsh-hitl)](LICENSE) [![Website](https://img.shields.io/badge/Website-4c566a)](https://yunpengdon.github.io/dsh-hitl-landing/)
4
4
 
@@ -146,6 +146,7 @@ Without `fields`, the proposal is **every parameter of this call**:
146
146
  | `whenUnavailable` | `reject` \| `wait` | `reject` | Behaviour with no browser connected: `reject` = fail-closed; `wait` = wait indefinitely, so **pair it with a `countdown`** or that call hangs until its Session ends (the host warns at mount time); see §7.1 |
147
147
  | `enabled` | boolean \| `(exec) => boolean` | `true` | Turn a mount off temporarily / make it conditional |
148
148
  | `maxFieldChars` | number | `20000` | Per-field render cap; longer values are truncated with a note |
149
+ | `resolveTimeoutMs` | number | `5000` | Bound on resolving one **computed** field; a timeout or a throw fails that field alone, never the call |
149
150
 
150
151
  ### FieldSpec
151
152
 
@@ -154,6 +155,7 @@ Without `fields`, the proposal is **every parameter of this call**:
154
155
  render: 'markdown', // markdown | text | diff | json | hidden
155
156
  editable: true, // overrides the default editability
156
157
  labels: ['irreversible'], // field-level labels
158
+ value: 'fixed copy', // this field's text: a literal, or (exec) => string | Promise<string>
157
159
  diff: { before: 'old_string', after: 'new_string', path: 'file_path' } } // used when render: 'diff'
158
160
  ```
159
161
 
@@ -161,6 +163,31 @@ Without `fields`, the proposal is **every parameter of this call**:
161
163
  - When `fields` is given, **only** the listed fields are shown (parameters you leave out never reach the panel).
162
164
  - Config rows may shorten a spec to a bare string: `fields: ['command', { param: 'cwd', title: 'Working directory' }]`.
163
165
 
166
+ ### Computed fields: putting what the arguments do not carry on the card
167
+
168
+ `diff.before` / `diff.after` / `diff.path` normally name a **parameter** (the host reads `args[name]`). Like a field's `value`, any of them may instead be a **function**: the host calls it with the pending `exec` before the card opens and uses what it returns as the text.
169
+
170
+ ```js
171
+ ctx.hitl.protect('update_index', {
172
+ title: 'Update the knowledge-tree index',
173
+ fields: [
174
+ { param: 'content', title: 'New index text', render: 'markdown' },
175
+ { param: 'index', title: 'What changes in the index file', render: 'diff', editable: false,
176
+ // `before` reads the old index from disk, `after` derives the new file text —
177
+ // neither of them is one of the call's parameters
178
+ diff: { path: 'index.md',
179
+ before: async exec => readIndexFile(exec),
180
+ after: exec => `# Knowledge tree index\n\n${exec.arguments.content}` } },
181
+ ],
182
+ })
183
+ ```
184
+
185
+ - A string `diff.path` that is **not** a parameter name is used as a literal path: the file a call is about to change is often not one of its arguments.
186
+ - Resolvers may be async; one field's resolution is bounded by `resolveTimeoutMs` (5s by default).
187
+ - **A failed resolution never blocks the decision**: a throw or a timeout turns *that field* into one `⚠️ …could not be resolved…` line, the card still opens, and the human still decides. If the resolution machinery itself breaks, the proposal degrades to the raw arguments instead of denying the call.
188
+ - The wire format and the browser half are unchanged: both sides of a diff always travelled in the frame; the panel just renders them.
189
+ - Each side of a diff is cut past `maxDiffLines` (2000 lines) with a note, so a whole-file diff cannot become an unbounded frame.
190
+
164
191
  ### The three endings of a countdown
165
192
 
166
193
  `countdown.action` decides what the host does when the countdown reaches zero:
@@ -385,22 +412,22 @@ Reply: `{ ok: true, accepted: true, held?, expiresAt? }` or
385
412
 
386
413
  ```text
387
414
  dsh-hitl/
388
- ├── index.js 639 host half: service + gate + countdown/hold + SSE & decision routes + token injection
415
+ ├── index.js 666 host half: service + gate + countdown/hold + SSE & decision routes + token injection
389
416
  ├── client.js 1918 browser half: decision panel + frame-level notice (self-contained classic script, no imports)
390
417
  ├── lib/ the host half's pure logic: no Cordis, no DOM, testable on its own
391
- │ ├── protocol.js 182 frame and uplink validation, limits, error codes (the single source of the protocol)
392
- │ ├── resolve.js 311 matcher compilation (name/glob/RegExp/predicate), option normalization and diagnostics
393
- │ ├── fields.js 244 default proposal derivation, diff pairing, decision → model-visible text
418
+ │ ├── protocol.js 195 frame and uplink validation, limits, error codes (the single source of the protocol)
419
+ │ ├── resolve.js 363 matcher compilation (name/glob/RegExp/predicate), option normalization and diagnostics
420
+ │ ├── fields.js 449 default proposal derivation, diff pairing, computed-field resolution, decision → model-visible text
394
421
  │ └── pending.js 217 the pending-decision state machine (injectable clock): countdown, hold, settle-once
395
422
  ├── locale/ the plugin list's name and description (shape must be {"meta":{...}}, see §6.3)
396
423
  │ ├── en.json 6
397
424
  │ └── zh.json 6
398
425
  ├── docs/ screenshot sources, deliberately NOT shipped: the README links the landing page instead
399
- ├── tests/ zero-dependency Node tests, 153 cases in total
426
+ ├── tests/ zero-dependency Node tests, 167 cases in total
400
427
  │ ├── client.test.js 818 (65) the browser half's pure helpers + store / connection / seat
401
- │ ├── fields.test.js 269 (26) proposal derivation and decision text
428
+ │ ├── fields.test.js 387 (37) proposal derivation, computed fields and decision text
402
429
  │ ├── pending.test.js 240 (18) the state machine (injected clock, no real waiting)
403
- │ ├── resolve.test.js 181 (19) matchers and option normalization
430
+ │ ├── resolve.test.js 224 (22) matchers and option normalization
404
431
  │ ├── protocol.test.js 117 (16) frame and uplink validation
405
432
  │ ├── docs.test.js 121 (5) the two READMEs, the landing-page link, and the numbers in this very table
406
433
  │ └── manifest.test.js 77 (4) package metadata and the locale resource shape
@@ -436,7 +463,7 @@ dsh-hitl/
436
463
 
437
464
  ```sh
438
465
  node --check index.js client.js lib/*.js # syntax
439
- npm test # 153 zero-dependency cases: pure functions, state machines, the channel
466
+ npm test # 167 zero-dependency cases: pure functions, state machines, the channel
440
467
  npm run check # both of the above (syntax + the whole suite)
441
468
  ```
442
469
 
package/README.zh.md CHANGED
@@ -159,6 +159,7 @@ export function apply(ctx) {
159
159
  | `whenUnavailable` | `reject` \| `wait` | `reject` | 没有任何浏览器连接时的行为:`reject` = fail-closed;`wait` = 一直等,**务必配 `countdown`**,否则那次调用会挂到会话结束(挂载时宿主会给 `dsh-hitl:` 警告),见 §7.1 |
160
160
  | `enabled` | boolean \| `(exec) => boolean` | `true` | 临时关闭 / 条件挂载 |
161
161
  | `maxFieldChars` | number | `20000` | 单字段渲染上限,超出会截断并提示 |
162
+ | `resolveTimeoutMs` | number | `5000` | 单个**计算字段**的解析上限;超时或抛错只让那个字段显示失败,不阻断这次调用 |
162
163
 
163
164
  ### FieldSpec
164
165
 
@@ -167,6 +168,7 @@ export function apply(ctx) {
167
168
  render: 'markdown', // markdown | text | diff | json | hidden
168
169
  editable: true, // 覆盖默认可编辑性
169
170
  labels: ['不可撤销'], // 字段级标签
171
+ value: '固定文案', // 该字段的文本:字面量,或 (exec) => string | Promise<string>
170
172
  diff: { before: 'old_string', after: 'new_string', path: 'file_path' } } // render: 'diff' 时用
171
173
  ```
172
174
 
@@ -174,6 +176,30 @@ export function apply(ctx) {
174
176
  - `fields` 给定时**只显示**列出的字段(未列出的参数不会出现在面板里)。
175
177
  - 配置行里可以简写成字符串:`fields: ['command', { param: 'cwd', title: '工作目录' }]`。
176
178
 
179
+ ### 计算字段:把参数之外的东西放进卡里
180
+
181
+ `diff.before` / `diff.after` / `diff.path` 平时填的是**参数名**(宿主取 `args[名字]`)。它们与字段的 `value` 一样,也可以填**函数**:宿主在弹卡前用这次调用的 `exec` 调它,把返回值当作文本。
182
+
183
+ ```js
184
+ ctx.hitl.protect('update_index', {
185
+ title: '更新知识树索引',
186
+ fields: [
187
+ { param: 'content', title: '新索引正文', render: 'markdown' },
188
+ { param: 'index', title: '索引文件的变化', render: 'diff', editable: false,
189
+ // before 读磁盘上的旧索引,after 由新正文推出来——两者都不在参数里
190
+ diff: { path: 'index.md',
191
+ before: async exec => readIndexFile(exec),
192
+ after: exec => `# 知识树索引\n\n${exec.arguments.content}` } },
193
+ ],
194
+ })
195
+ ```
196
+
197
+ - 字符串形式的 `diff.path` 若**不是**某个参数名,就当作字面路径使用:被改的文件常常不是这次调用的参数。
198
+ - 函数可以是 async;每个字段的解析上限是 `resolveTimeoutMs`(默认 5s)。
199
+ - **解析失败不阻断**:函数抛错或超时,只会把**那个字段**变成一行 `⚠️ 无法解析该字段:…`,卡照常弹出、人照常决策。整个解析过程坏掉时,提案退回到"参数原样",而不是拒绝调用。
200
+ - wire 格式与浏览器半都没有变化:diff 的两侧本来就在帧里,前端只是照常渲染。
201
+ - diff 的每一侧超过 `maxDiffLines`(2000 行)会被截断并标注,避免整文件 diff 变成无上限的一帧。
202
+
177
203
  ### 倒计时的三种结局
178
204
 
179
205
  `countdown.action` 决定倒计时归零后宿主怎么判:
@@ -448,22 +474,22 @@ Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. T
448
474
 
449
475
  ```text
450
476
  dsh-hitl/
451
- ├── index.js 639 宿主半:服务 + 门禁 + 倒计时/持握 + SSE/决策路由 + 令牌注入
477
+ ├── index.js 666 宿主半:服务 + 门禁 + 倒计时/持握 + SSE/决策路由 + 令牌注入
452
478
  ├── client.js 1918 浏览器半:决策面板 + 帧级浮层(自包含 classic script,无 import)
453
479
  ├── lib/ 宿主半的纯逻辑:不碰 Cordis、不碰 DOM,可单独跑测试
454
- │ ├── protocol.js 182 帧与上行校验、上限、错误码(协议规范的唯一出处)
455
- │ ├── resolve.js 311 matcher 编译(名字/通配/RegExp/谓词)、挂载选项归一化与诊断
456
- │ ├── fields.js 244 默认提案推导、diff 配对、决策 → 模型可见文本
480
+ │ ├── protocol.js 195 帧与上行校验、上限、错误码(协议规范的唯一出处)
481
+ │ ├── resolve.js 363 matcher 编译(名字/通配/RegExp/谓词)、挂载选项归一化与诊断
482
+ │ ├── fields.js 449 默认提案推导、diff 配对、计算字段解析、决策 → 模型可见文本
457
483
  │ └── pending.js 217 待决策状态机(可注入时钟):倒计时、持握、恰好一次的结算
458
484
  ├── locale/ 插件列表里的名字与简介(形状必须是 {"meta":{...}},见 §6.3)
459
485
  │ ├── en.json 6
460
486
  │ └── zh.json 6
461
487
  ├── docs/ 截图源文件,**故意不进 npm 包**:README 改为链接 landing page
462
- ├── tests/ 零依赖 Node 测试,共 153 个用例
488
+ ├── tests/ 零依赖 Node 测试,共 167 个用例
463
489
  │ ├── client.test.js 818 (65)浏览器半的纯函数 + store / connection / seat
464
- │ ├── fields.test.js 269 (26)提案推导与决策文本
490
+ │ ├── fields.test.js 387 (37)提案推导、计算字段与决策文本
465
491
  │ ├── pending.test.js 240 (18)状态机(注入时钟,无真实等待)
466
- │ ├── resolve.test.js 181 (19)matcher 与选项归一化
492
+ │ ├── resolve.test.js 224 (22)matcher 与选项归一化
467
493
  │ ├── protocol.test.js 117 (16)帧与上行校验
468
494
  │ ├── docs.test.js 121 (5)两份 README、landing page 链接,以及上面这张表里的每个数字
469
495
  │ └── manifest.test.js 77 (4)包元数据与 locale 资源形状
@@ -505,7 +531,7 @@ dsh-hitl/
505
531
 
506
532
  ```sh
507
533
  node --check index.js client.js lib/*.js # 语法
508
- npm test # 153 个纯函数/状态机/通道用例,零依赖
534
+ npm test # 167 个纯函数/状态机/通道用例,零依赖
509
535
  npm run check # 上面两步合起来(语法 + 全部用例)
510
536
  ```
511
537
 
package/index.js CHANGED
@@ -21,7 +21,9 @@ import {
21
21
  DEFAULT_ENDPOINT, ERROR_CODES, GLOBAL_KEY, LIMITS, OUTCOMES, errorBody, frameHoldAck,
22
22
  framePing, frameRequest, frameSettled, frameSnapshot, okBody, parseUplink, sseChunk,
23
23
  } from './lib/protocol.js'
24
- import { buildRequest, capReason, describeDecision, isRevision, revisionContext } from './lib/fields.js'
24
+ import {
25
+ buildRequest, capReason, describeDecision, hasComputed, isRevision, resolveFields, revisionContext,
26
+ } from './lib/fields.js'
25
27
  import { DEFAULT_HOLD_GRACE_MS, createPendingRegistry } from './lib/pending.js'
26
28
  import { matchMount, mountApplies, normalizeMount, normalizeProtectList } from './lib/resolve.js'
27
29
 
@@ -234,6 +236,8 @@ export function apply(ctx, config = {}) {
234
236
  rejectFeedback: mount.options.reject.feedback,
235
237
  modify: mount.options.modify.mode,
236
238
  whenUnavailable: mount.options.whenUnavailable,
239
+ /** Whether this mount's proposal needs the host to compute a field. */
240
+ computed: hasComputed(mount.options),
237
241
  })),
238
242
  /** Every decision still waiting for a human, oldest first. */
239
243
  pending: () => registry.list().map(request => ({
@@ -315,6 +319,23 @@ export function apply(ctx, config = {}) {
315
319
  }
316
320
  }
317
321
 
322
+ /**
323
+ * Collect a mount's computed fields for one call.
324
+ *
325
+ * If the resolution itself breaks, the proposal degrades to the call's own
326
+ * arguments: a panel of raw parameters is still a decision the human can make,
327
+ * while a denied call is not. A resolver that fails on its own degrades inside
328
+ * `resolveFields` instead, into one field that says so.
329
+ */
330
+ async function resolutionFor(exec, mount) {
331
+ try {
332
+ return await resolveFields(exec, mount, { timeoutMs: mount.options.resolveTimeoutMs, warn })
333
+ } catch (error) {
334
+ warn(`the computed fields of tool ${JSON.stringify(exec.name)} could not be resolved: ${String(error)}`)
335
+ return undefined
336
+ }
337
+ }
338
+
318
339
  async function gate(exec, next) {
319
340
  let mount
320
341
  try {
@@ -335,7 +356,13 @@ export function apply(ctx, config = {}) {
335
356
  }
336
357
  const id = `hitl:${String(exec.callId ?? randomUUID())}`
337
358
  try {
338
- const request = buildRequest({ execution: exec, mount, sessionId, id })
359
+ const request = buildRequest({
360
+ execution: exec,
361
+ mount,
362
+ sessionId,
363
+ id,
364
+ resolution: await resolutionFor(exec, mount),
365
+ })
339
366
  const settlement = await waitForDecision(id, request, mount, exec)
340
367
  return toPreToolDecision(exec, request, mount, settlement)
341
368
  } catch (error) {
package/lib/fields.js CHANGED
@@ -6,6 +6,11 @@
6
6
  * Defaults follow the plugin's core promise: with no configuration at all, the
7
7
  * proposal is the call's own parameters — one field per parameter, titled by the
8
8
  * parameter's key, rendered as an editable markdown box.
9
+ *
10
+ * A mount that must show something the arguments do not carry — the file a call
11
+ * is about to overwrite, the node it is about to delete — declares it as a
12
+ * function; `resolveFields` runs those before the request is built. The browser
13
+ * half stays a pure renderer of the wire shape and needs no change for them.
9
14
  */
10
15
 
11
16
  import { LIMITS, truncate } from './protocol.js'
@@ -14,6 +19,9 @@ import { defaultEditable } from './resolve.js'
14
19
  /** Model-facing prefix every reason this plugin produces starts with. */
15
20
  export const REASON_PREFIX = 'HITL'
16
21
 
22
+ /** Default bound on one computed field, used when the mount sets no `resolveTimeoutMs`. */
23
+ export const RESOLVE_TIMEOUT_MS = LIMITS.resolveTimeoutMs
24
+
17
25
  /** Render a parameter value as proposal text. */
18
26
  export function renderValue(value) {
19
27
  if (typeof value === 'string') return value
@@ -36,6 +44,25 @@ export function inferRender() {
36
44
  return 'markdown'
37
45
  }
38
46
 
47
+ /** Whether a value is computed from the pending call instead of read from an argument. */
48
+ export function isResolver(value) {
49
+ return typeof value === 'function'
50
+ }
51
+
52
+ /**
53
+ * The diff option that applies to one field: the field's own, else the mount's.
54
+ *
55
+ * Shared by the renderer and the resolver on purpose: a field must never be
56
+ * resolved as a diff and then rendered as something else, or the other way
57
+ * round.
58
+ */
59
+ function diffOptionOf(spec, mount) {
60
+ const requested = spec.diff ?? mount.options.diff
61
+ return typeof requested === 'object' && requested !== null && !Array.isArray(requested)
62
+ ? requested
63
+ : undefined
64
+ }
65
+
39
66
  function schemaOf(execution) {
40
67
  const parameters = execution.schema?.parameters
41
68
  return typeof parameters === 'object' && parameters !== null ? parameters : undefined
@@ -66,10 +93,12 @@ export function defaultFieldSpecs(execution) {
66
93
  }
67
94
 
68
95
  function diffSpec(spec, mount, execution) {
69
- const requested = spec.diff ?? mount.options.diff
70
- if (requested === undefined || requested === null || typeof requested !== 'object') return undefined
96
+ const requested = diffOptionOf(spec, mount)
97
+ if (requested === undefined) return undefined
71
98
  const before = requested.before
72
99
  const after = requested.after
100
+ // Function sides are computed before the request is built (see
101
+ // `resolveFields`); this path only pairs two arguments.
73
102
  if (typeof before !== 'string' || typeof after !== 'string') return undefined
74
103
  const args = argumentsOf(execution)
75
104
  if (args === undefined) return undefined
@@ -80,16 +109,161 @@ function diffSpec(spec, mount, execution) {
80
109
  }
81
110
  }
82
111
 
112
+ // ── computed fields ─────────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Await one resolver with a bound.
116
+ *
117
+ * A resolver is caller-supplied code, so it can hang; the gate must not hang
118
+ * with it. The losing side of the race keeps running — a promise cannot be
119
+ * cancelled — but nothing awaits it, and the timer is unref'd so a pending
120
+ * timeout never holds the process open on its own.
121
+ */
122
+ async function callResolver(resolver, execution, timeoutMs, label) {
123
+ let timer
124
+ try {
125
+ return await Promise.race([
126
+ Promise.resolve().then(() => resolver(execution)),
127
+ new Promise((_resolve, reject) => {
128
+ timer = setTimeout(() => { reject(new Error(`${label} timed out after ${timeoutMs}ms`)) }, timeoutMs)
129
+ if (typeof timer?.unref === 'function') timer.unref()
130
+ }),
131
+ ])
132
+ } finally {
133
+ clearTimeout(timer)
134
+ }
135
+ }
136
+
137
+ /** Render whatever a resolver returned as field text. */
138
+ function renderComputed(value) {
139
+ return value === undefined || value === null ? '' : renderValue(value)
140
+ }
141
+
142
+ /**
143
+ * Read one diff side: a resolver computes it, a string names the argument that
144
+ * carries it. `path` may also be a literal, because the file a call is about to
145
+ * change is often not one of the call's parameters.
146
+ */
147
+ async function readDiffSide(declared, key, execution, timeoutMs) {
148
+ if (isResolver(declared)) {
149
+ return renderComputed(await callResolver(declared, execution, timeoutMs, `diff.${key}`))
150
+ }
151
+ const args = argumentsOf(execution)
152
+ const carried = args === undefined ? undefined : args[declared]
153
+ if (carried === undefined && key === 'path') return declared
154
+ return renderValue(carried)
155
+ }
156
+
157
+ /**
158
+ * Resolve the computed parts of one field spec.
159
+ * @param spec - the field spec.
160
+ * @param mount - the matched mount, for a mount-level diff.
161
+ * @param execution - the pending call.
162
+ * @param settings - `{ timeoutMs, warn }`.
163
+ * @returns `{ kind: 'text' | 'diff' | 'error', ... }`, or undefined when the spec computes nothing.
164
+ */
165
+ async function resolveSpec(spec, mount, execution, settings) {
166
+ const requested = diffOptionOf(spec, mount)
167
+ const value = isResolver(spec.value) ? spec.value : undefined
168
+ const sides = requested === undefined
169
+ ? []
170
+ : ['path', 'before', 'after'].filter(key => isResolver(requested[key]))
171
+ if (value === undefined && sides.length === 0) return undefined
172
+ try {
173
+ if (sides.length === 0) {
174
+ const computed = await callResolver(value, execution, settings.timeoutMs, 'fields[].value')
175
+ return { kind: 'text', text: renderComputed(computed) }
176
+ }
177
+ const [path, oldText, newText] = await Promise.all([
178
+ readDiffSide(requested.path, 'path', execution, settings.timeoutMs),
179
+ readDiffSide(requested.before, 'before', execution, settings.timeoutMs),
180
+ readDiffSide(requested.after, 'after', execution, settings.timeoutMs),
181
+ ])
182
+ return { kind: 'diff', diff: { path, oldText, newText } }
183
+ } catch (error) {
184
+ const message = error instanceof Error ? error.message : String(error)
185
+ settings.warn(`a computed field could not be resolved (${message}); the panel shows the failure instead`)
186
+ // Degrade to a visible field, never to a blocked gate: the human can still
187
+ // decide on the call itself, and every other field still renders.
188
+ return { kind: 'error', message: `⚠️ 无法解析该字段:${message}` }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Resolve every computed field of one mount against the pending call.
194
+ *
195
+ * Only specs that declare a resolver are resolved, so a mount made of plain
196
+ * argument fields pays nothing and keeps its proposal unchanged.
197
+ *
198
+ * @param execution - the pending call.
199
+ * @param mount - the matched mount.
200
+ * @param options - `{ timeoutMs, warn }`; defaults to `RESOLVE_TIMEOUT_MS` and silence.
201
+ * @returns `{ fields: Map<spec, result>, mountDiff? }`, to hand to `buildRequest`.
202
+ */
203
+ export async function resolveFields(execution, mount, options = {}) {
204
+ const timeoutMs = Number.isSafeInteger(options.timeoutMs) && options.timeoutMs > 0
205
+ ? options.timeoutMs
206
+ : RESOLVE_TIMEOUT_MS
207
+ const settings = {
208
+ timeoutMs,
209
+ warn: typeof options.warn === 'function' ? options.warn : () => {},
210
+ }
211
+ const fields = new Map()
212
+ if (mount.options.fields !== undefined) {
213
+ for (const spec of mount.options.fields) {
214
+ const resolved = await resolveSpec(spec, mount, execution, settings)
215
+ if (resolved !== undefined) fields.set(spec, resolved)
216
+ }
217
+ }
218
+ // Without an explicit field list, `buildFields` derives one field per
219
+ // parameter and appends the mount's diff as a field of its own; that diff
220
+ // computes on its own too.
221
+ const mountDiff = mount.options.fields === undefined
222
+ ? await resolveSpec({ param: 'diff' }, mount, execution, settings)
223
+ : undefined
224
+ return { fields, mountDiff }
225
+ }
226
+
227
+ /** Whether a mount asks the host to compute any part of its proposal. */
228
+ export function hasComputed(options) {
229
+ const computed = requested => requested !== undefined && requested !== null
230
+ && ['path', 'before', 'after'].some(key => isResolver(requested[key]))
231
+ if (computed(options.diff)) return true
232
+ return (options.fields ?? []).some(spec => isResolver(spec.value) || computed(spec.diff))
233
+ }
234
+
235
+ /** Bound one side of a diff by lines, marking what was cut. */
236
+ function boundDiffSide(text) {
237
+ const lines = text.split('\n')
238
+ if (lines.length <= LIMITS.maxDiffLines) return text
239
+ return [
240
+ ...lines.slice(0, LIMITS.maxDiffLines),
241
+ `… [已截断,另有 ${lines.length - LIMITS.maxDiffLines} 行未显示]`,
242
+ ].join('\n')
243
+ }
244
+
245
+ /** Bound both sides of one diff, so a whole-file diff cannot become an unbounded frame. */
246
+ function boundDiff(diff) {
247
+ return { ...diff, oldText: boundDiffSide(diff.oldText), newText: boundDiffSide(diff.newText) }
248
+ }
249
+
250
+ /** Label of the field a mount-level `diff` produces when its sides are computed. */
251
+ function diffPairLabel(requested) {
252
+ const side = entry => (typeof entry === 'string' ? entry : 'computed')
253
+ return `${side(requested.before)}→${side(requested.after)}`
254
+ }
255
+
83
256
  /**
84
257
  * Build the wire fields of one request.
85
258
  * @param execution - the pending tool execution.
86
259
  * @param mount - the matched mount.
260
+ * @param resolution - optional `resolveFields` result for this call.
87
261
  * @returns an ordered field array, values already rendered and truncated.
88
262
  */
89
- export function buildFields(execution, mount) {
263
+ export function buildFields(execution, mount, resolution) {
90
264
  const options = mount.options
91
265
  const args = argumentsOf(execution)
92
- const mountDiff = options.diff === undefined || options.diff === null ? undefined : options.diff
266
+ const mountDiff = diffOptionOf({}, mount)
93
267
  // A mount-level diff pairs two parameters, so those parameters must not also
94
268
  // appear as their own fields in the defaulted list.
95
269
  const paired = options.fields === undefined && mountDiff !== undefined
@@ -106,18 +280,33 @@ export function buildFields(execution, mount) {
106
280
  const raw = param === '(arguments)' && args === undefined
107
281
  ? execution.arguments
108
282
  : (args === undefined ? undefined : args[param])
109
- const render = spec.render ?? (diffSpec(spec, mount, execution) === undefined ? inferRender(raw) : 'diff')
110
- const diff = render === 'diff' ? diffSpec(spec, mount, execution) : undefined
283
+ const computed = resolution === undefined || resolution.fields === undefined
284
+ ? undefined
285
+ : resolution.fields.get(spec)
286
+ const computedDiff = computed !== undefined && computed.kind === 'diff' ? computed.diff : undefined
287
+ const render = computed !== undefined && computed.kind === 'error'
288
+ ? 'text'
289
+ : computedDiff !== undefined
290
+ ? 'diff'
291
+ : spec.render ?? (diffSpec(spec, mount, execution) === undefined ? inferRender(raw) : 'diff')
292
+ const diff = computedDiff ?? (render === 'diff' ? diffSpec(spec, mount, execution) : undefined)
111
293
  const title = spec.title
112
294
  ?? (typeof schema?.title === 'string' ? schema.title : undefined)
113
295
  ?? param
114
296
  const description = spec.description
115
297
  ?? (typeof schema?.description === 'string' ? schema.description : undefined)
116
298
  const editable = spec.editable ?? defaultEditable(render)
299
+ // A literal `value` replaces the parameter text; a function `value` was
300
+ // already turned into `computed` before this ran.
301
+ const declared = typeof spec.value === 'string' ? spec.value : undefined
117
302
  // A hidden field carries no value: it exists only to stay out of the panel.
118
- const value = diff === undefined && render !== 'hidden'
119
- ? truncate(renderValue(raw), options.maxFieldChars)
120
- : { text: '', truncated: false }
303
+ const value = computed !== undefined && computed.kind === 'error'
304
+ ? { text: computed.message, truncated: false }
305
+ : (computed !== undefined && computed.kind === 'text' && diff === undefined && render !== 'hidden'
306
+ ? truncate(computed.text, options.maxFieldChars)
307
+ : (diff === undefined && render !== 'hidden'
308
+ ? truncate(declared ?? renderValue(raw), options.maxFieldChars)
309
+ : { text: '', truncated: false }))
121
310
  fields.push({
122
311
  param,
123
312
  title,
@@ -125,20 +314,36 @@ export function buildFields(execution, mount) {
125
314
  render,
126
315
  editable: render === 'diff' || render === 'hidden' ? false : editable,
127
316
  labels: spec.labels ?? [],
128
- ...(diff === undefined ? { value: value.text, truncated: value.truncated } : { diff }),
317
+ ...(diff === undefined ? { value: value.text, truncated: value.truncated } : { diff: boundDiff(diff) }),
129
318
  })
130
319
  }
131
320
  if (options.fields === undefined && mountDiff !== undefined) {
132
- const diff = diffSpec({ diff: mountDiff }, mount, execution)
133
- if (diff !== undefined) {
321
+ const computed = resolution === undefined ? undefined : resolution.mountDiff
322
+ const title = typeof options.diff.title === 'string' ? options.diff.title : 'diff'
323
+ if (computed !== undefined && computed.kind === 'error') {
134
324
  fields.push({
135
- param: `${options.diff.before}→${options.diff.after}`,
136
- title: typeof options.diff.title === 'string' ? options.diff.title : 'diff',
137
- render: 'diff',
325
+ param: 'diff',
326
+ title,
327
+ render: 'text',
138
328
  editable: false,
139
329
  labels: [],
140
- diff,
330
+ value: computed.message,
331
+ truncated: false,
141
332
  })
333
+ } else {
334
+ const diff = computed !== undefined && computed.kind === 'diff'
335
+ ? computed.diff
336
+ : diffSpec({ diff: mountDiff }, mount, execution)
337
+ if (diff !== undefined) {
338
+ fields.push({
339
+ param: diffPairLabel(options.diff),
340
+ title,
341
+ render: 'diff',
342
+ editable: false,
343
+ labels: [],
344
+ diff: boundDiff(diff),
345
+ })
346
+ }
142
347
  }
143
348
  }
144
349
  if (fields.length === 0) {
@@ -149,12 +354,12 @@ export function buildFields(execution, mount) {
149
354
 
150
355
  /**
151
356
  * Build one complete wire request.
152
- * @param input - `{ execution, mount, sessionId, id, now }`.
357
+ * @param input - `{ execution, mount, sessionId, id, now, resolution }`.
153
358
  * @returns the request object broadcast to browsers.
154
359
  */
155
- export function buildRequest({ execution, mount, sessionId, id, now = Date.now() }) {
360
+ export function buildRequest({ execution, mount, sessionId, id, now = Date.now(), resolution }) {
156
361
  const options = mount.options
157
- const fields = buildFields(execution, mount)
362
+ const fields = buildFields(execution, mount, resolution)
158
363
  const countdown = options.countdown === null ? null : {
159
364
  remainingMs: options.countdown.seconds * 1000,
160
365
  action: options.countdown.action,
package/lib/protocol.js CHANGED
@@ -30,6 +30,19 @@ export const LIMITS = {
30
30
  maxFieldChars: 20000,
31
31
  /** Longest decision text folded into one model-facing message. */
32
32
  maxDecisionChars: 8000,
33
+ /**
34
+ * Lines kept per side of one diff. Deliberately far above what a reader
35
+ * compares by eye: it exists so a computed diff of a whole file cannot become
36
+ * an unbounded SSE frame, not to shorten small ones. The browser half stops
37
+ * aligning side by side past 2000 lines of its own, so a cut here is the only
38
+ * one worth announcing.
39
+ */
40
+ maxDiffLines: 2000,
41
+ /**
42
+ * Default bound on one computed field's resolver. Caller-supplied code can
43
+ * hang, and the gate must not hang with it.
44
+ */
45
+ resolveTimeoutMs: 5000,
33
46
  }
34
47
 
35
48
  /** Failure vocabulary shared by the HTTP replies and the panel's error state. */
package/lib/resolve.js CHANGED
@@ -42,6 +42,7 @@ export const DEFAULT_OPTIONS = {
42
42
  whenUnavailable: 'reject',
43
43
  enabled: true,
44
44
  maxFieldChars: LIMITS.maxFieldChars,
45
+ resolveTimeoutMs: LIMITS.resolveTimeoutMs,
45
46
  }
46
47
 
47
48
  /**
@@ -150,6 +151,45 @@ function normalizeButtons(value, report) {
150
151
  return buttons
151
152
  }
152
153
 
154
+ /** One side of a diff: the name of a parameter, or a function computing the text. */
155
+ function isDiffSource(value) {
156
+ return typeof value === 'string' || typeof value === 'function'
157
+ }
158
+
159
+ /**
160
+ * Check one `diff` option, at field or mount level.
161
+ *
162
+ * A malformed diff is reported and dropped rather than kept: keeping it would
163
+ * only make the field silently render as a markdown box instead of the diff the
164
+ * mount asked for, which is the harder failure to notice.
165
+ *
166
+ * @param value - the raw `diff` value.
167
+ * @param report - diagnostics sink.
168
+ * @param label - where the value came from, for the warning text.
169
+ * @returns the normalized diff, or undefined.
170
+ */
171
+ function normalizeDiff(value, report, label) {
172
+ if (value === undefined || value === null) return undefined
173
+ if (typeof value !== 'object' || Array.isArray(value)) {
174
+ report(`${label} must be an object like { before, after }`)
175
+ return undefined
176
+ }
177
+ if (!isDiffSource(value.before) || !isDiffSource(value.after)) {
178
+ report(`${label}.before and ${label}.after must each be a parameter name or a function of the call`)
179
+ return undefined
180
+ }
181
+ if (value.path !== undefined && !isDiffSource(value.path)) {
182
+ report(`${label}.path must be a parameter name, a literal path, or a function of the call`)
183
+ return undefined
184
+ }
185
+ return {
186
+ before: value.before,
187
+ after: value.after,
188
+ ...(value.path === undefined ? {} : { path: value.path }),
189
+ ...(typeof value.title === 'string' ? { title: value.title } : {}),
190
+ }
191
+ }
192
+
153
193
  function normalizeField(entry, report, index) {
154
194
  if (typeof entry === 'string') return { param: entry }
155
195
  if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
@@ -164,6 +204,13 @@ function normalizeField(entry, report, index) {
164
204
  report(`fields[${index}].render must be one of ${RENDER_MODES.join(', ')}`)
165
205
  return undefined
166
206
  }
207
+ // `value` overrides where the field's text comes from: a literal, or a
208
+ // function of the pending call that the host resolves before the panel opens.
209
+ if (entry.value !== undefined && typeof entry.value !== 'string' && typeof entry.value !== 'function') {
210
+ report(`fields[${index}].value must be a literal string or a function of the call`)
211
+ return undefined
212
+ }
213
+ const diff = normalizeDiff(entry.diff, report, `fields[${index}].diff`)
167
214
  return {
168
215
  param: entry.param,
169
216
  ...(typeof entry.title === 'string' ? { title: entry.title } : {}),
@@ -171,7 +218,8 @@ function normalizeField(entry, report, index) {
171
218
  ...(entry.render === undefined ? {} : { render: entry.render }),
172
219
  ...(entry.editable === undefined ? {} : { editable: entry.editable === true }),
173
220
  ...(Array.isArray(entry.labels) ? { labels: entry.labels.filter(label => typeof label === 'string') } : {}),
174
- ...(entry.diff === undefined ? {} : { diff: entry.diff }),
221
+ ...(entry.value === undefined ? {} : { value: entry.value }),
222
+ ...(diff === undefined ? {} : { diff }),
175
223
  }
176
224
  }
177
225
 
@@ -213,6 +261,9 @@ export function normalizeMount(input, source) {
213
261
  const maxFieldChars = Number.isSafeInteger(input.maxFieldChars) && input.maxFieldChars > 0
214
262
  ? input.maxFieldChars
215
263
  : DEFAULT_OPTIONS.maxFieldChars
264
+ const resolveTimeoutMs = Number.isSafeInteger(input.resolveTimeoutMs) && input.resolveTimeoutMs > 0
265
+ ? input.resolveTimeoutMs
266
+ : DEFAULT_OPTIONS.resolveTimeoutMs
216
267
  const labels = Array.isArray(input.labels)
217
268
  ? input.labels.filter(label => typeof label === 'string' && label !== '')
218
269
  : []
@@ -236,13 +287,14 @@ export function normalizeMount(input, source) {
236
287
  labels,
237
288
  buttons: normalizeButtons(input.buttons, report),
238
289
  fields,
239
- diff: input.diff,
290
+ diff: normalizeDiff(input.diff, report, 'diff'),
240
291
  countdown,
241
292
  reject: normalizeReject(input.reject, report),
242
293
  modify: normalizeModify(input.modify, report),
243
294
  whenUnavailable: unavailable,
244
295
  enabled: input.enabled === undefined ? true : input.enabled,
245
296
  maxFieldChars,
297
+ resolveTimeoutMs,
246
298
  },
247
299
  }
248
300
  return { ok: true, mount, warnings }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-hitl",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Human-in-the-loop gate for DSH tool calls: mount any tool behind an approve / modify / reject decision card in the DSH Web UI, with an optional countdown.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -3,8 +3,9 @@ import { describe, it } from 'node:test'
3
3
 
4
4
  import {
5
5
  REASON_PREFIX, buildFields, buildRequest, capReason, defaultFieldSpecs, describeDecision,
6
- inferRender, isRevision, renderValue, revisionContext, revisionText,
6
+ hasComputed, inferRender, isRevision, renderValue, resolveFields, revisionContext, revisionText,
7
7
  } from '../lib/fields.js'
8
+ import { LIMITS } from '../lib/protocol.js'
8
9
  import { normalizeMount } from '../lib/resolve.js'
9
10
 
10
11
  /** Normalize one mount through the same path the host half uses. */
@@ -267,3 +268,120 @@ describe('fields: decisions back to the model', () => {
267
268
  assert.equal(capped.includes('已截断'), true)
268
269
  })
269
270
  })
271
+
272
+ describe('fields: computed fields', () => {
273
+ const call = executionOf({ path: 'notes.md', content: 'new text' })
274
+
275
+ it('takes a literal or a function as a field value', async () => {
276
+ const mount = mountOf({
277
+ fields: [
278
+ { param: 'content', title: '正文', render: 'markdown', editable: false, value: execution => `读到 ${execution.arguments.path}` },
279
+ { param: 'note', render: 'text', editable: false, value: '固定文案' },
280
+ { param: 'path', render: 'text', editable: false },
281
+ ],
282
+ })
283
+ const fields = buildFields(call, mount, await resolveFields(call, mount))
284
+ assert.equal(fields[0].value, '读到 notes.md')
285
+ assert.equal(fields[0].editable, false)
286
+ assert.equal(fields[1].value, '固定文案')
287
+ // A field without a resolver keeps reading its own argument.
288
+ assert.equal(fields[2].value, 'notes.md')
289
+ })
290
+
291
+ it('awaits an async resolver and renders a non-string result', async () => {
292
+ const mount = mountOf({
293
+ fields: [
294
+ { param: 'a', render: 'text', editable: false, value: async () => 'awaited' },
295
+ { param: 'b', render: 'text', editable: false, value: () => ({ lines: 2 }) },
296
+ ],
297
+ })
298
+ const fields = buildFields(call, mount, await resolveFields(call, mount))
299
+ assert.equal(fields[0].value, 'awaited')
300
+ assert.equal(fields[1].value, '{\n "lines": 2\n}')
301
+ })
302
+
303
+ it('renders a computed diff against a literal path', async () => {
304
+ const mount = mountOf({
305
+ fields: [{
306
+ param: 'index', title: '索引变化', render: 'diff', editable: false,
307
+ diff: { path: 'index.md', before: async () => 'old line', after: 'content' },
308
+ }],
309
+ })
310
+ const [field] = buildFields(call, mount, await resolveFields(call, mount))
311
+ assert.equal(field.render, 'diff')
312
+ assert.equal(field.editable, false)
313
+ assert.deepEqual(field.diff, { path: 'index.md', oldText: 'old line', newText: 'new text' })
314
+ })
315
+
316
+ it('resolves a mount-level diff that carries no field list', async () => {
317
+ const mount = mountOf({ diff: { path: () => 'tree.md', before: async () => 'a', after: () => 'b' } })
318
+ const fields = buildFields(call, mount, await resolveFields(call, mount))
319
+ assert.deepEqual(fields.map(field => field.param), ['path', 'content', 'computed→computed'])
320
+ assert.deepEqual(fields[2].diff, { path: 'tree.md', oldText: 'a', newText: 'b' })
321
+ })
322
+
323
+ it('shows a failing resolver in its own field instead of blocking the gate', async () => {
324
+ const mount = mountOf({
325
+ fields: [
326
+ { param: 'path', render: 'text', editable: false },
327
+ { param: 'ghost', render: 'markdown', editable: false, value: () => { throw new Error('磁盘不可读') } },
328
+ ],
329
+ })
330
+ const warnings = []
331
+ const fields = buildFields(call, mount, await resolveFields(call, mount, { warn: message => warnings.push(message) }))
332
+ assert.equal(fields[0].value, 'notes.md')
333
+ assert.equal(fields[1].render, 'text')
334
+ assert.equal(fields[1].editable, false)
335
+ assert.equal(fields[1].value.includes('磁盘不可读'), true)
336
+ assert.equal(warnings.length, 1)
337
+ })
338
+
339
+ it('bounds a hanging resolver with the mount timeout', async () => {
340
+ const mount = mountOf({ resolveTimeoutMs: 20, fields: [{ param: 'slow', value: () => new Promise(() => {}) }] })
341
+ const resolution = await resolveFields(call, mount, { timeoutMs: mount.options.resolveTimeoutMs })
342
+ const [field] = buildFields(call, mount, resolution)
343
+ assert.equal(field.value.includes('timed out after 20ms'), true)
344
+ })
345
+
346
+ it('truncates a computed value at maxFieldChars', async () => {
347
+ const mount = mountOf({ maxFieldChars: 4, fields: [{ param: 'long', value: () => 'abcdefgh' }] })
348
+ const [field] = buildFields(call, mount, await resolveFields(call, mount))
349
+ assert.equal(field.truncated, true)
350
+ assert.match(field.value, /已截断,共 8 字/)
351
+ })
352
+
353
+ it('bounds each side of a computed diff by lines', async () => {
354
+ const long = Array.from({ length: LIMITS.maxDiffLines + 2 }, (_entry, index) => `L${index}`).join('\n')
355
+ const mount = mountOf({
356
+ fields: [{ param: 'big', render: 'diff', editable: false, diff: { before: () => '', after: () => long } }],
357
+ })
358
+ const [field] = buildFields(call, mount, await resolveFields(call, mount))
359
+ assert.equal(field.diff.newText.split('\n').length, LIMITS.maxDiffLines + 1)
360
+ assert.match(field.diff.newText, /已截断,另有 2 行未显示/)
361
+ })
362
+
363
+ it('leaves a mount without resolvers exactly as it was', async () => {
364
+ const mount = mountOf({ fields: [{ param: 'content', render: 'markdown' }] })
365
+ const resolution = await resolveFields(call, mount)
366
+ assert.equal(resolution.fields.size, 0)
367
+ assert.equal(resolution.mountDiff, undefined)
368
+ assert.deepEqual(buildFields(call, mount, resolution), buildFields(call, mount))
369
+ })
370
+
371
+ it('reports which mounts compute a field', () => {
372
+ assert.equal(hasComputed(mountOf({}).options), false)
373
+ assert.equal(hasComputed(mountOf({ fields: [{ param: 'a', value: () => 'x' }] }).options), true)
374
+ assert.equal(hasComputed(mountOf({ diff: { before: 'old', after: () => 'x' } }).options), true)
375
+ assert.equal(hasComputed(mountOf({ fields: [{ param: 'a', diff: { before: 'old', after: () => 'x' } }] }).options), true)
376
+ })
377
+
378
+ it('carries a resolved diff through buildRequest', async () => {
379
+ const mount = mountOf({
380
+ fields: [{ param: 'index', title: '索引变化', render: 'diff', editable: false, diff: { path: 'index.md', before: () => 'old', after: 'content' } }],
381
+ })
382
+ const request = buildRequest({
383
+ execution: call, mount, sessionId: 's1', id: 'r1', now: 0, resolution: await resolveFields(call, mount),
384
+ })
385
+ assert.deepEqual(request.fields[0].diff, { path: 'index.md', oldText: 'old', newText: 'new text' })
386
+ })
387
+ })
@@ -61,6 +61,7 @@ describe('resolve: normalizeMount', () => {
61
61
  whenUnavailable: 'reject',
62
62
  enabled: true,
63
63
  maxFieldChars: DEFAULT_OPTIONS.maxFieldChars,
64
+ resolveTimeoutMs: DEFAULT_OPTIONS.resolveTimeoutMs,
64
65
  })
65
66
  })
66
67
 
@@ -111,6 +112,48 @@ describe('resolve: normalizeMount', () => {
111
112
  assert.match(warnings[0], /fields\[2\]\.render/)
112
113
  })
113
114
 
115
+ it('keeps a literal or computed field value and drops an unusable one', () => {
116
+ const read = () => 'value'
117
+ const { mount, warnings } = normalizeMount({
118
+ tool: 'edit',
119
+ fields: [
120
+ { param: 'note', value: '固定文案' },
121
+ { param: 'computed', value: read },
122
+ { param: 'content', value: undefined, diff: { path: 'file_path', before: read, after: 'new_string' } },
123
+ ],
124
+ }, 'src')
125
+ assert.equal(mount.options.fields[0].value, '固定文案')
126
+ assert.equal(mount.options.fields[1].value, read)
127
+ assert.deepEqual(Object.keys(mount.options.fields[2].diff), ['before', 'after', 'path'])
128
+ assert.equal(mount.options.fields[2].diff.before, read)
129
+ assert.equal(warnings.length, 0)
130
+
131
+ const bad = normalizeMount({ tool: 'edit', fields: [{ param: 'x', value: 7 }] }, 'src')
132
+ assert.deepEqual(bad.mount.options.fields, [])
133
+ assert.equal(bad.warnings.length, 1)
134
+ assert.match(bad.warnings[0], /fields\[0\]\.value/)
135
+ })
136
+
137
+ it('drops a malformed diff instead of letting the field render as markdown', () => {
138
+ const bad = normalizeMount({ tool: 'edit', diff: { before: 'old_string' } }, 'src')
139
+ assert.equal(bad.mount.options.diff, undefined)
140
+ assert.equal(bad.warnings.length, 1)
141
+ assert.match(bad.warnings[0], /^src: diff\.before and diff\.after/)
142
+
143
+ const fieldLevel = normalizeMount({
144
+ tool: 'edit', fields: [{ param: 'x', diff: { before: 'a', after: 'b', path: 7 } }],
145
+ }, 'src')
146
+ assert.equal('diff' in fieldLevel.mount.options.fields[0], false)
147
+ assert.equal(fieldLevel.warnings.length, 1)
148
+ assert.match(fieldLevel.warnings[0], /fields\[0\]\.diff\.path/)
149
+ })
150
+
151
+ it('bounds a computed field with resolveTimeoutMs, defaulting from the protocol', () => {
152
+ assert.equal(normalizeMount({ tool: 'bash' }, 'src').mount.options.resolveTimeoutMs, DEFAULT_OPTIONS.resolveTimeoutMs)
153
+ assert.equal(normalizeMount({ tool: 'bash', resolveTimeoutMs: 250 }, 'src').mount.options.resolveTimeoutMs, 250)
154
+ assert.equal(normalizeMount({ tool: 'bash', resolveTimeoutMs: 0 }, 'src').mount.options.resolveTimeoutMs, DEFAULT_OPTIONS.resolveTimeoutMs)
155
+ })
156
+
114
157
  it('reports a bad layout, unavailable policy, and modify mode', () => {
115
158
  const { mount, warnings } = normalizeMount({
116
159
  tool: 'bash', layout: 'grid', whenUnavailable: 'maybe', modify: { mode: 'ignore' }, maxFieldChars: -1, labels: ['ok', 3],