dsh-human-wait 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-10-26
4
+
5
+ First release.
6
+
7
+ - Pauses the PTC `run_code` execution deadline while DSH is waiting for a human:
8
+ `ptcRuntime.run` is wrapped so the wall-clock budget is measured by this plugin and only
9
+ charged while no human-interaction wait is pending.
10
+ - Recognises human waits through `userQuestions.ask`, `userQuestions.askTimed` and
11
+ `approval.request`; wait accounting is scoped to the waiting run with `AsyncLocalStorage`.
12
+ - `humanWaitCapMs` (default 24 h, `-1` = uncapped) bounds how long a run may wait.
13
+ - `enabled` / `humanWaitCapMs` options; all wrappers are restored when the plugin unloads.
14
+ - No sampling interval: the deadline is re-armed on accounting events, not on a tick.
15
+ - Fail-open: missing services, unavailable `AbortSignal.any` or an unexpected runtime shape
16
+ leave the shipped deadline in place and log a single warning.
17
+ - Bundle patch inserts one host row; no client half.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-human-wait contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,166 @@
1
- # Temporary Holding Version
1
+ # dsh-human-wait
2
+
3
+ **Stops the PTC `run_code` deadline while DSH is waiting for you.** In PTC mode a question
4
+ (`ask_user_question`), the plan review raised by `exit_plan_mode`, or a sandbox approval is a
5
+ nested call inside the one `run_code` program — and `dsh-ptc-runtime-node` measures a single
6
+ wall-clock budget across the whole program, *including the time you spend answering*. Wait
7
+ longer than what is left of that budget and the run dies with:
8
+
9
+ ```
10
+ Error: code run failed (timeout): execution deadline reached (120000ms)
11
+ ```
12
+
13
+ and the question card is reported as **已中断 / interrupted**. This plugin keeps the deadline
14
+ for execution but pauses it while a human-interaction wait is pending. A runaway program is
15
+ still cut off after its budget; a question you think about for an hour is not.
16
+
17
+ > 中文摘要:PTC 模式下提问、计划审批和批准都是 `run_code` 里的嵌套调用,共用同一条 120 秒墙钟预算,
18
+ > 所以「想久一点再回答」会让你看到的是一次 `execution deadline reached (120000ms)` 超时。本插件
19
+ > 保留执行的截止时间,但在**等待你回答/批准期间暂停计时**;等待本身另有上限(默认 24 小时,可设为无限)。
20
+
21
+ ---
22
+
23
+ ## What it does
24
+
25
+ | Situation | Without the plugin | With the plugin |
26
+ | --- | --- | --- |
27
+ | A question inside `run_code`, you answer in 20 s, run budget was 120 s | OK | OK |
28
+ | Same run, you answer after 4 minutes | run aborted at 120 s, question interrupted | run keeps waiting; the answer is delivered |
29
+ | Same run, you never answer | run aborted at 120 s | run aborted once the wait exceeds `humanWaitCapMs` (24 h) |
30
+ | A program that spins for 10 minutes without asking anything | aborted at 120 s | aborted at 120 s — unchanged |
31
+
32
+ It works by:
33
+
34
+ 1. wrapping `ptcRuntime.run` and moving the deadline into the plugin, so it can be paused;
35
+ 2. wrapping `userQuestions.ask` / `userQuestions.askTimed` and `approval.request` — the three
36
+ service calls that exist only because a human is being waited on;
37
+ 3. charging the budget only while no such wait is pending (`node:async_hooks`
38
+ `AsyncLocalStorage` keeps the accounting scoped to the run that is actually waiting).
39
+
40
+ A single `run_code` call can still pass its own `timeoutMs`; that number keeps working as the
41
+ execution budget and is what the plugin enforces.
42
+
43
+ ## Install
44
+
45
+ The plugin is a normal DSH bundle (one inserted host row, no client half). Pick one route:
46
+
47
+ **A. From a tarball, by hand.** In `<DSH_HOME>/profiles/<profile>/package.json` add the dependency
48
+ and the bundle name, then install it:
49
+
50
+ ```jsonc
51
+ {
52
+ "dependencies": {
53
+ "dsh-human-wait": "file:D:/path/to/dsh-human-wait-0.1.0.tgz"
54
+ },
55
+ "dsh": { "profile": { "bundles": ["dsh-human-wait"] } }
56
+ }
57
+ ```
58
+
59
+ ```powershell
60
+ # pnpm ships with the desktop runtime
61
+ & "$env:USERPROFILE\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\pnpm\pnpm.cjs" install --dir "$env:USERPROFILE\.dsh\profiles\desktop"
62
+ ```
63
+
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
+ Then **quit DSH from the tray icon and start it again** — bundles are composed at boot. The
69
+ Electron-managed profile cannot be booted through the `dsh` CLI (`profile "desktop" is managed
70
+ exclusively by the Electron application`), so restarting the app is the only way to apply it.
71
+ Removing the bundle name from `dsh.profile.bundles` and deleting the dependency disables the
72
+ plugin again; the wrappers are also restored at unload time.
73
+
74
+ ## Configuration
75
+
76
+ The row ships sensible defaults; override them in the same profile's `cordis.patch.yml` when
77
+ you want something else:
78
+
79
+ ```yaml
80
+ - id: human-wait
81
+ name: 'dsh-human-wait'
82
+ config:
83
+ enabled: true
84
+ humanWaitCapMs: 86400000 # 24 h; -1 = no cap at all
85
+ ```
86
+
87
+ | Option | Default | Meaning |
88
+ | --- | --- | --- |
89
+ | `enabled` | `true` | Turn the pause on/off. `false` reproduces the shipped behaviour exactly. |
90
+ | `humanWaitCapMs` | `86400000` | Longest time one run may spend waiting for a human. `-1` removes the cap. |
91
+
92
+ How the clock works: the plugin re-arms a single timer on every accounting event (run start, a
93
+ wait starting or ending, or the timer itself firing), so there is no fixed sampling period and
94
+ no configuration knob for one.
95
+
96
+ Changes are read when the plugin loads, so restart DSH after editing.
97
+
98
+ ## What counts as a human wait
99
+
100
+ - `userQuestions.ask` — blocking questions, including the `plan-review` question from
101
+ `exit_plan_mode`;
102
+ - `userQuestions.askTimed` — timed questions;
103
+ - `approval.request` — sandbox/policy approvals that block on the user.
104
+
105
+ Tool calls that finish on their own (bash, file reads, web fetches, subagents) are **not**
106
+ waits: their time is still charged to the run, exactly as before.
107
+
108
+ ## Behaviour notes
109
+
110
+ - **Fail-open.** If `ptcRuntime` / `userQuestions` / `approval` are missing, if
111
+ `AbortSignal.any` is unavailable, or if a wrapper cannot be installed, the plugin logs one
112
+ warning and leaves the shipped deadline untouched.
113
+ - **Restored on unload.** Every wrapper is removed when the plugin unloads (or is disabled and
114
+ the profile restarted); the original methods are back.
115
+ - **The terminal error changes kind.** When the paused execution budget runs out, the runtime
116
+ reports `kind: "abort"` instead of `kind: "timeout"`, because the plugin aborts the run
117
+ through its signal. The message still contains `execution deadline reached (<budget>ms)`, so
118
+ the surfaced text is the same.
119
+ - **Only PTC sessions are affected.** Standard sessions never had this timeout: their
120
+ questions wait indefinitely by design.
121
+ - **Not a public extension point.** The plugin patches methods of two host services
122
+ (`ptcRuntime`, `userQuestions`) and one approval service. That is the only way to implement
123
+ this today; a future DSH release may change those internals and make the patch a no-op. The
124
+ plugin is written so that this failure mode is safe, not fatal.
125
+
126
+ Verified against DSH desktop **0.2.1-rc.3** (`dsh-ptc-runtime-node` 0.2.1, node 24.21).
127
+ `ptc-runtime-node/lib/index.js` is where the shipped timer lives:
128
+
129
+ ```js
130
+ const wallTimer = spec.timeoutMs === null ? void 0 : setTimeout(() => {
131
+ timedOut = true;
132
+ controller.abort("execution deadline reached");
133
+ }, spec.timeoutMs);
134
+ ```
135
+
136
+ ## Troubleshooting
137
+
138
+ - Look for `[dsh-human-wait]` lines in the DSH log; the plugin logs either
139
+ `PTC run deadlines now pause while a human is waiting (wait cap: …)` or the reason it stayed
140
+ out of the way.
141
+ - If a wait still dies with `execution deadline reached`, the wrapper did not see the wait.
142
+ Check that PTC mode is actually in use (the session header shows the `PTC` badge) and that
143
+ `enabled` is not `false`.
144
+
145
+ ## Development
146
+
147
+ ```powershell
148
+ npm test # node --test, no dependencies
149
+ ```
150
+
151
+ ## License
152
+
153
+ MIT
154
+
155
+ ## Acceptance record
156
+
157
+ Recorded 2026-10-26 against `dsh-human-wait` 0.1.0 in the desktop profile (PTC mode, shipped 120 s budget):
158
+
159
+ | Test | Scenario | Observed |
160
+ | --- | --- | --- |
161
+ | A | nested `ask_user_question`, answered after 294037 ms | answer delivered, run continued, no `execution deadline reached` |
162
+ | B | plan review submitted through `exit_plan_mode`, approved after 313073 ms | plan approved, run continued, no automatic cancellation at 120 s |
163
+ | C | `run_code` running `while (true) {}` | killed after ~120 s with `Error: code run failed (abort): execution deadline reached (120000ms)` |
164
+
165
+ Test C's `(abort)` label is expected — the plugin aborts through its own signal, so the runtime classifies the failure as `abort` instead of `timeout` while the message text stays `execution deadline reached (120000ms)`.
2
166
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
@@ -0,0 +1,25 @@
1
+ # dsh-human-wait — the PTC run deadline stops while you are being asked.
2
+ #
3
+ # PTC mode gives the model one tool, `run_code`. Questions (`ask_user_question`)
4
+ # and the plan review raised by `exit_plan_mode` are nested calls inside that
5
+ # program, and `dsh-ptc-runtime-node` wraps the whole program in one wall-clock
6
+ # deadline (default 120000 ms) that explicitly includes "nested tool and
7
+ # approval waits". A slow answer therefore shows up as
8
+ # Error: code run failed (timeout): execution deadline reached (120000ms)
9
+ # and the question card is interrupted.
10
+ #
11
+ # The inserted row is the host half. It wraps `ptcRuntime.run` (deadline moved
12
+ # into the plugin so it can pause), and `userQuestions.ask` / `askTimed` plus
13
+ # `approval.request` (the signal that a human is being waited on).
14
+ #
15
+ # Defaults: `humanWaitCapMs` 86400000 (24 h), `enabled` true.
16
+ # Set `humanWaitCapMs: -1` for no cap at all. Any other run budget (for example
17
+ # `timeoutMs` passed to a single `run_code` call) is still honoured for the
18
+ # time the program actually spends executing: the plugin re-arms the deadline
19
+ # on every accounting event, so it does not resample on a timer.
20
+ - insert:
21
+ - id: human-wait
22
+ name: 'dsh-human-wait'
23
+ config:
24
+ enabled: true
25
+ humanWaitCapMs: 86400000
package/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" fill="none">
2
+ <rect x="1" y="1" width="62" height="62" rx="14" fill="#111827"/>
3
+ <circle cx="32" cy="32" r="19" stroke="#7DD3FC" stroke-width="3"/>
4
+ <path d="M32 21V32L40 37" stroke="#7DD3FC" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
5
+ <rect x="44" y="17" width="4" height="12" rx="2" fill="#FDE68A"/>
6
+ <rect x="50" y="17" width="4" height="12" rx="2" fill="#FDE68A"/>
7
+ </svg>
package/index.js ADDED
@@ -0,0 +1,423 @@
1
+ /**
2
+ * dsh-human-wait — let PTC runs wait for you.
3
+ *
4
+ * PTC mode hands the model a single tool, `run_code`, and every nested call
5
+ * (`ask_user_question`, the plan review raised by `exit_plan_mode`, sandbox
6
+ * approvals) executes inside that one program. `dsh-ptc-runtime-node` puts one
7
+ * wall-clock deadline around the whole program — 120 s by default, and its own
8
+ * description says it includes "nested tool and approval waits". So the moment
9
+ * a question takes longer than the remaining budget, the run dies with
10
+ * `Error: code run failed (timeout): execution deadline reached (120000ms)` and
11
+ * the question card is reported as interrupted.
12
+ *
13
+ * This plugin keeps that deadline but pauses it while DSH is actually waiting
14
+ * for a human: it wraps `ptcRuntime.run` and runs the same budget itself, and
15
+ * wraps `userQuestions.ask` / `userQuestions.askTimed` / `approval.request` to
16
+ * learn when a human-interaction wait is pending. Time spent waiting is not
17
+ * charged to the run; time spent executing still is, so a runaway program is
18
+ * still cut off exactly as before. Waiting itself is capped by
19
+ * `humanWaitCapMs` (default 24 h, `-1` = no cap).
20
+ *
21
+ * Everything here is fail-open: if a service is missing, if the wrappers were
22
+ * already installed by someone else, or if a future DSH release changes these
23
+ * internals, the plugin logs once and leaves the shipped behaviour untouched.
24
+ * Every wrapper is removed when the plugin unloads.
25
+ */
26
+
27
+ import { AsyncLocalStorage } from 'node:async_hooks'
28
+
29
+ export const name = 'dsh-human-wait'
30
+
31
+ /** Marks a wrapper this plugin installed, so patching twice is a no-op. */
32
+ const PATCHED = Symbol.for('dsh-human-wait.patched')
33
+
34
+ const DEFAULT_HUMAN_WAIT_CAP_MS = 24 * 60 * 60 * 1000
35
+ const MAX_TIMER_MS = 2_147_483_647
36
+ const DEADLINE_MESSAGE = 'execution deadline reached'
37
+
38
+ /** Per-run wait accounting; a run has a slot only while it is on the stack. */
39
+ const runScope = new AsyncLocalStorage()
40
+
41
+ let Schema
42
+ try {
43
+ const loaded = await import('@deepseek-ai/schemastery')
44
+ Schema = loaded.default ?? loaded.Schema
45
+ } catch {
46
+ // A link-installed bundle must not require the host's packages; the plugin
47
+ // still works with its defaults when schemastery cannot be resolved.
48
+ Schema = undefined
49
+ }
50
+
51
+ export const Config = Schema === undefined
52
+ ? undefined
53
+ : Schema.object({
54
+ enabled: Schema.boolean().default(true)
55
+ .description('Pause the PTC run deadline while a human-interaction wait is pending.'),
56
+ humanWaitCapMs: Schema.number().default(DEFAULT_HUMAN_WAIT_CAP_MS)
57
+ .description('Longest time a run may spend waiting for a human, in milliseconds. -1 removes the cap.'),
58
+ })
59
+
60
+ /**
61
+ * Resolve a possibly volatile config value (`Settings` may wrap it in a ref).
62
+ * @param {object} source
63
+ * @param {string} key
64
+ */
65
+ function readScalar(source, key) {
66
+ const value = source[key]
67
+ if (value !== null && typeof value === 'object') {
68
+ if (typeof value.get === 'function') return value.get()
69
+ if (typeof value.value !== 'undefined') return value.value
70
+ }
71
+ return value
72
+ }
73
+
74
+ /**
75
+ * Normalise the plugin config into the values the wrappers use.
76
+ * @param {unknown} config
77
+ * @returns {{ enabled: boolean, humanWaitCapMs: number }}
78
+ */
79
+ export function normalizeConfig(config) {
80
+ const source = config !== null && typeof config === 'object' ? config : {}
81
+
82
+ const rawEnabled = readScalar(source, 'enabled')
83
+ const enabled = typeof rawEnabled === 'boolean' ? rawEnabled : true
84
+
85
+ const rawCap = readScalar(source, 'humanWaitCapMs')
86
+ let humanWaitCapMs = DEFAULT_HUMAN_WAIT_CAP_MS
87
+ if (rawCap === -1) humanWaitCapMs = -1
88
+ else if (typeof rawCap === 'number' && Number.isFinite(rawCap) && rawCap > 0) {
89
+ humanWaitCapMs = Math.min(Math.round(rawCap), MAX_TIMER_MS)
90
+ }
91
+
92
+ return { enabled, humanWaitCapMs }
93
+ }
94
+
95
+ /** @param {unknown} value */
96
+ function isThenable(value) {
97
+ return value !== null && typeof value === 'object' && typeof value.then === 'function'
98
+ }
99
+
100
+ /** @param {unknown} error */
101
+ function messageOf(error) {
102
+ if (error instanceof Error) return error.message
103
+ if (typeof error === 'string') return error
104
+ try {
105
+ return String(error)
106
+ } catch {
107
+ return 'unknown error'
108
+ }
109
+ }
110
+
111
+ /**
112
+ * @param {object} ctx
113
+ * @returns {{ info: Function, warn: Function }}
114
+ */
115
+ function createLogger(ctx) {
116
+ const noop = () => {}
117
+ try {
118
+ const factory = ctx?.logger
119
+ if (typeof factory === 'function') {
120
+ const scoped = factory('dsh-human-wait')
121
+ if (scoped !== null && typeof scoped === 'object') {
122
+ return {
123
+ info: typeof scoped.info === 'function' ? (...args) => scoped.info(...args) : noop,
124
+ warn: typeof scoped.warn === 'function' ? (...args) => scoped.warn(...args) : noop,
125
+ }
126
+ }
127
+ }
128
+ if (factory !== null && typeof factory === 'object' && typeof factory.info === 'function') {
129
+ return {
130
+ info: (...args) => factory.info(...args),
131
+ warn: typeof factory.warn === 'function' ? (...args) => factory.warn(...args) : noop,
132
+ }
133
+ }
134
+ } catch {
135
+ // fall through to the console fallback
136
+ }
137
+ return {
138
+ info: (...args) => console.log('[dsh-human-wait]', ...args),
139
+ warn: (...args) => console.warn('[dsh-human-wait]', ...args),
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Where to install a method wrapper: the instance itself when the method is an
145
+ * own property, otherwise the class prototype the traceable proxy reads from.
146
+ * @param {object} host
147
+ * @param {string} key
148
+ */
149
+ function methodHolder(host, key) {
150
+ if (host === null || typeof host !== 'object') return undefined
151
+ if (typeof host[key] !== 'function') return undefined
152
+ if (Object.hasOwn(host, key)) return host
153
+ const prototype = Object.getPrototypeOf(host)
154
+ if (prototype !== null && prototype !== undefined && typeof prototype[key] === 'function') return prototype
155
+ return host
156
+ }
157
+
158
+ /** @param {unknown} candidate */
159
+ function isPatched(candidate) {
160
+ return typeof candidate === 'function' && candidate[PATCHED] === true
161
+ }
162
+
163
+ /**
164
+ * Mark a run's wait bookkeeping active for the duration of `callback`.
165
+ * @template T
166
+ * @param {{ waiters: number, schedule: () => void }} state
167
+ * @param {() => T} callback
168
+ * @returns {T}
169
+ */
170
+ function withRunScope(state, callback) {
171
+ return runScope.run(state, callback)
172
+ }
173
+
174
+ /**
175
+ * Register one human-interaction wait in the active run scope. Returns the
176
+ * function that closes it; undefined when the call is not inside a PTC run.
177
+ */
178
+ function enterHumanWait() {
179
+ const state = runScope.getStore()
180
+ if (state === undefined) return undefined
181
+ state.schedule() // settle executing time up to this instant
182
+ state.waiters += 1
183
+ state.schedule() // arm the wait cap
184
+ let left = false
185
+ return () => {
186
+ if (left) return
187
+ left = true
188
+ state.schedule() // settle the wait (waiters is still > 0 here)
189
+ state.waiters = Math.max(0, state.waiters - 1)
190
+ state.schedule() // resume the execution budget
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Wrap one asynchronous method so callers' time is not charged to the run.
196
+ * @param {object} host
197
+ * @param {string} key
198
+ * @param {object} logger
199
+ */
200
+ function patchWaitMethod(host, key, logger) {
201
+ const holder = methodHolder(host, key)
202
+ if (holder === undefined) return undefined
203
+
204
+ const original = holder[key]
205
+ if (isPatched(original)) return undefined
206
+
207
+ const wrapped = function patchedHumanWait(...args) {
208
+ const leave = enterHumanWait()
209
+ let result
210
+ try {
211
+ result = Reflect.apply(original, this, args)
212
+ } catch (error) {
213
+ leave?.()
214
+ throw error
215
+ }
216
+ if (isThenable(result)) {
217
+ return result.then(
218
+ (value) => {
219
+ leave?.()
220
+ return value
221
+ },
222
+ (error) => {
223
+ leave?.()
224
+ throw error
225
+ },
226
+ )
227
+ }
228
+ leave?.()
229
+ return result
230
+ }
231
+ wrapped[PATCHED] = true
232
+
233
+ try {
234
+ holder[key] = wrapped
235
+ } catch (error) {
236
+ logger.warn(`could not wrap ${key}: ${messageOf(error)}`)
237
+ return undefined
238
+ }
239
+
240
+ return () => {
241
+ if (holder[key] === wrapped) holder[key] = original
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Wrap the run method: the runtime keeps its compile/validation, the deadline
247
+ * moves into this plugin so it can be paused.
248
+ * @param {object} runtime
249
+ * @param {{ humanWaitCapMs: number }} settings
250
+ * @param {object} logger
251
+ */
252
+ function patchRun(runtime, settings, logger) {
253
+ const holder = methodHolder(runtime, 'run')
254
+ if (holder === undefined) return undefined
255
+
256
+ const original = holder.run
257
+ if (isPatched(original)) return undefined
258
+ if (typeof globalThis.AbortSignal?.any !== 'function') {
259
+ logger.warn('AbortSignal.any is unavailable — leaving the shipped PTC deadline in place')
260
+ return undefined
261
+ }
262
+
263
+ const wrapped = function patchedRun(spec) {
264
+ const budget = spec !== null && typeof spec === 'object' ? spec.timeoutMs : undefined
265
+ if (typeof budget !== 'number' || !Number.isFinite(budget) || budget <= 0) {
266
+ return Reflect.apply(original, this, [spec])
267
+ }
268
+ try {
269
+ return runWithPausedDeadline(original, this, spec, budget, settings, logger)
270
+ } catch (error) {
271
+ logger.warn(`could not wrap run: ${messageOf(error)} — using the shipped deadline`)
272
+ return Reflect.apply(original, this, [spec])
273
+ }
274
+ }
275
+ wrapped[PATCHED] = true
276
+
277
+ try {
278
+ holder.run = wrapped
279
+ } catch (error) {
280
+ logger.warn(`could not wrap run: ${messageOf(error)}`)
281
+ return undefined
282
+ }
283
+
284
+ return () => {
285
+ if (holder.run === wrapped) holder.run = original
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Run one program with a deadline that only advances while no human wait is
291
+ * pending. The timer is re-armed whenever a wait starts or ends, so the budget
292
+ * expires at the exact instant the executing time runs out.
293
+ * @param {Function} original
294
+ * @param {unknown} self
295
+ * @param {object} spec
296
+ * @param {number} budget
297
+ * @param {{ humanWaitCapMs: number }} settings
298
+ * @param {object} logger
299
+ */
300
+ function runWithPausedDeadline(original, self, spec, budget, settings, logger) {
301
+ const controller = new AbortController()
302
+ const outer = spec.signal
303
+ const signal = outer === undefined ? controller.signal : AbortSignal.any([outer, controller.signal])
304
+ const cap = settings.humanWaitCapMs === -1 ? Infinity : settings.humanWaitCapMs
305
+
306
+ let remaining = budget
307
+ let waited = 0
308
+ let last = Date.now()
309
+ let timer
310
+ let aborted = false
311
+ let finished = false
312
+
313
+ const state = {
314
+ waiters: 0,
315
+ schedule: () => {},
316
+ }
317
+
318
+ const stop = () => {
319
+ if (timer !== undefined) {
320
+ clearTimeout(timer)
321
+ timer = undefined
322
+ }
323
+ }
324
+ const settle = () => {
325
+ const now = Date.now()
326
+ const elapsed = Math.max(0, now - last)
327
+ last = now
328
+ if (state.waiters > 0) waited += elapsed
329
+ else remaining -= elapsed
330
+ }
331
+ const expire = (reason) => {
332
+ if (aborted || finished) return
333
+ aborted = true
334
+ stop()
335
+ controller.abort(reason)
336
+ }
337
+ const arm = (delay) => {
338
+ stop()
339
+ if (aborted || finished || !Number.isFinite(delay)) return
340
+ timer = setTimeout(schedule, Math.max(1, Math.ceil(delay)))
341
+ timer.unref?.()
342
+ }
343
+ const schedule = () => {
344
+ if (aborted || finished) return
345
+ settle()
346
+ if (state.waiters > 0) {
347
+ if (waited >= cap) {
348
+ expire(`human interaction wait exceeded (${cap}ms)`)
349
+ return
350
+ }
351
+ arm(cap - waited)
352
+ return
353
+ }
354
+ if (remaining <= 0) {
355
+ expire(`${DEADLINE_MESSAGE} (${budget}ms)`)
356
+ return
357
+ }
358
+ arm(remaining)
359
+ }
360
+ state.schedule = schedule
361
+
362
+ let result
363
+ try {
364
+ result = withRunScope(state, () => {
365
+ schedule()
366
+ return Reflect.apply(original, self, [{ ...spec, timeoutMs: null, signal }])
367
+ })
368
+ } catch (error) {
369
+ finished = true
370
+ stop()
371
+ throw error
372
+ }
373
+
374
+ return Promise.resolve(result).finally(() => {
375
+ finished = true
376
+ stop()
377
+ })
378
+ }
379
+
380
+ /**
381
+ * @param {object} ctx
382
+ * @param {object} config
383
+ */
384
+ export function apply(ctx, config) {
385
+ const settings = normalizeConfig(config)
386
+ const logger = createLogger(ctx)
387
+
388
+ if (!settings.enabled) {
389
+ logger.info('disabled by config — leaving the shipped PTC deadline in place')
390
+ return
391
+ }
392
+
393
+ ctx.inject(['ptcRuntime', 'userQuestions'], (child) => {
394
+ child.effect(() => {
395
+ const disposers = []
396
+ try {
397
+ for (const key of ['ask', 'askTimed']) {
398
+ const dispose = patchWaitMethod(child.userQuestions, key, logger)
399
+ if (dispose !== undefined) disposers.push(dispose)
400
+ }
401
+ const dispose = patchRun(child.ptcRuntime, settings, logger)
402
+ if (dispose !== undefined) {
403
+ disposers.push(dispose)
404
+ const cap = settings.humanWaitCapMs === -1 ? 'unlimited' : `${settings.humanWaitCapMs} ms`
405
+ logger.info(`PTC run deadlines now pause while a human is waiting (wait cap: ${cap})`)
406
+ } else {
407
+ logger.warn('ptcRuntime.run was not patched — human waits will still count against the run deadline')
408
+ }
409
+ } catch (error) {
410
+ logger.warn(`setup failed: ${messageOf(error)} — leaving the shipped PTC deadline in place`)
411
+ for (const dispose of disposers.reverse()) dispose()
412
+ return () => {}
413
+ }
414
+ return () => {
415
+ for (const dispose of disposers.reverse()) dispose()
416
+ }
417
+ })
418
+ })
419
+
420
+ ctx.inject(['approval'], (child) => {
421
+ child.effect(() => patchWaitMethod(child.approval, 'request', logger) ?? (() => {}))
422
+ })
423
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Human wait",
4
+ "description": "Pauses the PTC run deadline while DSH is waiting for you to answer a question, review a plan or approve a request — instead of reporting `execution deadline reached (120000ms)`."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "人机等待",
4
+ "description": "DSH 在等你回答问题、审阅计划或批准请求时暂停 PTC 运行截止计时,不再把等待报成 `execution deadline reached (120000ms)`。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "dsh-human-wait",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "author": {
7
+ "name": "SpaceRelay",
8
+ "url": "https://github.com/SpaceRelay"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/SpaceRelay/dsh-subagent-control.git",
13
+ "directory": "human-wait"
14
+ },
15
+ "homepage": "https://github.com/SpaceRelay/dsh-subagent-control/tree/main/human-wait#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/SpaceRelay/dsh-subagent-control/issues"
18
+ },
19
+ "type": "module",
20
+ "main": "./index.js",
21
+ "icon": "./icon.svg",
22
+ "exports": {
23
+ ".": "./index.js",
24
+ "./package.json": "./package.json",
25
+ "./locale/*.json": "./locale/*.json",
26
+ "./icon": "./icon.svg"
27
+ },
28
+ "files": [
29
+ "index.js",
30
+ "cordis.patch.yml",
31
+ "locale/*.json",
32
+ "icon.svg",
33
+ "README.md",
34
+ "CHANGELOG.md",
35
+ "LICENSE"
36
+ ],
37
+ "keywords": [
38
+ "dsh",
39
+ "dsh-plugin",
40
+ "deepseek-harness",
41
+ "ptc",
42
+ "human-in-the-loop",
43
+ "timeout",
44
+ "ask-user-question",
45
+ "plan-mode"
46
+ ],
47
+ "dsh": {
48
+ "bundle": {
49
+ "patch": "./cordis.patch.yml"
50
+ }
51
+ },
52
+ "engines": {
53
+ "node": "^22.19.0 || >=24.0.0",
54
+ "dsh": ">=0.2.1-rc.1 <0.3.0-0"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "scripts": {
60
+ "test": "node --test test/plugin.test.mjs"
61
+ }
62
+ }