thincoder 0.12.66 → 0.12.68

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +23 -5
  3. package/bin/thincoder.mjs +43 -331
  4. package/package.json +2 -2
  5. package/src/acp/bridge.mjs +13 -12
  6. package/src/acp/handlers-session.mjs +12 -1
  7. package/src/cli/make-agent.mjs +44 -119
  8. package/src/cli/memory-command.mjs +45 -3
  9. package/src/cli/permission.mjs +1 -1
  10. package/src/command-interactive.mjs +186 -0
  11. package/src/command-table.mjs +185 -0
  12. package/src/completions.mjs +36 -12
  13. package/src/crash-reports.mjs +42 -20
  14. package/src/distill.mjs +3 -3
  15. package/src/heap-watch.mjs +5 -14
  16. package/src/prompt-injections.mjs +1 -1
  17. package/src/tui/agent-turn.mjs +114 -69
  18. package/src/tui/clipboard.mjs +74 -30
  19. package/src/tui/cmd-advisor.mjs +54 -13
  20. package/src/tui/cmd-clear.mjs +2 -2
  21. package/src/tui/cmd-config.mjs +26 -2
  22. package/src/tui/cmd-eng.mjs +1 -1
  23. package/src/tui/cmd-mcp.mjs +1 -1
  24. package/src/tui/cmd-new.mjs +2 -2
  25. package/src/tui/cmd-reindex.mjs +1 -1
  26. package/src/tui/cmd-session.mjs +13 -5
  27. package/src/tui/cmd-think.mjs +35 -17
  28. package/src/tui/cmd-timers.mjs +33 -0
  29. package/src/tui/conversation-writer.mjs +2 -2
  30. package/src/tui/display-budget.mjs +9 -9
  31. package/src/tui/fold-block.mjs +1 -1
  32. package/src/tui/index.mjs +23 -5
  33. package/src/tui/interaction.mjs +5 -3
  34. package/src/tui/key-handler-busy.mjs +17 -12
  35. package/src/tui/key-handler-ctrlc.mjs +5 -5
  36. package/src/tui/key-handler-edit.mjs +12 -8
  37. package/src/tui/key-handler-search.mjs +1 -1
  38. package/src/tui/key-handler.mjs +2 -2
  39. package/src/tui/key-modes.mjs +1 -1
  40. package/src/tui/layout.mjs +5 -5
  41. package/src/tui/ledger-surface.mjs +26 -5
  42. package/src/tui/lifecycle-records.mjs +199 -0
  43. package/src/tui/model-picker.mjs +16 -199
  44. package/src/tui/mouse.mjs +5 -5
  45. package/src/tui/pickers.mjs +6 -2
  46. package/src/tui/provider-admin.mjs +213 -0
  47. package/src/tui/queued-merge.mjs +12 -0
  48. package/src/tui/queued-pickup.mjs +34 -0
  49. package/src/tui/render-conversation.mjs +49 -4
  50. package/src/tui/render-frame.mjs +54 -11
  51. package/src/tui/render-loop.mjs +2 -3
  52. package/src/tui/render-segments.mjs +4 -4
  53. package/src/tui/render.mjs +1 -1
  54. package/src/tui/slash-commands.mjs +3 -0
  55. package/src/tui/startup.mjs +28 -3
  56. package/src/tui/subagent-blocks.mjs +37 -29
  57. package/src/tui/subagent-children.mjs +5 -5
  58. package/src/tui/subagent-freeze.mjs +43 -41
  59. package/src/tui/subagent-panel.mjs +11 -11
  60. package/src/tui/suspension-drive.mjs +173 -253
  61. package/src/tui/timer-watch.mjs +64 -0
  62. package/src/tui/tool-args.mjs +10 -71
  63. package/src/tui/tool-display.mjs +7 -7
  64. package/src/tui/tool-events.mjs +40 -35
  65. package/src/tui/tui-lifecycle.mjs +13 -4
  66. package/src/tui/tui-state.mjs +3 -2
  67. package/src/tui/turn-face.mjs +6 -3
  68. package/src/tui/wizard.mjs +7 -2
  69. package/src/tui/wrapped-spawn.mjs +3 -3
@@ -1,221 +1,182 @@
1
1
  /**
2
- * suspension-drive.mjs — §17 挂起会话驱动器(2026-09-05 module-split:agent-turn.mjs
3
- * 535 > 500 硬限——poolCounts + §17 挂起会话段 verbatim 迁入,语义零变;agent-turn.mjs
4
- * re-import(runAgentTurn ↔ suspensionSession 函数级静态环——模块求值期无顶层调用,
5
- * 环安全——session-slots ↔ session.mjs 同款先例)。
2
+ * suspension-drive.mjs — AGENT-LOOP-ASYNC-POOL.md §6.8 挂起会话驱动器(TUI 面——**取核驱动装配**)。
6
3
  *
7
- * §17(2026-09-02,AGENT-LOOP.md §9 D-S1..S9):回合尾后台池非空 → 不阻塞等待,
8
- * 进入挂起态——挂起空闲输入开放(Enter = 新回合填单槽 + 唤醒)、busy(processing 含
9
- * digest)提交亦入同槽(busy-extend 批 2026-09-22——`TUI-INPUT-BOX.md` §4.1)、settle 事件驱动
10
- * auto-turn 消化(手动档 organize-only / AUTO 档全语义)、池空 + 无待处理输入 → 补发
11
- * done 冻结自然退出。状态机行表见 AGENT-LOOP.md §9.2。
12
- * F-UC7(2026-09-19 批,AGENT-LOOP-UPSTREAM.md §6.27.12):第二开轮源 = 未 drain 的上行
13
- * ask(`upstreamWaiting` 谓词——ask 入队即唤醒本驱动);唤醒轮同走 auto-turn(旗标
14
- * `upstreamTurn` 供核域文本选择)。
15
- * F-UC8(2026-09-21 批,§6.27.12.13):可见提示面**按因两档**(ask 携「谁 + 啥」——
16
- * 核单点 `upstreamAskLabelVars` / digest)+ 起跑数行与轮尾收尾行同守 `pend0 > 0`。
4
+ * B1-P3(2026-09-29 · 批档 `2026-09-29-parity-b1-vsc-core` §2.4-2):状态机本体(步骤 1–4 ∕ 退出清场 ∕
5
+ * 等待三态 ∕ timer 第四态)出核 `@thincoder/core/agent/suspension.mjs` `startSuspension`——本档 =
6
+ * 装配面(carrier ∕ runTurn ∕ hooks ∕ injectResidual ∕ timerFace)+ 壳(ticker ∕ `_suspAborted` ∕
7
+ * `_suspWake` ∕ 渲染 ∕ 状态行文本)。端差面:carrier = `agent`(CLI 形——池 ∕ pending ∕ `_suspended`
8
+ * 跨 run 存活);输入面 = **宿主队列同数组**(核增补 C `ctx.inputQueue`——key-handler 直写 ∕ 步边界
9
+ * pickup ∕ 渲染与驱动消费同一数组;取项 = 合并批 + 消费回执行);唤醒面 = `state._suspWake`(绑定
10
+ * `handle.wake`);残输入 = 核 `done.residualInput`(中止径 → `state.queue` 回搬 + 提示行;idle 径 →
11
+ * 回填 `state.pendingInput`——零丢失)。digest 可见面落**本档 runTurn 包装**(不注册核钩——desktop
12
+ * 先例:核钩在非 Abort 失败径不出 `end`);F-UC7:唤醒轮同走 auto-turn。
17
13
  */
18
-
19
- // 函数级静态环(2026-09-05):drive 的 digestTurn/用户回合经 runAgentTurn 递归进入
20
- // agent-turn;agent-turn 的回合尾经 suspensionSession 进入本文件——互相 import。
14
+ // 函数级静态环(2026-09-05):drive 的回合经 runAgentTurn 递归进入 agent-turn;agent-turn 回合尾
15
+ // 经 suspensionSession 进入本文件——互相 import(求值期无顶层调用,环安全)。
21
16
  import { runAgentTurn } from "./agent-turn.mjs"
22
- import { freezeAllSubTasks, freezeReclaimDigestedBlocks } from "./subagent-blocks.mjs"
17
+ import { deliverExpiredTimers, timerWakeEnabled } from "./timer-watch.mjs"
18
+ import { freezeAllSubTasks, freezeReclaimDigestedBlocks, freezeSubTaskLines } from "./subagent-blocks.mjs"
23
19
  import { sweepToolBlocks } from "./tool-events.mjs"
20
+ import { planQueuedInput } from "./queued-merge.mjs"
24
21
  import { logEvent } from "@thincoder/core/log.mjs"
22
+ import { pendingTimerDeadline } from "@thincoder/core/agent/timers.mjs"
25
23
  import { C } from "./ansi.mjs"
26
- // ASYNC-RESULT-CONTAINER.md D1/D2:池 accessor(双池 absorb)+ pending 单容器停靠
27
- import { getAsyncPool, parkAsyncPending, releaseSettledEntry } from "@thincoder/core/agent-tools/async-settle.mjs"
28
- // F-UC7(AGENT-LOOP-UPSTREAM.md §6.27.12——2026-09-19 批):上行 ask 开轮谓词单点(核导出)
29
- // F-UC8(§6.27.12.13 ②——2026-09-21 批):ask 提示行携参单点(同档导出——显示面单源)
30
- import { upstreamAskLabelVars, upstreamWaiting } from "@thincoder/core/agent-tools/parent-channel.mjs"
31
- // X9(2026-09-20 端差·显示面消差批):消化收尾文案单源 = 核 i18n 容器(禁第三份字面)
32
- import { t } from "@thincoder/core/i18n.mjs"
33
- // 批 4 CLI-ASYNC-DISCARD(AGENT-LOOP-ASYNC-POOL.md §6.20):中止分支「只清已死」收尾单点
34
- import { discardAbortedPool, discardAbortedAdvisors } from "@thincoder/core/agent-tools/async-discard.mjs"
24
+ // OOM 释放面(AGENT-LOOP.md §6.15 消费点③);ask 携参 = 核单源
25
+ import { releaseSettledEntry } from "@thincoder/core/agent-tools/async-settle.mjs"
26
+ import { upstreamAskLabelVars } from "@thincoder/core/agent-tools/parent-channel.mjs"
27
+ // B1-P3:驱动本体 = 核单源(本档 re-export 保端内 import 面——agent-turn.mjs 零改)
28
+ import { poolLive, startSuspension } from "@thincoder/core/agent/suspension.mjs"
29
+ // #726 写点①:痕行 ∥ 起跑·收尾记录同点双动作(记录形/文案 = lifecycle-records 单一实现)
30
+ import { pushRecord } from "@thincoder/core/context.mjs"
31
+ import { digestEndRecord, digestStartRecord, digestTraceLines, recordCarrier } from "./lifecycle-records.mjs"
35
32
 
36
- // INPUT-LOCK-ASYNC(C'——2026-09-09,本档 INPUT-LOCK-ASYNC.md):R15 排队
37
- // 用户指令合并(§11.3 D-24c——攒批计划/合并文案/上限常量)整批废弃
38
- // ——pendingInput 单槽化(至多一条待交接——单消息逐发不攒批;busy 提交入槽 = busy-extend 批 2026-09-22 扩面)。
33
+ export { poolLive }
39
34
 
40
- /** 后台池计数(LOGGING susp/digest 事件字段——pendingN/poolN)。
41
- * poolN = _asyncSubagents + _asyncAdvisors(§11.2 D-24b advisor 池同面板计数——queued
42
- * 条目同样在 map 内——2026-09-03 code review #2:不再 +queue.length 双计)。
43
- * R17(§25 D-R17a/b):pendingN = pending 单容器 `_pendingAsyncResults` 条数(
44
- * ASYNC-RESULT-CONTAINER.md D2——四族统一停靠——digest 驱动判据同单容器)。 */
45
- export function poolCounts(agent) {
46
- const map = agent?._asyncSubagents
47
- const adv = agent?._asyncAdvisors
48
- const running = (map ? [...map.values()].filter((e) => e.status === "running").length : 0)
49
- + (adv ? [...adv.values()].filter((e) => e.status === "running").length : 0)
50
- + consultRunningChildren(agent)
35
+ /** 池快照(susp 日志 ∕ 状态行共用):两池合计 + consult children(R17);`queued` = `_asyncQueue`
36
+ * 长度(与核池 `status === "queued"` 同值——入/出队与 status 翻转同点);`done` = 留池 settled。 */
37
+ function poolSnapshot(agent) {
38
+ const entries = []
39
+ for (const key of ["_asyncSubagents", "_asyncAdvisors"]) {
40
+ const map = agent?.[key]
41
+ if (map instanceof Map) entries.push(...map.values())
42
+ }
43
+ let consult = 0
44
+ for (const s of agent?._consultSessions?.values() ?? []) if (!s.stopped) consult += Math.max(0, s.pending ?? 0)
51
45
  return {
52
- poolN: (map?.size ?? 0) + (adv?.size ?? 0),
53
- pendingN: pendingFamilyCount(agent),
54
- runningN: running,
46
+ poolN: entries.length,
47
+ running: entries.filter((e) => e.status === "running").length + consult,
48
+ queued: agent?._asyncQueue?.length ?? 0,
49
+ pending: pendingFamilyCount(agent),
50
+ done: entries.filter((e) => e.done).length,
55
51
  }
56
52
  }
57
53
 
58
- /** 运行中 consult children(会诊会话已跨回合——挂起判据/状态行计数同源)。 */
59
- function consultRunningChildren(agent) {
60
- let n = 0
61
- for (const s of agent?._consultSessions?.values() ?? []) {
62
- if (!s.stopped) n += Math.max(0, s.pending ?? 0)
63
- }
64
- return n
54
+ /** 后台池计数(LOGGING susp/digest 事件字段——pendingN/poolN;D2:pendingN = 单容器条数)。 */
55
+ export function poolCounts(agent) {
56
+ const s = poolSnapshot(agent)
57
+ return { poolN: s.poolN, pendingN: s.pending, runningN: s.running }
65
58
  }
66
59
 
67
- /** pending 单容器条数(§25 D-R17a 消费驱动判据——T-R17j——ASYNC-RESULT-CONTAINER.md
68
- * D2:四族统一 `_pendingAsyncResults` +role,不再分族三容器)。 */
60
+ /** pending 单容器条数(CONSULTATION.md §6.2 D-R17a 消费驱动判据——T-R17j;D2 四族统一容器)。 */
69
61
  export function pendingFamilyCount(agent) {
70
62
  return agent?._pendingAsyncResults?.length ?? 0
71
63
  }
72
64
 
73
- /** pending 单容器非空(digest 触发判据——D-S2/D-S9——§25 D-R17a)。 */
65
+ /** pending 单容器非空(导出面保留名——核驱动判据已内化,消费面名不变)。 */
74
66
  export function pendingFamiliesNonEmpty(agent) {
75
67
  return pendingFamilyCount(agent) > 0
76
68
  }
77
69
 
78
- /** pending 单容器条目(freezeReclaimDigestedBlocks 的归属比对表——四族同容器)。 */
70
+ /** pending 单容器条目(保留读面——导出面沿 parity 批保留名;回收点归属判据现 = `consumed` 实参——#748)。 */
79
71
  export function allPendingEntries(agent) {
80
72
  return [...(agent?._pendingAsyncResults ?? [])]
81
73
  }
82
74
 
83
- // ─── 挂起会话(AGENT-LOOP-ASYNC-POOL.md §6.8 D-S2/D-S9 状态机行表)────────────────
84
-
85
- /** 后台池存活判据(D-S2/F5 口径):running/queued 子代理或后台评审,或已 settle 未注入结果
86
- * (pending 单容器非空 = D-S3 "未注入"),或 running consult 会话(会诊跨回合——
87
- * §25 D-R17a 挂起活度钩子——consult 启动回合尾即入挂起态)。回合尾与每次轮末都用它
88
- * 评估退出。§11.2 D-24b:_asyncAdvisors(后台评审池)与子代理池同判——评审飞行中挂起
89
- * 会话必须存活。 */
90
- export function poolLive(agent) {
91
- const map = agent._asyncSubagents
92
- const adv = agent._asyncAdvisors
93
- return (map && map.size > 0) || (adv && adv.size > 0)
94
- || pendingFamilyCount(agent) > 0
95
- || consultRunningChildren(agent) > 0
96
- }
97
-
98
- /** D-S3 ③ 记账清扫:回合边界竞态落下的已 settle 项(settle 回调未及移交——发生在
99
- * 回合刚结束、_suspended 尚未置位的窗口)补入 pending。幂等:回调已移交的条目已从
100
- * map 删除并带 _inPending 标记,不会重复入列。
101
- * ASYNC-RESULT-CONTAINER.md D2:pending 单容器(parkAsyncPending——四族统一停靠
102
- * +role;escalate 不再走独立流)。 */
103
- function sweepSettledToPending(agent) {
104
- const maps = [getAsyncPool(agent, "subagent"), getAsyncPool(agent, "advisor")].filter((m) => m instanceof Map && m.size > 0)
105
- if (maps.length === 0) return
106
- for (const map of maps) {
107
- for (const e of [...map.values()]) {
108
- if (e.done && !e._inPending) {
109
- parkAsyncPending(agent, e)
110
- map.delete(String(e.id))
111
- }
112
- }
113
- }
114
- }
115
-
116
- /** 等待下一次 settle(running 子代理 promise 完成)或用户唤醒(Enter 入队 / Ctrl+C /
117
- * 会话 abort)。唤醒器经 state._suspWake 单槽注入;abort 监听兜底。 */
118
- function waitForSettleOrWake(agent, state) {
119
- return new Promise((resolve) => {
120
- let finished = false
121
- const cleanup = () => {
122
- state._suspWake = null
123
- const i = (agent._asyncWaiters ?? []).indexOf(w)
124
- if (i >= 0) agent._asyncWaiters.splice(i, 1)
125
- agent._sessionAbort?.signal.removeEventListener("abort", onAbort)
126
- }
127
- const finish = (why) => {
128
- if (finished) return
129
- finished = true
130
- cleanup()
131
- resolve(why)
132
- }
133
- const w = () => finish("settle")
134
- const wake = () => finish("wake")
135
- const onAbort = () => finish("aborted")
136
- ;(agent._asyncWaiters ??= []).push(w)
137
- state._suspWake = wake
138
- if (agent._sessionAbort?.signal.aborted) { onAbort(); return }
139
- agent._sessionAbort?.signal.addEventListener("abort", onAbort, { once: true })
140
- })
141
- }
142
-
143
- /** 后台模式状态行文本(D-S8;17.5.4 #6 顺手对齐):"后台 N 子代理运行中 · M 完成待消化"
144
- * ——"运行中" = running + queued(含后台评审——§11.2 D-24b 同面板计数)+ running consult
145
- * children(R17——会诊跨回合);"完成待消化" = pending 任一族移交项 + §17.5 回合尾留池
146
- * 的 settled 未消费项(挂起会话 sweep 前的可见窗口)。 */
75
+ /** 后台状态行文本(D-S8):"后台 N 子代理运行中 · M 完成待消化";文案保形。 */
147
76
  function backgroundStatusText(agent) {
148
- const map = agent._asyncSubagents
149
- const adv = agent._asyncAdvisors
150
- const running = (map ? [...map.values()].filter((e) => e.status === "running").length : 0)
151
- + (adv ? [...adv.values()].filter((e) => e.status === "running").length : 0)
152
- + consultRunningChildren(agent)
153
- const queued = agent._asyncQueue?.length ?? 0
154
- const pending = pendingFamilyCount(agent)
155
- const doneInPool = (map ? [...map.values()].filter((e) => e.done).length : 0)
156
- + (adv ? [...adv.values()].filter((e) => e.done).length : 0) // §17.5 留池未消费
157
- const awaiting = pending + doneInPool
158
- const active = running + queued
77
+ const s = poolSnapshot(agent)
78
+ const awaiting = s.pending + s.done
79
+ const active = s.running + s.queued
159
80
  return active > 0 || awaiting > 0
160
81
  ? `后台 ${active} 子代理运行中${awaiting ? ` · ${awaiting} 完成待消化` : ""}`
161
82
  : "后台子代理收尾…"
162
83
  }
163
84
 
164
- /** 消化轮:系统驱动的 auto-turn(D-S6)。手动档不传权限/问答 handler(D-S7 装配
165
- * 契约——denied 不弹面板、不悬挂);AUTO 档沿用普通回调(autoApprove 短路自动
166
- * 执行)。_suspended 保持 true:消化中 settle 延迟冻结 + 移交 pending。R17:pending
167
- * 计数/消化触发 = 任一 pending 族(T-R17j——consult/escalate 空闲 settle 也触发消化轮)。
168
- * F-UC7(§6.27.12.5 D):`upstream` = 未 drain 的 ask 在场(唤醒轮)——旗标随 auto 轮
169
- * 贯通到核 `runAgent`(域文本选择面)、`digest:*` 载荷条件携带 `upstream: true`(宿主
170
- * 日志区分唤醒轮与 digest 轮——两端同规)。
171
- * F-UC8(§6.27.12.13 ①–③):可见提示面**按因两档 × 全档**——标签行 ask 携「谁 + 啥」
172
- * (核容器键,CLI 零自持字面)/ digest(manual 与 AUTO 同判——泛句退场);起跑数行
173
- * 与轮尾收尾行同守 `pend0 > 0`(ask-only 轮两行皆不出)。 */
174
- async function digestTurn(ctx, upstream = false) {
175
- const { agent, pushLine } = ctx
85
+ /** 消化轮:系统驱动的 auto-turn(D-S6)。手动档不传权限/问答 handler(D-S7——denied 不弹面板
86
+ * 不悬挂);F-UC8:标签按因两档、两行同守 `pend0 > 0`、终态 ≠ ok ⇒ aborted。
87
+ * #726 写点①:起跑 ∥ 收尾各「可见行 + 记录」同点双动作——文案 = lifecycle-records 单一实现
88
+ * (live ∥ 重建同调);`ms` 与行文案 `seconds` 同值单算式(同一 `ms` 派生)。
89
+ * **本批 2026-10-01(自然形跟正 · 台账 #768 —— 用户 08:21「CLI/VSC也跟。」)**:① 行出即留——
90
+ * 起跑行 ∥ 计数行 ∥ cap 行 ∥ 终态行:不改 ∥ 不删 ∥ 不退场(零清理机器;退场机随拆);② 起跑窗
91
+ * (沿 #754 裁 A)——起跑行族落盘后 ⇒ 起跑快照(起跑刻 pending 单容器)逐条冻结入流,落位 = 本族之后;
92
+ * ③ 终态行 = **到达序追加于当刻流末**(`pushLine`——零就地换文 ∥ 不动原起跑行)。
93
+ * 导出面 = 批内件直驱(生产唯一调用 = 本档 `driveTurn`)。 */
94
+ export async function digestTurn(ctx, upstream = false) {
95
+ const { agent, state, pushLine } = ctx
176
96
  const manual = !agent.autoApprove
177
- // 起跑数前置(§6.27.12.13 ③):起跑行与轮尾收尾行同源(皆 = 起跑口径)
178
- const pend0 = pendingFamilyCount(agent)
179
- // 标签两档(§6.27.12.13 ①):ask 因恒优先——携参单源 = 核 `upstreamAskLabelVars`
180
- // (队首 ask 的 `from` + 单行归一截断 `msg`);digest 因沿用既有字面;AUTO 档同判
181
- // (泛句无生产者——开轮因穷尽;模式可见性另有 `AUTO│` 横幅载体)。
97
+ const pend0 = pendingFamilyCount(agent) // 起跑数前置(起跑行与收尾行同源)
182
98
  const ask = upstream ? upstreamAskLabelVars(agent) : null
183
- pushLine(ask ? t("digest.turnLabelAsk", ask) : t("digest.turnLabel"), C.dim)
184
- // 起跑数行(对位 VSC `.digest-status` 元素):`n > 0` 规则——ask-only 轮(`n = 0`)零行
185
- if (pend0 > 0) pushLine(t("digest.start", { n: pend0 }), C.dim)
99
+ const carrier = recordCarrier(state, agent)
100
+ const startRec = digestStartRecord({ n: pend0, upstream, ask })
101
+ for (const l of digestTraceLines(startRec)) pushLine(l.text, l.color)
102
+ pushRecord(carrier, startRec) // 起跑记录(与起跑行同点——核写缝尽力面)
103
+ // 起跑窗(起跑行族落盘后——沿 #754 裁 A):起跑快照逐条冻结入流(主面;`reclaim` = 兜底幂等)
104
+ const tail = state.lines.length
105
+ freezeStartSnapshot(state, agent, tail)
186
106
  const digestCtx = manual
187
107
  ? { ...ctx, askPermission: null, askBatchPermission: null, askQuestion: null }
188
108
  : ctx
189
- // LOGGING:digest:* 事件(D-S9 消化轮边界——LOGGING.md F-L4 挂起态覆盖)
190
109
  const d0 = Date.now()
191
110
  logEvent("digest:start", { pendingN: pend0, ...(upstream ? { upstream: true } : {}) })
192
111
  const outcome = await runAgentTurn(digestCtx, "", { autoTurn: true, upstreamTurn: upstream, skipSession: true })
193
- logEvent("digest:end", { pendingN: pendingFamilyCount(agent), ms: Date.now() - d0, ...(upstream ? { upstream: true } : {}) })
194
- // X9(2026-09-20 端差·显示面消差批):轮尾**可见**收尾行(修前只有 `digest:end` 日志事件——
195
- // 用户不可见;对位 VSC `webview/chat.js` `showDigestStatus` end 段)。计数口径 = **起跑数**
196
- // `pend0`(与 VSC `suspension.mjs` 起跑 post 同源);文案单源 = 核 i18n `digest.done` /
197
- // `digest.aborted`;终态 ≠ ok(中止/失败)⇒ aborted 形态(VSC ok 旗标同口径)。
198
- // F-UC8 守卫(§6.27.12.13 ③):`pend0 = 0`(ask-only 轮)⇒ **零收尾行**——与起跑行
199
- // 同规则(两行成对);done / aborted 两形态同判(VSC 零动作守卫先于 ok 判)。
200
- const seconds = ((Date.now() - d0) / 1000).toFixed(1)
201
- if (pend0 > 0) pushLine(t(outcome === "ok" ? "digest.done" : "digest.aborted", { n: pend0, seconds }), C.dim)
112
+ const ms = Date.now() - d0
113
+ logEvent("digest:end", { pendingN: pendingFamilyCount(agent), ms, ...(upstream ? { upstream: true } : {}) })
114
+ const endRec = digestEndRecord({ ok: outcome === "ok", ms })
115
+ // ③ 终态行 = 到达序追加于当刻流末(自然形——零就地换文 ∥ 不动原起跑行)
116
+ for (const l of digestTraceLines(endRec, pend0)) pushLine(l.text, l.color)
117
+ pushRecord(carrier, endRec) // 收尾记录(与终态行同点)
118
+ }
119
+
120
+ /** 起跑快照逐条冻结入流(**本批 2026-10-01 —— 起跑窗主面**):起跑刻 pending 单容器(= 本轮将消费的驻留条目)
121
+ * 对应的盘面块 ⇒ 冻结载体行入流;**落位 = 本族之后**(显式锚 = 族尾 + 到达序位——`freezeSubTaskLines` 第三参,
122
+ * 逐条落于前枚之后;与桌面「起跑窗 ∥ 居消费行族之后」∥ VSC 同形);consult 会话本体无行——按 `childIds`
123
+ * 展开子块键(consult 同族收齐批 · #748——子块按会话消费判据起跑刻冻结;VSC ∥ 桌面 `reemitDone` 同判);
124
+ * 返回冻结数。 */
125
+ function freezeStartSnapshot(state, agent, tail) {
126
+ const entries = Array.isArray(agent?._pendingAsyncResults) ? agent._pendingAsyncResults : []
127
+ const keys = new Set()
128
+ for (const e of entries) {
129
+ if (e === null || e === undefined || e.id === undefined || e.id === null) continue
130
+ if (e.role === "consult") { // #748:会话本体无行——展开子块键(子块各居一行)
131
+ for (const cid of e.childIds ?? []) keys.add(`consult#${cid}`)
132
+ continue
133
+ }
134
+ keys.add(`${e.role ?? "subagent"}#${e.id}`)
135
+ }
136
+ let frozen = 0
137
+ for (const sub of Object.values(state.subTasks ?? {})) {
138
+ if (keys.has(sub.key) !== true || sub.awaitingDigest !== true) continue
139
+ freezeSubTaskLines(state, sub, tail + frozen) // 落位 = 起跑族尾(本批 —— 显式锚;逐条落于前枚之后)
140
+ delete state.subTasks[sub.key]
141
+ frozen += 1
142
+ }
143
+ return frozen
144
+ }
145
+
146
+ /** 回合执行器(核 `ctx.runTurn`):用户回合(取项缝产物 text)/ 消化轮 / 唤醒轮 / timer 轮。
147
+ * digest ∕ 唤醒两族走 digestTurn;用户/timer 轮直发;失败两径照旧上抛(核 catch 语义)。 */
148
+ function driveTurn(ctx, item, opts = {}) {
149
+ const text = typeof item === "string" ? item : String(item?.text ?? "")
150
+ if (opts.autoTurn === true && opts.timerTurn !== true) return digestTurn(ctx, opts.upstreamTurn === true)
151
+ return runAgentTurn(ctx, text, { ...opts, skipSession: true })
152
+ }
153
+
154
+ /** 取项缝(核 `ctx.takeInput`——缺省 shift):按合并计划取批 + 消费回执行(TUI.md §7.5);
155
+ * `/cmd` 首动作 = 不可达支(斜杠 busy 禁发——`key-handler-busy.mjs:24` ∕ `key-handler-edit.mjs:59`
156
+ * 双门禁)⇒ null 零动作(条目不消费,落核第 2 步)。 */
157
+ function takeTurnInput(ctx, queue) {
158
+ const action = planQueuedInput(queue)[0]
159
+ if (action.kind !== "turn") return null
160
+ queue.splice(0, action.count)
161
+ ctx.pushLine("[sending queued message]", C.tool)
162
+ return action.text
163
+ }
164
+
165
+ /** 退出残余注入(idle 清场——结果零丢失):核注入器按 role 分发 + 释放条目持有(OOM 面)。 */
166
+ async function injectResidual(agent, item) {
167
+ if (item?.role === "consult") {
168
+ const { injectConsultResult } = await import("@thincoder/core/agent-tools/consult.mjs")
169
+ await injectConsultResult(agent, item)
170
+ } else {
171
+ const { injectAsyncResult } = await import("@thincoder/core/agent-tools/subagent.mjs")
172
+ await injectAsyncResult(agent, item)
173
+ }
174
+ releaseSettledEntry(item)
202
175
  }
203
176
 
204
177
  /**
205
- * §17 挂起会话驱动(D-S9 行表;由 runAgentTurn 回合尾进入,池空自然退出):
206
- * - suspension:池项 settle → 入 pending → 开 auto-turn(合并消化近邻 settle);
207
- * 上行 ask 入队 → 唤醒 + 谓词 → 开唤醒轮(F-UC7——谓词先于池空退出判,见第 2 步);
208
- * 挂起空闲用户 Enter、会话内 busy Enter → pendingInput 单槽(busy-extend 2026-09-22 同判据)
209
- * ——用户输入优先于 digest;
210
- * - auto-turn:消化中 settle 不并发开新轮(单 runAgent 循环),轮末按 pending/池态
211
- * 续开合并消化轮或回挂起;pendingInput 非空 → 以该消息开新回合(不触发新 digest);
212
- * - §17.5.5:每次消化/会话内用户回合消费 pending 后 → freezeReclaimDigestedBlocks
213
- * 逐条冻结回收(消化完成块不滞留面板——不等池空;settle 锚点 splice——digest 总览
214
- * 文本之前——round1 #1 裁定);
215
- * - 退出:池空 + pending 空 + 无待处理输入 → freezeAllSubTasks 补发冻结(仅兜底
216
- * 未消化残项——17.5.5 块回收与池空解耦)→ idle。
217
- * _suspended 翻转:会话期 true(settle 回调据此延迟冻结 + 移交 pending);会话内
218
- * 用户回合执行期翻 false(普通回合语义:settle 即冻结 + 回合尾直注入 ①)。
178
+ * §6.8 挂起会话驱动(D-S9 行表;由 runAgentTurn 回合尾进入,池空自然退出):状态机本体在核
179
+ * `startSuspension`;本档 = 装配 + 壳。`agent._sessionSignal/_sessionAbort` 生命周期留端。
219
180
  */
220
181
  export async function suspensionSession(ctx) {
221
182
  const { agent, state, render, pushLine } = ctx
@@ -224,7 +185,7 @@ export async function suspensionSession(ctx) {
224
185
  agent._suspended = true
225
186
  agent._sessionSignal = agent._sessionAbort.signal // 会话内 spawn 的 children 共享(subagent.mjs)
226
187
  state.suspended = true
227
- state._suspPending = false // 偏差 #1:进入真正挂起态——标志只在释放窗口期有效(此后由 state.suspended 分流)
188
+ state._suspPending = false // 偏差 #1:进入真正挂起态——标志只在释放窗口期有效
228
189
  const suspTick = setInterval(() => {
229
190
  if (state.suspended && !state.processing) {
230
191
  state.status = backgroundStatusText(agent)
@@ -233,54 +194,40 @@ export async function suspensionSession(ctx) {
233
194
  }, 1000)
234
195
  state.status = backgroundStatusText(agent)
235
196
  render()
236
- // LOGGING:susp:* 事件(挂起态进入/退出——F-L4;挂起期输入事件 v1 不记——refinement #1)
237
197
  const s0 = Date.now()
238
198
  logEvent("susp:enter", poolCounts(agent))
199
+ const abortSignal = agent._sessionAbort.signal
200
+ const handle = startSuspension({
201
+ carrier: agent,
202
+ inputQueue: state.pendingInput,
203
+ abortSignal,
204
+ runTurn: (item, opts = {}) => driveTurn(ctx, item, opts),
205
+ takeInput: (queue) => takeTurnInput(ctx, queue),
206
+ injectResidual: (item) => injectResidual(agent, item),
207
+ timerFace: {
208
+ // §6.30.2 载体③:窗内 deadline 现算 + 到期兑现(交付真 ⇒ timer 轮;不等池空)
209
+ // §6.30.10 关的射程:开关关 ⇒ `deadline` 亦 `null`(零注册)——判据包形与桌面 `timerFaceOf` 同形同源
210
+ deadline: () => (timerWakeEnabled(agent) ? pendingTimerDeadline(agent) : null),
211
+ deliver: () => deliverExpiredTimers(ctx) > 0,
212
+ },
213
+ hooks: {
214
+ onCounts: () => { state.status = backgroundStatusText(agent); render() },
215
+ // 回收:consumed 实参(本 run 已消费条目——核 hooks.reclaim 直传;#748);冻结:退出兜底补发(T-S14)
216
+ reclaim: (consumed) => freezeReclaimDigestedBlocks(state, consumed),
217
+ freezeAll: () => { freezeAllSubTasks(state); sweepToolBlocks(state); state.status = "Ready"; render() },
218
+ },
219
+ timer: ctx.timer,
220
+ clear: ctx.clear,
221
+ })
222
+ // 唤醒单槽 → 核句柄(key-handler Enter 入队 ∕ Ctrl+C 中止同点调用——核等待期外零动作)。
223
+ state._suspWake = () => handle.wake()
224
+ let exitInput = null // 残输入(成功径 = 核兑现值;失败径 = null ⇒ 回落共享数组实况)
239
225
  try {
240
- while (!state._suspAborted && !agent._sessionAbort.signal.aborted) {
241
- sweepSettledToPending(agent)
242
- // 1. 用户输入优先(D-S5):pendingInput 单槽(INPUT-LOCK-ASYNC C'——busy(processing
243
- // 含 digest)提交亦入本槽——TUI-INPUT-BOX.md §4.1;至多一条待交接——driver
244
- // 消费清槽即开新回合)。R15 攒批/queue 双源已随排队机制废弃收敛(2026-09-09——
245
- // 单消息交接——无合并无 /cmd 分流——key-handler 只收非斜杠文本——单消息逐发)。
246
- if ((state.pendingInput?.length ?? 0) > 0) {
247
- const head = String(state.pendingInput.shift())
248
- pushLine("[sending queued message]", C.tool) // F16 消费回执(TUI.md §7.5——driver 消费点)
249
- agent._suspended = false // 用户回合 = 普通回合语义(① 直注入 + settle 即冻结)
250
- await runAgentTurn(ctx, head, { skipSession: true })
251
- agent._suspended = true
252
- // §17.5.5:该回合消化完 pending(run 首行注入)→ 逐条冻结回收驻留块
253
- // (不等池空——settle 锚点 splice——digest 总览文本之前;与 digest 回收同规则)
254
- // R17:回收比对 = pending 单容器(四族统一——ASYNC-RESULT-CONTAINER.md D2)
255
- freezeReclaimDigestedBlocks(state, allPendingEntries(agent))
256
- state.status = backgroundStatusText(agent)
257
- continue
258
- }
259
- // 2. pending 任一族非空**或存在未 drain 的 ask** → 合并消化轮 / 唤醒轮(注入由 runAgent
260
- // 首行 + 循环头统一完成——D-S3 单注入点;R17 判据推广——consult/escalate 族同样触发
261
- // ——T-R17j。F-UC7(§6.27.12.5 D):谓词**必须先于第 3 步池空退出判**——「池空 +
262
- // 队列留 ask」(子代理已 settle 且报告已消化)仍须开一轮把它 drain 出来)
263
- const upstream = upstreamWaiting(agent)
264
- if (pendingFamiliesNonEmpty(agent) || upstream) {
265
- await digestTurn(ctx, upstream)
266
- // §17.5.5 实测修订(2026-09-03):digest 消化完成(pending 条目已注入)→ 逐条补发
267
- // done 冻结回收——不等池空——块从面板移除进流(settle 锚点 splice 落位——digest
268
- // 总览文本之前——round1 #1 裁定);池空 freeze-out 仅兜底未消化残项(挂起会话
269
- // 结束统一清场)——块回收与池空解耦(T-H7/AC-H5)。
270
- // 归属不变式:会话内任何 run 开始前 pinned 块(awaitingDigest)的条目必在 pending
271
- // 任一族——run 消费后不在 pending 的 pinned 块即本 run 消化者(无需快照即精确归属)。
272
- freezeReclaimDigestedBlocks(state, allPendingEntries(agent))
273
- state.status = backgroundStatusText(agent)
274
- continue
275
- }
276
- // 3. 池空(无 running/queued/未注入)→ 自然退出回 idle(补发冻结在 finally)
277
- if (!poolLive(agent)) break
278
- // 4. 等下一 settle / 用户唤醒(Enter 入队、Ctrl+C)
279
- await waitForSettleOrWake(agent, state)
280
- }
226
+ const res = await handle.done
227
+ exitInput = Array.isArray(res?.residualInput) ? res.residualInput : []
281
228
  } finally {
282
229
  clearInterval(suspTick)
283
- const aborted = state._suspAborted || agent._sessionAbort.signal.aborted
230
+ const aborted = state._suspAborted || abortSignal.aborted
284
231
  if (aborted && (agent._asyncSubagents?.size ?? 0) > 0) logEvent("ev:stopped", { poolN: agent._asyncSubagents?.size ?? 0, where: "suspension-abort" })
285
232
  logEvent("susp:exit", { ...poolCounts(agent), ms: Date.now() - s0, reason: aborted ? "aborted" : "idle" })
286
233
  agent._suspended = false
@@ -291,44 +238,17 @@ export async function suspensionSession(ctx) {
291
238
  state._suspWake = null
292
239
  state.suspAbortArmed = false // round2 偏差 #4:会话退出即解除挂起中止武装(防跨会话粘滞)
293
240
  if (aborted) {
294
- // §15 abort 语义:只清已死条目(AGENT-LOOP-ASYNC-POOL.md §6.20——墓碑/出池/队列剔除/
295
- // 整批一次提醒;存活与已 settle 者留池消化——不注入陈旧错误)。
296
- discardAbortedPool(agent)
297
- discardAbortedAdvisors(agent)
298
- // ASYNC-RESULT-CONTAINER.md D2:pending 单容器——中止清容器不注入陈旧结果(四族
299
- // 统一一处清;consult 会话标记 stopped——settle 不入 digest 流——T-R17c)+
300
- // children abort。
301
- agent._pendingAsyncResults = []
302
- const { cleanupConsultSessions } = await import("@thincoder/core/agent-tools/consult.mjs")
303
- cleanupConsultSessions(agent)
304
- // §17 round2 偏差 #2-CLI(code review round2 #2-CLI)+ INPUT-LOCK 单槽化:中止时
305
- // 不静默丢弃挂起期输入——Enter 已清空输入框并入 pendingInput(用户视为已发送)——
306
- // 单槽语义下残余至多一条——转回 state.queue({text} 单条目,下个普通回合的队列
307
- // 循环续发——零丢失)+ 提示行明示去向(不静默丢)。
308
- if ((state.pendingInput?.length ?? 0) > 0) {
309
- state.queue.push({ text: String(state.pendingInput.shift()) })
241
+ // 中止不静默丢弃队列内输入(用户视为已发送):按合并计划全量转回 state.queue + 提示行明示去向。
242
+ const residual = exitInput ?? state.pendingInput
243
+ if (residual.length > 0) {
244
+ for (const a of planQueuedInput(residual)) state.queue.push({ text: a.text })
245
+ residual.length = 0
310
246
  pushLine(`[background work stopped — the message you entered will run as a normal turn]`, C.warn)
311
247
  }
312
- } else {
313
- // D-S3 ③ 兜底:退出前残余(极端竞态)直注入再退——结果零丢失(AC-S2;
314
- // ASYNC-RESULT-CONTAINER.md D2:pending 单容器一处清——注入器按 role 分发
315
- // (consult → injectConsultResult;其余 → injectAsyncResult——四族同容器)。
316
- const { injectAsyncResult } = await import("@thincoder/core/agent-tools/subagent.mjs")
317
- const { injectConsultResult } = await import("@thincoder/core/agent-tools/consult.mjs")
318
- const residual = agent._pendingAsyncResults
319
- if (residual?.length) {
320
- for (const e of residual.splice(0)) {
321
- if (e.role === "consult") await injectConsultResult(agent, e)
322
- else await injectAsyncResult(agent, e)
323
- // TUI-OOM-ROOTCAUSE(§23.3.1 消费点③——挂起残差注入):注入完成 → 释放条目持有
324
- releaseSettledEntry(e)
325
- }
326
- }
248
+ } else if (exitInput?.length > 0) {
249
+ // idle:残项回填宿主队列(既有消费点续接——核 splice 已取出 ⇒ 同值回填、零重复)
250
+ state.pendingInput.push(...exitInput)
327
251
  }
328
- // 补发 done 冻结:驻留面板的 awaiting-digest 块随池空冻结进流(T-S14)
329
- freezeAllSubTasks(state)
330
- sweepToolBlocks(state)
331
- state.status = "Ready"
332
252
  render()
333
253
  }
334
254
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * timer-watch.mjs — timer 空闲唤醒端面壳(CLI:判据族 + 显示裁 + 核转口)。
3
+ *
4
+ * 机制单源 = `docs/core/design/AGENT-LOOP-ASYNC-POOL.md` §6.30(闩 ∕ 派发 ∕ 火策略住核
5
+ * `thincoder-core/agent/timers.mjs`——B3 批改指 = §6.30.16;显示形态 = `docs/cli/design/TUI.md` §7.6)。
6
+ * 本档零自持闩 ∕ 零判据副本:`createTimerWatch` ∕ `deliverExpiredTimers` ∕ `fireTimerWake` = 核转口
7
+ * (签名与返回零变——存量批测冻结面)· `timerWakeEnabled` = 核 re-export;注入缝:`timer` / `clear` / `now`(可测性 · 零真实等待)。
8
+ */
9
+ import {
10
+ createTimerWatch as coreCreateTimerWatch,
11
+ deliverExpiredTimers as coreDeliverExpiredTimers,
12
+ fireTimerWake as coreFireTimerWake,
13
+ timerWakeEnabled,
14
+ } from "@thincoder/core/agent/timers.mjs"
15
+ import { C } from "./ansi.mjs"
16
+ import { REMINDER_CAP } from "./tool-display.mjs"
17
+
18
+ export { timerWakeEnabled } // 判据单源转口(§6.30.3 门三件③ D-TW5):默认开——只有显式 `false` 才关(键 `agent.timerWake`)
19
+
20
+ /** 「唤醒会武装」判据(`docs/cli/design/TUI.md` §7.1 awaiting 行除外 / §7.2 置位谓词):主 agent 在途 timer
21
+ * 非空 ∧ 开关开 ⇒ 自动续跑在途(不计 attention / 闩会在回合链尾武装)。 */
22
+ export function timerWakeArmed(agent) {
23
+ return timerWakeEnabled(agent) && (agent?._pendingTimers?.length ?? 0) > 0
24
+ }
25
+
26
+ /** 触发落流形态(TUI.md §7.6):逐字 = 系统提醒原文;三行 + 省略号上限同既有提醒镜像口径
27
+ * (`tool-events.mjs:434-441`,`REMINDER_CAP` 单源)。 */
28
+ export function reminderDisplay(text) {
29
+ const lines = String(text).split("\n")
30
+ return lines.length > REMINDER_CAP ? lines.slice(0, REMINDER_CAP).join("\n") + "\n…" : text
31
+ }
32
+
33
+ /** 到期批送达(两路共用:空闲闩 fire ∥ 挂起窗第三兑现态):核派发(出列幂等 → 注入历史)+ `onLine` 落流(显示裁 `reminderDisplay`);返回送达条数(0 = 无到期项——零注入零行)。 */
34
+ export function deliverExpiredTimers(ctx, now = Date.now()) {
35
+ const { agent, pushLine } = ctx
36
+ return coreDeliverExpiredTimers(agent, { now, onLine: (line) => pushLine(reminderDisplay(line), C.warn) }).length
37
+ }
38
+
39
+ /** 一次性 deadline 闩转口(KD-B3-1 · 签名零变):`agent` 缝 ⇒ 核 `{getAgent: () => agent}` **冻结捕获**(与改前
40
+ * 逐字同判——`turnCtx.agent` 实读零写点);闩体全责 = 核 `createTimerWatch`(武装 ∕ 到点自撤 ∕ 重臂撤旧 ∕ 开关关零注册)。 */
41
+ export function createTimerWatch({ agent, onFire, timer = setTimeout, clear = clearTimeout, now = Date.now } = {}) {
42
+ return coreCreateTimerWatch({ getAgent: () => agent, onFire, timer, clear, now })
43
+ }
44
+
45
+ /** 用户自发模态判据(#448① · KD-6——单源:火面抑制 ∥「关闭后补评估」两处同谓词):picker ∕ wizard
46
+ * 在场即真(工具权限 ∕ 提问面板属**在飞回合**面——由 `state.processing` 门承担,不入本判据)。 */
47
+ export function modalOpen(state) {
48
+ return state?.picker != null || state?.wizard != null
49
+ }
50
+
51
+ /**
52
+ * 空闲闩触发(§6.30.3 开轮两处① = 核策略 + 端两缝——B3 改指 §6.30.16):**非空闲零动作**(processing ∥
53
+ * suspended ∥ `_suspPending` ∥ `modalOpen`——在途表零触碰,链尾重同步再武装);**模态期抑制**(#448① KD-6):
54
+ * 模态在场 ⇒ 零送达零开轮(兑现交**关闭后补评估**——装配 = `index.mjs` `onModalClose`);空闲 ⇒ 到期批送达 +
55
+ * 开 timer 轮(顶层链:`skipSession` 缺省)。@returns {Promise<boolean>} 是否开轮
56
+ */
57
+ export async function fireTimerWake(ctx, { runTurn, now = Date.now } = {}) {
58
+ const { state } = ctx
59
+ return coreFireTimerWake({
60
+ busy: state.processing || state.suspended || state._suspPending || modalOpen(state),
61
+ deliver: () => deliverExpiredTimers(ctx, now()) > 0,
62
+ openTurn: () => (runTurn ?? ctx.runTurn)("", { autoTurn: true, timerTurn: true }),
63
+ })
64
+ }