dsh-agent-board 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -86,6 +86,7 @@ dsh plugin --profile web remove dsh-agent-board
86
86
  ### 看板 UI
87
87
 
88
88
  - 会话标题栏「智能看板」按钮 → 顶部抽屉面板(看板 / 团队 / 仪表盘三视图)
89
+ - 面板右上角「+ 新建任务」:表单弹层填标题/描述/优先级/管线/touches/依赖多选/验收脚本,Team 托管默认存为草稿(提交走 create-task RPC,成功即刷新、失败表单内联报错)
89
90
  - 六列状态流:草稿 → 待办 → 进行中 → 验证中 → 已完成 → 阻塞
90
91
  - 拖拽流转、多选批量操作(带一步撤销)、文本/优先级/标签筛选
91
92
  - 归档区:时间倒序 + 排序选择器 + 纵向滚动
@@ -104,6 +105,7 @@ draft → pending → in-progress → verifying → resolved → archived
104
105
  - **依赖调度**:`dependsOn` 声明依赖(DFS 环检测),依赖全部完成后才会被派发,串行链路自动编排
105
106
  - **管线分档**:`full`(执行+验证)/ `work`(只做不验)/ `direct`(不进池,主窗口直接处理),创建时按规则自动分类、可手动覆盖
106
107
  - **硬性验收**:`acceptance` 字段写验收脚本命令,Worker 必须实际运行、Verifier 必须独立复跑
108
+ - **文件级排他**:`touches` 声明本任务要改的文件/glob(如 `["src/**", "README.md"]`);进行中的任务持有文件锁,派发器发现候选与活动任务 touches 重叠就跳过本轮(卡片显示 `🔒 等文件释放`,详情页列出在等谁),锁在提交验收/完成后自动释放——避免并行 Worker 改同一批文件互踩。手动「派发」遇到冲突会列出冲突任务,确认后才以 `force` 越权派发
107
109
  - **子任务**:父子层级 + 上下文继承 + 父任务自动流转 + 级联归档
108
110
 
109
111
  ### 一次性派发(v74 去池化)
@@ -116,24 +118,32 @@ draft → pending → in-progress → verifying → resolved → archived
116
118
  - 手动派发:详情页「派发 / 派发验收」按钮可随时手动触发单任务派发(auto 模式补派、manual 模式主通道)
117
119
  - 会话隔离:看板按会话分桶,多会话互不干扰
118
120
 
119
- ### 手动 / 自动派发模式
121
+ ### 工作模式(三档)
120
122
 
121
- | | 🤖 自动 | 👤 手动 |
122
- |---|---|---|
123
- | Worker 派发 | poolCycle 自动调度(并发上限可配) | 主窗口自行 claim 处理,或详情页手动「派发」 |
124
- | Verifier 派发 | 自动 | **自动**(主窗口手动做完的 full 档任务也会自动验收) |
125
- | 孤儿回收 | 开启 | 开启 |
123
+ 看板上一个选择器切换三档工作模式(RPC 单入口 `set-work-mode`,`mode` = `list` / `auto` / `team`):
126
124
 
127
- Team 模式开启时强制自动派发(防止"引导派发 + 手动模式"死锁组合)。
125
+ | 档位 | 内部映射 | 行为 | 适合场景 |
126
+ |---|---|---|---|
127
+ | 📋 清单模式 | `boardMode=manual` + `teamMode=false` | 看板当 TODO 列表:任务建了就是 `pending` 躺着,主窗口自己 claim 办理,或逐张在详情页点「派发」才起 Worker | 需求还在拆、想自己盯着逐条推进;或只想借看板记账 |
128
+ | ⚡ 自动派发 | `boardMode=auto` + `teamMode=false` | 即建即派:`pending` 任务在心跳周期内自动派给一级 Worker,主窗口也可以自己 claim 干活 | 任务描述已经写清楚、依赖也理顺,交给 Worker 跑 |
129
+ | 🤖 Team 托管 | `boardMode=auto` + `teamMode=true` | 主窗口当调度员:`task_create` 缺省建草稿(补完 dependsOn/上下文再 publish 统一发布),Worker 歧义上报主窗口裁决 | 多任务编排、长链路、需要人工把关键决策点 |
128
130
 
129
- ### Team 模式
131
+ 三档通用(**不是某一档专属**):
130
132
 
131
- 开启后(Team 开关):
132
- - 主窗口 system prompt 注入派发引导(提示词层面建议实质性改动走看板,不硬拦截)
133
- - 引导含**上下文书写提示**:子代理是全新会话、无会话记忆,description 写不够会自行调研跑偏
134
- - Worker 歧义自动上报主窗口聊天流,等待裁决
133
+ - **歧义裁决**:Worker 遇歧义不猜测,一律上报;裁决后新 Worker 携带答案接手(Team 托管档附带 system prompt 派发引导 + 默认草稿护栏)
134
+ - **Verifier 验收**:`acceptance` 硬性验收脚本命令,Worker 必须实际运行、Verifier 必须独立复跑;跨档一致
135
+ - **touches 排他**:`touches` 文件级排他锁在活动任务间生效,冲突任务跳过本轮派发,提交验收/完成后释放;跨档一致
136
+ - 孤儿回收、看门狗、级联归档、会话隔离同样三档一致
137
+
138
+ Team 托管档独有(调度员体验):
139
+ - 主窗口 system prompt 注入派发引导(提示词层面建议实质性改动走看板,不硬拦截);引导含**上下文书写提示**——子代理是全新会话、无会话记忆,description 写不够会自行调研跑偏
140
+ - **默认草稿护栏**:`task_create` / `create-task` 缺省建为草稿(草稿不派发),先把所有任务的 dependsOn、contextNotes/contextFiles 补齐,再逐个 `task_update publish=true` 统一发布;确实要立即派发的单个任务才显式传 `draft:false`
141
+ - **歧义通知 25s 去抖**:通知延迟 25s 投递,投递前重读看板——歧义已被裁决、或任务已 resolved/archived 就静默跳过(消除主窗口 turn 排队导致的过期回声);同一任务连续多次上报只投最新一条
135
142
  - 任务完成/阻塞时主窗口收到**批量聚合回执**(45s 窗口或满 5 条聚合,等主窗口空闲再发,不打断对话)
136
143
 
144
+ > 兼容:旧的 `set-board-mode` / `set-team-mode` 两个 RPC 原样保留(旧客户端与脚本不受影响),
145
+ > 内部仍以 `boardMode` + `teamMode` 两个字段落盘,老看板文件无损;`get-tasks` 额外返回派生字段 `workMode` 供 UI 单点读取。
146
+
137
147
  ## 13 个 Agent 工具
138
148
 
139
149
  | 类别 | 工具 |
@@ -151,7 +161,7 @@ Team 模式开启时强制自动派发(防止"引导派发 + 手动模式"死
151
161
  │ ├── index.mjs # host 端:IO 编排(工具/RPC/一次性派发引擎接线)
152
162
  │ ├── lib/core.mjs # 纯逻辑核心:状态机/依赖/分类/prompt/解析(无 IO,可单测)
153
163
  │ ├── lib/client.js # client 端(ModuleLoader 包装,图标统一走 ICONS + ic())
154
- │ ├── test/core.test.mjs # 单元测试(node --test,30 例)
164
+ │ ├── test/core.test.mjs # 单元测试(node --test,54 例)
155
165
  │ ├── package.json # dsh.bundle.patch + dsh.client 元数据
156
166
  │ └── cordis.patch.yml # bundle 挂载行
157
167
  └── docs/
@@ -198,7 +208,7 @@ git push --follow-tags # tag 推送触发流水线
198
208
  - **README 单一来源**:本文件(根 README)即唯一来源;发版前在 `packages/dsh-agent-board` 跑一次 `npm run sync-readme` 同步进包(npm 页面展示的是包内 README)
199
209
  - 需在仓库 **Settings → Secrets and variables → Actions** 配置 `NPM_TOKEN`
200
210
  (npm granular access token:bypass 2FA + direct publish)
201
- - 日常 push / PR 有 `test.yml` 跑语法检查 + 30 例单测
211
+ - 日常 push / PR 有 `test.yml` 跑语法检查 + 54 例单测
202
212
  - 本地手动发布仍然可用:`npm publish --registry=https://registry.npmjs.org`(本机默认源是镜像时必须显式指定)
203
213
 
204
214
  ## License
package/index.mjs CHANGED
@@ -10,7 +10,7 @@ import { zstdDecompressSync } from 'node:zlib'
10
10
  import os from 'node:os'
11
11
  import path from 'node:path'
12
12
  import fsNode from 'node:fs'
13
- const { ah, isb, gsb, gpt, vt, validateDeps, depsSatisfied, depsCancelled, classifyPipeline, seed, normalizeBoard, cfg, claimCheck, claimApply, checkParentAuto, resolveApply, verifyApply, parseSections, parseVerdict, isEscalation, outputText, histNotes, buildContextPackSection, buildWorkerPrompt, buildVerifierPrompt, pickDispatch, isOrphan, PRIO_RANK } = core
13
+ const { ah, isb, gsb, gpt, vt, validateDeps, depsSatisfied, depsCancelled, classifyPipeline, seed, normalizeBoard, cfg, claimCheck, claimApply, checkParentAuto, resolveApply, verifyApply, parseSections, parseVerdict, isEscalation, outputText, histNotes, buildContextPackSection, buildWorkerPrompt, buildVerifierPrompt, pickDispatch, isOrphan, PRIO_RANK, patOverlap, overlapsTouches, touchesConflict, holdsFiles } = core
14
14
 
15
15
  function defineTool(options) {
16
16
  var userExecute = options.execute
@@ -62,6 +62,12 @@ export function apply(ctx) {
62
62
  function touchSession(sid) { if (sid && typeof sid === 'string' && sid !== 'unknown') knownSessions[sid] = Date.now() }
63
63
  // teamMode 缓存:由 rt() 同步,供 systemPrompt 动态引导段读取(v65)
64
64
  var teamModeCache = {}
65
+ // 工作模式三档收敛(UI 一维化):内部仍存 boardMode+teamMode 两个 flag(老数据/老 RPC 无损),
66
+ // workMode 是纯派生字段——由两 flag 算出,不落盘(normalizeBoard 无需改动)。
67
+ // 'team' → boardMode=auto + teamMode=true(主窗口当调度员:默认草稿 + 裁决歧义)
68
+ // 'auto' → boardMode=auto + teamMode=false(即建即派给 Worker)
69
+ // 'list' → boardMode=manual + teamMode=false(看板=TODO 列表,手动 claim 或逐张派发)
70
+ function deriveWorkMode(d) { return (d && d.teamMode) ? 'team' : ((d && d.boardMode) === 'auto' ? 'auto' : 'list') }
65
71
  // 看板文件用「家目录绝对路径 + node:fs」直读写——不再走 fs 服务的相对路径解析
66
72
  // (fs 服务的相对路径解析根 = 进程启动 cwd,cwd 一变所有看板静默读成空板;
67
73
  // 曾因此导致整个看板状态异常。绝对路径对此永久免疫)
@@ -112,6 +118,9 @@ export function apply(ctx) {
112
118
  function kickCycle(sid) { if (cyclePending[sid]) return; cyclePending[sid] = true; var tm = ctx.timer; var go = function () { cyclePending[sid] = false; poolCycle(sid).catch(function () {}) }; if (tm) tm.timeout(50).then(go); else Promise.resolve().then(go) }
113
119
  function mutateLocked(sid, mutate, skipKick) { return withLock(sid, async function () { var d = await rt(sid); var r = await mutate(d); if (r !== null && r !== undefined) { await wt(sid, d); if (!skipKick) kickCycle(sid); return r } return r }) }
114
120
  function jo() { return { schema: { type: 'object', additionalProperties: true }, render: function (a, v) { return [{ type: 'text', text: JSON.stringify(v, null, 2) }] } } }
121
+ // touches(文件级排他声明)归一化:非字符串项过滤掉,空数组 = 不声明(无锁语义)。
122
+ // 上限 20 条,与 contextFiles 上限一致,防止 prompt/看板文件被超长清单撑爆。
123
+ function normTouches(v) { return Array.isArray(v) ? v.filter(function (x) { return typeof x === 'string' && x.trim() }).slice(0, 20).map(function (x) { return x.trim() }) : [] }
115
124
  // 按会话找 root agent(静态插件挂 host 层后多会话共存,不能"取第一个"——会把 worker 挂到别的会话上)
116
125
  function rootForSession(sid) { var s = ctx.agents; if (!s) return undefined; var r = s.roots(); for (var i = 0; i < r.length; i++) { if (String(r[i].id) === sid) return r[i] } return undefined }
117
126
  function makeSignal() { try { return new AbortController().signal } catch (_) { return { aborted: false, addEventListener: function () {}, removeEventListener: function () {} } } }
@@ -263,7 +272,8 @@ export function apply(ctx) {
263
272
  tm.timeout(softMs).then(function () {
264
273
  if (rec.settled) return
265
274
  var mins = Math.round((Date.now() - startedAt) / 60000)
266
- pushSysNote(sid, '⏱ 任务「' + t.title + '」的 ' + role + '(' + t.id + ')已运行 ' + mins + ' 分钟仍未完成——如属正常长任务可忽略;需要干预可在看板详情页「立即终止」(硬超时 ' + c.hardTimeoutMin + ' 分钟后将自动终止并重试)')
275
+ // 带 taskId:投递前会按任务现状复查,任务已完成/落定的过期告警直接丢弃(避免误报)
276
+ pushSysNote(sid, '⏱ 任务「' + t.title + '」的 ' + role + '(' + t.id + ')已运行 ' + mins + ' 分钟仍未完成——如属正常长任务可忽略;需要干预可在看板详情页「立即终止」(硬超时 ' + c.hardTimeoutMin + ' 分钟后将自动终止并重试)', t.id)
267
277
  softArm() // 持续提醒直到结算或硬超时
268
278
  }).catch(function () {})
269
279
  })()
@@ -360,12 +370,36 @@ export function apply(ctx) {
360
370
  }
361
371
 
362
372
  // 歧义上报通知:任何模式都通知主窗口(escalation 需要人工裁决,不能静默吞掉)
363
- function notifyMainWindow(sid, t, question) {
364
- var root = rootForSession(sid)
365
- if (!root) return
366
- try { root.followup(makeMsg('⚠️ [任务看板] Worker 上报歧义,等待裁决:\n\n任务: ' + t.title + ' (' + t.id + ')\n\n疑问:\n' + question.slice(0, 1500) + '\n\n请在看板详情页裁决,或直接回复指示。裁决后会有新 Worker 带着裁决答案接手。\n\n(若收到时任务已被裁决或归档,说明本通知投递晚于处理——先用 task_list/get-tasks 核对状态,勿重复裁决。)', 'notice', '任务待裁决: ' + t.title)) } catch (e) { console.error('[task-board] escalate notify failed:', String(e)) }
373
+ // 25s 去抖投递:主窗口 turn 进行中时 followup 只在宿主侧排队,送达时任务常已被裁决/归档(过期回声);
374
+ // 排队无法撤回,插件侧唯一可行的方案就是延迟 + 投递前重查看板。
375
+ // escNotifyTimers 存每个任务最新一次调度:同一任务再次上报即顶替旧调度(旧回调身份不匹配 → 静默丢弃);
376
+ // 投递时才读 escalation.question,所以连续多次上报只会收到一条、且一定是最新疑问。
377
+ var escNotifyTimers = {}
378
+ function deliverEscalation(sid, taskId) {
379
+ rt(sid).then(function (d) {
380
+ var t = null
381
+ var list = (d && d.tasks) || []
382
+ for (var i = 0; i < list.length; i++) { if (list[i].id === taskId) { t = list[i]; break } }
383
+ // escalation 已消失(已被裁决)或任务已 resolved/archived → 通知已过期,静默跳过(history 不记)
384
+ if (!t || !t.escalation || t.status === 'resolved' || t.status === 'archived') return
385
+ var root = rootForSession(sid)
386
+ if (!root) return
387
+ try { root.followup(makeMsg('⚠️ [任务看板] Worker 上报歧义,等待裁决:\n\n任务: ' + t.title + ' (' + t.id + ')\n\n疑问:\n' + String(t.escalation.question || '').slice(0, 1500) + '\n\n请在看板详情页裁决,或直接回复指示。裁决后会有新 Worker 带着裁决答案接手。\n\n(若收到时任务已被裁决或归档,说明本通知投递晚于处理——先用 task_list/get-tasks 核对状态,勿重复裁决。)', 'notice', '任务待裁决: ' + t.title)) } catch (e) { console.error('[task-board] escalate notify failed:', String(e)) }
388
+ }).catch(function (e) { console.error('[task-board] escalate notify failed:', String(e)) })
367
389
  }
368
- function maybeNotify(sid, task) { if (task && task.escalation) { notifyMainWindow(sid, task, task.escalation.question) } }
390
+ function notifyMainWindow(sid, t) {
391
+ var tm = ctx.timer
392
+ if (!tm) { deliverEscalation(sid, t.id); return } // timer 不可用 → 直接投递(保持原即时行为)
393
+ var key = sid + ':' + t.id
394
+ var mine = tm.timeout(25000)
395
+ escNotifyTimers[key] = mine
396
+ mine.then(function () {
397
+ if (escNotifyTimers[key] !== mine) return // 已被该任务更新的一次上报顶替 → 丢弃,避免重复通知
398
+ delete escNotifyTimers[key]
399
+ deliverEscalation(sid, t.id)
400
+ }).catch(function () {}) // 插件销毁时 timeout 会 reject("Context has been disposed"),静默吞掉
401
+ }
402
+ function maybeNotify(sid, task) { if (task && task.escalation) { notifyMainWindow(sid, task) } }
369
403
 
370
404
  // ===== 任务回执通知(批量聚合 + 空闲门控):派发执行的任务在 完成/阻塞 时通知主窗口 =====
371
405
  // 只通知派发执行的任务(isDispatched),主窗口自己手动处理的任务不回执(自己干的自己知道)。
@@ -397,9 +431,11 @@ export function apply(ctx) {
397
431
  }
398
432
  // 系统级异常通知队列(易失,随回执冲刷):模型熔断/spawn 失败/孤儿回收/看门狗标记
399
433
  var sysNotesBuf = {}
400
- function pushSysNote(sid, text) {
434
+ // taskId 可选:告警类通知(软超时提醒等)语义只对「任务仍在执行中」成立,
435
+ // 带上任务 id 后 flush 投递前可重读看板校验,任务已落定的过期告警直接丢弃
436
+ function pushSysNote(sid, text, taskId) {
401
437
  var arr = (sysNotesBuf[sid] = sysNotesBuf[sid] || [])
402
- arr.push({ text: text, at: new Date().toISOString() })
438
+ arr.push({ text: text, at: new Date().toISOString(), taskId: taskId || null })
403
439
  if (arr.length > 10) arr.splice(0, arr.length - 10)
404
440
  }
405
441
  function flushReceipts(sid) {
@@ -409,28 +445,64 @@ export function apply(ctx) {
409
445
  receiptBuf[sid] = null
410
446
  sysNotesBuf[sid] = []
411
447
  var root = rootForSession(sid); if (!root) return
412
- var done = [], blocked = []
413
- if (buf) for (var i = 0; i < buf.items.length; i++) { (buf.items[i].kind === 'resolved' ? done : blocked).push(buf.items[i]) }
414
- var lines = [done.length || blocked.length ? '📋 [任务看板] 回执摘要(' + buf.items.length + ' 条)' : '📋 [任务看板] 系统通知', '']
415
- if (done.length) {
416
- lines.push('✅ 完成 ' + done.length + ' 个:')
417
- for (var j = 0; j < done.length && j < 8; j++) lines.push(' · ' + done[j].title + ' (' + done[j].id + ')' + (done[j].summary ? ' — ' + done[j].summary.slice(0, 120) : ''))
418
- }
419
- if (blocked.length) {
420
- lines.push('🛑 阻塞 ' + blocked.length + ' 个(需关注):')
421
- for (var k = 0; k < blocked.length && k < 8; k++) lines.push(' · ' + blocked[k].title + ' (' + blocked[k].id + ')' + (blocked[k].note ? ' — ' + blocked[k].note.slice(0, 150) : ''))
448
+ var items = (buf && buf.items) ? buf.items.slice() : []
449
+ function noteOf(status) { return status === 'verifying' ? '验证中' : '进行中' }
450
+ // 组装投递文本(入参已是过滤后的存活项,避免用已丢弃项的计数)
451
+ function composeText(items, notes) {
452
+ var done = [], blocked = []
453
+ for (var i = 0; i < items.length; i++) { (items[i].kind === 'resolved' ? done : blocked).push(items[i]) }
454
+ var lines = [done.length || blocked.length ? '📋 [任务看板] 回执摘要(' + items.length + ' 条)' : '📋 [任务看板] 系统通知', '']
455
+ if (done.length) {
456
+ lines.push('✅ 完成 ' + done.length + ' 个:')
457
+ for (var j = 0; j < done.length && j < 8; j++) lines.push(' · ' + done[j].title + ' (' + done[j].id + ')' + (done[j].summary ? ' — ' + done[j].summary.slice(0, 120) : ''))
458
+ }
459
+ if (blocked.length) {
460
+ lines.push('🛑 阻塞 ' + blocked.length + ' 个(需关注):')
461
+ for (var k = 0; k < blocked.length && k < 8; k++) lines.push(' · ' + blocked[k].title + ' (' + blocked[k].id + ')' + (blocked[k].note ? ' — ' + blocked[k].note.slice(0, 150) : ''))
462
+ }
463
+ if (notes.length) {
464
+ lines.push('', '⚠️ 系统异常 ' + notes.length + ' 条:')
465
+ for (var n = 0; n < notes.length && n < 8; n++) lines.push(' · ' + notes[n].text)
466
+ }
467
+ lines.push('', '可用 task_list 查看全部;阻塞项可在看板拖回待办重新投放。')
468
+ return lines.join('\n')
422
469
  }
423
- if (notes.length) {
424
- lines.push('', '⚠️ 系统异常 ' + notes.length + ' 条:')
425
- for (var n = 0; n < notes.length && n < 8; n++) lines.push(' · ' + notes[n].text)
470
+ // 投递前状态过滤:入队到投递之间隔着 45s 聚合窗口 + 等主窗口空闲(最长 5 分钟),
471
+ // 期间任务可能已经完成/归档——过期告警与死回执会误报(实测软超时告警 6/6 全误报)。
472
+ // 所以 send 之前重读一次看板,按任务现状决定丢哪些项。
473
+ function deliver() {
474
+ return Promise.resolve().then(function () { return rt(sid) }).catch(function () { return null }).then(function (snap) {
475
+ var tasks = (snap && snap.tasks) || []
476
+ function findTask(id) { for (var i = 0; i < tasks.length; i++) { if (tasks[i].id === id) return tasks[i] } return null }
477
+ // a. 带 taskId 的告警项:任务状态不在 in-progress/verifying 即已落定 → 丢弃;
478
+ // 保留的项在文本前标注投递时状态(读到的是发送瞬间的真实状态,人可据此判断时效)
479
+ var keptNotes = []
480
+ for (var i = 0; i < notes.length; i++) {
481
+ var nt = notes[i]
482
+ if (nt.taskId) {
483
+ var t = findTask(nt.taskId)
484
+ if (!t || (t.status !== 'in-progress' && t.status !== 'verifying')) continue
485
+ keptNotes.push({ text: '(投递时状态:' + noteOf(t.status) + ')' + nt.text })
486
+ } else keptNotes.push(nt)
487
+ }
488
+ // b. 回执项:任务已 archived(人已手动归档 = 已知悉)→ 丢弃;
489
+ // resolved/blocked 保留(回执是主通道,任务查不到也保留,不能因读盘失败丢回执)
490
+ var keptItems = []
491
+ for (var j = 0; j < items.length; j++) {
492
+ var it = items[j]
493
+ var tt = findTask(it.id)
494
+ if (tt && tt.status === 'archived') continue
495
+ keptItems.push(it)
496
+ }
497
+ // c. 过滤后全空 → 不再打扰主窗口
498
+ if (!keptItems.length && !keptNotes.length) return
499
+ try { root.followup(makeMsg(composeText(keptItems, keptNotes), 'recall')) } catch (e) { console.error('[task-board] receipt flush failed:', String(e)) }
500
+ })
426
501
  }
427
- lines.push('', '可用 task_list 查看全部;阻塞项可在看板拖回待办重新投放。')
428
- var text = lines.join('\n')
429
- function send() { try { root.followup(makeMsg(text, 'recall')) } catch (e) { console.error('[task-board] receipt flush failed:', String(e)) } }
430
502
  if (typeof root.whenIdle === 'function') {
431
503
  var waited = withTimeout(root.whenIdle(), 300000, 'receipt-idle-wait') // 最多等 5 分钟,超时也发(不能丢回执)
432
- Promise.resolve(waited).then(send).catch(send)
433
- } else send()
504
+ Promise.resolve(waited).then(deliver, deliver).catch(function (e) { console.error('[task-board] receipt flush failed:', String(e)) })
505
+ } else deliver().catch(function (e) { console.error('[task-board] receipt flush failed:', String(e)) })
434
506
  }
435
507
 
436
508
  // ===== 派发周期(15s 心跳 + 写入后 kickCycle 触发)=====
@@ -471,6 +543,15 @@ export function apply(ctx) {
471
543
  var picked = pickDispatch(d, capW, Math.max(0, c.maxVerifiers - activeV), runs)
472
544
  picked.pendings.forEach(function (t) { claimApply(d, t, 'spawn-pending', 'dispatch'); toSpawn.push({ role: 'worker', t: t }); info.push('dispatch ' + t.id) })
473
545
  picked.verifs.forEach(function (t) { t.verifierRun = 'spawn-pending'; t.verifierRunAt = new Date().toISOString(); toSpawn.push({ role: 'verifier', t: t }); info.push('verify ' + t.id) })
546
+ // touches 文件级排他展示态:被拦候选写 t.waitingForTouches = [持有者任务id...],
547
+ // 未被拦/已派发/已落定的任务清除该字段(每心跳刷新的 UI 展示态,不参与任何派发逻辑,
548
+ // 但必须显式清——只在写入时报字段会留下"锁已释放仍显示 🔒 等待"的永久误导)。
549
+ var waitMap = {}
550
+ picked.blockedTouches.forEach(function (b) { waitMap[b.id] = b.conflicts })
551
+ d.tasks.forEach(function (t) {
552
+ if (waitMap[t.id]) { t.waitingForTouches = waitMap[t.id]; info.push('wait-touches ' + t.id + '<-' + waitMap[t.id].join(',')) }
553
+ else if (t.waitingForTouches) delete t.waitingForTouches
554
+ })
474
555
  // UI 池状态:来自活跃 run(一次性模型:没有成员名册,只有在跑的任务)
475
556
  d.poolStatus = { workers: [], verifiers: [] }
476
557
  Object.keys(runs).forEach(function (k) { var rc = runs[k]; d.poolStatus[rc.role === 'worker' ? 'workers' : 'verifiers'].push({ id: k, num: '-', busy: true, taskId: rc.taskId, runId: String(rc.run.id), done: 0, queueLen: 0, suspect: false, model: rc.model || '' }) })
@@ -520,7 +601,7 @@ export function apply(ctx) {
520
601
  // 不能走 resolveRoot:该函数在每个 agent 每次 prompt 组装时同步执行,
521
602
  // resolveRoot 全表扫 agent 注册表会把宿主事件循环卡死(曾导致全局界面卡顿、用户消息延迟渲染)。
522
603
  if (!teamModeCache[String(agent.id)]) return ''
523
- return '【任务看板 Team 模式已开启】\n本会话的任务看板处于 Team 模式。请遵循以下工作方式:\n1. 涉及代码改动、文件创建、命令执行等实质性工作时,优先用 task_create 提交为看板任务(由一次性 Worker/Verifier 子代理执行与验收),不要自己直接动手实现。\n2. 你仍保有全部工具能力——调研、读代码、讨论方案、回答问题时直接进行,无需提交任务。\n3. 创建任务时,务必在 description 里写清任务目标和约束;调研结论/原始需求/思路用 contextNotes 带上,调研时读过的关键文件用 contextFiles 把路径带上——两者都会通过「上下文注入」通道传给子代理(独立注入区块,不占对话流)。子代理是全新会话、无你的会话记忆,上下文不够它需要从零自行调研,效率大打折扣甚至跑偏方向。\n4. Worker 上报歧义时会通过 task_arbitrate 等待你裁决,请及时响应。驳回重派时同样:新 Worker 没有上一轮的记忆,驳回原因会在 prompt 里,但额外上下文需你在 description 里补上。\n5. 创建多个相互关联的任务时,必须「先草稿后发布」:所有任务先用 task_create draft:true 建为草稿(草稿不会被派发领取),等全部任务创建完成、dependsOn 依赖关系都写好后,再逐个 task_update publish=true 统一发布。严禁直接创建 pending 任务再后补依赖——依赖还没写入,后向任务就会被 Worker 提前领走。'
604
+ return '【任务看板 Team 模式已开启】\n本会话的任务看板处于 Team 模式。请遵循以下工作方式:\n1. 涉及代码改动、文件创建、命令执行等实质性工作时,优先用 task_create 提交为看板任务(由一次性 Worker/Verifier 子代理执行与验收),不要自己直接动手实现。\n2. 你仍保有全部工具能力——调研、读代码、讨论方案、回答问题时直接进行,无需提交任务。\n3. 创建任务时,务必在 description 里写清任务目标和约束;调研结论/原始需求/思路用 contextNotes 带上,调研时读过的关键文件用 contextFiles 把路径带上——两者都会通过「上下文注入」通道传给子代理(独立注入区块,不占对话流)。子代理是全新会话、无你的会话记忆,上下文不够它需要从零自行调研,效率大打折扣甚至跑偏方向。\n4. Worker 上报歧义时会通过 task_arbitrate 等待你裁决,请及时响应。驳回重派时同样:新 Worker 没有上一轮的记忆,驳回原因会在 prompt 里,但额外上下文需你在 description 里补上。\n5. Team 模式下 task_create 默认建为草稿(草稿不会被派发领取)。把所有任务的 dependsOn 依赖关系、contextNotes/contextFiles 都补完后,再逐个 task_update publish=true 统一发布。确实需要立即派发的单个任务才显式传 draft:false。'
524
605
  },
525
606
  })
526
607
  ctx.effect(function () { return disposeSection })
@@ -561,8 +642,8 @@ export function apply(ctx) {
561
642
  ctx.tools.register(defineTool({ name: 'task_resolve', description: '提交验证(verifying)或阻塞(blocked)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, status: { type: 'string', enum: ['verifying', 'blocked'] }, resolution: { type: 'string' } }, required: ['taskId', 'status'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'in-progress') return { ok: false, error: 'not in-progress' }; if (t.claimedBy !== actor) return { ok: false, error: 'not claimed by you' }; if (args.status === 'verifying' && !args.resolution) return { ok: false, error: 'resolution required' }; return resolveApply(d, t, actor, args.status, args.resolution, args.resolution || args.status) }) } }))
562
643
  ctx.tools.register(defineTool({ name: 'task_verify', description: '验收:approved→resolved,rejected→in-progress。子任务全完成父任务自动verifying。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, verdict: { type: 'string', enum: ['approved', 'rejected'] }, comment: { type: 'string' } }, required: ['taskId', 'verdict'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'verifying') return { ok: false, error: 'not verifying' }; return verifyApply(d, t, actor, args.verdict, args.comment) }) } }))
563
644
  ctx.tools.register(defineTool({ name: 'task_archive', description: '归档已解决/已取消任务。', parameters: { type: 'object', properties: { taskId: { type: 'string' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var a = d.tasks; var t = a.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'resolved' && t.status !== 'cancelled') return { ok: false, error: 'only resolved/cancelled' }; var ps = t.status; t.status = 'archived'; t.archivedAt = new Date().toISOString(); ah(t, ps, 'archived', actor, 'archived'); var ca = 0; gsb(t.id, a).forEach(function (c) { if (c.status !== 'archived') { ah(c, c.status, 'archived', actor, 'cascade'); c.status = 'archived'; c.archivedAt = new Date().toISOString(); ca++ } }); var r = { ok: true, task: t }; if (ca) r.childrenArchived = ca; return r }) } }))
564
- ctx.tools.register(defineTool({ name: 'task_update', description: '更新任务字段,可选重置为 pending。dependsOn/pipeline 也可更新(环检测会拒绝成环依赖)。contextFiles 可更新预研文件清单。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, assignMode: { type: 'string', enum: ['auto', 'manual'] }, assignee: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(替换式更新),派发时经上下文注入传给子代理' }, contextNotes: { type: 'string', description: '预研笔记(替换式更新):调研结论/原始需求/思路' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, resetToPending: { type: 'boolean' }, publish: { type: 'boolean', description: '发布草稿为 pending(仅 draft 状态有效)' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (args.title !== undefined) t.title = args.title; if (args.description !== undefined) t.description = args.description; if (args.priority !== undefined) t.priority = args.priority; if (args.assignMode !== undefined) t.assignMode = args.assignMode; if (args.assignee !== undefined) t.assignee = args.assignee || null; if (args.dependsOn !== undefined) { var derr = validateDeps(d, t.id, args.dependsOn); if (derr) return { ok: false, error: derr }; t.dependsOn = args.dependsOn } if (args.pipeline !== undefined) { t.pipeline = args.pipeline; t.pipelineAuto = false } if (args.contextFiles !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : [] } if (args.contextNotes !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : '' } if (args.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published') } if (args.resetToPending) { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; t.resolvedAt = null; t.resolution = null; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; return { ok: true, task: t } }) } }))
565
- ctx.tools.register(defineTool({ name: 'task_create', description: '创建新任务到当前会话看板。acceptance 可选:硬性验收脚本命令(如 "node --test src/x.test.js"),Worker 必须实际运行、Verifier 必须独立复跑。dependsOn 可选:依赖任务 id 数组,依赖全部完成后才会被派发。pipeline 可选:full(默认,工作+验证)/work(只做不验)/direct(不进池,主窗口直接处理)。contextFiles 可选:你在调研中已经读过的关键文件路径数组——派发时 host 会从磁盘读取最新内容,通过「上下文注入」通道传给 Worker/Verifier(呈现为独立注入区块,不占对话流),避免子代理从零重复调研。contextNotes 可选:调研结论/原始需求/思路等非文件类上下文,同样走上下文注入。', parameters: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, tags: { type: 'array', items: { type: 'string' } }, parentId: { type: 'string' }, instructions: { type: 'string' }, acceptance: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(相对 workspace 或绝对路径),内容将在派发时经上下文注入传给子代理' }, contextNotes: { type: 'string', description: '预研笔记:调研结论/原始需求/思路等(≤8000 字符),经上下文注入传给子代理' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, draft: { type: 'boolean', description: 'true 创建为草稿(不派发不可领取),补全信息后用 task_update publish=true 发布' } }, required: ['title'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id: ' + args.id }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title, description: args.description || '', status: args.draft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, messages: [], history: [{ from: 'created', to: args.draft ? 'draft' : 'pending', timestamp: now, actor: actor, note: args.draft ? 'created as draft' : 'created' }] }; if (!t.pipeline) t.pipeline = classifyPipeline(t); t.pipelineAuto = !args.pipeline; d.tasks.push(t); return { ok: true, task: t } }) } }))
645
+ ctx.tools.register(defineTool({ name: 'task_update', description: '更新任务字段,可选重置为 pending。dependsOn/pipeline 也可更新(环检测会拒绝成环依赖)。contextFiles 可更新预研文件清单。unfreeze:true 解除裁决挂起冻结(frozen)并触发派发。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, assignMode: { type: 'string', enum: ['auto', 'manual'] }, assignee: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(替换式更新),派发时经上下文注入传给子代理' }, contextNotes: { type: 'string', description: '预研笔记(替换式更新):调研结论/原始需求/思路' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, resetToPending: { type: 'boolean' }, unfreeze: { type: 'boolean', description: '解除冻结(frozen)并立即触发派发,用于 hold 裁决补完上下文后重新入池' }, publish: { type: 'boolean', description: '发布草稿为 pending(仅 draft 状态有效)' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (args.title !== undefined) t.title = args.title; if (args.description !== undefined) t.description = args.description; if (args.priority !== undefined) t.priority = args.priority; if (args.assignMode !== undefined) t.assignMode = args.assignMode; if (args.assignee !== undefined) t.assignee = args.assignee || null; if (args.dependsOn !== undefined) { var derr = validateDeps(d, t.id, args.dependsOn); if (derr) return { ok: false, error: derr }; t.dependsOn = args.dependsOn } if (args.pipeline !== undefined) { t.pipeline = args.pipeline; t.pipelineAuto = false } if (args.contextFiles !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : [] } if (args.contextNotes !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : '' } if (args.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published') } if (args.unfreeze && t.frozen) { delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, t.status, t.status, actor, '解除冻结,重新进入派发池') } if (args.resetToPending) { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; t.resolvedAt = null; t.resolution = null; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; return { ok: true, task: t } }) } }))
646
+ ctx.tools.register(defineTool({ name: 'task_create', description: '创建新任务到当前会话看板。acceptance 可选:硬性验收脚本命令(如 "node --test src/x.test.js"),Worker 必须实际运行、Verifier 必须独立复跑。dependsOn 可选:依赖任务 id 数组,依赖全部完成后才会被派发。pipeline 可选:full(默认,工作+验证)/work(只做不验)/direct(不进池,主窗口直接处理)。contextFiles 可选:你在调研中已经读过的关键文件路径数组——派发时 host 会从磁盘读取最新内容,通过「上下文注入」通道传给 Worker/Verifier(呈现为独立注入区块,不占对话流),避免子代理从零重复调研。contextNotes 可选:调研结论/原始需求/思路等非文件类上下文,同样走上下文注入。touches 可选:本任务将要改动的文件路径/glob 数组(如 "src/x.mjs"、"src/**")——派发器在同一时刻只派发 touches 不冲突的任务,避免并行 Worker 改同一批文件互踩;任务进行中持有文件锁,提交验收/完成后释放。', parameters: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, tags: { type: 'array', items: { type: 'string' } }, parentId: { type: 'string' }, instructions: { type: 'string' }, acceptance: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(相对 workspace 或绝对路径),内容将在派发时经上下文注入传给子代理' }, contextNotes: { type: 'string', description: '预研笔记:调研结论/原始需求/思路等(≤8000 字符),经上下文注入传给子代理' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, touches: { type: 'array', items: { type: 'string' }, description: '本任务将改动的文件路径/glob(文件级排他锁):与活动任务 touches 冲突时不派发,等锁释放;支持 "src/**" 目录、"./a/b.mjs"、裸文件名等写法' }, draft: { type: 'boolean', description: '创建为草稿(不派发)。Team 模式下缺省即为 true——补全 dependsOn/上下文后用 task_update publish=true 统一发布;非 Team 模式缺省 false,显式 draft:false 可跳过草稿' } }, required: ['title'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id: ' + args.id }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title, description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (!t.pipeline) t.pipeline = classifyPipeline(t); t.pipelineAuto = !args.pipeline; d.tasks.push(t); return { ok: true, task: t } }) } }))
566
647
 
567
648
  // ===== RPC =====
568
649
  // get-tasks 是纯读路径(rt 只读文件)——poolCycle 由 15s 心跳 + 写入后 kickCycle 驱动,
@@ -570,6 +651,8 @@ export function apply(ctx) {
570
651
  handle('get-tasks', async function (args) { var sid = rpcSessionId(args); var d = await rt(sid); d.sessionId = sid; var __ag = ctx.agents; d.isRoot = true; if (__ag) { var __roots = __ag.roots(); var __rids = []; for (var __i = 0; __i < __roots.length; __i++) __rids.push(String(__roots[__i].id)); d.isRoot = __rids.indexOf(sid) >= 0 }
571
652
  // poolStatus 防幽灵:只保留指向当前活跃任务的条目(重启后内存 runs 清空,文件快照可能残留)
572
653
  if (d.poolStatus) { var __act = {}; (d.tasks || []).forEach(function (t) { if (t.status === 'in-progress' || t.status === 'verifying') __act[t.id] = true }); d.poolStatus.workers = (d.poolStatus.workers || []).filter(function (w) { return __act[w.taskId] }); d.poolStatus.verifiers = (d.poolStatus.verifiers || []).filter(function (v) { return __act[v.taskId] }) }
654
+ // 工作模式派生字段:UI 只读这一个字段决定三档选中态(不落盘,写入仍走 boardMode/teamMode)
655
+ d.workMode = deriveWorkMode(d)
573
656
  return d })
574
657
  // 派发前上下文预览:主 agent 用它确认"我将注入给子代理的材料"是否足够(不发任务、不落盘)
575
658
  handle('preview-context', async function (args) {
@@ -667,26 +750,61 @@ export function apply(ctx) {
667
750
  handle('resolve-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'in-progress') return { ok: false, error: 'not in-progress' }; return resolveApply(d, t, actor, args.status, args.resolution, args.resolution || args.status) }) })
668
751
  handle('verify-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'verifying') return { ok: false, error: 'not verifying' }; delete t.escalation; delete t.verifyRetries; return verifyApply(d, t, actor, args.verdict, args.comment) }) })
669
752
  handle('archive-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var a = d.tasks; var t = a.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'resolved' && t.status !== 'cancelled') return { ok: false, error: 'cannot archive' }; var ps = t.status; t.status = 'archived'; t.archivedAt = new Date().toISOString(); ah(t, ps, 'archived', actor, 'manual archive'); var ca = 0; gsb(t.id, a).forEach(function (c) { if (c.status !== 'archived') { ah(c, c.status, 'archived', actor, 'cascade'); c.status = 'archived'; c.archivedAt = new Date().toISOString(); ca++ } }); var r = { ok: true, task: t }; if (ca) r.childrenArchived = ca; return r }) })
670
- handle('update-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (args.title !== undefined) t.title = args.title; if (args.description !== undefined) t.description = args.description; if (args.priority !== undefined) t.priority = args.priority; if (args.assignMode !== undefined) t.assignMode = args.assignMode; if (args.assignee !== undefined) t.assignee = args.assignee || null; if (args.dependsOn !== undefined) { var derr = validateDeps(d, t.id, args.dependsOn); if (derr) return { ok: false, error: derr }; t.dependsOn = args.dependsOn } if (args.pipeline !== undefined) { t.pipeline = args.pipeline; t.pipelineAuto = false } if (args.contextFiles !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : [] } if (args.contextNotes !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : '' } if (args.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published') } if (args.resetToPending) { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; t.resolvedAt = null; t.resolution = null; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; return { ok: true, task: t } }) })
671
- handle('set-board-mode', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { d.boardMode = args.mode === 'manual' ? 'manual' : 'auto'; if (d.boardMode === 'manual' && d.teamMode) { d.teamMode = false; teamModeCache[sid] = false }; return { ok: true, boardMode: d.boardMode, teamMode: !!d.teamMode } }) })
672
- handle('set-team-mode', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { d.teamMode = !!args.enabled; if (d.teamMode) d.boardMode = 'auto'; teamModeCache[sid] = d.teamMode; return { ok: true, teamMode: d.teamMode, boardMode: d.boardMode } }) })
753
+ handle('update-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (args.title !== undefined) t.title = args.title; if (args.description !== undefined) t.description = args.description; if (args.priority !== undefined) t.priority = args.priority; if (args.assignMode !== undefined) t.assignMode = args.assignMode; if (args.assignee !== undefined) t.assignee = args.assignee || null; if (args.dependsOn !== undefined) { var derr = validateDeps(d, t.id, args.dependsOn); if (derr) return { ok: false, error: derr }; t.dependsOn = args.dependsOn } if (args.pipeline !== undefined) { t.pipeline = args.pipeline; t.pipelineAuto = false } if (args.contextFiles !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : [] } if (args.contextNotes !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : '' } if (args.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published') } if (args.unfreeze && t.frozen) { delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, t.status, t.status, actor, '解除冻结,重新进入派发池') } if (args.resetToPending) { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; t.resolvedAt = null; t.resolution = null; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; return { ok: true, task: t } }) })
754
+ handle('set-board-mode', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { d.boardMode = args.mode === 'manual' ? 'manual' : 'auto'; if (d.boardMode === 'manual' && d.teamMode) { d.teamMode = false; teamModeCache[sid] = false }; return { ok: true, boardMode: d.boardMode, teamMode: !!d.teamMode, workMode: deriveWorkMode(d) } }) })
755
+ handle('set-team-mode', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { d.teamMode = !!args.enabled; if (d.teamMode) d.boardMode = 'auto'; teamModeCache[sid] = d.teamMode; return { ok: true, teamMode: d.teamMode, boardMode: d.boardMode, workMode: deriveWorkMode(d) } }) })
756
+ // 工作模式三档单入口(v75 UI 收敛):一次写入 boardMode+teamMode 两个字段,
757
+ // 复用与老 RPC 完全相同的写入语义(team 档强制 auto;list 档关 team)——
758
+ // 老 RPC set-board-mode/set-team-mode 原样保留,旧客户端/脚本/E2E 不受影响。
759
+ handle('set-work-mode', async function (args) {
760
+ var sid = rpcSessionId(args)
761
+ var m = args && args.mode
762
+ var mode = (m === 'list' || m === 'team') ? m : 'auto' // 缺省/非法值兜底 auto
763
+ return mutateLocked(sid, function (d) {
764
+ if (mode === 'list') { d.boardMode = 'manual'; d.teamMode = false }
765
+ else if (mode === 'team') { d.boardMode = 'auto'; d.teamMode = true }
766
+ else { d.boardMode = 'auto'; d.teamMode = false }
767
+ teamModeCache[sid] = !!d.teamMode // 同步 systemPrompt 引导段的 teamMode 缓存
768
+ return { ok: true, mode: mode, workMode: deriveWorkMode(d), boardMode: d.boardMode, teamMode: !!d.teamMode }
769
+ })
770
+ })
673
771
  // 裁决回流(v74 一次性模型):原 Worker 已结束,答案写入 history 后任务回 pending,
674
772
  // 下个派发周期 spawn 新 Worker,裁决内容随 prompt 注入(histNotes 匹配"裁决")
675
- async function doResolveEscalation(sid, actor, taskId, answer) {
773
+ // 结构化动作(裁决竞态保护):
774
+ // action='resume'(默认,保持现状)= 裁决后立即回到派发池,~50ms 内被自动重派;
775
+ // action='hold' = 任务置 frozen 冻结,不参与任何自动派发(pickDispatch 跳过),
776
+ // 留给主窗口补 dependsOn/补上下文的时间窗口,补完再显式解冻(unfreeze-task)。
777
+ // hold 时 mutateLocked 传 skipKick:冻结任务本就不该派发,省掉一次无意义的 poolCycle。
778
+ async function doResolveEscalation(sid, actor, taskId, answer, action) {
779
+ var act = action === 'hold' ? 'hold' : 'resume'
676
780
  var result = await mutateLocked(sid, function (d) {
677
781
  var t = d.tasks.find(function (x) { return x.id === taskId })
678
782
  if (!t) return { ok: false, error: 'not found' }
679
783
  if (!t.escalation) return { ok: false, error: 'not escalated' }
680
784
  delete t.escalation
681
785
  if (!Array.isArray(t.messages)) t.messages = []
682
- t.messages.push({ kind: 'arbitration', text: answer || '', at: new Date().toISOString(), by: actor })
683
- ah(t, t.status, t.status, actor, '主窗口裁决: ' + (answer || '').slice(0, 200))
786
+ t.messages.push({ kind: 'arbitration', text: answer || '', at: new Date().toISOString(), by: actor, action: act })
787
+ ah(t, t.status, t.status, actor, (act === 'hold' ? '主窗口裁决(挂起冻结): ' : '主窗口裁决: ') + (answer || '').slice(0, 200))
684
788
  // in-progress 的歧义任务:回 pending 重派(新 Worker 带裁决上下文);verifying 的 verifier 故障升级:保持待审,下轮派新 verifier
685
- if (t.status === 'in-progress') { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, ps, 'pending', 'system', '带裁决重新排队') }
686
- return { ok: true, task: t, answer: answer || '' }
687
- })
789
+ if (t.status === 'in-progress') { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, ps, 'pending', 'system', act === 'hold' ? '带裁决挂起冻结(不参与自动派发)' : '带裁决重新排队') }
790
+ if (act === 'hold') { t.frozen = true; t.frozenAt = new Date().toISOString(); t.frozenBy = actor; ah(t, t.status, t.status, actor, '冻结:不参与自动派发,待主窗口补完上下文后解冻') }
791
+ else { delete t.frozen; delete t.frozenAt; delete t.frozenBy }
792
+ return { ok: true, task: t, answer: answer || '', action: act, frozen: !!t.frozen }
793
+ }, act === 'hold')
688
794
  return result
689
795
  }
796
+ // 解冻(竞态保护配套通道):清 frozen + 记历史 + 触发派发(mutateLocked 默认 kickCycle)
797
+ async function doUnfreezeTask(sid, actor, taskId) {
798
+ return mutateLocked(sid, function (d) {
799
+ var t = d.tasks.find(function (x) { return x.id === taskId })
800
+ if (!t) return { ok: false, error: 'not found' }
801
+ if (!t.frozen) return { ok: false, error: 'not frozen' }
802
+ delete t.frozen; delete t.frozenAt; delete t.frozenBy
803
+ ah(t, t.status, t.status, actor, '解除冻结,重新进入派发池')
804
+ return { ok: true, task: t, unfrozen: true }
805
+ })
806
+ }
807
+ handle('unfreeze-task', async function (args) { return doUnfreezeTask(rpcSessionId(args), getActorId(), args.taskId) })
690
808
  // 高优介入(v74):有活跃 run 则直接打进其会话;无则只记录 history(下次派发随 prompt 注入)
691
809
  // 通道选择(v1.2.4):steer 优先——下一个 step 边界即消费;followup 要等整个 turn 结束,
692
810
  // 长 turn 下干预形同失联(实测:Worker 单 turn 跑 10+ 分钟,「口径重写」类干预到位时活已按旧口径干完)。
@@ -737,13 +855,22 @@ export function apply(ctx) {
737
855
  handle('dismiss-suspect', async function (args) { return doDismiss(rpcSessionId(args), getActorId(), args.taskId) })
738
856
  ctx.tools.register(defineTool({ name: 'task_terminate', description: 'Team 模式:终止执行某任务的池中 Agent(卡死/跑偏时)。in-progress 任务回 pending 重派,verifying 由新 verifier 接手。', parameters: { type: 'object', properties: { taskId: { type: 'string' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; return doTerminate(toolSessionId(), getActorId(), args.taskId) } }))
739
857
  // 手动触发单任务派发(manual 模式下"派发给 Worker"按钮,或 auto 模式手动补派)
858
+ // touches 文件级排他:默认尊重文件锁——候选与活动任务 touches 冲突时返回
859
+ // { ok:false, error:'touches-conflict', conflicts:[...] }(客户端 confirm 后带 force:true 重发)。
860
+ // force:true 是人工越权通道:明知会与在跑 Worker 改同一批文件,由人决定是否强行并行。
740
861
  handle('dispatch-task', async function (args) {
741
862
  var sid = rpcSessionId(args)
742
863
  var role = args.role === 'verifier' ? 'verifier' : 'worker'
864
+ var force = args.force === true
743
865
  var claimed = await mutateLocked(sid, function (d) {
744
866
  var t = d.tasks.find(function (x) { return x.id === args.taskId })
745
867
  if (!t) return { ok: false, error: 'not found' }
746
- if (role === 'worker') { if (t.status !== 'pending') return { ok: false, error: 'not pending (状态: ' + t.status + ')' }; if (t.claimedBy) return { ok: false, error: 'already claimed' }; claimApply(d, t, 'spawn-pending', 'manual dispatch') }
868
+ if (role === 'worker') {
869
+ if (t.status !== 'pending') return { ok: false, error: 'not pending (状态: ' + t.status + ')' }
870
+ if (t.claimedBy) return { ok: false, error: 'already claimed' }
871
+ if (!force) { var cf = touchesConflict(t, holdsFiles(d)); if (cf.length) return { ok: false, error: 'touches-conflict', conflicts: cf } }
872
+ claimApply(d, t, 'spawn-pending', 'manual dispatch' + (force ? '(force 越权:忽略 touches 冲突)' : ''))
873
+ }
747
874
  else { if (t.status !== 'verifying') return { ok: false, error: 'not verifying (状态: ' + t.status + ')' }; if (t.escalation) return { ok: false, error: 'escalated, 待裁决' } }
748
875
  return { ok: true }
749
876
  })
@@ -761,10 +888,10 @@ export function apply(ctx) {
761
888
  if (role === 'worker') { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === args.taskId }); if (t2 && t2.status === 'in-progress' && t2.claimedBy === 'spawn-pending') { t2.status = 'pending'; t2.claimedBy = null; t2.claimedAt = null; ah(t2, 'in-progress', 'pending', 'system', 'spawn 失败') }; return t2 }, true) }
762
889
  return { ok: false, error: 'spawn failed' }
763
890
  })
764
- handle('resolve-escalation', async function (args) { return doResolveEscalation(rpcSessionId(args), getActorId(), args.taskId, args.answer) })
891
+ handle('resolve-escalation', async function (args) { return doResolveEscalation(rpcSessionId(args), getActorId(), args.taskId, args.answer, args.action) })
765
892
  handle('intervene-agent', async function (args) { return doIntervene(rpcSessionId(args), getActorId(), args.taskId, args.message) })
766
893
  // 主 Agent 工具版(Team 模式下主 Agent 通过工具裁决/介入)
767
- ctx.tools.register(defineTool({ name: 'task_arbitrate', description: 'Team 模式:裁决 Worker 上报的歧义(escalation)。答案直接转达给原 Worker 继续执行。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, answer: { type: 'string' } }, required: ['taskId', 'answer'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; return doResolveEscalation(toolSessionId(), getActorId(), args.taskId, args.answer) } }))
894
+ ctx.tools.register(defineTool({ name: 'task_arbitrate', description: 'Team 模式:裁决 Worker 上报的歧义(escalation)。action=resume(默认)裁决后任务回派发池立即重派;action=hold 任务挂起冻结、不参与自动派发(留给主窗口补 dependsOn/上下文,补完后用 task_update unfreeze:true 解冻)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, answer: { type: 'string' }, action: { type: 'string', enum: ['resume', 'hold'], description: 'resume(默认)=裁决后重新派发;hold=挂起冻结不派发' } }, required: ['taskId', 'answer'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; return doResolveEscalation(toolSessionId(), getActorId(), args.taskId, args.answer, args.action) } }))
768
895
  ctx.tools.register(defineTool({ name: 'task_intervene', description: 'Team 模式:向执行某任务的池中 Agent 发起高优先级指令(steer 通道,当前 step 结束即响应;无活跃 run 时记录随下次派发注入)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, message: { type: 'string' } }, required: ['taskId', 'message'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; return doIntervene(toolSessionId(), getActorId(), args.taskId, args.message) } }))
769
896
  // ===== 池中 Agent 结构化回报工具(双模:工具优先,文本分段为降级路径)=====
770
897
  ctx.tools.register(defineTool({ name: 'board_report', description: '[任务看板 Worker 专用] 上报任务结果。kind=complete 时填 summary/changes/selfTest/diffStat(git 仓库内改动附 git diff --stat 概要);kind=escalate 时填 question(歧义上报,等待主窗口裁决)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, kind: { type: 'string', enum: ['complete', 'escalate'] }, summary: { type: 'string' }, changes: { type: 'string' }, selfTest: { type: 'string' }, diffStat: { type: 'string', description: '变更概要:git diff --stat(含 git status --short)输出,≤1500 字符' }, question: { type: 'string' } }, required: ['taskId', 'kind'] }, output: jo(), execute: async function (args) {
@@ -831,7 +958,7 @@ export function apply(ctx) {
831
958
  return { ok: true, models: out }
832
959
  })
833
960
  handle('set-board-config', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { if (args.key === 'maxWorkers') d.maxWorkers = Math.max(1, Math.min(10, args.value || 3)); else if (args.key === 'maxVerifiers') d.maxVerifiers = Math.max(0, Math.min(5, args.value || 0)); else if (args.key === 'workerModel') d.workerModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'verifierModel') d.verifierModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'softTimeoutMin') d.softTimeoutMin = Math.max(1, Math.min(480, Number(args.value) || 30)); else if (args.key === 'hardTimeoutMin') d.hardTimeoutMin = Math.max(1, Math.min(1440, Number(args.value) || 120)); return { ok: true } }) })
834
- handle('create-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id' }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title || 'Untitled', description: args.description || '', status: args.draft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, messages: [], history: [{ from: 'created', to: args.draft ? 'draft' : 'pending', timestamp: now, actor: actor, note: args.draft ? 'created as draft' : 'created' }] }; if (!t.pipeline) { t.pipeline = classifyPipeline(t); t.pipelineAuto = true }; d.tasks.push(t); return { ok: true, task: t } }) })
961
+ handle('create-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id' }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title || 'Untitled', description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (!t.pipeline) { t.pipeline = classifyPipeline(t); t.pipelineAuto = true }; d.tasks.push(t); return { ok: true, task: t } }) })
835
962
  handle('list-children', async function (args) { var sid = rpcSessionId(args); var subs = ctx.subagents; if (!subs) return { ok: true, children: [] }; try { var list = await subs.listChildren(sid); var children = (list || []).map(function (c) { return { id: String(c.sessionId || c.id || ''), label: String(c.label || c.title || c.mode || '') } }).filter(function (c) { return c.id.length > 0 }); return { ok: true, children: children } } catch (e) { return { ok: true, children: [], error: String(e) } } })
836
963
  // ===== #14 批量操作:archive(仅 resolved/cancelled)/ set-priority(全部)=====
837
964
  handle('batch-op', async function (args) {