dsh-human-wait 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 — 2026-10-27
4
+
5
+ - Adds a browser half (`client.js`), so the plugin gets its own settings page under **Plugins**:
6
+ a wait-cap field with 1 h / 24 h / unlimited quick values, an "overridden" chip and a
7
+ "restore default" action.
8
+ - `enabled` and `humanWaitCapMs` are now volatile: they are re-read while the plugin runs, so a
9
+ save applies in about 1.5 s without restarting DSH. The wrappers also check `enabled` on
10
+ every call, so turning the plugin off through the patch takes effect immediately.
11
+ - The host half takes over the settings page (`settings.configure({ auto: false })`) instead of
12
+ letting DSH auto-generate a second, schema-only one.
13
+ - Tests: adds `test/client.test.mjs` (bundle id, slot key, page and summary rendering) and two
14
+ runtime cases for re-reading the settings.
15
+
16
+ ## 0.1.1 — 2026-10-26
17
+
18
+ First release published by GitHub Actions with npm provenance (trusted publishing).
19
+
20
+ - No behaviour change from 0.1.0; the source is the same apart from this entry and the docs.
21
+ - README: installing from the registry is now the primary route, the tarball route moved to the
22
+ fallback slot.
23
+
3
24
  ## 0.1.0 — 2026-10-26
4
25
 
5
26
  First release.
package/README.md CHANGED
@@ -42,15 +42,20 @@ execution budget and is what the plugin enforces.
42
42
 
43
43
  ## Install
44
44
 
45
- The plugin is a normal DSH bundle (one inserted host row, no client half). Pick one route:
45
+ The plugin is a normal DSH bundle: one inserted host row, plus a browser half that renders the
46
+ settings page under **Plugins**. Pick one route:
46
47
 
47
- **A. From a tarball, by hand.** In `<DSH_HOME>/profiles/<profile>/package.json` add the dependency
48
+ **A. From the registry (recommended).** Use the Plugins settings page, or a
49
+ `plugin_manager install_bundle` call in a session, with the spec `dsh-human-wait@0.2.1`; the
50
+ plugin manager adds both the dependency and the bundle entry for you.
51
+
52
+ **B. From a tarball, by hand.** In `<DSH_HOME>/profiles/<profile>/package.json` add the dependency
48
53
  and the bundle name, then install it:
49
54
 
50
55
  ```jsonc
51
56
  {
52
57
  "dependencies": {
53
- "dsh-human-wait": "file:D:/path/to/dsh-human-wait-0.1.0.tgz"
58
+ "dsh-human-wait": "file:D:/path/to/dsh-human-wait-0.2.1.tgz"
54
59
  },
55
60
  "dsh": { "profile": { "bundles": ["dsh-human-wait"] } }
56
61
  }
@@ -61,10 +66,6 @@ and the bundle name, then install it:
61
66
  & "$env:USERPROFILE\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\pnpm\pnpm.cjs" install --dir "$env:USERPROFILE\.dsh\profiles\desktop"
62
67
  ```
63
68
 
64
- **B. From the registry, once published.** Use the Plugins settings page (or a
65
- `plugin_manager install_bundle` call in a session) with the spec `dsh-human-wait@0.1.0`; the
66
- plugin manager adds both the dependency and the bundle entry for you.
67
-
68
69
  Then **quit DSH from the tray icon and start it again** — bundles are composed at boot. The
69
70
  Electron-managed profile cannot be booted through the `dsh` CLI (`profile "desktop" is managed
70
71
  exclusively by the Electron application`), so restarting the app is the only way to apply it.
@@ -93,7 +94,24 @@ How the clock works: the plugin re-arms a single timer on every accounting event
93
94
  wait starting or ending, or the timer itself firing), so there is no fixed sampling period and
94
95
  no configuration knob for one.
95
96
 
96
- Changes are read when the plugin loads, so restart DSH after editing.
97
+ Both options are **volatile**: they are read again while the plugin is running, so a change
98
+ takes effect within about a second and a half, with no restart. Editing the
99
+ patch by hand is still read at boot.
100
+
101
+ ## Settings page
102
+
103
+ The plugin ships a browser half, so it gets its own card on its page under **Plugins** (the same
104
+ place as Team limits):
105
+
106
+ - a number field for `humanWaitCapMs`, with quick buttons for 1 hour / 24 hours / unlimited;
107
+ - an "overridden" chip and a "restore default" action for that field;
108
+ - a one-line summary on the card itself.
109
+
110
+ `enabled` stays in the patch layer (`cordis.patch.yml`), where it is read live within about a
111
+ second and a half.
112
+
113
+ The values are written back to this profile's patch layer through the normal settings path;
114
+ uninstalling the plugin does not roll them back.
97
115
 
98
116
  ## What counts as a human wait
99
117
 
package/client.js ADDED
@@ -0,0 +1,363 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-human-wait',
3
+ factory: (require) => {
4
+ var module = { exports: {} }
5
+ var exports = module.exports
6
+
7
+ var React = require('react')
8
+ var h = React.createElement
9
+
10
+ var NS = 'human-wait'
11
+
12
+ var DEFAULTS = {
13
+ humanWaitCapMs: 86400000,
14
+ }
15
+
16
+ var HOUR = 3600000
17
+
18
+ var QUICK_CAPS = [
19
+ { value: HOUR, zh: '1 小时', en: '1 hour' },
20
+ { value: 24 * HOUR, zh: '24 小时', en: '24 hours' },
21
+ { value: -1, zh: '无限', en: 'Unlimited' },
22
+ ]
23
+
24
+ function isZh() {
25
+ try {
26
+ return (navigator.language || '').toLowerCase().indexOf('zh') === 0
27
+ } catch (error) {
28
+ return false
29
+ }
30
+ }
31
+
32
+ var COPY = {
33
+ capLabel: ['等待上限(毫秒)', 'Wait cap (ms)'],
34
+ capHelp: [
35
+ '一次运行最多在等待人上花多久。-1 表示不设上限。',
36
+ 'How long one run may spend waiting for a human. -1 means no cap.',
37
+ ],
38
+ overridden: ['已覆盖', 'Overridden'],
39
+ reset: ['恢复默认', 'Restore default'],
40
+ loading: ['正在读取配置…', 'Loading configuration…'],
41
+ unavailable: [
42
+ '当前连接不支持写入设置,这一页只能查看。',
43
+ 'This connection cannot write settings, so this page is read-only.',
44
+ ],
45
+ saveFailed: ['保存失败:', 'Save failed: '],
46
+ note: [
47
+ '数值保存在当前 profile 的补丁层;卸载本插件不会自动撤回。',
48
+ 'Values persist in this profile patch layer; uninstalling the plugin does not roll them back.',
49
+ ],
50
+ }
51
+
52
+ function t(key) {
53
+ var pair = COPY[key]
54
+ if (pair === undefined) return key
55
+ return isZh() ? pair[0] : pair[1]
56
+ }
57
+
58
+ var CSS = [
59
+ '.dhw { display: flex; flex-direction: column; gap: 26px; max-width: 820px; padding: 4px 2px 8px; }',
60
+ '.dhw-group { display: flex; flex-direction: column; gap: 14px; }',
61
+ '.dhw-group-title { font-size: 14.5px; font-weight: 600; margin: 0; line-height: 1.4; }',
62
+ '.dhw-cell { display: flex; flex-direction: column; gap: 8px; min-width: 0; }',
63
+ '.dhw-labelrow { display: flex; align-items: center; gap: 8px; min-height: 20px; }',
64
+ '.dhw-label { font-size: 13px; font-weight: 500; }',
65
+ '.dhw-chip { font-size: 11.5px; line-height: 18px; padding: 0 8px; border-radius: 999px; background: rgba(128,128,128,.14); opacity: .8; }',
66
+ '.dhw-spacer { flex: 1 1 auto; }',
67
+ '.dhw-reset { font-size: 12.5px; color: inherit; opacity: .55; background: none; border: none; padding: 0; cursor: pointer; }',
68
+ '.dhw-reset:hover:not(:disabled) { opacity: .85; text-decoration: underline; }',
69
+ '.dhw-reset:disabled { opacity: .25; cursor: default; }',
70
+ '.dhw-input { width: 100%; box-sizing: border-box; height: 38px; padding: 0 12px; font-size: 13.5px; font-variant-numeric: tabular-nums; border-radius: 9px; border: 1px solid rgba(128,128,128,.36); background: transparent; color: inherit; outline: none; }',
71
+ '.dhw-input:focus { border-color: rgba(76,139,245,.85); }',
72
+ '.dhw-input:disabled { opacity: .45; }',
73
+ '.dhw-quick { display: flex; flex-wrap: wrap; gap: 8px; }',
74
+ '.dhw-quickbtn { font-size: 12.5px; padding: 5px 11px; border-radius: 999px; border: 1px solid rgba(128,128,128,.36); background: transparent; color: inherit; cursor: pointer; }',
75
+ '.dhw-quickbtn:hover:not(:disabled) { border-color: rgba(76,139,245,.7); }',
76
+ '.dhw-quickbtn:disabled { opacity: .4; cursor: default; }',
77
+ '.dhw-help { font-size: 12px; opacity: .5; line-height: 1.5; }',
78
+ '.dhw-foot { font-size: 12px; opacity: .48; line-height: 1.6; margin: 0; max-width: 64ch; }',
79
+ '.dhw-error { font-size: 12.5px; color: #d9534f; }',
80
+ '.dhw-summary { font-size: 13px; opacity: .78; font-variant-numeric: tabular-nums; }',
81
+ ].join('\n')
82
+
83
+ /** Unwrap a volatile ref if the host handed one through. */
84
+ function unwrap(value) {
85
+ if (value !== null && typeof value === 'object') {
86
+ if (typeof value.get === 'function') return value.get()
87
+ if (typeof value.value !== 'undefined') return value.value
88
+ }
89
+ return value
90
+ }
91
+
92
+ function capOf(value, fallback) {
93
+ var raw = unwrap(value)
94
+ if (raw === -1) return -1
95
+ var parsed = typeof raw === 'number' ? raw : Number(raw)
96
+ return Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : fallback
97
+ }
98
+
99
+ function valuesOf(source) {
100
+ var bag = source !== null && typeof source === 'object' ? source : {}
101
+ return {
102
+ humanWaitCapMs: capOf(bag.humanWaitCapMs, DEFAULTS.humanWaitCapMs),
103
+ }
104
+ }
105
+
106
+ function formatCap(ms) {
107
+ if (ms === -1) return isZh() ? '无限' : 'unlimited'
108
+ if (ms % HOUR === 0) {
109
+ var hours = ms / HOUR
110
+ return isZh() ? hours + ' 小时' : hours + (hours === 1 ? ' hour' : ' hours')
111
+ }
112
+ return ms + ' ms'
113
+ }
114
+
115
+ function HumanWaitPage(props) {
116
+ var form = props.configForm || props.form
117
+ var summary = props.view === 'summary'
118
+
119
+ if (!form) {
120
+ return h('p', { className: 'dhw-foot' }, t('loading'))
121
+ }
122
+
123
+ var snapshotPair = React.useState(function () {
124
+ return form.getSnapshot()
125
+ })
126
+ var snapshot = snapshotPair[0]
127
+ var setSnapshot = snapshotPair[1]
128
+
129
+ var draftPair = React.useState(function () {
130
+ return valuesOf(snapshot.value)
131
+ })
132
+ var draft = draftPair[0]
133
+ var setDraft = draftPair[1]
134
+
135
+ var errorPair = React.useState('')
136
+ var error = errorPair[0]
137
+ var setError = errorPair[1]
138
+
139
+ var pendingPair = React.useState({})
140
+ var pending = pendingPair[0]
141
+ var setPending = pendingPair[1]
142
+
143
+ React.useEffect(function () {
144
+ var off = form.subscribe(function () {
145
+ setSnapshot(form.getSnapshot())
146
+ })
147
+ setSnapshot(form.getSnapshot())
148
+ return off
149
+ }, [form])
150
+
151
+ React.useEffect(function () {
152
+ var next = valuesOf(snapshot.value)
153
+ var keys = Object.keys(pending)
154
+ setDraft(function (previous) {
155
+ var merged = {}
156
+ for (var name in previous) merged[name] = previous[name]
157
+ for (var name in next) {
158
+ if (keys.indexOf(name) < 0) merged[name] = next[name]
159
+ }
160
+ return merged
161
+ })
162
+ // eslint-disable-next-line react-hooks/exhaustive-deps
163
+ }, [snapshot.value])
164
+
165
+ React.useEffect(function () {
166
+ var keys = Object.keys(pending)
167
+ if (keys.length === 0) return undefined
168
+ var timer = setTimeout(function () {
169
+ var chain = Promise.resolve()
170
+ keys.forEach(function (key) {
171
+ chain = chain.then(function () {
172
+ return form.set(key, pending[key])
173
+ })
174
+ })
175
+ chain
176
+ .then(function () {
177
+ setPending({})
178
+ })
179
+ .catch(function (caught) {
180
+ setPending({})
181
+ setError(t('saveFailed') + String((caught && caught.message) || caught))
182
+ })
183
+ }, 700)
184
+ return function () {
185
+ clearTimeout(timer)
186
+ }
187
+ }, [pending, form])
188
+
189
+ var ready = snapshot.status === 'ready'
190
+ var disabled = !ready
191
+
192
+ function write(key, value) {
193
+ setError('')
194
+ setPending(function (previous) {
195
+ var next = {}
196
+ for (var name in previous) next[name] = previous[name]
197
+ next[key] = value
198
+ return next
199
+ })
200
+ }
201
+
202
+ function onCapInput(raw) {
203
+ if (raw === '' || raw === '-') {
204
+ setDraft(function (previous) {
205
+ var merged = {}
206
+ for (var name in previous) merged[name] = previous[name]
207
+ merged.humanWaitCapMs = raw === '-' ? -1 : DEFAULTS.humanWaitCapMs
208
+ return merged
209
+ })
210
+ return
211
+ }
212
+ var parsed = Number(raw)
213
+ if (!Number.isFinite(parsed)) return
214
+ var safe = parsed === -1 ? -1 : Math.max(1, Math.floor(parsed))
215
+ setDraft(function (previous) {
216
+ var merged = {}
217
+ for (var name in previous) merged[name] = previous[name]
218
+ merged.humanWaitCapMs = safe
219
+ return merged
220
+ })
221
+ write('humanWaitCapMs', safe)
222
+ }
223
+
224
+ function onQuick(value) {
225
+ setDraft(function (previous) {
226
+ var merged = {}
227
+ for (var name in previous) merged[name] = previous[name]
228
+ merged.humanWaitCapMs = value
229
+ return merged
230
+ })
231
+ write('humanWaitCapMs', value)
232
+ }
233
+
234
+ function onReset(key) {
235
+ setDraft(function (previous) {
236
+ var merged = {}
237
+ for (var name in previous) merged[name] = previous[name]
238
+ merged[key] = DEFAULTS[key]
239
+ return merged
240
+ })
241
+ write(key, DEFAULTS[key])
242
+ }
243
+
244
+ if (summary) {
245
+ return h('span', { className: 'dhw-summary' }, formatCap(draft.humanWaitCapMs))
246
+ }
247
+
248
+ var capOverridden = draft.humanWaitCapMs !== DEFAULTS.humanWaitCapMs
249
+
250
+ var capGroup = h(
251
+ 'section',
252
+ { className: 'dhw-group', key: 'cap' },
253
+ h('h3', { className: 'dhw-group-title' }, isZh() ? '等待上限' : 'Wait cap'),
254
+ h(
255
+ 'div',
256
+ { className: 'dhw-cell' },
257
+ h(
258
+ 'div',
259
+ { className: 'dhw-labelrow' },
260
+ h('label', { className: 'dhw-label', htmlFor: 'dhw-cap' }, t('capLabel')),
261
+ capOverridden ? h('span', { className: 'dhw-chip' }, t('overridden')) : null,
262
+ h('span', { className: 'dhw-spacer' }),
263
+ capOverridden
264
+ ? h(
265
+ 'button',
266
+ {
267
+ type: 'button',
268
+ className: 'dhw-reset',
269
+ disabled: disabled,
270
+ onClick: function () {
271
+ onReset('humanWaitCapMs')
272
+ },
273
+ },
274
+ t('reset'),
275
+ )
276
+ : null,
277
+ ),
278
+ h('input', {
279
+ id: 'dhw-cap',
280
+ className: 'dhw-input',
281
+ type: 'number',
282
+ min: -1,
283
+ step: 1,
284
+ inputMode: 'numeric',
285
+ disabled: disabled,
286
+ value: String(draft.humanWaitCapMs),
287
+ onChange: function (event) {
288
+ onCapInput(event.target.value)
289
+ },
290
+ }),
291
+ h(
292
+ 'div',
293
+ { className: 'dhw-quick' },
294
+ QUICK_CAPS.map(function (item) {
295
+ return h(
296
+ 'button',
297
+ {
298
+ type: 'button',
299
+ className: 'dhw-quickbtn',
300
+ key: item.value,
301
+ disabled: disabled,
302
+ onClick: function () {
303
+ onQuick(item.value)
304
+ },
305
+ },
306
+ isZh() ? item.zh : item.en,
307
+ )
308
+ }),
309
+ ),
310
+ h('span', { className: 'dhw-help' }, t('capHelp')),
311
+ ),
312
+ )
313
+
314
+ return h(
315
+ 'div',
316
+ { className: 'dhw' },
317
+ h('style', null, CSS),
318
+ ready ? null : h('p', { className: 'dhw-foot' }, t(snapshot.status === 'unavailable' ? 'unavailable' : 'loading')),
319
+ capGroup,
320
+ error ? h('p', { className: 'dhw-error' }, error) : null,
321
+ h('p', { className: 'dhw-foot' }, t('note')),
322
+ )
323
+ }
324
+
325
+ function apply(ctx) {
326
+ try {
327
+ var forms = ctx.configForms
328
+ var form = forms ? forms.get(NS) : null
329
+ if (!form) {
330
+ console.error('[human-wait] configForms service is unavailable; no settings page is registered')
331
+ return
332
+ }
333
+ ctx.effect(
334
+ function () {
335
+ return forms.whileServed([NS], function () {
336
+ return ctx.slots.inject('plugins.bundle.config', function () {
337
+ return ctx.slots.register(
338
+ {
339
+ name: 'plugins.bundle.config',
340
+ key: 'dsh-human-wait',
341
+ inject: function () {
342
+ return { configForm: form }
343
+ },
344
+ },
345
+ HumanWaitPage,
346
+ )
347
+ })
348
+ })
349
+ },
350
+ 'human-wait: bundle config',
351
+ )
352
+ } catch (error) {
353
+ console.error('[human-wait] client half failed to mount: ' + String((error && error.message) || error))
354
+ }
355
+ }
356
+
357
+ exports.name = 'dsh-human-wait'
358
+ exports.inject = ['slots', 'configForms']
359
+ exports.apply = apply
360
+
361
+ return module.exports
362
+ },
363
+ })
package/cordis.patch.yml CHANGED
@@ -17,6 +17,12 @@
17
17
  # `timeoutMs` passed to a single `run_code` call) is still honoured for the
18
18
  # time the program actually spends executing: the plugin re-arms the deadline
19
19
  # on every accounting event, so it does not resample on a timer.
20
+ #
21
+ # Both options are volatile and re-read while the plugin runs, so the wait cap
22
+ # saved from the plugin's page under Settings → Plugins takes effect within
23
+ # about 1.5 s; `enabled` is read from here (patch edits also apply live). The
24
+ # browser half (`client.js`, declared by `dsh.client`) renders that page; this
25
+ # file stays free of user values.
20
26
  - insert:
21
27
  - id: human-wait
22
28
  name: 'dsh-human-wait'
package/index.js CHANGED
@@ -35,6 +35,9 @@ const DEFAULT_HUMAN_WAIT_CAP_MS = 24 * 60 * 60 * 1000
35
35
  const MAX_TIMER_MS = 2_147_483_647
36
36
  const DEADLINE_MESSAGE = 'execution deadline reached'
37
37
 
38
+ /** How often the live settings are compared against the volatile Config refs. */
39
+ const POLL_INTERVAL_MS = 1500
40
+
38
41
  /** Per-run wait accounting; a run has a slot only while it is on the stack. */
39
42
  const runScope = new AsyncLocalStorage()
40
43
 
@@ -51,9 +54,9 @@ try {
51
54
  export const Config = Schema === undefined
52
55
  ? undefined
53
56
  : Schema.object({
54
- enabled: Schema.boolean().default(true)
57
+ enabled: Schema.boolean().default(true).volatile()
55
58
  .description('Pause the PTC run deadline while a human-interaction wait is pending.'),
56
- humanWaitCapMs: Schema.number().default(DEFAULT_HUMAN_WAIT_CAP_MS)
59
+ humanWaitCapMs: Schema.number().default(DEFAULT_HUMAN_WAIT_CAP_MS).volatile()
57
60
  .description('Longest time a run may spend waiting for a human, in milliseconds. -1 removes the cap.'),
58
61
  })
59
62
 
@@ -195,9 +198,10 @@ function enterHumanWait() {
195
198
  * Wrap one asynchronous method so callers' time is not charged to the run.
196
199
  * @param {object} host
197
200
  * @param {string} key
201
+ * @param {{ enabled: boolean }} live
198
202
  * @param {object} logger
199
203
  */
200
- function patchWaitMethod(host, key, logger) {
204
+ function patchWaitMethod(host, key, live, logger) {
201
205
  const holder = methodHolder(host, key)
202
206
  if (holder === undefined) return undefined
203
207
 
@@ -205,6 +209,7 @@ function patchWaitMethod(host, key, logger) {
205
209
  if (isPatched(original)) return undefined
206
210
 
207
211
  const wrapped = function patchedHumanWait(...args) {
212
+ if (!live.enabled) return Reflect.apply(original, this, args)
208
213
  const leave = enterHumanWait()
209
214
  let result
210
215
  try {
@@ -246,10 +251,10 @@ function patchWaitMethod(host, key, logger) {
246
251
  * Wrap the run method: the runtime keeps its compile/validation, the deadline
247
252
  * moves into this plugin so it can be paused.
248
253
  * @param {object} runtime
249
- * @param {{ humanWaitCapMs: number }} settings
254
+ * @param {{ enabled: boolean, humanWaitCapMs: number }} live
250
255
  * @param {object} logger
251
256
  */
252
- function patchRun(runtime, settings, logger) {
257
+ function patchRun(runtime, live, logger) {
253
258
  const holder = methodHolder(runtime, 'run')
254
259
  if (holder === undefined) return undefined
255
260
 
@@ -261,12 +266,13 @@ function patchRun(runtime, settings, logger) {
261
266
  }
262
267
 
263
268
  const wrapped = function patchedRun(spec) {
269
+ if (!live.enabled) return Reflect.apply(original, this, [spec])
264
270
  const budget = spec !== null && typeof spec === 'object' ? spec.timeoutMs : undefined
265
271
  if (typeof budget !== 'number' || !Number.isFinite(budget) || budget <= 0) {
266
272
  return Reflect.apply(original, this, [spec])
267
273
  }
268
274
  try {
269
- return runWithPausedDeadline(original, this, spec, budget, settings, logger)
275
+ return runWithPausedDeadline(original, this, spec, budget, live, logger)
270
276
  } catch (error) {
271
277
  logger.warn(`could not wrap run: ${messageOf(error)} — using the shipped deadline`)
272
278
  return Reflect.apply(original, this, [spec])
@@ -294,14 +300,14 @@ function patchRun(runtime, settings, logger) {
294
300
  * @param {unknown} self
295
301
  * @param {object} spec
296
302
  * @param {number} budget
297
- * @param {{ humanWaitCapMs: number }} settings
303
+ * @param {{ enabled: boolean, humanWaitCapMs: number }} live
298
304
  * @param {object} logger
299
305
  */
300
- function runWithPausedDeadline(original, self, spec, budget, settings, logger) {
306
+ function runWithPausedDeadline(original, self, spec, budget, live, logger) {
301
307
  const controller = new AbortController()
302
308
  const outer = spec.signal
303
309
  const signal = outer === undefined ? controller.signal : AbortSignal.any([outer, controller.signal])
304
- const cap = settings.humanWaitCapMs === -1 ? Infinity : settings.humanWaitCapMs
310
+ const capOf = () => (live.humanWaitCapMs === -1 ? Infinity : live.humanWaitCapMs)
305
311
 
306
312
  let remaining = budget
307
313
  let waited = 0
@@ -344,6 +350,7 @@ function runWithPausedDeadline(original, self, spec, budget, settings, logger) {
344
350
  if (aborted || finished) return
345
351
  settle()
346
352
  if (state.waiters > 0) {
353
+ const cap = capOf()
347
354
  if (waited >= cap) {
348
355
  expire(`human interaction wait exceeded (${cap}ms)`)
349
356
  return
@@ -382,12 +389,38 @@ function runWithPausedDeadline(original, self, spec, budget, settings, logger) {
382
389
  * @param {object} config
383
390
  */
384
391
  export function apply(ctx, config) {
385
- const settings = normalizeConfig(config)
386
392
  const logger = createLogger(ctx)
393
+ // The wrappers read this object on every call, so a settings-page save takes
394
+ // effect without reloading the bundle. `enabled` is not on that page — it is
395
+ // still re-read here, so turning it off through this patch file applies live.
396
+ // `humanWaitCapMs` is the current wait cap.
397
+ const live = normalizeConfig(config)
398
+
399
+ // Own the settings page: the browser half renders the form, so the host must
400
+ // not auto-generate a second one from the Config schema.
401
+ ctx.inject(['settings'], (child) => {
402
+ try {
403
+ child.settings.configure({ auto: false }, ctx.fiber)
404
+ } catch (error) {
405
+ logger.warn(`could not take over the settings page: ${messageOf(error)}`)
406
+ }
407
+ })
408
+
409
+ ctx.effect(() => {
410
+ const timer = setInterval(() => {
411
+ const next = normalizeConfig(config)
412
+ if (next.enabled === live.enabled && next.humanWaitCapMs === live.humanWaitCapMs) return
413
+ live.enabled = next.enabled
414
+ live.humanWaitCapMs = next.humanWaitCapMs
415
+ const cap = live.humanWaitCapMs === -1 ? 'unlimited' : `${live.humanWaitCapMs} ms`
416
+ logger.info(`settings changed — ${live.enabled ? `enabled, wait cap: ${cap}` : 'disabled'}`)
417
+ }, POLL_INTERVAL_MS)
418
+ if (typeof timer.unref === 'function') timer.unref()
419
+ return () => clearInterval(timer)
420
+ })
387
421
 
388
- if (!settings.enabled) {
422
+ if (!live.enabled) {
389
423
  logger.info('disabled by config — leaving the shipped PTC deadline in place')
390
- return
391
424
  }
392
425
 
393
426
  ctx.inject(['ptcRuntime', 'userQuestions'], (child) => {
@@ -395,13 +428,13 @@ export function apply(ctx, config) {
395
428
  const disposers = []
396
429
  try {
397
430
  for (const key of ['ask', 'askTimed']) {
398
- const dispose = patchWaitMethod(child.userQuestions, key, logger)
431
+ const dispose = patchWaitMethod(child.userQuestions, key, live, logger)
399
432
  if (dispose !== undefined) disposers.push(dispose)
400
433
  }
401
- const dispose = patchRun(child.ptcRuntime, settings, logger)
434
+ const dispose = patchRun(child.ptcRuntime, live, logger)
402
435
  if (dispose !== undefined) {
403
436
  disposers.push(dispose)
404
- const cap = settings.humanWaitCapMs === -1 ? 'unlimited' : `${settings.humanWaitCapMs} ms`
437
+ const cap = live.humanWaitCapMs === -1 ? 'unlimited' : `${live.humanWaitCapMs} ms`
405
438
  logger.info(`PTC run deadlines now pause while a human is waiting (wait cap: ${cap})`)
406
439
  } else {
407
440
  logger.warn('ptcRuntime.run was not patched — human waits will still count against the run deadline')
@@ -418,6 +451,6 @@ export function apply(ctx, config) {
418
451
  })
419
452
 
420
453
  ctx.inject(['approval'], (child) => {
421
- child.effect(() => patchWaitMethod(child.approval, 'request', logger) ?? (() => {}))
454
+ child.effect(() => patchWaitMethod(child.approval, 'request', live, logger) ?? (() => {}))
422
455
  })
423
456
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-human-wait",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Pauses the PTC run_code deadline while DSH is waiting for you to answer a question, review a plan or approve a request, so long human waits are no longer reported as `execution deadline reached (120000ms)`.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -23,10 +23,12 @@
23
23
  ".": "./index.js",
24
24
  "./package.json": "./package.json",
25
25
  "./locale/*.json": "./locale/*.json",
26
- "./icon": "./icon.svg"
26
+ "./icon": "./icon.svg",
27
+ "./client": "./client.js"
27
28
  },
28
29
  "files": [
29
30
  "index.js",
31
+ "client.js",
30
32
  "cordis.patch.yml",
31
33
  "locale/*.json",
32
34
  "icon.svg",
@@ -47,6 +49,15 @@
47
49
  "dsh": {
48
50
  "bundle": {
49
51
  "patch": "./cordis.patch.yml"
52
+ },
53
+ "client": {
54
+ "platform": "web",
55
+ "immediately": true,
56
+ "inject": [
57
+ "@deepseek-ai/dsh-api-remotes",
58
+ "@deepseek-ai/dsh-client-locale",
59
+ "@deepseek-ai/dsh-client-ui-settings"
60
+ ]
50
61
  }
51
62
  },
52
63
  "engines": {
@@ -57,6 +68,6 @@
57
68
  "access": "public"
58
69
  },
59
70
  "scripts": {
60
- "test": "node --test test/plugin.test.mjs"
71
+ "test": "node --test test/plugin.test.mjs test/client.test.mjs"
61
72
  }
62
73
  }