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 +17 -0
- package/LICENSE +21 -0
- package/README.md +165 -2
- package/cordis.patch.yml +25 -0
- package/icon.svg +7 -0
- package/index.js +423 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
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
|
-
#
|
|
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.
|
package/cordis.patch.yml
ADDED
|
@@ -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
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-human-wait",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|