dsh-tool-wait-until 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tongshuanglong
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,163 @@
1
- # Temporary Holding Version
1
+ # dsh-tool-wait-until
2
2
 
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.
3
+ [![npm version](https://img.shields.io/npm/v/dsh-tool-wait-until)](https://www.npmjs.com/package/dsh-tool-wait-until)
4
+ [![license: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
5
+
6
+ **Event-driven waiting for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh).** It gives the model a `wait_until` tool that returns the moment a background job finishes, a teammate goes idle, a file appears, or a log line matches — and fails fast on nonzero exit codes, error patterns and stalls, instead of burning a full timeout. It also **blocks the model's favorite empty-wait paths**: `sleep`-polling in bash and over-long `job_output` / `wait_agent` waits.
7
+
8
+ ```sh
9
+ dsh plugin --profile web add dsh-tool-wait-until
10
+ ```
11
+
12
+ Requires dsh 0.2.1 or later (declared as `engines.dsh`); verified against 0.2.1-rc.5. In the desktop app, install it from its plugin manager instead.
13
+
14
+ - **One call, one verdict**: `status` is `completed` / `failed` / `stalled` / `timeout` / `cancelled`, plus `elapsed`, `failure_reason` and `output_tail` — no second query needed.
15
+ - **Event-driven**: job settle / output growth wakes the wait immediately, independent of `poll_interval_sec`.
16
+ - **Stall detection**: no progress for N seconds returns `stalled` instead of waiting out the timeout.
17
+ - **Behavior guard**: foreground `sleep >= 20s` and waits over 120s are rejected with a ready-to-use replacement.
18
+ - **Zero dependencies**, standard JSON Schema, Node 20+.
19
+
20
+ 中文说明见下。
21
+
22
+ 消除 DSH 里「空等」的 Host 插件。它给模型一个**事件驱动**的等待原语 `wait_until`,并**主动拦截**模型最容易走的空等路径(`sleep` 轮询、超长 `job_output` / `wait_agent`)。
23
+
24
+ ## 要解决的问题
25
+
26
+ 我们实际踩过的现象:
27
+
28
+ | 现象 | 现有行为 |
29
+ | --- | --- |
30
+ | 等 teammate | `wait_agent(timeout=30min)` 在对方卡死时等满 30 分钟,且只返回一个布尔值,事后还得查 `list_agents` |
31
+ | 等后台脚本 | `job_output(wait:true, timeout=15min)` 在作业卡住时等满上限 |
32
+ | 手工轮询 | `sleep 90` → 查文件 → 再估时间 → 再 sleep,一轮往返 1.5 分钟 |
33
+ | 等待中不可观测 | 只看得到「在等」,看不到中间日志、产出进度,无法判断「在跑」还是「卡住」 |
34
+ | 超时 ≠ 失败 | 超时返回后还要再查一轮才知道是超时、失败还是成功 |
35
+
36
+ 关键观察:**光提供一个新工具不够**。模型在思考里会自己决定「等 180 秒再查」,如果行为不变,工具再好也不会被用。所以本插件做三层:
37
+
38
+ 1. **决策前给规则**:往系统提示词注入「等待纪律」,要求等待一律用 `wait_until`,不许 `sleep` 空等。
39
+ 2. **决策落地时硬拦截**:注册 `tools.guard`,把 `sleep ≥ 20s` 的前台 bash、超过 120s 的 `job_output` / `wait_agent` 等待直接拒绝,并在拒绝原因里给出可复制的 `wait_until` 替代调用。
40
+ 3. **替代原语本身**:`wait_until` 工具,条件满足、目标失败或连续无进展时**立即返回结构化结果**。
41
+
42
+ ## wait_until 工具
43
+
44
+ ```js
45
+ wait_until({
46
+ conditions: [
47
+ { kind: 'job', id: '<job_id>' }, // 后台作业
48
+ { kind: 'agent', target: 'worker' }, // 团队成员
49
+ { kind: 'file', path: '/tmp/out.zip', until: 'exists' },
50
+ { kind: 'log', path: '/tmp/build.log', matches: '/EXIT=0/' },
51
+ ],
52
+ fail_fast: true, // 或 ['job_failed', 'stderr_matches:/error|failed|Traceback/']
53
+ stall_after_sec: 120, // 连续 120s 无任何进展 → 立刻返回 stalled
54
+ timeout_sec: 900, // 总兜底,不是主要判定手段
55
+ poll_interval_sec: 2,
56
+ })
57
+ ```
58
+
59
+ 返回(一次调用即可决策,无需二次查询):
60
+
61
+ ```json
62
+ {
63
+ "status": "failed",
64
+ "elapsed_ms": 1250,
65
+ "elapsed": "1.2s",
66
+ "matched": ["后台作业 bash-xxxx(结束)"],
67
+ "conditions": [{ "condition": "后台作业 bash-xxxx(结束)", "state": "failed", "detail": "状态 failed — exit code: 3" }],
68
+ "failure_reason": "后台作业 bash-xxxx 失败:exit code: 3",
69
+ "output_tail": "BOOM: intentional failure\n",
70
+ "hint": "目标已失败,不必再等:按 output_tail / failure_reason 决策……"
71
+ }
72
+ ```
73
+
74
+ `status` 五态:`completed` / `failed` / `stalled` / `timeout` / `cancelled`。**超时不是失败**,单独一态。
75
+
76
+ 多个 `conditions` 是 **wait_all** 语义:全部条件都满足才返回 `completed`;`fail_fast` 命中时任一条件失败立即返回 `failed`。(只想要「任一完成就返回」,请把条件拆成多次 `wait_until`。)
77
+
78
+ ### 条件语义
79
+
80
+ | kind | 必填 | until | 说明 |
81
+ | --- | --- | --- | --- |
82
+ | `job` | `id` | `settled`(默认)/ `failed` | 作业结束(含失败)或仅失败 |
83
+ | `agent` | — | `inactive`(默认)/ `failed` / `status_changed` | 成员全部空闲 / 任一失败 / 任意状态变化 |
84
+ | `file` | `path` | `exists`(默认)/ `changed` | 文件出现 / 版本变化 |
85
+ | `log` | `path`, `matches` | `matches` | 日志尾部或新增内容命中正则 |
86
+
87
+ ### fail_fast 模式
88
+
89
+ - `true`(默认):`job_failed` + `job_killed` + `agent_failed`
90
+ - `false`:关闭
91
+ - 数组:`job_failed` / `job_killed` / `exit_code_nonzero` / `agent_failed` / `stderr_matches:/re/` / `output_matches:/re/`
92
+
93
+ `exit_code_nonzero` 会从作业输出尾部识别 `EXIT=N`(N≠0),因此**进程还没退出、但日志已宣布失败**时也能提前返回。
94
+
95
+ ## 事件驱动到什么程度
96
+
97
+ - 作业条件:订阅 `jobs.events`,作业 settle / 输出增长 / progress 时立刻唤醒主循环,不受 `poll_interval_sec` 限制。
98
+ - 文件 / 日志 / 队友:按 `poll_interval_sec` 采样(默认 2s)。
99
+ - 取消:全程响应 `exec.signal`,调用方打断时立即返回 `cancelled`。
100
+
101
+ ## 拦截规则(硬约束)
102
+
103
+ | 拦截对象 | 条件 | 拒绝原因 |
104
+ | --- | --- | --- |
105
+ | `bash` | 前台命令里含 `sleep ≥ 20s`(不含 `run_in_background`、不含 `&` 后台) | 给出 5 条 `wait_until` 替代示例 |
106
+ | `job_output` | `wait:true` 且 `timeout_ms > 120000` | 要求改用 `wait_until` 分段等待 |
107
+ | `wait_agent` | `timeout_ms > 120000` | 要求改用 `wait_until` 的 agent 条件 |
108
+
109
+ 其余调用一律放行。`sleep 3`、后台 `sleep`、普通命令不受影响。
110
+
111
+ ## 文件结构
112
+
113
+ ```
114
+ index.js 插件本体(零依赖,不 import 任何 @deepseek-ai 包)
115
+ cordis.patch.yml 插入 Loader 条目
116
+ package.json bundle 清单(dsh.bundle.patch 指向 patch)
117
+ locale/ 中英文显示元数据(Plugin Manager / Settings 用)
118
+ icon.svg 插件图标
119
+ test/unit.mjs 本地单测(mock 服务,215 条断言,不依赖运行时)
120
+ docs/baseline.md 改造前基线实测报告
121
+ ```
122
+
123
+ 零依赖的原因:profile 的 `node_modules` 里没有 `@deepseek-ai/dsh-tools` 等包,插件里 import 它们会解析失败。工具 schema 直接手写标准 JSON Schema(`parameters` 原样投影给模型,`output.schema` 由 `tools.register` 校验)。
124
+
125
+ ## 本地测试
126
+
127
+ ```bash
128
+ node test/unit.mjs
129
+ ```
130
+
131
+ 覆盖:立即失败、完成事件唤醒(poll 间隔 5s 时仍 <1.5s 返回)、卡住检测、超时、取消、批量 fail_fast、文件出现/变化、日志已命中/新增命中、队友空闲/失败、参数校验、两条 guard 规则、系统提示词注册。
132
+
133
+ ## 安装
134
+
135
+ ```js
136
+ plugin_manager({ action: 'install_bundle', target: '/Users/loong/dsh-plugins/dsh-tool-wait-until' })
137
+ ```
138
+
139
+ 安装后由 Plugin Manager 写入 profile 的 `package.json` 与 bundle 列表,`cordis.patch.yml` 插入 `tool-wait-until` 条目。卸载用 `plugin_manager remove_bundle`。
140
+
141
+ ## 已知限制
142
+
143
+ - 模型在**思考阶段**是否萌生「等 180 秒」的念头无法直接干预;本插件只保证这个念头一旦落成工具调用就会被拦下并改道。
144
+ - `agent` 条件依赖 Agent Teams 服务;非 Team 会话调用会立即返回 `failed` 并说明原因,不会空等。
145
+ - `wait_agent` / `job_output` 的拦截是硬拒绝,模型会消耗一步重试;拒绝原因里已附可直接复制的替代调用。
146
+ - 日志跨轮读取按字节窗口进行,多字节字符在窗口边界可能短暂出现替换符,不影响 ASCII 正则(如 `EXIT=0`、`error`)匹配。
147
+ - 多 `conditions` 是 `wait_all` 语义(全部满足才 `completed`),`fail_fast` 命中时任一失败即 `failed`。
148
+
149
+ ## 修复版生效说明(重要)
150
+
151
+ `index.js` 中「进程退出码非零 → `failed`」的修复只对**新加载的模块**生效:DSH 会在宿主进程内缓存已加载的插件模块,因此只修改本地 link 包的 JS 再重装,运行中的进程仍跑旧代码。
152
+
153
+ 本次从 `dsh-tool-watch` 改名为 `dsh-tool-wait-until` 恰好绕开了这个限制——**模块路径变了就是新的 ESM URL**,重装后新代码立即加载,无需重启。实测同一个「退出码 3」的作业已从 `completed` 变为 `failed`(`failure_reason` 含「退出码 3」,`output_tail` 含 stderr)。
154
+
155
+ 以后若只改 `index.js` 而不改路径,仍需要重启 Desktop;想免重启,可改包名或目录后重新安装。
156
+
157
+ 下面的回归清单保留,供重启后或换环境时验证:
158
+
159
+ 1. **退出码识别**:后台启动 `sh -c 'echo BOOM >&2; exit 3'`,拿到 job id 后 `wait_until({conditions:[{kind:"job",id:"<id>"}], fail_fast:true, timeout_sec:60})` → 期望 `status: "failed"`、`failure_reason` 含「退出码 3」、`output_tail` 含 `BOOM`。
160
+ 2. **等待中被事件唤醒**:后台启动 `sh -c 'sleep 20; echo "EXIT=3" >&2; exit 3'`,在运行中用 `poll_interval_sec:10` 调 `wait_until` → 期望在约 20 秒的失败瞬间返回,而不是等到 30 秒的轮询边界。
161
+ 3. **卡住检测**:后台启动 `sh -c 'sleep 300'`,`wait_until({conditions:[{kind:"job",id:"<id>"}], stall_after_sec:5, timeout_sec:60})` → 期望约 5 秒返回 `stalled`,测完 `job_kill`。
162
+
163
+ `node test/unit.mjs` 覆盖上述语义(249 条断言)。
@@ -0,0 +1,4 @@
1
+ - insert:
2
+ - id: tool-wait-until
3
+ name: '@local/dsh-tool-wait-until'
4
+ config: {}
package/icon.svg ADDED
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Event-driven watch">
2
+ <defs>
3
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#2f6df6"/>
5
+ <stop offset="1" stop-color="#12b886"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="4" y="4" width="56" height="56" rx="14" fill="url(#g)"/>
9
+ <circle cx="32" cy="34" r="15" fill="none" stroke="#ffffff" stroke-width="4"/>
10
+ <path d="M32 34V24" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
11
+ <path d="M32 34l7 6" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
12
+ <path d="M25 12h14" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
13
+ <path d="M32 12v5" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
14
+ <circle cx="47" cy="19" r="5" fill="#ffd43b"/>
15
+ </svg>
package/index.js ADDED
@@ -0,0 +1,954 @@
1
+ /**
2
+ * @module @local/dsh-tool-wait-until
3
+ *
4
+ * 「空等」消除插件:注册模型可见的 `wait_until` 工具。
5
+ *
6
+ * 设计要点:
7
+ * - 事件驱动:订阅 `jobs.events`,后台作业 settle / 输出增长时立刻唤醒主循环,
8
+ * 不依赖固定 sleep 轮询;文件、日志、队友条件在 poll_interval 上采样。
9
+ * - 早期失败:作业非零退出、stderr/输出命中错误模式、队友 failed,在等待过程中
10
+ * 就判定 failed,不必等条件满足,也不等到超时。
11
+ * - 卡住检测:任一目标的进展信号(作业输出字节数、作业状态、队友状态、文件版本、
12
+ * 日志大小)连续 stall_after_sec 秒没有变化,就返回 stalled,不等到 timeout。
13
+ * - 统一结果:status / elapsed_ms / matched / failure_reason / output_tail,
14
+ * 调用方凭一次返回即可决策,无需二次查询。
15
+ * - 可取消:全程响应 exec.signal,调用方打断时立即返回 cancelled。
16
+ *
17
+ * 本插件保持零依赖:不 import 任何 @deepseek-ai 包(profile 的 node_modules 里
18
+ * 并没有这些包,导入会解析失败),工具 schema 直接手写标准 JSON Schema。
19
+ */
20
+
21
+ export const name = 'tool-wait-until'
22
+
23
+ /** 激活所需的服务;fs / agentTeams 通过 ctx.get 可选访问。 */
24
+ export const inject = ['tools', 'jobs', 'workingDirectory']
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // 参数默认值与边界
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const DEFAULT_TIMEOUT_SEC = 60
31
+ const MIN_TIMEOUT_SEC = 1
32
+ const MAX_TIMEOUT_SEC = 900
33
+
34
+ const DEFAULT_POLL_SEC = 2
35
+ const MIN_POLL_SEC = 0.25
36
+ const MAX_POLL_SEC = 30
37
+
38
+ const MIN_STALL_SEC = 5
39
+
40
+ const DEFAULT_TAIL_BYTES = 4096
41
+ const MIN_TAIL_BYTES = 256
42
+ const MAX_TAIL_BYTES = 65536
43
+
44
+ /** 单次读取日志尾部的窗口上限。 */
45
+ const LOG_WINDOW_BYTES = 65536
46
+
47
+ const JOB_SETTLED = new Set(['completed', 'killed', 'failed'])
48
+ const AGENT_INACTIVE = new Set(['inactive', 'failed'])
49
+ const CONDITION_KINDS = new Set(['job', 'agent', 'file', 'log'])
50
+ const SIMPLE_FAIL_FAST = new Set(['job_failed', 'job_killed', 'exit_code_nonzero', 'agent_failed'])
51
+ const DEFAULT_FAIL_FAST = ['job_failed', 'job_killed', 'agent_failed']
52
+
53
+ const encoder = new TextEncoder()
54
+ const decoder = new TextDecoder()
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // 小工具
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function clamp(value, min, max) {
61
+ return Math.min(max, Math.max(min, value))
62
+ }
63
+
64
+ function numberOr(value, fallback) {
65
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
66
+ }
67
+
68
+ function formatElapsed(ms) {
69
+ if (ms < 1000) return `${Math.round(ms)}ms`
70
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
71
+ const minutes = Math.floor(ms / 60000)
72
+ const seconds = Math.round((ms % 60000) / 1000)
73
+ return `${minutes}m${seconds}s`
74
+ }
75
+
76
+ function describeError(error) {
77
+ return error instanceof Error ? error.message : String(error)
78
+ }
79
+
80
+ /**
81
+ * 解析 `/pattern/flags` 或裸字符串形式的正则。
82
+ * @param {string} spec - 正则表达式来源。
83
+ * @param {string} label - 出错信息里指代该来源的名字。
84
+ * @returns {RegExp} 编译后的正则。
85
+ */
86
+ function parsePattern(spec, label) {
87
+ if (typeof spec !== 'string' || spec.length === 0) throw new Error(`${label} 必须是非空正则表达式`)
88
+ const wrapped = /^\/([\s\S]*)\/([a-z]*)$/.exec(spec)
89
+ try {
90
+ return wrapped === null ? new RegExp(spec) : new RegExp(wrapped[1], wrapped[2])
91
+ } catch (error) {
92
+ throw new Error(`${label} 不是合法正则:${spec}(${describeError(error)})`)
93
+ }
94
+ }
95
+
96
+ /** 重置 g/y 状态后测试一次,避免调用方传带 flag 的正则时出现 lastIndex 串扰。 */
97
+ function matchesRegex(regex, text) {
98
+ regex.lastIndex = 0
99
+ const matched = regex.test(text)
100
+ regex.lastIndex = 0
101
+ return matched
102
+ }
103
+
104
+ /** 按字节上限保留文本尾部,并在 UTF-8 字符边界上裁剪。 */
105
+ function truncateTail(text, maxBytes) {
106
+ if (text.length === 0) return text
107
+ const bytes = encoder.encode(text)
108
+ if (bytes.byteLength <= maxBytes) return text
109
+ return decoder.decode(bytes.subarray(bytes.byteLength - maxBytes)).replace(/^\uFFFD+/, '')
110
+ }
111
+
112
+ /** 把 job 环形缓冲里的 chunk 渲染成可读正文(stderr 单独分段)。 */
113
+ function renderChunks(chunks) {
114
+ const visible = chunks.filter((chunk) => chunk.channel !== 'log')
115
+ const out = visible.filter((chunk) => chunk.channel !== 'stderr').map((chunk) => chunk.text).join('')
116
+ const err = visible.filter((chunk) => chunk.channel === 'stderr').map((chunk) => chunk.text).join('')
117
+ if (err.length === 0) return out
118
+ return `${out}${out.length > 0 && !out.endsWith('\n') ? '\n' : ''}[stderr]\n${err}`
119
+ }
120
+
121
+ /**
122
+ * 单个待唤醒槽:事件到达时唤醒当前等待者;没有等待者时记一次「已触发」,
123
+ * 让紧随其后的等待立即返回,避免事件落在等待与检查的缝隙里被丢掉。
124
+ * @returns {{ setPending: (fn: () => void) => void, clearPending: () => void, wake: () => void }}
125
+ */
126
+ function createWake() {
127
+ let pending
128
+ let fired = false
129
+ return {
130
+ setPending(fn) {
131
+ if (fired) {
132
+ fired = false
133
+ fn()
134
+ return
135
+ }
136
+ pending = fn
137
+ },
138
+ clearPending() {
139
+ pending = undefined
140
+ },
141
+ wake() {
142
+ const fn = pending
143
+ if (fn === undefined) {
144
+ fired = true
145
+ return
146
+ }
147
+ pending = undefined
148
+ fn()
149
+ },
150
+ }
151
+ }
152
+
153
+ /** 固定时长、事件唤醒、调用方取消三者任一即返回。 */
154
+ function waitForSignal(durationMs, wake, signal) {
155
+ return new Promise((resolve) => {
156
+ let settled = false
157
+ let timer
158
+ const finish = () => {
159
+ if (settled) return
160
+ settled = true
161
+ clearTimeout(timer)
162
+ signal.removeEventListener('abort', onAbort)
163
+ wake.clearPending()
164
+ resolve()
165
+ }
166
+ const onAbort = () => finish()
167
+ timer = setTimeout(finish, durationMs)
168
+ if (signal.aborted) {
169
+ finish()
170
+ return
171
+ }
172
+ signal.addEventListener('abort', onAbort, { once: true })
173
+ wake.setPending(finish)
174
+ })
175
+ }
176
+
177
+ /** 读取会话工作目录;没有 workingDirectory 或调用方不是 Agent 时回落到 undefined。 */
178
+ async function resolveCwd(ctx, exec) {
179
+ const directory = ctx.get('workingDirectory')
180
+ if (directory === undefined || exec.agent === undefined) return undefined
181
+ try {
182
+ return await directory.ensure(exec.agent)
183
+ } catch {
184
+ return undefined
185
+ }
186
+ }
187
+
188
+ function resolveOptions(cwd) {
189
+ return cwd === undefined ? {} : { cwd }
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // 入参归一化
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * 校验并归一化 conditions。
198
+ * @param {unknown} input - 模型传入的 conditions。
199
+ * @returns {Array<object>} 归一化后的条件数组(保持传入顺序)。
200
+ */
201
+ function normalizeConditions(input) {
202
+ if (!Array.isArray(input) || input.length === 0) throw new Error('conditions 必须是非空数组')
203
+ return input.map((raw, index) => {
204
+ const label = `conditions[${index}]`
205
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`${label} 必须是对象`)
206
+ const kind = raw.kind
207
+ if (typeof kind !== 'string' || !CONDITION_KINDS.has(kind)) {
208
+ throw new Error(`${label}.kind 必须是 job / agent / file / log 之一`)
209
+ }
210
+ if (kind === 'job') {
211
+ if (typeof raw.id !== 'string' || raw.id.length === 0) throw new Error(`${label}(job)必须提供非空 id`)
212
+ const until = raw.until ?? 'settled'
213
+ if (until !== 'settled' && until !== 'failed') throw new Error(`${label}.until 对 job 只能是 settled / failed`)
214
+ return { kind, label, id: raw.id, until }
215
+ }
216
+ if (kind === 'agent') {
217
+ const until = raw.until ?? 'inactive'
218
+ if (until !== 'inactive' && until !== 'failed' && until !== 'status_changed') {
219
+ throw new Error(`${label}.until 对 agent 只能是 inactive / failed / status_changed`)
220
+ }
221
+ if (raw.target !== undefined && (typeof raw.target !== 'string' || raw.target.length === 0)) {
222
+ throw new Error(`${label}.target 必须是非空字符串`)
223
+ }
224
+ return { kind, label, target: raw.target, until }
225
+ }
226
+ if (typeof raw.path !== 'string' || raw.path.length === 0) throw new Error(`${label}(${kind})必须提供非空 path`)
227
+ if (kind === 'file') {
228
+ const until = raw.until ?? 'exists'
229
+ if (until !== 'exists' && until !== 'changed') throw new Error(`${label}.until 对 file 只能是 exists / changed`)
230
+ return { kind, label, path: raw.path, until }
231
+ }
232
+ const until = raw.until ?? 'matches'
233
+ if (until !== 'matches') throw new Error(`${label}.until 对 log 只能是 matches`)
234
+ if (typeof raw.matches !== 'string' || raw.matches.length === 0) {
235
+ throw new Error(`${label}(log)必须提供非空 matches 正则`)
236
+ }
237
+ return { kind, label, path: raw.path, until, matches: raw.matches, regex: parsePattern(raw.matches, `${label}.matches`) }
238
+ })
239
+ }
240
+
241
+ /**
242
+ * 归一化 fail_fast 配置。
243
+ * @param {unknown} value - true / false / 模式字符串数组 / 省略。
244
+ * @returns {{ enabled: boolean, simple: Set<string>, textRules: Array<object> }}
245
+ */
246
+ function normalizeFailFast(value) {
247
+ if (value === false) return { enabled: false, simple: new Set(), textRules: [] }
248
+ const entries = value === undefined || value === true ? DEFAULT_FAIL_FAST : value
249
+ if (!Array.isArray(entries)) throw new Error('fail_fast 必须是布尔值或字符串数组')
250
+ const simple = new Set()
251
+ const textRules = []
252
+ for (const entry of entries) {
253
+ if (typeof entry !== 'string' || entry.length === 0) throw new Error('fail_fast 的每一项必须是非空字符串')
254
+ const wrapped = /^(stderr_matches|output_matches):([\s\S]+)$/.exec(entry)
255
+ if (wrapped !== null) {
256
+ textRules.push({
257
+ channel: wrapped[1] === 'stderr_matches' ? 'stderr' : 'output',
258
+ label: entry,
259
+ regex: parsePattern(wrapped[2], entry),
260
+ })
261
+ continue
262
+ }
263
+ if (!SIMPLE_FAIL_FAST.has(entry)) {
264
+ throw new Error(`未知的 fail_fast 模式:${entry}。支持 job_failed / job_killed / exit_code_nonzero / agent_failed / stderr_matches:/re/ / output_matches:/re/`)
265
+ }
266
+ simple.add(entry)
267
+ }
268
+ return { enabled: true, simple, textRules }
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // 条件探针
273
+ // ---------------------------------------------------------------------------
274
+
275
+ /**
276
+ * 读取作业输出尾部的三份视图:完整正文、stdout 部分、stderr 部分。
277
+ * 用 readAt 读取,不移动模型在 job_output 上的消费游标。
278
+ */
279
+ function readJobWindow(ctx, id, caller, maxBytes) {
280
+ try {
281
+ const job = ctx.jobs.get(id, caller)
282
+ const total = job.output.total
283
+ const from = Math.max(0, total - Math.max(maxBytes * 2, 4096))
284
+ const read = ctx.jobs.readAt(id, from, caller)
285
+ const visible = read.chunks.filter((chunk) => chunk.channel !== 'log')
286
+ const output = visible.filter((chunk) => chunk.channel !== 'stderr').map((chunk) => chunk.text).join('')
287
+ const stderr = visible.filter((chunk) => chunk.channel === 'stderr').map((chunk) => chunk.text).join('')
288
+ return {
289
+ text: truncateTail(renderChunks(read.chunks), maxBytes),
290
+ output: truncateTail(output, maxBytes),
291
+ stderr: truncateTail(stderr, maxBytes),
292
+ }
293
+ } catch {
294
+ return { text: '', output: '', stderr: '' }
295
+ }
296
+ }
297
+
298
+ /** 作业条件:等待 settle,同时做早期失败判定。 */
299
+ function createJobProbe(ctx, condition, caller, failFast, tailBytes) {
300
+ let lastTotal = -1
301
+ let lastStatus
302
+ let window = { text: '', output: '', stderr: '' }
303
+ return {
304
+ label: condition.label,
305
+ describe: () => `后台作业 ${condition.id}(${condition.until === 'failed' ? '失败' : '结束'})`,
306
+ async poll() {
307
+ let job
308
+ try {
309
+ job = ctx.jobs.get(condition.id, caller)
310
+ } catch (error) {
311
+ return { fatal: `无法读取后台作业 ${condition.id}:${describeError(error)}` }
312
+ }
313
+ const total = job.output.total
314
+ const progressed = total !== lastTotal || job.status !== lastStatus
315
+ lastTotal = total
316
+ lastStatus = job.status
317
+ if (progressed) window = readJobWindow(ctx, condition.id, caller, tailBytes)
318
+
319
+ const status = job.status
320
+ const detailText = job.detail ?? ''
321
+ // 平台把「进程正常退出但退出码非零」记成 completed + detail: "exit code: N",
322
+ // 只有被 kill 或启动失败才是 killed/failed。所以非零退出码必须自己识别,
323
+ // 否则用户抱怨的「脚本报错退出」会伪装成成功。
324
+ const exitMatch = /exit code:\s*(-?\d+)/i.exec(detailText)
325
+ const exitCode = exitMatch === null ? undefined : Number(exitMatch[1])
326
+ const failedExit = exitCode !== undefined && exitCode !== 0
327
+ const failed = status === 'failed' || failedExit
328
+ const killed = status === 'killed'
329
+ const settled = JOB_SETTLED.has(status)
330
+ const detail = `状态 ${status}${job.detail === undefined ? '' : ` — ${job.detail}`}`
331
+ const satisfied = condition.until === 'failed' ? failed : settled
332
+
333
+ let fail
334
+ if (failFast.enabled) {
335
+ if (failed && failFast.simple.has('job_failed')) {
336
+ fail = {
337
+ reason: failedExit
338
+ ? `后台作业 ${job.id} 失败:退出码 ${exitCode}`
339
+ : `后台作业 ${job.id} 失败:${job.detail ?? '无 detail'}`,
340
+ tail: window.text,
341
+ }
342
+ } else if (killed && failFast.simple.has('job_killed')) {
343
+ fail = { reason: `后台作业 ${job.id} 被终止:${job.detail ?? '无 detail'}`, tail: window.text }
344
+ } else if (failFast.simple.has('exit_code_nonzero')) {
345
+ if (failedExit) {
346
+ fail = { reason: `后台作业 ${job.id} 退出码 ${exitCode}(非零退出)`, tail: window.text }
347
+ } else {
348
+ const exit = /EXIT=(-?\d+)/.exec(window.text)
349
+ if (exit !== null && Number(exit[1]) !== 0) {
350
+ fail = { reason: `后台作业 ${job.id} 输出 EXIT=${exit[1]}(非零退出)`, tail: window.text }
351
+ }
352
+ }
353
+ }
354
+ if (fail === undefined) {
355
+ for (const rule of failFast.textRules) {
356
+ const haystack = rule.channel === 'stderr' ? window.stderr : window.output
357
+ if (matchesRegex(rule.regex, haystack)) {
358
+ fail = { reason: `后台作业 ${job.id} 的${rule.channel === 'stderr' ? ' stderr' : '输出'}命中失败模式 ${rule.label}`, tail: window.text }
359
+ break
360
+ }
361
+ }
362
+ }
363
+ }
364
+ return { satisfied, progress: progressed, detail, tail: window.text, fail }
365
+ },
366
+ }
367
+ }
368
+
369
+ /** 队友条件:等待成员变为 inactive / failed,或出现状态变化。 */
370
+ function createAgentProbe(ctx, condition, callerAgent) {
371
+ const teams = ctx.get('agentTeams')
372
+ const describe = () => `队友 ${condition.target ?? '(任意成员)'}(${
373
+ condition.until === 'failed' ? '失败' : condition.until === 'status_changed' ? '状态变化' : '空闲'
374
+ })`
375
+ if (teams === undefined || callerAgent === undefined) {
376
+ return {
377
+ label: condition.label,
378
+ describe,
379
+ async poll() {
380
+ return { unsupported: '当前运行环境没有 Agent Teams 服务,agent 条件无法评估(请改用其它条件)' }
381
+ },
382
+ }
383
+ }
384
+ let lastSignature
385
+ return {
386
+ label: condition.label,
387
+ describe,
388
+ async poll() {
389
+ let members
390
+ try {
391
+ members = teams.listMembers(callerAgent)
392
+ } catch (error) {
393
+ return { fatal: `无法读取团队成员:${describeError(error)}` }
394
+ }
395
+ const peers = members.filter((member) => member.id !== callerAgent.id)
396
+ const watched = condition.target === undefined ? peers : peers.filter((member) => member.name === condition.target)
397
+ if (watched.length === 0) {
398
+ return { fatal: `团队中没有名为 ${String(condition.target)} 的成员` }
399
+ }
400
+ const signature = watched.map((member) => `${member.name}:${member.status}:${member.diagnostics.length}`).join('|')
401
+ const progressed = lastSignature !== undefined && signature !== lastSignature
402
+ lastSignature = signature
403
+ const detail = watched
404
+ .map((member) => `${member.name}=${member.status}${member.diagnostics.length === 0 ? '' : `(diagnostics: ${member.diagnostics.join('; ')})`}`)
405
+ .join(', ')
406
+ const failedMember = watched.find((member) => member.status === 'failed')
407
+ const anyFailed = failedMember !== undefined
408
+ let satisfied
409
+ if (condition.until === 'failed') satisfied = anyFailed
410
+ else if (condition.until === 'status_changed') satisfied = progressed
411
+ else satisfied = watched.every((member) => AGENT_INACTIVE.has(member.status))
412
+ const fail = anyFailed
413
+ ? { reason: `队友 ${failedMember.name} 失败:${failedMember.diagnostics.join('; ') || '无 diagnostics'}`, tail: detail }
414
+ : undefined
415
+ return { satisfied, progress: progressed, detail, fail }
416
+ },
417
+ }
418
+ }
419
+
420
+ /** 文件条件:等待出现或版本变化。 */
421
+ function createFileProbe(ctx, condition, cwd) {
422
+ const fs = ctx.get('fs')
423
+ const describe = () => `文件 ${condition.path}(${condition.until === 'exists' ? '出现' : '变化'})`
424
+ if (fs === undefined) {
425
+ return {
426
+ label: condition.label,
427
+ describe,
428
+ async poll() {
429
+ return { unsupported: '当前运行环境没有 fs 服务,file 条件无法评估' }
430
+ },
431
+ }
432
+ }
433
+ let target
434
+ let baselineVersion
435
+ let lastVersion
436
+ let initialized = false
437
+ return {
438
+ label: condition.label,
439
+ describe,
440
+ async poll() {
441
+ if (target === undefined) {
442
+ try {
443
+ target = await fs.resolve(condition.path, resolveOptions(cwd))
444
+ } catch (error) {
445
+ return { fatal: `无法解析路径 ${condition.path}:${describeError(error)}` }
446
+ }
447
+ }
448
+ let info
449
+ try {
450
+ info = await fs.stat(target)
451
+ } catch (error) {
452
+ return { error: `读取 ${condition.path} 的状态失败:${describeError(error)}` }
453
+ }
454
+ const exists = info !== undefined
455
+ const version = exists ? String(info.version) : undefined
456
+ const progressed = initialized && version !== lastVersion
457
+ lastVersion = version
458
+ if (!initialized) {
459
+ initialized = true
460
+ baselineVersion = version
461
+ }
462
+ const detail = exists ? `文件存在(${info.size === undefined ? '大小未知' : `${info.size} 字节`})` : '文件不存在'
463
+ let satisfied
464
+ if (condition.until === 'exists') satisfied = exists
465
+ else satisfied = exists && baselineVersion !== undefined && version !== baselineVersion
466
+ return { satisfied, progress: progressed, detail }
467
+ },
468
+ }
469
+ }
470
+
471
+ /** 日志条件:等待文件内容命中正则。 */
472
+ function createLogProbe(ctx, condition, cwd) {
473
+ const fs = ctx.get('fs')
474
+ const describe = () => `日志 ${condition.path} 匹配 ${condition.matches}`
475
+ if (fs === undefined) {
476
+ return {
477
+ label: condition.label,
478
+ describe,
479
+ async poll() {
480
+ return { unsupported: '当前运行环境没有 fs 服务,log 条件无法评估' }
481
+ },
482
+ }
483
+ }
484
+ let target
485
+ let readOffset
486
+ let recentText = ''
487
+ return {
488
+ label: condition.label,
489
+ describe,
490
+ async poll() {
491
+ if (target === undefined) {
492
+ try {
493
+ target = await fs.resolve(condition.path, resolveOptions(cwd))
494
+ } catch (error) {
495
+ return { fatal: `无法解析日志路径 ${condition.path}:${describeError(error)}` }
496
+ }
497
+ }
498
+ let info
499
+ try {
500
+ info = await fs.stat(target)
501
+ } catch (error) {
502
+ return { error: `读取日志 ${condition.path} 的状态失败:${describeError(error)}` }
503
+ }
504
+ if (info === undefined) {
505
+ readOffset = undefined
506
+ recentText = ''
507
+ return { satisfied: false, progress: false, detail: '日志文件还不存在' }
508
+ }
509
+ const size = info.size ?? 0
510
+ // 首轮(或文件被轮转截断后)读取尾部窗口,让启动前已存在的内容也能命中。
511
+ if (readOffset === undefined || size < readOffset) {
512
+ const from = Math.max(0, size - LOG_WINDOW_BYTES)
513
+ let bytes
514
+ try {
515
+ bytes = await fs.readByteRange(target, { offset: from, length: size - from })
516
+ } catch (error) {
517
+ return { error: `读取日志 ${condition.path} 失败:${describeError(error)}` }
518
+ }
519
+ recentText = decoder.decode(bytes)
520
+ readOffset = size
521
+ const matched = matchesRegex(condition.regex, recentText)
522
+ return {
523
+ satisfied: matched,
524
+ progress: false,
525
+ detail: `日志 ${size} 字节${matched ? ',命中' : ',未命中'}`,
526
+ tail: truncateTail(recentText, DEFAULT_TAIL_BYTES),
527
+ }
528
+ }
529
+ if (size === readOffset) {
530
+ return { satisfied: false, progress: false, detail: `日志无新增(${size} 字节)`, tail: truncateTail(recentText, DEFAULT_TAIL_BYTES) }
531
+ }
532
+ let bytes
533
+ try {
534
+ bytes = await fs.readByteRange(target, { offset: readOffset, length: Math.min(size - readOffset, LOG_WINDOW_BYTES) })
535
+ } catch (error) {
536
+ return { error: `读取日志 ${condition.path} 新增内容失败:${describeError(error)}` }
537
+ }
538
+ const grew = bytes.byteLength
539
+ readOffset += grew
540
+ recentText = truncateTail(`${recentText}${decoder.decode(bytes)}`, LOG_WINDOW_BYTES)
541
+ const matched = matchesRegex(condition.regex, recentText)
542
+ return {
543
+ satisfied: matched,
544
+ progress: grew > 0,
545
+ detail: `日志新增 ${grew} 字节${matched ? ',命中' : ''}`,
546
+ tail: truncateTail(recentText, DEFAULT_TAIL_BYTES),
547
+ }
548
+ },
549
+ }
550
+ }
551
+
552
+ // ---------------------------------------------------------------------------
553
+ // 结果构造
554
+ // ---------------------------------------------------------------------------
555
+
556
+ const STATUS_HINTS = {
557
+ completed: undefined,
558
+ failed: '目标已失败,不必再等:按 output_tail / failure_reason 决策(需要完整日志时用 job_output 读取)。',
559
+ stalled: '目标在该窗口内没有任何进展(无新输出、状态未变、文件未更新),可能已卡死:检查进程与日志,必要时先停止再重试。',
560
+ timeout: '本次是超时,不是失败:条件在 timeout_sec 内仍未满足。可增大 timeout_sec 再次 wait_until,或先检查目标与日志。',
561
+ cancelled: '等待已被调用方取消,目标仍在运行(没有被停止)。',
562
+ }
563
+
564
+ /**
565
+ * 组装最终结构化结果。
566
+ * @param {string} status - completed / failed / stalled / timeout / cancelled。
567
+ * @param {number} startedAt - 起始时间戳(毫秒)。
568
+ * @param {object} input - { matched, rows, failureReason, stalledForSec, tail, hint, tailBytes }。
569
+ * @returns {object} 符合 output.schema 的值。
570
+ */
571
+ function buildResult(status, startedAt, input) {
572
+ const elapsedMs = Date.now() - startedAt
573
+ const value = {
574
+ status,
575
+ elapsed_ms: elapsedMs,
576
+ elapsed: formatElapsed(elapsedMs),
577
+ matched: input.matched ?? [],
578
+ conditions: input.rows ?? [],
579
+ }
580
+ if (input.failureReason !== undefined) value.failure_reason = truncateTail(input.failureReason, 8192)
581
+ if (input.stalledForSec !== undefined) value.stalled_for_sec = input.stalledForSec
582
+ if (input.tail !== undefined && input.tail !== '') value.output_tail = truncateTail(input.tail, input.tailBytes ?? DEFAULT_TAIL_BYTES)
583
+ const hint = input.hint ?? STATUS_HINTS[status]
584
+ if (hint !== undefined) value.hint = hint
585
+ return value
586
+ }
587
+
588
+ // ---------------------------------------------------------------------------
589
+ // 主循环
590
+ // ---------------------------------------------------------------------------
591
+
592
+ /**
593
+ * 执行一次 wait_until:条件驱动返回,带早期失败、卡住检测与统一结果。
594
+ * @param {object} ctx - 插件上下文。
595
+ * @param {object} args - 模型传入的参数。
596
+ * @param {object} exec - 工具执行上下文(提供 agent 与 signal)。
597
+ * @returns {Promise<object>} 结构化结果。
598
+ */
599
+ async function runWaitUntil(ctx, args, exec) {
600
+ const startedAt = Date.now()
601
+ const conditions = normalizeConditions(args.conditions)
602
+ const failFast = normalizeFailFast(args.fail_fast)
603
+ const timeoutMs = clamp(numberOr(args.timeout_sec, DEFAULT_TIMEOUT_SEC), MIN_TIMEOUT_SEC, MAX_TIMEOUT_SEC) * 1000
604
+ const pollMs = clamp(numberOr(args.poll_interval_sec, DEFAULT_POLL_SEC), MIN_POLL_SEC, MAX_POLL_SEC) * 1000
605
+ const stallRaw = numberOr(args.stall_after_sec, 0)
606
+ const stallMs = stallRaw <= 0 ? 0 : Math.max(stallRaw, MIN_STALL_SEC) * 1000
607
+ const tailBytes = clamp(numberOr(args.output_tail_bytes, DEFAULT_TAIL_BYTES), MIN_TAIL_BYTES, MAX_TAIL_BYTES)
608
+
609
+ const caller = exec.agent?.id
610
+ const callerAgent = exec.agent
611
+ const cwd = await resolveCwd(ctx, exec)
612
+
613
+ const probes = conditions.map((condition) => {
614
+ switch (condition.kind) {
615
+ case 'job': return createJobProbe(ctx, condition, caller, failFast, tailBytes)
616
+ case 'agent': return createAgentProbe(ctx, condition, callerAgent)
617
+ case 'file': return createFileProbe(ctx, condition, cwd)
618
+ case 'log': return createLogProbe(ctx, condition, cwd)
619
+ default: throw new Error(`未知的条件类型:${String(condition.kind)}`)
620
+ }
621
+ })
622
+
623
+ const rows = probes.map((probe) => ({ condition: probe.describe(), state: 'pending', detail: '尚未检查' }))
624
+ const wake = createWake()
625
+ const disposers = []
626
+
627
+ const finish = (status, extra) => buildResult(status, startedAt, { rows: snapshotRows(rows), tailBytes, ...extra })
628
+ const snapshotRows = () => rows.map((row) => ({ condition: row.condition, state: row.state, detail: row.detail }))
629
+
630
+ try {
631
+ if (conditions.some((condition) => condition.kind === 'job')) {
632
+ const filter = caller === undefined ? { owners: 'all' } : { owner: caller }
633
+ disposers.push(ctx.jobs.events.subscribe(filter, () => wake.wake()))
634
+ }
635
+
636
+ let lastProgressAt = Date.now()
637
+ let latestTail = ''
638
+ const deadline = startedAt + timeoutMs
639
+
640
+ for (;;) {
641
+ if (exec.signal.aborted) return finish('cancelled', { tail: latestTail })
642
+ const now = Date.now()
643
+ let progressed = false
644
+ let allSatisfied = true
645
+ const matched = []
646
+ let failure
647
+ let fatal
648
+
649
+ for (let index = 0; index < probes.length; index += 1) {
650
+ const probe = probes[index]
651
+ let state
652
+ try {
653
+ state = await probe.poll()
654
+ } catch (error) {
655
+ state = { fatal: `${probe.describe()}:${describeError(error)}` }
656
+ }
657
+ const row = rows[index]
658
+ if (typeof state?.detail === 'string') row.detail = state.detail
659
+ if (typeof state?.tail === 'string' && state.tail.length > 0) latestTail = state.tail
660
+ if (state?.fatal !== undefined) {
661
+ row.state = 'error'
662
+ fatal = state.fatal
663
+ break
664
+ }
665
+ if (state?.unsupported !== undefined) {
666
+ row.state = 'unsupported'
667
+ row.detail = state.unsupported
668
+ fatal = state.unsupported
669
+ break
670
+ }
671
+ if (state?.error !== undefined) {
672
+ row.state = 'error'
673
+ allSatisfied = false
674
+ continue
675
+ }
676
+ if (state?.progress === true) progressed = true
677
+ if (state?.satisfied === true) {
678
+ row.state = 'satisfied'
679
+ matched.push(probe.describe())
680
+ } else {
681
+ row.state = 'pending'
682
+ allSatisfied = false
683
+ }
684
+ if (state?.fail !== undefined && failFast.enabled) {
685
+ row.state = 'failed'
686
+ failure = state.fail
687
+ break
688
+ }
689
+ }
690
+
691
+ if (fatal !== undefined) return finish('failed', { failureReason: fatal, tail: latestTail, matched })
692
+ if (failure !== undefined) return finish('failed', { failureReason: failure.reason, tail: failure.tail ?? latestTail, matched })
693
+ if (allSatisfied && matched.length === probes.length) return finish('completed', { matched, tail: latestTail })
694
+ if (progressed) lastProgressAt = now
695
+
696
+ if (stallMs > 0 && now - lastProgressAt >= stallMs) {
697
+ return finish('stalled', {
698
+ stalledForSec: Math.round((now - lastProgressAt) / 1000),
699
+ tail: latestTail,
700
+ matched,
701
+ })
702
+ }
703
+ const remaining = deadline - now
704
+ if (remaining <= 0) return finish('timeout', { tail: latestTail, matched })
705
+ await waitForSignal(Math.min(pollMs, remaining), wake, exec.signal)
706
+ }
707
+ } finally {
708
+ for (const dispose of disposers) {
709
+ try {
710
+ dispose()
711
+ } catch {
712
+ // 清理监听器失败不影响已经产出的结果
713
+ }
714
+ }
715
+ }
716
+ }
717
+
718
+ // ---------------------------------------------------------------------------
719
+ // 工具定义(标准 JSON Schema,零依赖)
720
+ // ---------------------------------------------------------------------------
721
+
722
+ const CONDITION_SCHEMA = {
723
+ type: 'object',
724
+ additionalProperties: false,
725
+ properties: {
726
+ kind: {
727
+ type: 'string',
728
+ enum: ['job', 'agent', 'file', 'log'],
729
+ description: '条件类型。job=后台作业;agent=团队成员;file=文件;log=日志文件正则匹配。',
730
+ },
731
+ id: { type: 'string', description: 'kind=job:job_output/job_list 返回的作业 id。' },
732
+ target: { type: 'string', description: 'kind=agent:成员名字(spawn_teammate/list_agents 的名字);省略表示该会话中任意其他成员。' },
733
+ path: { type: 'string', description: 'kind=file/log:文件路径,相对路径按会话工作目录解析。' },
734
+ until: {
735
+ type: 'string',
736
+ description: '完成条件。job:settled(默认,结束即可,含失败)/ failed(仅失败);agent:inactive(默认,全部空闲)/ failed(失败)/ status_changed(任意状态变化);file:exists(默认,出现)/ changed(版本变化);log:matches(默认,必须配 matches)。',
737
+ },
738
+ matches: { type: 'string', description: 'kind=log:正则表达式,支持 /pattern/flags 或裸正则;匹配日志新增内容或尾部窗口。' },
739
+ },
740
+ required: ['kind'],
741
+ }
742
+
743
+ const WAIT_UNTIL_PARAMETERS = {
744
+ type: 'object',
745
+ additionalProperties: false,
746
+ properties: {
747
+ conditions: {
748
+ type: 'array',
749
+ description: '要等待的条件。全部条件都满足才返回 completed(即 wait_all 语义);fail_fast 命中时任一条件失败立即返回 failed。只想要「任一完成就返回」时,把条件拆成多次 wait_until 或配合 timeout_sec 使用。',
750
+ items: CONDITION_SCHEMA,
751
+ },
752
+ fail_fast: {
753
+ oneOf: [
754
+ { type: 'boolean' },
755
+ { type: 'array', items: { type: 'string' } },
756
+ ],
757
+ description: '早期失败检测。true(默认)= job_failed + job_killed + agent_failed;false=关闭;也可给模式数组:job_failed / job_killed / exit_code_nonzero / agent_failed / stderr_matches:/error|failed|Traceback/ / output_matches:/EXIT=[1-9]/。',
758
+ },
759
+ stall_after_sec: {
760
+ type: 'number',
761
+ description: '卡住判定:连续这么多秒没有任何进展信号(作业输出/状态、队友状态、文件版本、日志大小都没变)就返回 stalled。0(默认)=关闭。长等待时建议 120–600。',
762
+ },
763
+ timeout_sec: {
764
+ type: 'number',
765
+ description: `总兜底超时,默认 ${DEFAULT_TIMEOUT_SEC},范围 ${MIN_TIMEOUT_SEC}–${MAX_TIMEOUT_SEC}。单次建议 30–60 秒,需要更长等待时反复 wait_until 而不是一次等满。`,
766
+ },
767
+ poll_interval_sec: {
768
+ type: 'number',
769
+ description: `采样间隔,默认 ${DEFAULT_POLL_SEC},范围 ${MIN_POLL_SEC}–${MAX_POLL_SEC}。作业事件仍会立即唤醒,不受该间隔限制。`,
770
+ },
771
+ output_tail_bytes: {
772
+ type: 'number',
773
+ description: `返回 output_tail 的字节上限,默认 ${DEFAULT_TAIL_BYTES}。`,
774
+ },
775
+ },
776
+ required: ['conditions'],
777
+ }
778
+
779
+ const WAIT_UNTIL_RESULT_SCHEMA = {
780
+ type: 'object',
781
+ additionalProperties: false,
782
+ properties: {
783
+ status: {
784
+ type: 'string',
785
+ enum: ['completed', 'failed', 'stalled', 'timeout', 'cancelled'],
786
+ description: '最终判定:完成 / 失败 / 卡住 / 超时 / 被取消。超时不是失败。',
787
+ },
788
+ elapsed_ms: { type: 'integer', description: '本次 wait_until 实际耗时(毫秒)。' },
789
+ elapsed: { type: 'string', description: '实际耗时的可读形式。' },
790
+ matched: { type: 'array', items: { type: 'string' }, description: '已完成/失败的条件描述。' },
791
+ conditions: {
792
+ type: 'array',
793
+ description: '每个条件的最终状态快照。',
794
+ items: {
795
+ type: 'object',
796
+ additionalProperties: false,
797
+ properties: {
798
+ condition: { type: 'string', description: '条件描述。' },
799
+ state: { type: 'string', description: 'pending / satisfied / failed / error / unsupported。' },
800
+ detail: { type: 'string', description: '该条件的最近观测细节。' },
801
+ },
802
+ required: ['condition', 'state'],
803
+ },
804
+ },
805
+ failure_reason: { type: 'string', description: 'status=failed 时的失败原因(含早期失败检测命中的模式)。' },
806
+ stalled_for_sec: { type: 'number', description: 'status=stalled 时无进展持续的秒数。' },
807
+ output_tail: { type: 'string', description: '相关目标最后输出的尾部(作业输出、日志窗口、成员状态等)。' },
808
+ hint: { type: 'string', description: '基于 status 的下一步建议。' },
809
+ },
810
+ required: ['status', 'elapsed_ms', 'elapsed', 'matched', 'conditions'],
811
+ }
812
+
813
+ function renderWaitUntilResult(_args, value) {
814
+ const lines = [`[wait_until ${value.status}] 耗时 ${value.elapsed}(${value.elapsed_ms}ms)`]
815
+ if (value.failure_reason !== undefined) lines.push(`失败原因:${value.failure_reason}`)
816
+ if (value.stalled_for_sec !== undefined) lines.push(`无进展持续 ${value.stalled_for_sec}s`)
817
+ if (value.matched.length > 0) lines.push(`已满足:${value.matched.join(';')}`)
818
+ for (const row of value.conditions) {
819
+ lines.push(`- ${row.condition}:${row.state}${row.detail === undefined ? '' : `(${row.detail})`}`)
820
+ }
821
+ if (value.output_tail !== undefined) lines.push('', '--- 输出尾部 ---', value.output_tail)
822
+ if (value.hint !== undefined) lines.push('', value.hint)
823
+ return [{ type: 'text', text: lines.join('\n') }]
824
+ }
825
+
826
+ // ---------------------------------------------------------------------------
827
+ // 行为干预:系统提示词规则 + 「sleep 空等 / 超长等待」拦截
828
+ //
829
+ // 只提供一个新工具不足以改变行为:模型会凭惯性继续「sleep 180 再查一次」。
830
+ // 因此在工具之外再补两层——决策前给规则,决策落地时拦下来并给出替代路径。
831
+ // ---------------------------------------------------------------------------
832
+
833
+ /** 前台 sleep 达到这个秒数就视为空等并拒绝。 */
834
+ const SLEEP_DENY_SEC = 20
835
+ /** job_output / wait_agent 单次等待超过这个毫秒数就要求改用 wait_until 分段等待。 */
836
+ const LONG_WAIT_DENY_MS = 120000
837
+
838
+ const SLEEP_SEGMENT = /(?:^|[;&|]|\n)\s*sleep\s+([0-9]+(?:\.[0-9]+)?)\s*([smhd]?)\b/gi
839
+ const SLEEP_UNITS = { '': 1, s: 1, m: 60, h: 3600, d: 86400 }
840
+
841
+ /**
842
+ * 找出命令里最长的一次前台 sleep(秒)。
843
+ * @param {string} command - bash 命令原文。
844
+ * @returns {number} 最长 sleep 秒数;没有则为 0。
845
+ */
846
+ function longestSleepSeconds(command) {
847
+ let longest = 0
848
+ for (const match of command.matchAll(SLEEP_SEGMENT)) {
849
+ const unit = SLEEP_UNITS[(match[2] ?? '').toLowerCase()] ?? 1
850
+ longest = Math.max(longest, Number(match[1]) * unit)
851
+ }
852
+ return longest
853
+ }
854
+
855
+ /** 拦截理由里给出的替代调用示例。 */
856
+ const WAIT_UNTIL_USAGE_HINT = `wait_until 用法(条件满足、目标失败或连续无进展时立即返回):
857
+ wait_until({conditions:[{kind:"job",id:"<job_id>"}]}) // 等后台作业结束
858
+ wait_until({conditions:[{kind:"file",path:"<path>",until:"exists"}]}) // 等文件出现
859
+ wait_until({conditions:[{kind:"file",path:"<path>",until:"changed"}], timeout_sec:900, stall_after_sec:120}) // 等文件持续变化,卡住即返回
860
+ wait_until({conditions:[{kind:"log",path:"<path>",matches:"/DONE|EXIT=0/"}]}) // 等日志关键字
861
+ wait_until({conditions:[{kind:"agent",target:"<name>",until:"inactive"}], timeout_sec:600, stall_after_sec:300}) // 等队友`
862
+
863
+ /**
864
+ * 拦截「用 sleep 空等」的前台 bash 调用。
865
+ * @param {object} execution - 工具执行上下文(含 name / arguments)。
866
+ * @returns {string|undefined} 拒绝原因;undefined 表示放行。
867
+ */
868
+ function guardSleepWait(execution) {
869
+ if (execution.name !== 'bash') return undefined
870
+ const args = execution.arguments
871
+ if (args === null || typeof args !== 'object') return undefined
872
+ if (args.run_in_background === true) return undefined
873
+ if (typeof args.command !== 'string') return undefined
874
+ const command = args.command.trim()
875
+ if (command.length === 0) return undefined
876
+ // 以单个 & 结尾的是 shell 后台任务,本身不阻塞这次调用。
877
+ if (/(?:^|[^&])&$/.test(command)) return undefined
878
+ const seconds = longestSleepSeconds(command)
879
+ if (seconds < SLEEP_DENY_SEC) return undefined
880
+ return `拒绝了 ${seconds} 秒的前台 sleep 空等:等待期间既看不到进展,完成或失败时也不会立即返回。改用 wait_until 工具(事件驱动、可观测、失败与卡住立刻返回):
881
+ ${WAIT_UNTIL_USAGE_HINT}
882
+ 确需节奏控制时,把 sleep 放进 run_in_background 的作业,或写进带实际工作的脚本。`
883
+ }
884
+
885
+ /**
886
+ * 拦截 job_output / wait_agent 的超长单次等待。
887
+ * @param {object} execution - 工具执行上下文(含 name / arguments)。
888
+ * @returns {string|undefined} 拒绝原因;undefined 表示放行。
889
+ */
890
+ function guardLongToolWait(execution) {
891
+ const args = execution.arguments
892
+ if (args === null || typeof args !== 'object') return undefined
893
+ if (execution.name === 'job_output' && args.wait === true && typeof args.timeout_ms === 'number') {
894
+ if (args.timeout_ms <= LONG_WAIT_DENY_MS) return undefined
895
+ return `job_output 的单个等待最多 ${LONG_WAIT_DENY_MS / 1000} 秒(本次 ${Math.round(args.timeout_ms / 1000)} 秒):超长等待会在作业卡住时白等,也拿不到中间进展。改用 wait_until 分段等待:
896
+ ${WAIT_UNTIL_USAGE_HINT}`
897
+ }
898
+ if (execution.name === 'wait_agent') {
899
+ const timeout = typeof args.timeout_ms === 'number' ? args.timeout_ms : 30000
900
+ if (timeout <= LONG_WAIT_DENY_MS) return undefined
901
+ return `wait_agent 的单个等待最多 ${LONG_WAIT_DENY_MS / 1000} 秒(本次 ${Math.round(timeout / 1000)} 秒):它只回一个布尔值,卡住时要等满窗口且事后还得再查。改用 wait_until 的 agent 条件,卡住即返回并带状态快照:
902
+ wait_until({conditions:[{kind:"agent",target:"<name>",until:"inactive"}], timeout_sec:600, stall_after_sec:300})`
903
+ }
904
+ return undefined
905
+ }
906
+
907
+ /** 进入模型系统提示词的等待纪律,放在 jobs 工具说明之后。 */
908
+ const WAIT_UNTIL_POLICY = `# 等待纪律
909
+ 需要等待后台作业、队友、文件或日志时,不要用 sleep 空等或反复轮询:
910
+ - bash 中前台 sleep 达到 ${SLEEP_DENY_SEC} 秒会被拒绝;job_output / wait_agent 单次等待超过 ${LONG_WAIT_DENY_MS / 1000} 秒也会被拒绝。
911
+ - 改用 wait_until:条件满足、目标失败或连续无进展时立即返回 status / elapsed / failure_reason / output_tail,一次调用即可判定结果,无需再查一次。
912
+ ${WAIT_UNTIL_USAGE_HINT}
913
+ 长等待用 timeout_sec + stall_after_sec 分段,而不是一次设 30 分钟。`
914
+
915
+ /**
916
+ * 注册 `wait_until` 工具、等待纪律提示词,以及两条行为拦截。
917
+ * @param {object} ctx - 插件上下文(tools / jobs / workingDirectory)。
918
+ */
919
+ export function apply(ctx) {
920
+ // 第一层:决策前的规则。模型在生成「等 180 秒」这个念头之前就应看到它。
921
+ const prompt = ctx.get('systemPrompt')
922
+ if (prompt !== undefined) {
923
+ ctx.effect(() => prompt.section({
924
+ name: 'tool:wait-until',
925
+ order: prompt.getSectionOrder('TOOL_JOBS') + 50,
926
+ text: WAIT_UNTIL_POLICY,
927
+ interpolate: false,
928
+ }), 'tool-wait-until.policy()')
929
+ }
930
+
931
+ // 第二层:决策落地时的硬拦截。即使模型仍决定空等,调用会被拒绝并给出替代路径。
932
+ if (typeof ctx.tools.guard === 'function') {
933
+ ctx.effect(() => ctx.tools.guard(guardSleepWait), 'tool-wait-until.guard.sleep()')
934
+ ctx.effect(() => ctx.tools.guard(guardLongToolWait), 'tool-wait-until.guard.long-wait()')
935
+ }
936
+
937
+ // 第三层:替代原语本身——事件驱动的等待。
938
+ ctx.tools.register({
939
+ name: 'wait_until',
940
+ description: '事件驱动的等待:等到目标完成、失败或卡住就立刻返回,替代「长超时 + sleep 轮询 + 再查一轮」。适用于后台作业(job_output 的 id)、团队成员、文件出现/变化、日志正则命中。返回统一的 status(completed/failed/stalled/timeout/cancelled)、elapsed、failure_reason、output_tail,一次调用即可判定结果,无需二次查询。作业事件会立即唤醒,不受 poll_interval_sec 限制。',
941
+ parameters: WAIT_UNTIL_PARAMETERS,
942
+ output: {
943
+ schema: WAIT_UNTIL_RESULT_SCHEMA,
944
+ render: renderWaitUntilResult,
945
+ },
946
+ // 等待类工具不独占调度,可以与其它工具并行。
947
+ isConcurrencySafe: () => true,
948
+ // 工具级兜底:略大于最长总超时,避免被调用超时策略先截断。
949
+ timeoutMs: MAX_TIMEOUT_SEC * 1000 + 60000,
950
+ async execute(args, exec) {
951
+ return await runWaitUntil(ctx, args, exec)
952
+ },
953
+ })
954
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Wait until: event-driven wait",
4
+ "description": "Wait for background jobs, teammates, files and logs with fail-fast, stall detection and one structured result, instead of burning a full timeout and querying again."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "等待直到(wait_until)",
4
+ "description": "等待后台作业、队友、文件与日志:完成、早期失败或卡住时立即返回结构化结果,替代等人等满超时后再二次查询,并拦截 sleep 空等。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "dsh-tool-wait-until",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Event-driven wait for DeepSeek Harness: returns the moment the target completes, fails early, or stalls — instead of burning a full timeout and querying again. Also blocks sleep-polling and over-long waits.",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "wait",
10
+ "timeout",
11
+ "background-jobs",
12
+ "stall-detection",
13
+ "polling",
14
+ "agent-tools"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "tongshuanglong <tongsh6@gmail.com>",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/tongsh6/dsh-tool-wait-until.git"
21
+ },
22
+ "homepage": "https://github.com/tongsh6/dsh-tool-wait-until#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/tongsh6/dsh-tool-wait-until/issues"
25
+ },
26
+ "type": "module",
27
+ "engines": {
28
+ "node": ">=20",
29
+ "dsh": ">=0.2.1-rc.1 <0.3.0"
30
+ },
31
+ "exports": {
32
+ ".": "./index.js",
33
+ "./locale/*.json": "./locale/*.json",
34
+ "./icon": "./icon.svg"
35
+ },
36
+ "files": [
37
+ "index.js",
38
+ "cordis.patch.yml",
39
+ "locale/*.json",
40
+ "icon.svg",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "dsh": {
45
+ "bundle": {
46
+ "patch": "./cordis.patch.yml"
47
+ }
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }