dsh-tree-task-flow 0.3.12 → 0.3.14

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/README.md CHANGED
@@ -27,12 +27,12 @@
27
27
  规则只有一条,**只看表层,与计划树无关**:
28
28
 
29
29
  ```
30
- 从「上一次 tree_task_done 之后的第一个 tree_task_create」之后
30
+ 从「上一次 tree_task_done 之后的第一条 tree_task_create 调用」之后
31
31
  → 到本次 tree_task_done 之前,这一段收走
32
32
  ```
33
33
 
34
- 起点是上一次 `tree_task_done` 之后的**第一个** `tree_task_create`;
35
- 会话里还没有过 `tree_task_done` 时,就是表层第一个 `tree_task_create`;
34
+ 起点是上一次 `tree_task_done` 之后的**第一条** `tree_task_create`;
35
+ 会话里还没有过 `tree_task_done` 时,就是表层第一条 `tree_task_create`;
36
36
  区间里一次 `tree_task_create` 都没有时,回退到上一次 `tree_task_done` 之后。
37
37
  **`tree_task_plan` 不是边界**——它的调用与返回跟普通过程一样,随这一段一起收走。
38
38
  起点那次 `create` 的调用与返回留在上下文里,本次 `tree_task_done` 的调用与返回
@@ -40,11 +40,17 @@
40
40
  折叠只收它们**之间**的过程,换成一条计数通告:
41
41
 
42
42
  ```
43
- [tree_task_create 调用] [返回:计划已建立 + 整棵树]
43
+ [tree_task_create 调用] [返回:计划已建立 + 这个目标的树]
44
44
  [tree_task消息:隐藏了 12 条过程上下文。读取:… 写入:…]
45
45
  [tree_task_done 调用] [返回:接下来需要进行步骤:「二」(s-4)。]
46
46
  ```
47
47
 
48
+ > **`tree_task_create` 与 `tree_task_plan` 不能合并成一个工具。**
49
+ > 起点取的是"上一次 `done` 之后最早的**开工**那一次",而 `plan` 每补一次步骤就调一次;
50
+ > 并成一个工具之后,每次补步骤都会把起点往后推,`done` 与它之间那段过程就永远收不走。
51
+ > 靠"实参里有没有 `parentId`"去区分,也只是把认错起点的机会交给模型。
52
+ > 代价是两个工具的实参形状有点像,所以 `plan` 的说明里专门写了一句"建新目标不归它管"。
53
+
48
54
  两条硬约束:
49
55
 
50
56
  - 折叠区间里**夹着你的消息就不折**——往前折丢了需求,往后折丢了回应,
@@ -99,19 +105,45 @@ tree_task消息:接下来需要进行步骤:「步骤二」(s-4)。
99
105
  但插件**不会拦住任何工具**——"这一层到底做完了没有"由模型判断,插件既不替它定死,
100
106
  也不靠拒绝别的工具把它逼到墙角。
101
107
 
102
- ## 六个工具
108
+ ## 七个工具
109
+
110
+ **写树分两个工具,不是可以合并的重复。** `tree_task_create` 与 `tree_task_done` 是折叠的
111
+ 两个边界:`create` 是"开工"那一下,`done` 是"收工"那一下。`create` 只在新建目标时调一次,
112
+ `plan` 却在任务中途反复调用——并成一个工具,折叠起点就只能靠实参去猜了(见上一节)。
103
113
 
104
114
  | 工具 | 用途 |
105
115
  |---|---|
106
- | `tree_task_status` | 查看整棵树、各节点状态与已提交的结果,以及现在该推进哪个节点 |
107
- | `tree_task_create` | 新增一个目标:这个目标 + 它下面的若干任务。可多次调用,多个目标并存,不替换已有计划 |
108
- | `tree_task_plan` | 给一个节点追加子节点:给目标追加任务,给任务追加子任务 |
116
+ | `tree_task_status` | 查看计划树:默认只展开**还没结束**的目标,已完成的目标只留目标那一行;填 `id` 就返回那个目标(或该节点所在目标)的完整树 |
117
+ | `tree_task_create` | 新建一个目标:这个目标 + 它下面的若干任务。可多次调用,多个目标并存,不替换已有计划。**它也是折叠的起点标记** |
118
+ | `tree_task_plan` | 给一个节点追加子节点:父节点是目标加的是任务,父节点是任务加的是子任务。**建新目标不归它管** |
109
119
  | `tree_task_done` | 完成一个节点并提交它的结果,**`result` 必填**;返回的就是"下一步干什么",这次调用连同返回留在上下文里,被收走的只是它前面的执行过程 |
110
120
  | `tree_task_update` | 修改一个节点的标题或说明 |
111
- | `tree_task_drop` | 丢弃一个节点;丢弃目标会连同它下面的任务与子任务一起走,丢弃任务带上它的子任务 |
121
+ | `tree_task_drop` | 丢弃一个节点:留个记号说「这件事不做了」,节点**留在树里**可查(划掉)。丢弃目标会连同它下面的任务与子任务一起走,丢弃任务带上它的子任务 |
122
+ | `tree_task_delete` | **删除**一个节点:把它连同下面的任务或子任务从树里**摘掉**、不留痕,**不看状态**(已完成、已丢弃的都删得掉)。清理写错 / 建重 / 结果记错的内容用它 |
123
+
124
+ **丢与删是两件事**:丢弃回答"这件事还要不要做"(历史留着),删除是"这条内容本身就是错的"
125
+ (凭空摘掉)。一个节点做完之后再想让它消失,只能用删除。
112
126
 
113
127
  层级固定三级,子任务是最低一级,不能再往下拆。
114
128
 
129
+ **返回的三条规矩:**
130
+
131
+ - 写树类工具只返回**本次动到的那个目标**的树,不再每次倾倒整棵树;
132
+ - 树用**规范的 Markdown 嵌套任务清单**渲染(`- [ ]` 开头、每层两个空格缩进),
133
+ 复制出去能正常预览,不会被当成代码块;
134
+ - **结果全文给出,一个字都不截**。
135
+
136
+ ```markdown
137
+ - [x] **目标** `g-mv50h48v1` 安卓端播放
138
+ - **结果**:手机连上 PC 并把声音收下来了。
139
+ 验收:模拟器上跑通一次端到端。
140
+ - [x] **任务** `t-mv50h48v2` 工程骨架
141
+ - **结果**:交付物 android\settings.gradle;`cd android; powershell -File tools\build.ps1` 冷构建成功。
142
+ - [ ] **子任务** `s-mv50h48v5` 写 build.gradle ← 当前
143
+ - [ ] **目标** `g-mv50h48v6` 安卓端来源 ← 当前
144
+ - [ ] **任务** `t-mv50h48v7` 采集层
145
+ ```
146
+
115
147
  ### 三层怎么分
116
148
 
117
149
  | 层 | 是什么 | 怎么算分对了 |
@@ -189,7 +221,9 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
189
221
  当前节点的虚线圈会匀速转,已完成打勾、丢弃的划掉。
190
222
  - 节点标题**不缩略**:有多长写多长,多了自动换行。
191
223
  - 提交的结果**默认只占一行**,点一下摊开看全文。
192
- - 节点上可以直接完成、丢弃,不用敲命令。
224
+ - 节点上可以直接完成、**丢弃**(划掉,历史留着)、**删除**(垃圾桶,连子节点一起摘掉),
225
+ 不用敲命令。删除对**任何状态**的节点都开着——已完成的那个也在,清理错内容正是它的用途。
226
+ - 进度只数**还在做的事**:被丢弃的节点分子分母都不算,所以丢过几个之后进度照样能满。
193
227
  - 数据每 4 秒自动拉一次,模型在后台改计划时界面会自己跟上。
194
228
 
195
229
  **没有计划的会话一个像素都不占**——读不到计划就返回 `null`,不会平白挤掉内置的
@@ -205,7 +239,8 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
205
239
  |---|---|
206
240
  | `/task-tree` | 查看本会话的任务树与检查点状态 |
207
241
  | `/task-tree done <id> <结果>` | 提交某个节点的结果并完成它 |
208
- | `/task-tree drop <id>` | 丢弃某个节点 |
242
+ | `/task-tree drop <id>` | 丢弃某个节点:留着可查,标记为不做了 |
243
+ | `/task-tree delete <id>` | 删除某个节点:连子节点一起从树里摘掉,不看状态 |
209
244
  | `/task-tree pause` | 暂停:正在跑的工具跑完就停住,不再往下推进 |
210
245
  | `/task-tree resume` | 继续:从停住的地方接着跑,不往会话里插消息 |
211
246
  | `/task-tree reset` | 清掉本会话的计划树 |
@@ -217,11 +252,72 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
217
252
  | 字段 | 默认 | 说明 |
218
253
  |---|---|---|
219
254
  | `enabled` | `true` | 总开关;**装上即启用**,写 `false` 关掉 |
220
- | `autoContinue` | `false` | 模型停下时是否自动续行 |
255
+ | `autoContinue` | `false` | 模型停下时是否自动续行(见下节;**在 DSH 设置面板里能改**) |
221
256
  | `maxAutoRounds` | `5` | 连续自动续行的轮次上限 |
257
+ | `interruptRecovery` | `true` | 意外中断后是否自动拉起(见下节;**在 DSH 设置面板里能改**) |
258
+ | `interruptDelaysMs` | `null` | 网络类中断的拉起时刻,`null` = 1/5/10/30 分钟 |
222
259
  | `promptOrder` | `1800` | 工具说明段落的排序 |
223
260
  | `rootDir` | `null` | 状态目录,默认 `$DSH_HOME/dsh-task-tree` |
224
261
 
262
+ ## 设置
263
+
264
+ 设置**在 DSH 自己的设置面板里**:打开侧边栏的设置,导航里有一页叫「树形任务流」
265
+ (跟「通用」「市场」并排),点开就是这两个开关。**改完立刻生效,不用重启。**
266
+
267
+ > 它不在任务条上。任务条只干两件事:看进度、按暂停 / 继续;偏好属于设置,就该待在设置里。
268
+
269
+ | 设置 | 默认 | 做什么 |
270
+ |---|---|---|
271
+ | 自动续行 | **关** | 模型自己停下、计划树还没跑完时,要不要推一条"接下来做 X"把它唤醒 |
272
+ | 工具出错自动拉起 | 开 | 这一轮不是正常结束(网络断了 / 模型输出坏了 / 工具结果没了)时,要不要自动重来 |
273
+
274
+ 两项是**两件事**,别混:前者是"要不要往前推",后者是"要不要救"。默认这么定是因为
275
+ 自动续行会在你没看着的时候自己往下跑,该由人来点;而中断拉起只在"这一轮根本不是正常
276
+ 结束"时动手,正常停下它一步都不动。
277
+
278
+ 设置写在状态目录的 `settings.json` 里(**不按会话分**,整个插件一份),重启后还在。
279
+ 优先级是 **设置面板里改过的 > profile 里的 config > `DEFAULT_CONFIG`**:在面板里点过就以
280
+ 面板为准,免得界面对不上 YAML。`enabled`、`maxAutoRounds`、`interruptDelaysMs` 这类
281
+ "装插件时就该定好"的项不在面板上,仍然只认 profile 配置。
282
+
283
+ ## 意外中断自动拉起
284
+
285
+ 上一节 `autoContinue` 管的是"模型自己正常停下"。这一节管的是**这一轮没能正常
286
+ 结束**:网络断了、模型 API 报错、或者工具执行到一半进程没了。这两种停法插件原先不管,
287
+ 会话就一直躺在那里。
288
+
289
+ 判据全部取自 DSH 自己写下的数据,不看文本:
290
+
291
+ | 中断 | 怎么认 | 什么时候拉起 |
292
+ |---|---|---|
293
+ | 网络 / API | `turn/end` 的原因是 `error`,错误码是 `TRANSPORT`、`TIMEOUT`、`SERVER`、`RATE_LIMIT`、`EMPTY_RESPONSE` | 中断后 **1 分钟、5 分钟、10 分钟、30 分钟**各一次 |
294
+ | 模型输出损坏 | `turn/end` 的原因是 `error`,错误码是 `MALFORMED_RESPONSE`(模型给出的工具入参不是合法 JSON) | **立即**一次 |
295
+ | 工具执行 | 这一轮出现过 `TOOL_OUTCOME_UNKNOWN` / `TOOL_NOT_STARTED` 的合成工具结果(意思是"这次调用没留下结果") | **立即**一次 |
296
+
297
+ 后两类虽然都"立即一次",处置并不一样——差别在于**有没有东西要勘察、有没有东西可收**:
298
+
299
+ - **工具执行类**:那次调用确实发生过,只是结果没落盘。拉起消息会明确说"它到底做没做
300
+ 没人知道,先确认那一步的实际影响再决定要不要重做";第二次仍然失败时,把那条拉起消息、
301
+ 发起该调用的助手消息、以及它的合成错误结果一起**收走**(和折叠同一套机制,界面上仍可
302
+ 展开),再拉一次。
303
+ - **模型输出损坏类**:DSH 是在解析响应流时就判了死刑——**整条响应被作废,一个工具都没
304
+ 执行,连 `tool/call` 都没落盘**,没有任何副作用要勘察。拉起消息只说"原样重做那一步,
305
+ 把引号括号写全,必要时一次只调一个工具";第二次仍然失败时**什么都不收**(没东西可收),
306
+ 直接再拉一次。
307
+
308
+ 两类都是第三次同类中断就停手,链上记 `exhausted`。
309
+
310
+ 几条边界:
311
+
312
+ - **余额不足(`QUOTA`)不拉。** 等再久也不会自己好。
313
+ - **你按下的停止,永远不拉。** 和自动续行的第 1 条闸门一致。
314
+ - **普通工具报错不拉。** 比如"文件没找到""edit 没匹配上""网址被拦"——这些模型看到就能
315
+ 自己改道,本来也没中断这一轮。
316
+ - 只在**这个会话的计划树还没走完**时才动手。等待时刻写在状态目录的 `revive/` 下,
317
+ 重启 `dsh web` 不会丢。
318
+ - **能在设置面板里关掉。** DSH 设置里的「树形任务流」那一页中,"工具出错自动拉起"就是
319
+ 这个开关,改完立刻生效(关掉之后插件连 `revive/` 都不再写)。
320
+
225
321
  ## 暂停与继续
226
322
 
227
323
  界面上的暂停按钮(或 `/task-tree pause`)是**真暂停**,不是"关掉自动续行":
@@ -234,10 +330,23 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
234
330
  接着请求,同样**零新增消息**。任务树的进度停在哪儿,就从哪儿接着跑。
235
331
 
236
332
  **重启 `dsh web` 之后暂停依然有效。** "被按住"这件事写在状态目录的 `held/` 下,
237
- 重启后条目仍显示「已暂停」,点「继续」即可接着跑。区别只有一处:进程重启过之后,
238
- 没有挂在岔口上的那一口气可放,插件只能给会话排一条「恢复续行」的消息把它从 `idle`
239
- 推起来——**只有跨过重启的这一次恢复会留下这条消息**。若那个会话还没被打开(DSH 还没把它
240
- 加载成实时 agent),点继续会提示你先打开它,并且**保留暂停状态**,不会白丢。
333
+ 重启后条目仍显示「已暂停」,点「继续」即可接着跑。
334
+
335
+ **点「继续」是无条件的。** 不管此刻闸门有没有挂在岔口上、会话是不是正忙,点一下,
336
+ "被按住"就当场解除——它是一句"接着跑",不是一次"此刻有没有闸门"的查询。区别只在
337
+ 插件接下来能做什么:
338
+
339
+ - **闸门正挂在岔口上** → 直接放行那一步,**零新增消息**;
340
+ - **没有岔口可放**(进程重启过,或者你点的时候那个工具还在跑)→ 退一步给会话排一条
341
+ 「恢复续行」的消息把它从 `idle` 推起来;会话正忙的话连推都不用推,它自己会在下一个
342
+ 岔口接着跑;
343
+ - **那个会话还没被 DSH 加载成实时 agent** → 提示你先打开它或在其中发一条消息,
344
+ 但"被按住"照样解除。
345
+
346
+ > 早先不是这样:"继续"只在闸门恰好挂着的那一瞬间才算数;没有闸门可放时又要求会话
347
+ > 正好停在 `idle`,忙就**直接拒绝、并且什么都不清**。于是"工具还在跑的时候点的那一下"
348
+ > 被静默吞掉,等闸门真挂上时已经没人在等它——会话就停在原地了。真回归见
349
+ > `verify-pause.mjs` 的场景 s-31。
241
350
 
242
351
  闸门开在 agent 的 `agent/pre-step` 上——一个 step 走完、工具结果都已落盘、模型还没
243
352
  发起下一次请求的那一刻。这是唯一同时满足"不打断工具"和"不留痕迹"的位置:再晚一点
@@ -249,31 +358,44 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
249
358
  暂停期间人按界面上的停止(中断本轮)也不会卡住:闸门盯着本轮的取消信号,
250
359
  信号一 abort 就立刻放行。
251
360
 
252
- ## 自动续行的三道闸门
361
+ ## 自动续行
253
362
 
254
- `autoContinue` 默认关闭,开着也只是**兜底**:`tree_task_done` 的返回文本里已经带了
255
- "请继续执行下一个子任务",模型通常会在同一轮里接着做。这里兜的是模型仍然停下来的情况。
363
+ **模型自己停下、计划树却没跑完,插件就推一条"接下来要做 X"把它唤醒。** 这只是**兜底**:
364
+ `tree_task_done` 的返回文本里已经带了"请继续执行下一个子任务",模型通常会在同一轮里接着做。
256
365
 
257
- 开启后仍有三道闸门,缺一不可:
366
+ `autoContinue` **默认关闭**——它会自己往下跑,所以默认由人来点。要开就在 DSH 设置面板的
367
+ 「树形任务流」那一页里打开(**改完立刻生效**,不用重启),或者在 profile 里写 `autoContinue: true`。
258
368
 
259
- 1. **默认关**——`autoContinue: false` 时不注册续行逻辑。
260
- 2. **中断即停**——用户按下停止(`agent/turn-stopping` 且 `signal.aborted`),
261
- 这个会话就被标记为不再自动续行,直到真人再发一条消息。
262
- 3. **轮次上限**——连续续行达到 `maxAutoRounds` 就停;只有真人发的消息才重置配额。
369
+ 开着也不会脱缰,有四道闸门:
263
370
 
264
- 队列里还有排队输入时也不抢,让用户先说话。
371
+ 1. **中断即停**——你按下停止,落盘的 `turn/end` 会记成 `reason.kind = "aborted"`,
372
+ 插件从那里认,把这个会话标记为不再自动续行(落盘在 `held/` 下,跨重启有效),
373
+ 直到真人再发一条消息。
374
+ > **这里不能用 `agent/turn-stopping` 的 `signal.aborted` 当判据。** 那个事件只在
375
+ > "这一轮本来就要正常收尾"时才发(`dsh-agent-loop` 里唯一一处 dispatch 前面刚做过
376
+ > `throwIfAborted()`);你按停止会让信号 abort、从别处抛出,catch 里直接记下
377
+ > `turn/end` 就重新抛出,那条 dispatch 走不到——`signal.aborted` 在那里几乎永远是
378
+ > `false`,拿它当闸门等于没闸门。
379
+ 2. **轮次上限**——连续续行达到 `maxAutoRounds`(默认 5)就停;只有真人发的消息才重置配额。
380
+ 3. **不抢排队输入**——队列里还有你排着的话时,让用户先说。
381
+ 4. **暂停压住它**——`/task-tree pause` 之后,即使模型自己停了下来,兜底也不会把它拉起来;
382
+ `/task-tree resume` 再放开。
265
383
 
266
- 另外,**暂停会一并压住它**:`/task-tree pause` 之后,即使模型自己停了下来,
267
- 兜底也不会把它重新拉起来;`/task-tree resume` 再放开。
384
+ > **`agent/status` 只在状态变化时发。** 重启 `dsh web` 之后,一个本来就停在 `idle` 上的
385
+ > 会话不会产生这个事件(新建的 agent 一开始就是 idle),所以它**不会**被自动唤醒——
386
+ > 要它动,你得在那个会话里先说一句话。从下一次模型自己停下开始,续行就接手了。
268
387
 
269
388
  ## 状态文件
270
389
 
271
- 全部在 `$DSH_HOME/dsh-task-tree/` 下,**以 `sessionId` 为键**:
390
+ 全部在 `$DSH_HOME/dsh-task-tree/` 下。除 `settings.json` 外都**以 `sessionId` 为键**:
272
391
 
273
392
  | 文件 | 内容 |
274
393
  |---|---|
275
394
  | `plans/<sessionId>.json` | 计划树(每个会话一棵) |
276
395
  | `surfaces/<sessionId>.json` | 三层的进度游标 `{ key, cursor }`(只给界面看,不参与折叠) |
396
+ | `held/<sessionId>.json` | 暂停 / 停止状态(跨重启保留) |
397
+ | `revive/<sessionId>.json` | 意外中断的拉起时刻表(跨重启保留) |
398
+ | `settings.json` | 设置面板里改过的设置(**整个插件一份,不按会话分**) |
277
399
 
278
400
  > 目录名沿用插件改名前的标识,为的是不让既有的计划文件失联;插件名与它无关。
279
401
 
@@ -289,7 +411,7 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
289
411
  空 `result` 会被拒绝。所以要写清"产出了什么",写成能独立看懂的样子——
290
412
  过程会丢,别把过程写进去。
291
413
 
292
- 2. **折叠只看表层。** 起点是上一次 `done` 之后的第一个 `create`;
414
+ 2. **折叠只看表层。** 起点是上一次 `done` 之后的第一条 `tree_task_create`;
293
415
  `tree_task_plan` 不是边界,它的调用与返回跟过程一样会被收走;区间里没有过程时
294
416
  什么都不注入,夹着你的消息时整段不折——那一段原样留着(往前折丢需求、
295
417
  往后折丢回应),代价是上下文不收缩。
@@ -306,6 +428,7 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
306
428
 
307
429
  5. **改需求用「丢弃 + 追加」,不要重建整棵树。** 丢掉的节点留在树里可查,
308
430
  它和完成的节点会一起被那一段折叠收走(通告只报条数,不区分完成还是丢弃)。
431
+ **内容写错了才用删除**——删除是摘掉,摘掉的东西查不回来,也没有任何通告替它留痕。
309
432
 
310
433
  6. **暂停不留痕迹。** 暂停后模型看到的是"没有下一条请求",看不到任何"你被暂停了"
311
434
  之类的提示;继续之后也不会有"继续"消息进上下文。想让模型知道中间发生过什么,
@@ -322,7 +445,7 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
322
445
  | 不做 | 原因 |
323
446
  |---|---|
324
447
  | 资料 / 文件快照 / 记忆 | 上下文里值得留下的只有结果,其余靠模型自己重读 |
325
- | 文件回退 / 撤销 | 改需求用「丢弃 + 追加」 |
448
+ | 文件回退 / 撤销 | 改需求用「丢弃 + 追加」;内容本身写错了才用删除摘掉,摘掉就找不回来 |
326
449
  | 并行推进多个目标 | 结构上支持一个会话有多个目标(目标层就是兄弟列表),但同一时刻只沿一条活动路径走 |
327
450
  | 把计划内容注入提示词 | 计划状态由 `tree_task_status` 现取,提示词里只放不随计划变化的说明 |
328
451
 
package/client.js CHANGED
@@ -173,14 +173,23 @@ window.__ModuleLoader__.load({
173
173
  }
174
174
  }
175
175
 
176
- /** 三层节点总数与已完成数,给单行条上的进度用。 */
176
+ /**
177
+ * 三层节点总数与已完成数,给单行条上的进度用。
178
+ *
179
+ * **丢弃的节点两边都不算。** 丢弃是"这件事我不做了",它不该压着进度——
180
+ * 早先 total 无条件 +1、done 只认 `done`,于是丢一个节点进度就永远差一格,
181
+ * 满不了。丢弃的证据在树里(划掉那一行),不需要分数再替它记一笔。
182
+ */
177
183
  function countNodes(goal) {
184
+ if (goal.status === "dropped") return { total: 0, done: 0 };
178
185
  let total = 1;
179
186
  let done = goal.status === "done" ? 1 : 0;
180
187
  for (const task of goal.tasks || []) {
188
+ if (task.status === "dropped") continue;
181
189
  total += 1;
182
190
  if (task.status === "done") done += 1;
183
191
  for (const step of task.steps || []) {
192
+ if (step.status === "dropped") continue;
184
193
  total += 1;
185
194
  if (step.status === "done") done += 1;
186
195
  }
@@ -261,10 +270,21 @@ window.__ModuleLoader__.load({
261
270
  "action.collapse": "收起",
262
271
  "action.done": "完成…",
263
272
  "action.drop": "丢弃",
273
+ "action.delete": "删除",
264
274
  "action.submit": "提交结果",
265
275
  "action.expandResult": "展开看全文",
266
276
  "action.collapseResult": "收起",
267
277
  "action.cancel": "取消",
278
+ "settings.nav": "树形任务流",
279
+ "settings.loading": "正在读设置…",
280
+ "settings.unavailable": "读不到设置:宿主那一半没在跑,或者接口不通。",
281
+ "settings.off": "插件在 profile 配置里被关掉了(enabled: false),这两个开关现在不起作用。",
282
+ "setting.note": "改完立刻生效,也会写进状态目录,重启 dsh web 之后还在。",
283
+ "setting.autoContinue": "任务没做完、模型自己停下时,自动把它叫起来接着跑",
284
+ "setting.autoContinueNote": "会往会话里推一条消息。默认关。",
285
+ "setting.interruptRecovery": "工具出错或网络断了,自动重试把会话拉起来",
286
+ "setting.interruptRecoveryNote":
287
+ "按 1 / 5 / 10 / 30 分钟各拉一次,一直拉不起来就停手。你按过停止的绝不拉起。",
268
288
  "label.result": "结果",
269
289
  "label.noResult": "(还没提交结果)",
270
290
  "label.current": "当前",
@@ -274,7 +294,9 @@ window.__ModuleLoader__.load({
274
294
  "写清这个节点产出了什么。它整段执行过程会被这条结果顶替,所以要能独立看懂。",
275
295
  "hint.noChildren": "(还没有子节点 → 让模型调用 tree_task_plan)",
276
296
  "confirm.reset": "确定要清空这个会话的计划树吗?",
277
- "confirm.drop": "确定要丢弃「{title}」吗?",
297
+ "confirm.drop": "确定要丢弃「{title}」吗?丢弃会把它标成「不做了」,节点留在树里可查。",
298
+ "confirm.delete":
299
+ "确定要删除「{title}」吗?它会连同下面的任务或子任务一起从树里消失,找不回来;已经提交的结果也会一起没掉。如果只是「这件事不做了」,请改用丢弃。",
278
300
  };
279
301
 
280
302
  const en = {
@@ -294,10 +316,21 @@ window.__ModuleLoader__.load({
294
316
  "action.collapse": "Collapse",
295
317
  "action.done": "Complete…",
296
318
  "action.drop": "Drop",
319
+ "action.delete": "Delete",
297
320
  "action.submit": "Submit result",
298
321
  "action.expandResult": "Show the full result",
299
322
  "action.collapseResult": "Collapse",
300
323
  "action.cancel": "Cancel",
324
+ "settings.nav": "Task tree",
325
+ "settings.loading": "Reading settings…",
326
+ "settings.unavailable": "Settings are unreachable: the host half is not running, or the endpoint is blocked.",
327
+ "settings.off": "The plugin is switched off in the profile config (enabled: false), so these switches do nothing right now.",
328
+ "setting.note": "Takes effect right away and is written to the state directory, so it survives a restart.",
329
+ "setting.autoContinue": "When the plan is unfinished and the model stops, wake it up to carry on",
330
+ "setting.autoContinueNote": "This pushes a message into the session. Off by default.",
331
+ "setting.interruptRecovery": "After a tool error or a dropped connection, retry to get the session going",
332
+ "setting.interruptRecoveryNote":
333
+ "Retries at 1 / 5 / 10 / 30 minutes, then gives up. A session you stopped is never revived.",
301
334
  "label.result": "Result",
302
335
  "label.noResult": "(no result submitted yet)",
303
336
  "label.current": "current",
@@ -307,7 +340,9 @@ window.__ModuleLoader__.load({
307
340
  "State what this node produced. Its whole execution range is replaced by this result, so it must stand on its own.",
308
341
  "hint.noChildren": "(no children yet → ask the model to call tree_task_plan)",
309
342
  "confirm.reset": "Clear this session's plan tree?",
310
- "confirm.drop": "Drop “{title}”?",
343
+ "confirm.drop": "Drop “{title}”? It is marked as not-to-do and stays in the tree.",
344
+ "confirm.delete":
345
+ "Delete “{title}”? It disappears from the tree together with its tasks or steps, and any submitted result goes with it. If you only mean “not doing this”, use Drop instead.",
311
346
  };
312
347
 
313
348
  // ---------------------------------------------------------------- 样式
@@ -393,6 +428,18 @@ window.__ModuleLoader__.load({
393
428
  ".dsh-ttf-btn:disabled:hover{background:var(--dsw-alias-bg-base)}",
394
429
  ".dsh-ttf-error{color:var(--dsw-alias-state-error-primary,#c00);font-size:12px;line-height:18px;white-space:pre-wrap}",
395
430
  ".dsh-ttf-hint{padding:4px 4px 6px 24px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
431
+ // 设置:两行开关,跟树共用同一张卡片。
432
+ ".dsh-ttf-settingsPage{display:flex;flex-direction:column;gap:10px;padding:4px 2px;max-width:640px}",
433
+ ".dsh-ttf-settings{display:flex;flex-direction:column;gap:8px;padding:4px 4px 8px}",
434
+ ".dsh-ttf-setting{display:flex;align-items:flex-start;gap:8px}",
435
+ ".dsh-ttf-switch{box-sizing:border-box;flex:none;position:relative;width:32px;height:18px;margin-top:1px;padding:0;border:.5px solid var(--dsw-alias-border-l4);border-radius:999px;background:var(--dsw-alias-bg-module-platform);cursor:pointer}",
436
+ ".dsh-ttf-switch:disabled{opacity:.4;cursor:default}",
437
+ ".dsh-ttf-switchOn{border-color:var(--dsw-alias-state-business-primary,#4d6bfe);background:var(--dsw-alias-state-business-primary,#4d6bfe)}",
438
+ ".dsh-ttf-switchKnob{position:absolute;top:1px;left:1px;width:14px;height:14px;border-radius:999px;background:#fff;transition:left .12s}",
439
+ ".dsh-ttf-switchOn .dsh-ttf-switchKnob{left:15px}",
440
+ ".dsh-ttf-settingText{display:flex;flex-direction:column;gap:2px;min-width:0}",
441
+ ".dsh-ttf-settingName{color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px}",
442
+ ".dsh-ttf-settingNote{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
396
443
  ].join("");
397
444
 
398
445
  if (
@@ -505,7 +552,7 @@ window.__ModuleLoader__.load({
505
552
  * 一个节点:一行标题 + 可展开的详情。
506
553
  *
507
554
  * 三层共用同一个组件——它们的字段与动作本来就一样,差别只有缩进,
508
- * 以及"这一层要不要给丢弃按钮"(目标、任务、子任务都能丢)。
555
+ * 以及"这一层要不要给丢弃与删除按钮"(目标、任务、子任务都能丢、都能删)。
509
556
  */
510
557
  function NodeRow({ node, depth, activeIds, openIds, checkpointId, canDrop, sessionId, reload, t }) {
511
558
  const children = node.tasks || node.steps || [];
@@ -574,7 +621,9 @@ window.__ModuleLoader__.load({
574
621
  : null,
575
622
  canAct && canDrop
576
623
  ? h(IconButton, {
577
- iconBase: "IconTrashOutline",
624
+ // 划掉 = 留个记号说"这件事不做了",和树里已丢弃节点的 × 标记是同一个意思。
625
+ // 垃圾桶留给真正的删除(见下一个按钮)。
626
+ iconBase: "IconCloseOutline",
578
627
  label: t("action.drop"),
579
628
  disabled: busy,
580
629
  onClick: () => {
@@ -584,6 +633,20 @@ window.__ModuleLoader__.load({
584
633
  },
585
634
  })
586
635
  : null,
636
+ // 删除对**任何状态**都开着:它就是用来清理写错/建重的内容的,
637
+ // 已完成的节点恰恰是最需要清掉的那种(结果记错了)。
638
+ canDrop
639
+ ? h(IconButton, {
640
+ iconBase: "IconTrashOutline",
641
+ label: t("action.delete"),
642
+ disabled: busy,
643
+ onClick: () => {
644
+ if (window.confirm(t("confirm.delete", { title: node.title }))) {
645
+ act({ action: "delete", id: node.id });
646
+ }
647
+ },
648
+ })
649
+ : null,
587
650
  expandable
588
651
  ? h(IconButton, {
589
652
  iconBase: open ? "IconChevronDownOutline" : "IconChevronRightOutline",
@@ -650,6 +713,121 @@ window.__ModuleLoader__.load({
650
713
 
651
714
  // ---------------------------------------------------------------- 条目
652
715
 
716
+ /**
717
+ * 设置里能改的开关。
718
+ *
719
+ * 键必须与 `lib/settings.js` 的 `SETTING_KEYS` **一模一样**:对不上时接口会
720
+ * 400 拒掉,而界面只会安静地什么都没发生,最难查。自检里有一条断言把两份
721
+ * 名单对起来(`verify-settings.mjs`)。
722
+ *
723
+ * 文案名就按 `setting.<键>` 拼,另配一条 `setting.<键>Note` 讲清楚它到底会做什么。
724
+ */
725
+ const SETTING_ROWS = [
726
+ { key: "autoContinue", note: "setting.autoContinueNote" },
727
+ { key: "interruptRecovery", note: "setting.interruptRecoveryNote" },
728
+ ];
729
+
730
+ /**
731
+ * 设置面板里的那一页:两个开关。
732
+ *
733
+ * ★ 这一页挂在 **DSH 自己的设置面板**里(`settings.section` 槽),不在插件的
734
+ * 任务条上。任务条是"看进度、按暂停"的地方;偏好属于设置,就该待在设置里。
735
+ *
736
+ * 数据走宿主那两个**与会话无关**的接口:读 `/dsh-tree-task-flow/settings`,
737
+ * 写 `action: "setting"`。跟任务条不同,这里没有要盯的东西,所以不轮询:
738
+ * 挂载时读一次,改完拿返回值就地更新。
739
+ */
740
+ function TaskTreeSettings() {
741
+ const t = useT();
742
+ const [state, setState] = useState({ loading: true, data: null });
743
+ const [busy, setBusy] = useState(false);
744
+ const [note, setNote] = useState("");
745
+
746
+ useEffect(() => {
747
+ let alive = true;
748
+ (async () => {
749
+ try {
750
+ const data = await api("/dsh-tree-task-flow/settings");
751
+ if (alive) setState({ loading: false, data });
752
+ } catch {
753
+ // 读不到就说读不到,别画一排骗人的开关。
754
+ if (alive) setState({ loading: false, data: null });
755
+ }
756
+ })();
757
+ return () => {
758
+ alive = false;
759
+ };
760
+ }, []);
761
+
762
+ if (state.loading) {
763
+ return h("div", { className: "dsh-ttf-settingsPage" }, h("div", { className: "dsh-ttf-settingNote" }, t("settings.loading")));
764
+ }
765
+ if (!state.data) {
766
+ return h(
767
+ "div",
768
+ { className: "dsh-ttf-settingsPage" },
769
+ h("div", { className: "dsh-ttf-settingNote" }, t("settings.unavailable")),
770
+ );
771
+ }
772
+
773
+ const settings = state.data.settings ?? {};
774
+
775
+ async function toggle(key, value) {
776
+ setBusy(true);
777
+ setNote("");
778
+ try {
779
+ const result = await post({ action: "setting", key, value });
780
+ if (result && result.settings) {
781
+ setState({ loading: false, data: Object.assign({}, state.data, { settings: result.settings }) });
782
+ }
783
+ } catch (failure) {
784
+ setNote(String((failure && failure.message) || failure));
785
+ } finally {
786
+ setBusy(false);
787
+ }
788
+ }
789
+
790
+ return h(
791
+ "div",
792
+ { className: "dsh-ttf-settingsPage" },
793
+ h("div", { className: "dsh-ttf-settingNote" }, t("setting.note")),
794
+ state.data.enabled === false
795
+ ? h("div", { className: "dsh-ttf-note" }, t("settings.off"))
796
+ : null,
797
+ h(
798
+ "div",
799
+ { className: "dsh-ttf-settings" },
800
+ SETTING_ROWS.map((row) => {
801
+ const on = settings[row.key] === true;
802
+ return h(
803
+ "div",
804
+ { className: "dsh-ttf-setting", key: row.key },
805
+ h(
806
+ "button",
807
+ {
808
+ type: "button",
809
+ role: "switch",
810
+ "aria-checked": on,
811
+ "aria-label": t("setting." + row.key),
812
+ className: "dsh-ttf-switch" + (on ? " dsh-ttf-switchOn" : ""),
813
+ disabled: busy,
814
+ onClick: () => toggle(row.key, !on),
815
+ },
816
+ h("span", { className: "dsh-ttf-switchKnob" }),
817
+ ),
818
+ h(
819
+ "div",
820
+ { className: "dsh-ttf-settingText" },
821
+ h("span", { className: "dsh-ttf-settingName" }, t("setting." + row.key)),
822
+ h("span", { className: "dsh-ttf-settingNote" }, t(row.note)),
823
+ ),
824
+ );
825
+ }),
826
+ ),
827
+ note ? h("div", { className: "dsh-ttf-note" }, note) : null,
828
+ );
829
+ }
830
+
653
831
  /**
654
832
  * 输入框上方的全宽条目。
655
833
  *
@@ -762,11 +940,15 @@ window.__ModuleLoader__.load({
762
940
  h("span", { className: "dsh-ttf-glyph", "aria-hidden": true }, listGlyph),
763
941
  h("span", { className: "dsh-ttf-label" + (held ? " dsh-ttf-labelHeld" : "") }, stateLabel),
764
942
  h("span", { className: "dsh-ttf-title", title: heading }, heading),
765
- h(
766
- "span",
767
- { className: "dsh-ttf-progress" },
768
- stats.done + "/" + stats.total + (current ? " · " + current : ""),
769
- ),
943
+ // 一个都算不上(整棵树都被丢弃了)就不报分数:0/0 比不显示更让人费解。
944
+ // 有 open 节点时 total 一定 ≥ 1,所以这里不会把"当前节点"那截弄丢。
945
+ stats.total > 0
946
+ ? h(
947
+ "span",
948
+ { className: "dsh-ttf-progress" },
949
+ stats.done + "/" + stats.total + (current ? " · " + current : ""),
950
+ )
951
+ : null,
770
952
  h(
771
953
  "div",
772
954
  { className: "dsh-ttf-actions" },
@@ -855,6 +1037,26 @@ window.__ModuleLoader__.load({
855
1037
  ),
856
1038
  "dsh-tree-task-flow: conversation dock",
857
1039
  );
1040
+
1041
+ // 设置面板里的那一页。用 `settings.section` 这个**列表槽**,所以它会作为
1042
+ // 一行出现在 DSH 设置面板的导航里(跟「通用」「市场」并排),点开就是
1043
+ // 那两个开关。槽没被组合出来时(比如没有设置面板的宿主)这里什么都不发生。
1044
+ ctx.effect(
1045
+ () =>
1046
+ ctx.slots.inject("settings.section", () =>
1047
+ ctx.slots.register(
1048
+ {
1049
+ name: "settings.section",
1050
+ id: ENTRY_ID,
1051
+ order: 60,
1052
+ label: () => translate("settings.nav"),
1053
+ locale: NS,
1054
+ },
1055
+ TaskTreeSettings,
1056
+ ),
1057
+ ),
1058
+ "dsh-tree-task-flow: settings section",
1059
+ );
858
1060
  }
859
1061
 
860
1062
  exports.name = name;
@@ -1,6 +1,12 @@
1
1
  # 这个文件只是示例,插件不需要它——**安装即启用**,装完就能用。
2
2
  #
3
- # 想改默认值时,把下面这段抄进 profile 自己的 cordis.patch.yml
3
+ # **两个开关平时不用改这里。** 打开 DSH 的设置面板,导航里「树形任务流」那一页点一下
4
+ # 就行——那是活的,改完立刻生效,而且**盖过这里写的值**。优先级是:
5
+ #
6
+ # 设置面板里改过的 > profile 里的 config > DEFAULT_CONFIG
7
+ #
8
+ # 这里留给"连设置面板也不想点"的场合(比如要一次性写进 profile 发给别人)。
9
+ # 想改成默认以外的东西时,把下面这段抄进 profile 自己的 cordis.patch.yml
4
10
  # (通常是 $DSH_HOME/profiles/<名字>/cordis.patch.yml):
5
11
  #
6
12
  # - id: task-tree
@@ -15,11 +21,19 @@
15
21
  # 总开关。默认 true(装上即启用);写 false 就一句话关掉,
16
22
  # 关掉后插件不注册工具、不加提示词、不折叠上下文、不续行,一个字节都不碰会话。
17
23
  enabled: true
18
- # 模型停下时是否自动续行。默认 false —— 开着意味着 agent 一空下来就被再推一把,
19
- # 会出现「按下停止却停不下来」,所以它从不默认打开。
24
+ # 模型停下时是否自动续行。**默认 false(不续)。**
25
+ # 它会在你没看着的时候自己往下跑,所以默认交给设置面板里的人来点——
26
+ # 设置面板里的那一页是个活的开关,改完立刻生效,不用重启。
20
27
  autoContinue: false
21
28
  # 连续自动续行的轮次上限,达到就停;只有真人发言才重置配额。
22
29
  maxAutoRounds: 5
30
+ # 意外中断后是否自动拉起(见 README「意外中断自动拉起」)。默认 true:
31
+ # 它只在「这一轮不是正常结束」时动手,正常停下不受影响。同样能在设置面板里开关,
32
+ # 而且改完立刻生效——这一项是"要不要救",与上一项"要不要往前推"是两件事。
33
+ interruptRecovery: true
34
+ # 网络/API 类中断的拉起时刻,单位毫秒,默认 [1分钟, 5分钟, 10分钟, 30分钟];
35
+ # null 表示用内置默认值。工具执行中断不看这张表,它永远立即拉一次。
36
+ interruptDelaysMs: null
23
37
  # 工具说明段落的排序。1800 落在内置的 TOOL_PTY(1700) 与 TOOL_WEB_SEARCH(2000)
24
38
  # 之间的空档,不与任何内置段落撞号。
25
39
  promptOrder: 1800