dsh-human-wait 0.1.1 → 0.2.2
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 +21 -0
- package/README.en.md +69 -0
- package/README.md +39 -136
- package/client.js +363 -0
- package/cordis.patch.yml +6 -0
- package/docs/implementation.en.md +77 -0
- package/docs/implementation.md +77 -0
- package/index.js +49 -16
- package/package.json +16 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2 — 2026-11-15
|
|
4
|
+
|
|
5
|
+
- Rewrote the README for users: when the plugin helps, what it does and does not change (a table
|
|
6
|
+
with the four cases), how to install and where the setting lives. The Chinese README is now
|
|
7
|
+
primary and `README.en.md` is the English translation.
|
|
8
|
+
- Moved the implementation notes, the acceptance record and the tests to `docs/implementation.md`
|
|
9
|
+
and `docs/implementation.en.md`.
|
|
10
|
+
|
|
11
|
+
## 0.2.1 — 2026-10-27
|
|
12
|
+
|
|
13
|
+
- Adds a browser half (`client.js`), so the plugin gets its own settings page under **Plugins**:
|
|
14
|
+
a wait-cap field with 1 h / 24 h / unlimited quick values, an "overridden" chip and a
|
|
15
|
+
"restore default" action.
|
|
16
|
+
- `enabled` and `humanWaitCapMs` are now volatile: they are re-read while the plugin runs, so a
|
|
17
|
+
save applies in about 1.5 s without restarting DSH. The wrappers also check `enabled` on
|
|
18
|
+
every call, so turning the plugin off through the patch takes effect immediately.
|
|
19
|
+
- The host half takes over the settings page (`settings.configure({ auto: false })`) instead of
|
|
20
|
+
letting DSH auto-generate a second, schema-only one.
|
|
21
|
+
- Tests: adds `test/client.test.mjs` (bundle id, slot key, page and summary rendering) and two
|
|
22
|
+
runtime cases for re-reading the settings.
|
|
23
|
+
|
|
3
24
|
## 0.1.1 — 2026-10-26
|
|
4
25
|
|
|
5
26
|
First release published by GitHub Actions with npm provenance (trusted publishing).
|
package/README.en.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# dsh-human-wait
|
|
2
|
+
|
|
3
|
+
**Stops the PTC `run_code` deadline while DSH is waiting for you.**
|
|
4
|
+
|
|
5
|
+
PTC mode wraps the whole program — including any question, plan review or approval raised mid-run — in one 120-second wall clock. Think a little too long and the run is already gone:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Error: code run failed (timeout): execution deadline reached (120000ms)
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
and the question card is reported as **interrupted**. With this plugin, the time you spend answering is no longer charged to that budget; a runaway program is still cut off when the budget runs out.
|
|
12
|
+
|
|
13
|
+
> English | [中文](README.md)
|
|
14
|
+
|
|
15
|
+
## When you need it
|
|
16
|
+
|
|
17
|
+
- You run programs in PTC mode that ask you questions or need approvals, and you want to read before answering
|
|
18
|
+
- You have seen a question card flip to "interrupted", or the timeout above
|
|
19
|
+
|
|
20
|
+
## What it changes, and what it does not
|
|
21
|
+
|
|
22
|
+
| Situation | Without it | With it |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| A question inside the run, answered in 20 s | fine | fine |
|
|
25
|
+
| Same run, answered after 4 minutes | aborted at 120 s, question shown as interrupted | keeps waiting, the answer is delivered |
|
|
26
|
+
| Never answered | aborted at 120 s | aborted once the wait hits the cap (24 h by default) |
|
|
27
|
+
| A program that spins for 10 minutes without asking anything | aborted at 120 s | aborted at 120 s — unchanged |
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
1. Open **Settings → Plugins → Market** and search for `dsh-human-wait`
|
|
32
|
+
2. Install it
|
|
33
|
+
3. **Quit DSH completely, then start it again**
|
|
34
|
+
|
|
35
|
+
## Settings
|
|
36
|
+
|
|
37
|
+
**Settings → Plugins → Installed → Human wait**:
|
|
38
|
+
|
|
39
|
+
- **Longest wait** (24 hours by default; "unlimited" removes the cap)
|
|
40
|
+
- Quick buttons: 1 hour / 24 hours / unlimited
|
|
41
|
+
- **Restore default** appears once the field differs from the shipped value
|
|
42
|
+
- A one-line summary sits on the card
|
|
43
|
+
|
|
44
|
+
A change takes effect in about 1.5 seconds, no restart.
|
|
45
|
+
|
|
46
|
+
## FAQ
|
|
47
|
+
|
|
48
|
+
**It still timed out?** Check three things: the session header shows the `PTC` badge; the plugin's `enabled` is not set to false in the profile patch; the log has a line starting with `[dsh-human-wait]`. If the wrapper did not see that wait, it cannot pause for it.
|
|
49
|
+
|
|
50
|
+
**The error kind became `abort`?** Expected. The plugin aborts through its own signal, so the runtime classifies it as `abort` instead of `timeout`; the text still contains `execution deadline reached`.
|
|
51
|
+
|
|
52
|
+
**Does it affect standard sessions?** No. Standard sessions never had this timeout — they wait indefinitely by design.
|
|
53
|
+
|
|
54
|
+
## Privacy and cost
|
|
55
|
+
|
|
56
|
+
- Wraps three host service calls in memory only; writes no files and opens no network connections
|
|
57
|
+
- Does not modify the conversation, and does not change the program's own execution budget
|
|
58
|
+
|
|
59
|
+
## Uninstall
|
|
60
|
+
|
|
61
|
+
Disable or remove it in the plugin panel, then restart DSH; the host's original methods come back.
|
|
62
|
+
|
|
63
|
+
## Technical notes
|
|
64
|
+
|
|
65
|
+
How it works, the two settings, the fail-open behaviour and the acceptance record: [docs/implementation.en.md](https://github.com/SpaceRelay/dsh-subagent-control/blob/main/human-wait/docs/implementation.en.md)
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT — see [LICENSE](./LICENSE). Issues: [github.com/SpaceRelay/dsh-subagent-control/issues](https://github.com/SpaceRelay/dsh-subagent-control/issues)
|
package/README.md
CHANGED
|
@@ -1,166 +1,69 @@
|
|
|
1
1
|
# dsh-human-wait
|
|
2
2
|
|
|
3
|
-
**
|
|
4
|
-
|
|
5
|
-
|
|
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:
|
|
3
|
+
**PTC 模式下,DSH 在等你回答问题时,不再让执行超时继续倒数。**
|
|
4
|
+
|
|
5
|
+
PTC(`run_code`)把整个程序——包括中途弹出的问题、计划审批、权限批准——包在一条 120 秒的墙钟预算里。你想久一点再回答,程序就已经超时了:
|
|
8
6
|
|
|
9
7
|
```
|
|
10
8
|
Error: code run failed (timeout): execution deadline reached (120000ms)
|
|
11
9
|
```
|
|
12
10
|
|
|
13
|
-
|
|
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).
|
|
11
|
+
问题卡还会被标成「已中断」。装上这个插件,等待你回答的那段时间不再计入预算;程序自己跑飞了,照样在预算到点时被掐断。
|
|
39
12
|
|
|
40
|
-
|
|
41
|
-
execution budget and is what the plugin enforces.
|
|
13
|
+
> [English](README.en.md) | 中文
|
|
42
14
|
|
|
43
|
-
##
|
|
15
|
+
## 什么时候用得上
|
|
44
16
|
|
|
45
|
-
|
|
17
|
+
- 用 PTC 模式跑程序,中途要你回答问题或审批,而你想读完再答
|
|
18
|
+
- 遇到过「问题卡突然变成已中断」或者上面那条超时
|
|
46
19
|
|
|
47
|
-
|
|
48
|
-
`plugin_manager install_bundle` call in a session, with the spec `dsh-human-wait@0.1.1`; the
|
|
49
|
-
plugin manager adds both the dependency and the bundle entry for you.
|
|
20
|
+
## 它改什么、不改什么
|
|
50
21
|
|
|
51
|
-
|
|
52
|
-
|
|
22
|
+
| 情况 | 没装 | 装了 |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| 程序里提问,20 秒答完 | 正常 | 正常 |
|
|
25
|
+
| 同一个程序,4 分钟后才答 | 120 秒就被中止,问题卡显示已中断 | 继续等,答案照常送达 |
|
|
26
|
+
| 一直不答 | 120 秒中止 | 等到最长等待上限(默认 24 小时)才中止 |
|
|
27
|
+
| 程序自己死循环 10 分钟 | 120 秒中止 | 120 秒中止,行为不变 |
|
|
53
28
|
|
|
54
|
-
|
|
55
|
-
{
|
|
56
|
-
"dependencies": {
|
|
57
|
-
"dsh-human-wait": "file:D:/path/to/dsh-human-wait-0.1.1.tgz"
|
|
58
|
-
},
|
|
59
|
-
"dsh": { "profile": { "bundles": ["dsh-human-wait"] } }
|
|
60
|
-
}
|
|
61
|
-
```
|
|
29
|
+
## 安装
|
|
62
30
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
```
|
|
31
|
+
1. 打开 设置 → 插件 → 市场,搜索 `dsh-human-wait`
|
|
32
|
+
2. 点安装
|
|
33
|
+
3. **完全退出 DSH,再重新打开**
|
|
67
34
|
|
|
68
|
-
|
|
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.
|
|
35
|
+
## 设置
|
|
73
36
|
|
|
74
|
-
|
|
37
|
+
设置 → 插件 → 已安装 → **人机等待**:
|
|
75
38
|
|
|
76
|
-
|
|
77
|
-
|
|
39
|
+
- **最长等待**(默认 24 小时;选「无限」表示不设上限)
|
|
40
|
+
- 快捷按钮:1 小时 / 24 小时 / 无限
|
|
41
|
+
- 字段偏离出厂值时会出现「恢复默认」
|
|
42
|
+
- 卡片上有一行摘要
|
|
78
43
|
|
|
79
|
-
|
|
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
|
-
```
|
|
44
|
+
改完约 1.5 秒生效,不用重启。
|
|
86
45
|
|
|
87
|
-
|
|
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
|
-
```
|
|
46
|
+
## 常见问题
|
|
135
47
|
|
|
136
|
-
|
|
48
|
+
**还是超时了?** 确认三件事:会话头部显示 PTC 标记;插件的 `enabled` 没在 profile 补丁里被关掉;日志里有 `[dsh-human-wait]` 开头的那行说明。如果等待期间恰好插件没看到这次等待,它就不会暂停计时。
|
|
137
49
|
|
|
138
|
-
|
|
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`.
|
|
50
|
+
**错误类型变成 `abort` 了?** 正常。插件用自己的信号中止程序,所以 kind 从 `timeout` 变成 `abort`,提示文字里仍然有 `execution deadline reached`。
|
|
144
51
|
|
|
145
|
-
|
|
52
|
+
**普通会话受影响吗?** 不受影响。普通会话本来就会一直等你回答,没有这个超时。
|
|
146
53
|
|
|
147
|
-
|
|
148
|
-
npm test # node --test, no dependencies
|
|
149
|
-
```
|
|
54
|
+
## 隐私与开销
|
|
150
55
|
|
|
151
|
-
|
|
56
|
+
- 只在内存里包住三个宿主服务的调用,不写文件、不联网
|
|
57
|
+
- 不修改会话内容,也不改变程序本身的执行预算
|
|
152
58
|
|
|
153
|
-
|
|
59
|
+
## 卸载
|
|
154
60
|
|
|
155
|
-
|
|
61
|
+
在插件面板里停用或卸载,然后重启 DSH;宿主原来的方法会恢复。
|
|
156
62
|
|
|
157
|
-
|
|
63
|
+
## 技术细节
|
|
158
64
|
|
|
159
|
-
|
|
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)` |
|
|
65
|
+
实现原理、两个配置项、失败时的降级行为与验收记录:[docs/implementation.md](https://github.com/SpaceRelay/dsh-subagent-control/blob/main/human-wait/docs/implementation.md)
|
|
164
66
|
|
|
165
|
-
|
|
67
|
+
## 许可
|
|
166
68
|
|
|
69
|
+
MIT,见 [LICENSE](./LICENSE)。问题反馈:[github.com/SpaceRelay/dsh-subagent-control/issues](https://github.com/SpaceRelay/dsh-subagent-control/issues)
|
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'
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# dsh-human-wait — implementation notes
|
|
2
|
+
|
|
3
|
+
For maintainers. User-facing docs live in [../README.en.md](../README.en.md).
|
|
4
|
+
|
|
5
|
+
## The problem
|
|
6
|
+
|
|
7
|
+
In PTC mode, `dsh-ptc-runtime-node` puts one wall-clock budget around the entire `run_code` program, nested tool calls and approval waits included:
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
const wallTimer = spec.timeoutMs === null ? void 0 : setTimeout(() => {
|
|
11
|
+
timedOut = true
|
|
12
|
+
controller.abort("execution deadline reached")
|
|
13
|
+
}, spec.timeoutMs)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
So the time spent waiting for you burns the budget too. This project takes the timer over and charges it only while no human-interaction wait is pending.
|
|
17
|
+
|
|
18
|
+
## How it works
|
|
19
|
+
|
|
20
|
+
1. Wrap `ptcRuntime.run`: the deadline moves into the plugin, which can pause it.
|
|
21
|
+
2. Wrap the three points where a human is actually being waited on: `userQuestions.ask`, `userQuestions.askTimed`, `approval.request`.
|
|
22
|
+
3. Use `node:async_hooks` `AsyncLocalStorage` to scope the accounting to the run that is really waiting, charging the budget only while no such wait is pending.
|
|
23
|
+
|
|
24
|
+
A single `run_code` call can still pass its own `timeoutMs`; that number keeps working as the execution budget.
|
|
25
|
+
|
|
26
|
+
## Configuration
|
|
27
|
+
|
|
28
|
+
The row ships sensible defaults; override them in the same profile's `cordis.patch.yml`:
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
- id: human-wait
|
|
32
|
+
name: 'dsh-human-wait'
|
|
33
|
+
config:
|
|
34
|
+
enabled: true
|
|
35
|
+
humanWaitCapMs: 86400000 # 24 h; -1 = no cap at all
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
| Option | Default | Meaning |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| `enabled` | `true` | Master switch for pausing. `false` reproduces the shipped behaviour exactly. |
|
|
41
|
+
| `humanWaitCapMs` | `86400000` | Longest time one run may spend waiting for a human. `-1` removes the cap. |
|
|
42
|
+
|
|
43
|
+
Both options are **volatile**: they are re-read while the plugin runs, so a change applies in about 1.5 seconds without a restart. A hand-edited patch is read at boot.
|
|
44
|
+
|
|
45
|
+
## Settings page
|
|
46
|
+
|
|
47
|
+
`client.js` is a hand-written browser half registered on the `plugins.bundle.config` slot (key `dsh-human-wait`): a `humanWaitCapMs` number field, 1 hour / 24 hours / unlimited quick buttons, a field-level "overridden" chip with "restore default", and a one-line summary on the card. `enabled` stays in the patch layer.
|
|
48
|
+
|
|
49
|
+
## Behaviour notes
|
|
50
|
+
|
|
51
|
+
- **Fail-open.** If `ptcRuntime` / `userQuestions` / `approval` are missing, if `AbortSignal.any` is unavailable, or if a wrapper cannot be installed, the plugin logs one warning and leaves the shipped deadline untouched.
|
|
52
|
+
- **Restored on unload.** Every wrapper is removed when the plugin unloads (or is disabled and the profile restarted); the original methods are back.
|
|
53
|
+
- **The terminal error changes kind.** When the paused execution budget runs out, the runtime reports `kind: "abort"` instead of `kind: "timeout"`, because the plugin aborts the run through its signal. The message still contains `execution deadline reached (<budget>ms)`.
|
|
54
|
+
- **Only PTC sessions are affected.** Standard sessions never had this timeout: their questions wait indefinitely by design.
|
|
55
|
+
- **Not a public extension point.** The plugin patches methods of two host services (`ptcRuntime`, `userQuestions`) and one approval service. That is the only way to implement this today; a future DSH release may change those internals and make the patch a no-op. The plugin is written so that this failure mode is safe, not fatal.
|
|
56
|
+
|
|
57
|
+
## Verified environment and acceptance record
|
|
58
|
+
|
|
59
|
+
Verified against DSH desktop **0.2.1-rc.3** (`dsh-ptc-runtime-node` 0.2.1, Node 24.21).
|
|
60
|
+
|
|
61
|
+
Recorded 2026-10-26 against `dsh-human-wait` 0.1.0 in the desktop profile (PTC mode, shipped 120 s budget):
|
|
62
|
+
|
|
63
|
+
| Test | Scenario | Observed |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| A | nested `ask_user_question`, answered after 294037 ms | answer delivered, run continued, no `execution deadline reached` |
|
|
66
|
+
| B | plan review submitted through `exit_plan_mode`, approved after 313073 ms | plan approved, run continued, no automatic cancellation at 120 s |
|
|
67
|
+
| C | `run_code` running `while (true) {}` | killed after ~120 s with `Error: code run failed (abort): execution deadline reached (120000ms)` |
|
|
68
|
+
|
|
69
|
+
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)`.
|
|
70
|
+
|
|
71
|
+
## Tests
|
|
72
|
+
|
|
73
|
+
```powershell
|
|
74
|
+
npm test # node --test, no dependencies
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Coverage: re-reading both settings, wrapping and restoring the three wait points, the fail-open branches, and the browser half (bundle id, slot key, page rendering).
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# dsh-human-wait 实现说明
|
|
2
|
+
|
|
3
|
+
面向维护者。用户向说明见 [../README.md](../README.md)。
|
|
4
|
+
|
|
5
|
+
## 问题
|
|
6
|
+
|
|
7
|
+
PTC 模式下,`dsh-ptc-runtime-node` 用一条墙钟预算覆盖整个 `run_code` 程序,嵌套的工具调用与审批等待都算在里面:
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
const wallTimer = spec.timeoutMs === null ? void 0 : setTimeout(() => {
|
|
11
|
+
timedOut = true
|
|
12
|
+
controller.abort("execution deadline reached")
|
|
13
|
+
}, spec.timeoutMs)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
于是「等你回答问题」也烧预算。本项目把这个计时器接管过来,只在没有人类交互等待时计费。
|
|
17
|
+
|
|
18
|
+
## 做法
|
|
19
|
+
|
|
20
|
+
1. 包装 `ptcRuntime.run`:把 deadline 移进插件,由插件自己计时并可暂停。
|
|
21
|
+
2. 包装三个人类等待点:`userQuestions.ask`、`userQuestions.askTimed`、`approval.request`。只有这三类调用会产生「等待人」的时间。
|
|
22
|
+
3. 用 `node:async_hooks` 的 `AsyncLocalStorage` 把计费限定在真正在等待的那次运行上,只在没有等待挂起时扣预算。
|
|
23
|
+
|
|
24
|
+
单个 `run_code` 调用仍可自带 `timeoutMs`,它继续作为执行预算生效。
|
|
25
|
+
|
|
26
|
+
## 配置
|
|
27
|
+
|
|
28
|
+
行内默认值够用;要改就在 profile 的 `cordis.patch.yml` 里覆盖:
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
- id: human-wait
|
|
32
|
+
name: 'dsh-human-wait'
|
|
33
|
+
config:
|
|
34
|
+
enabled: true
|
|
35
|
+
humanWaitCapMs: 86400000 # 24 小时;-1 = 不设上限
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
| 选项 | 默认 | 含义 |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| `enabled` | `true` | 暂停总开关。`false` 完全回到宿主原生行为 |
|
|
41
|
+
| `humanWaitCapMs` | `86400000` | 单次运行等待人类的最长时间,`-1` 表示不限 |
|
|
42
|
+
|
|
43
|
+
两个选项都是 **volatile**:插件运行期间会被重新读取,改完约 1.5 秒生效,不用重启。手动改补丁则在下次启动时读取。
|
|
44
|
+
|
|
45
|
+
## 设置页
|
|
46
|
+
|
|
47
|
+
`client.js` 是手写的浏览器半边,注册在 `plugins.bundle.config` 槽(key `dsh-human-wait`):一个 `humanWaitCapMs` 数字框、1 小时 / 24 小时 / 无限三个快捷按钮、字段级「已覆盖」与「恢复默认」,卡片上还有一行摘要。`enabled` 留在补丁层。
|
|
48
|
+
|
|
49
|
+
## 行为说明
|
|
50
|
+
|
|
51
|
+
- **Fail-open。** 缺少 `ptcRuntime` / `userQuestions` / `approval`、没有 `AbortSignal.any`、或者包装装不上时,插件只打一条警告,宿主原有 deadline 原样保留。
|
|
52
|
+
- **卸载时恢复。** 插件卸载(或被停用后重启)时每个包装都会被拆掉,宿主原方法恢复。
|
|
53
|
+
- **错误 kind 会变。** 暂停后的执行预算耗尽时,运行时报告 `kind: "abort"` 而不是 `"timeout"`,因为插件是通过自己的信号中止的;消息文本里仍有 `execution deadline reached (<budget>ms)`。
|
|
54
|
+
- **只影响 PTC 会话。** 标准会话本来就没有这个超时。
|
|
55
|
+
- **不是公开扩展点。** 插件包装的是两个宿主服务(`ptcRuntime`、`userQuestions`)和审批服务的方法,这是当前唯一可行的实现方式;未来 DSH 版本改动这些内部实现可能让它变成空操作——代码写成这种失败是安全的,不是致命的。
|
|
56
|
+
|
|
57
|
+
## 验证环境与验收记录
|
|
58
|
+
|
|
59
|
+
验证环境:DSH desktop **0.2.1-rc.3**(`dsh-ptc-runtime-node` 0.2.1,Node 24.21)。
|
|
60
|
+
|
|
61
|
+
2026-10-26 在 `dsh-human-wait` 0.1.0 上测得(桌面 profile,PTC 模式,出厂 120 秒预算):
|
|
62
|
+
|
|
63
|
+
| 用例 | 场景 | 结果 |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| A | 嵌套 `ask_user_question`,294037 ms 后才回答 | 答案送达,运行继续,没有 `execution deadline reached` |
|
|
66
|
+
| B | `exit_plan_mode` 计划审批,313073 ms 后才批准 | 计划通过,运行继续,没有被 120 秒取消 |
|
|
67
|
+
| C | `run_code` 里 `while (true) {}` | 约 120 秒后被终止,`Error: code run failed (abort): execution deadline reached (120000ms)` |
|
|
68
|
+
|
|
69
|
+
用例 C 的 `(abort)` 是预期行为:插件用自己的信号中止,运行时因此把失败归类为 `abort` 而不是 `timeout`,消息文本不变。
|
|
70
|
+
|
|
71
|
+
## 测试
|
|
72
|
+
|
|
73
|
+
```powershell
|
|
74
|
+
npm test # node --test,无依赖
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
覆盖:两个配置项的重读、三个等待点的包装与恢复、fail-open 分支,以及浏览器半边的 bundle id / 槽位 / 页面渲染。
|
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 }}
|
|
254
|
+
* @param {{ enabled: boolean, humanWaitCapMs: number }} live
|
|
250
255
|
* @param {object} logger
|
|
251
256
|
*/
|
|
252
|
-
function patchRun(runtime,
|
|
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,
|
|
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 }}
|
|
303
|
+
* @param {{ enabled: boolean, humanWaitCapMs: number }} live
|
|
298
304
|
* @param {object} logger
|
|
299
305
|
*/
|
|
300
|
-
function runWithPausedDeadline(original, self, spec, budget,
|
|
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
|
|
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 (!
|
|
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,
|
|
434
|
+
const dispose = patchRun(child.ptcRuntime, live, logger)
|
|
402
435
|
if (dispose !== undefined) {
|
|
403
436
|
disposers.push(dispose)
|
|
404
|
-
const cap =
|
|
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.
|
|
3
|
+
"version": "0.2.2",
|
|
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,14 +23,18 @@
|
|
|
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",
|
|
33
35
|
"README.md",
|
|
36
|
+
"README.en.md",
|
|
37
|
+
"docs",
|
|
34
38
|
"CHANGELOG.md",
|
|
35
39
|
"LICENSE"
|
|
36
40
|
],
|
|
@@ -47,6 +51,15 @@
|
|
|
47
51
|
"dsh": {
|
|
48
52
|
"bundle": {
|
|
49
53
|
"patch": "./cordis.patch.yml"
|
|
54
|
+
},
|
|
55
|
+
"client": {
|
|
56
|
+
"platform": "web",
|
|
57
|
+
"immediately": true,
|
|
58
|
+
"inject": [
|
|
59
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
60
|
+
"@deepseek-ai/dsh-client-locale",
|
|
61
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
62
|
+
]
|
|
50
63
|
}
|
|
51
64
|
},
|
|
52
65
|
"engines": {
|
|
@@ -57,6 +70,6 @@
|
|
|
57
70
|
"access": "public"
|
|
58
71
|
},
|
|
59
72
|
"scripts": {
|
|
60
|
-
"test": "node --test test/plugin.test.mjs"
|
|
73
|
+
"test": "node --test test/plugin.test.mjs test/client.test.mjs"
|
|
61
74
|
}
|
|
62
75
|
}
|