dsh-dock 0.11.2 → 0.11.3

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 (38) hide show
  1. package/LICENSE +18 -18
  2. package/README.md +242 -396
  3. package/client.js +173 -9
  4. package/cordis.patch.yml +2 -2
  5. package/features/animation/host.js +119 -119
  6. package/features/animation/view.jsx +1662 -1662
  7. package/features/balance/host.js +494 -494
  8. package/features/balance/view.js +351 -351
  9. package/features/games/games/breakout.jsx +227 -227
  10. package/features/games/games/gomoku.jsx +244 -244
  11. package/features/games/games/racer.jsx +169 -169
  12. package/features/games/games/shared.jsx +118 -118
  13. package/features/games/games/snake.jsx +144 -144
  14. package/features/games/games/sokoban.jsx +180 -180
  15. package/features/games/games/tank.jsx +128 -128
  16. package/features/games/games/tetris.jsx +308 -308
  17. package/features/games/games/xiangqi.jsx +277 -277
  18. package/features/games/tank-core.js +152 -152
  19. package/features/games/view.jsx +639 -639
  20. package/features/games/xiangqi-core.js +299 -299
  21. package/features/heartbeat/view.js +35 -35
  22. package/features/mobile-relay/gateway.js +472 -472
  23. package/features/mobile-relay/host.js +570 -570
  24. package/features/mobile-relay/view.jsx +300 -300
  25. package/features/modelconfig/host.js +406 -406
  26. package/features/modelconfig/view.js +490 -490
  27. package/features/notify/host.js +374 -374
  28. package/features/notify/view.jsx +751 -751
  29. package/features/theme/view.js +61 -61
  30. package/features/tokenlog/host.js +882 -883
  31. package/features/tokenlog/view.jsx +1117 -932
  32. package/features/visionproxy/host.js +315 -315
  33. package/index.js +195 -195
  34. package/package.json +66 -64
  35. package/src/client.jsx +677 -677
  36. package/src/host-core.js +207 -207
  37. package/src/shared.js +192 -192
  38. package/src/task-track.js +354 -359
package/src/task-track.js CHANGED
@@ -1,359 +1,354 @@
1
- // dsh-dock · 宿主共享内核:会话级任务追踪(【任务动画】【任务通知】【运行状态】共用一份数据)
2
- //
3
- // 为什么单独成模块:动画、通知与运行状态都需要「任务什么时候开始/结束、现在在干什么」,
4
- // 但三者是各自独立开关的功能模块(可只开其一,也可各自提取成独立包发布)。
5
- // 追踪逻辑放这里、用引用计数共享一份实例:三个功能都开也只有一份追踪(会话事件只订阅一次),
6
- // 全都关则立刻退订,不留下常驻开销。
7
- //
8
- // 用法(features/<id>/host.js):
9
- // const lease = acquireTaskTracker(ctx)
10
- // disposers.push(() => lease.release())
11
- // lease.tracker.snapshot(Date.now()) // → { now, active: [...], recent: [...] }
12
- // const off = lease.tracker.onFinish((rec) => { ... }) // 任务结束回调(群机器人推送用)
13
- //
14
- // 数据形状(active/recent)由客户端各模块消费,字段改动要同时看:
15
- // features/runstate/view.jsx、features/animation/view.jsx、features/notify/view.jsx
16
-
17
- /** 最近完成记录保留条数(新→旧)。 */
18
- const MAX_COMPLETED = 30
19
-
20
- /** 当前共享实例(引用计数 > 0 时存在)。 */
21
- let shared = null
22
-
23
- /**
24
- * 取得共享追踪器租约。返回 { tracker, release }:
25
- * 每个使用方在 setup 时 acquire、在 dispose 时 release(必须成对,否则追踪不会退订)。
26
- */
27
- export function acquireTaskTracker(ctx) {
28
- if (!shared) shared = { refs: 0, tracker: createTracker(ctx) }
29
- const slot = shared
30
- slot.refs += 1
31
- let released = false
32
- return {
33
- tracker: slot.tracker,
34
- release() {
35
- if (released) return
36
- released = true
37
- slot.refs -= 1
38
- if (slot.refs > 0) return
39
- if (shared === slot) shared = null
40
- try { slot.tracker.dispose() } catch { /* 退订失败不影响功能停用 */ }
41
- },
42
- }
43
- }
44
-
45
- function createTracker(ctx) {
46
- // ===== 会话级任务追踪(agent/status 驱动开始/结束,session/event 补统计) =====
47
- const activeSessions = new Map() // sessionId -> 追踪记录
48
- const completedSessions = [] // 最近完成(新→旧)
49
- const finishListeners = new Set()
50
- const disposers = []
51
-
52
- function num(v) { return typeof v === 'number' && Number.isFinite(v) ? v : 0 }
53
-
54
- function extractText(content) {
55
- if (!Array.isArray(content)) return ''
56
- const parts = []
57
- for (const block of content) {
58
- if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
59
- }
60
- return parts.join(' ').replace(/\s+/g, ' ').trim()
61
- }
62
-
63
- function truncate(str, max = 160) {
64
- if (!str) return ''
65
- return String(str).length > max ? String(str).slice(0, max) + '…' : str
66
- }
67
-
68
- // 工具名 → 阶段:检索类工具 = search,其余 = code
69
- function phaseOfToolName(name) {
70
- const n = String(name || '').toLowerCase()
71
- if (/web|search|fetch|grep|glob|find|read|ls$|^ls|tree|locate/.test(n)) return 'search'
72
- return 'code'
73
- }
74
-
75
- // 会话标题:官方标题服务,取不到回退首条用户输入(firstPrompt)
76
- async function getSessionTitle(sessionId, sessionQuery) {
77
- try {
78
- if (!sessionQuery) return ''
79
- if (typeof sessionQuery.readTitle === 'function') {
80
- const snap = await sessionQuery.readTitle(sessionId)
81
- if (snap && snap.title) return snap.title
82
- }
83
- if (typeof sessionQuery.readSession === 'function') {
84
- const snap = await sessionQuery.readSession(sessionId)
85
- if (snap && snap.header && snap.header.title) return snap.header.title
86
- }
87
- } catch { /* 标题取不到就用 firstPrompt 兜底 */ }
88
- return ''
89
- }
90
-
91
- function addActiveSession(sid, sessionQuery, extra = {}) {
92
- if (activeSessions.has(sid)) return activeSessions.get(sid)
93
- console.log('[dsh-dock] task-track: task started', sid.substring(0, 8))
94
- const record = {
95
- startTime: Date.now(),
96
- title: '',
97
- turns: 0,
98
- steps: 0,
99
- toolCalls: 0,
100
- models: new Set(),
101
- provider: '',
102
- inputTokens: 0,
103
- outputTokens: 0,
104
- totalTokens: 0,
105
- reasoningTokens: 0,
106
- cacheReadTokens: 0,
107
- endReason: '',
108
- errorMessage: '',
109
- lastText: '',
110
- firstPrompt: '',
111
- // 工具等待用户确认(dsh 会话流 approval/asked):浏览器侧据此弹"需确认"通知
112
- approvals: new Map(), // approvalId -> { toolName, reason, askedAt }
113
- phase: 'think',
114
- phaseAt: Date.now(),
115
- lastActivityAt: Date.now(),
116
- // assistant/chunk 是实时流;用轻量活动计数给浏览器侧映射动作速度,
117
- // 不等最终 usage 落盘,避免任务正在输出时机器人仍慢速。
118
- motionTicks: 0,
119
- lastMotionAt: Date.now(),
120
- ...extra,
121
- }
122
- activeSessions.set(sid, record)
123
- getSessionTitle(sid, sessionQuery).then((title) => {
124
- const s = activeSessions.get(sid)
125
- if (s) s.title = title
126
- })
127
- return record
128
- }
129
-
130
- // 任务结束:归档 + 通知订阅者(浏览器侧通知/群机器人推送都由各自消费方决定怎么用)
131
- function finishSession(sid) {
132
- const session = activeSessions.get(sid)
133
- if (!session) return
134
- activeSessions.delete(sid)
135
- const endTime = Date.now()
136
- const duration = endTime - session.startTime
137
- console.log('[dsh-dock] task-track: task finished', {
138
- sid: sid.substring(0, 8),
139
- duration,
140
- endReason: session.endReason || 'completed',
141
- })
142
- const record = {
143
- sessionId: sid,
144
- title: session.title || truncate(session.firstPrompt, 60) || '(无标题)',
145
- duration,
146
- turns: session.turns,
147
- steps: session.steps,
148
- toolCalls: session.toolCalls,
149
- models: [...session.models],
150
- provider: session.provider,
151
- inputTokens: session.inputTokens,
152
- outputTokens: session.outputTokens,
153
- totalTokens: session.totalTokens,
154
- reasoningTokens: session.reasoningTokens,
155
- cacheReadTokens: session.cacheReadTokens,
156
- endReason: session.endReason,
157
- errorMessage: session.errorMessage,
158
- lastText: session.lastText,
159
- startTime: session.startTime,
160
- endTime,
161
- }
162
- completedSessions.unshift(record)
163
- if (completedSessions.length > MAX_COMPLETED) completedSessions.pop()
164
- for (const fn of finishListeners) {
165
- try { fn(record) } catch (e) {
166
- console.error('[dsh-dock] task-track finish listener error:', e && e.message)
167
- }
168
- }
169
- }
170
-
171
- function handleSessionEvent(sessionQuery, session, event) {
172
- const sid = typeof session === 'object' && session ? (session.id || '') : String(session || '')
173
- if (!sid || !event || !event.type) return
174
-
175
- if (event.type === 'step/start') {
176
- const s = addActiveSession(sid, sessionQuery, { startTime: event.time || Date.now() })
177
- s.steps++
178
- s.phase = 'think' // 新步骤:模型正在推理下一步
179
- s.phaseAt = event.time || Date.now()
180
- s.lastActivityAt = s.phaseAt
181
- s.motionTicks += 2
182
- s.lastMotionAt = s.phaseAt
183
- if (event.data && event.data.turn !== undefined) {
184
- s.turns = Math.max(s.turns, num(event.data.turn) + 1)
185
- }
186
- return
187
- }
188
- const s = activeSessions.get(sid)
189
- if (!s) return
190
- const now = event.time || Date.now()
191
- // 流级阶段:chunk 高频到达(每 token 一条),只做最廉价的阶段切换
192
- if (event.type === 'assistant/chunk') {
193
- const chunk = event.data && event.data.chunk
194
- const ctype = chunk && chunk.type
195
- if (ctype === 'reasoning-delta') {
196
- s.phase = 'think'
197
- s.phaseAt = now
198
- } else if (ctype === 'text-delta') {
199
- s.phase = 'write'
200
- s.phaseAt = now
201
- }
202
- // 不同适配器的 delta 字段名并不完全一致。按可见文本量估算,
203
- // 无文本时每个 chunk 也记一个脉冲,保证推理流同样有动作反馈。
204
- if (ctype === 'reasoning-delta' || ctype === 'text-delta') {
205
- const delta = chunk && (chunk.text || chunk.delta || chunk.content)
206
- const units = typeof delta === 'string' ? Math.max(1, Math.ceil(delta.length / 24)) : 1
207
- s.motionTicks += units
208
- s.lastMotionAt = now
209
- }
210
- s.lastActivityAt = now
211
- return
212
- }
213
- if (event.type === 'turn/end' && event.data && event.data.reason) {
214
- const reason = event.data.reason
215
- s.endReason = (reason.kind || 'completed')
216
- if (reason.kind === 'error' && reason.error) {
217
- const msg = (reason.error && (reason.error.message || reason.error.code)) || ''
218
- s.errorMessage = typeof msg === 'string' ? msg : String(msg)
219
- }
220
- } else if (event.type === 'approval/asked' && event.data) {
221
- // 工具等待用户确认/批准:挂在会话记录上,/status 下发给浏览器弹"需确认"通知
222
- const id = typeof event.data.id === 'string' ? event.data.id : ''
223
- if (id) {
224
- s.approvals.set(id, {
225
- toolName: typeof event.data.toolName === 'string' ? event.data.toolName : '',
226
- reason: typeof event.data.reason === 'string' ? truncate(event.data.reason, 200) : '',
227
- askedAt: event.time || Date.now(),
228
- })
229
- }
230
- s.lastActivityAt = now
231
- } else if (event.type === 'approval/decided' && event.data) {
232
- s.approvals.delete(typeof event.data.id === 'string' ? event.data.id : '')
233
- } else if (event.type === 'tool/call') {
234
- s.toolCalls++
235
- s.phase = phaseOfToolName(event.data && event.data.name)
236
- s.phaseAt = now
237
- // 工具启动是稀疏事件,给一个更明显的短促动作,检索/写文件也不会显得停住。
238
- s.motionTicks += s.phase === 'search' ? 7 : 9
239
- s.lastMotionAt = now
240
- } else if (event.type === 'user/message') {
241
- if (!s.firstPrompt && event.data && event.data.content) {
242
- const text = extractText(event.data.content)
243
- if (text) s.firstPrompt = text
244
- }
245
- } else if (event.type === 'assistant/message' && event.data) {
246
- const usage = event.data.usage
247
- if (usage) {
248
- s.inputTokens += num(usage.inputTokens)
249
- s.outputTokens += num(usage.outputTokens)
250
- s.totalTokens += num(usage.inputTokens) + num(usage.outputTokens)
251
- s.reasoningTokens += num(usage.reasoningTokens)
252
- s.cacheReadTokens += num(usage.cacheReadTokens)
253
- }
254
- if (event.data.message && event.data.message.content) {
255
- const text = extractText(event.data.message.content)
256
- if (text) s.lastText = text
257
- }
258
- // 完整消息落盘后不改阶段:下一步要么 tool/call(code/search)要么收尾,
259
- // 维持 chunk 推导出的最后状态,避免"输完还显示思考中"的回跳
260
- } else if (event.type === 'request/context' && event.data) {
261
- if (event.data.provider) s.provider = event.data.provider
262
- if (event.data.model) s.models.add(event.data.model)
263
- } else if (event.type === 'request/header' && event.data && event.data.header && event.data.header.config) {
264
- const c = event.data.header.config
265
- if (c.provider) s.provider = c.provider
266
- if (c.model) s.models.add(c.model)
267
- }
268
- if (event.type !== 'request/header' && event.type !== 'request/context') s.lastActivityAt = now
269
- }
270
-
271
- function handleAgentStatus(sessionQuery, agent, status) {
272
- const sid = agent && agent.id ? String(agent.id) : ''
273
- if (!sid) return
274
- if (status === 'running') {
275
- const record = addActiveSession(sid, sessionQuery)
276
- if (agent.options) {
277
- if (agent.options.model) record.models.add(agent.options.model)
278
- if (agent.options.provider) record.provider = agent.options.provider
279
- }
280
- } else if (status === 'idle') {
281
- finishSession(sid)
282
- }
283
- }
284
-
285
- // 不能在 setup 时软获取 sessionQuery(可能早于服务激活,见 tokenlog 模块同款注释),用 inject 等就绪
286
- disposers.push(ctx.inject(['sessionQuery'], (sqCtx) => {
287
- const sessionQuery = sqCtx.sessionQuery
288
- disposers.push(sqCtx.on('session/event', (session, event) => {
289
- try {
290
- handleSessionEvent(sessionQuery, session, event)
291
- } catch (err) {
292
- console.error('[dsh-dock] task-track handle event error', err)
293
- }
294
- }))
295
- disposers.push(sqCtx.on('agent/status', (payload) => {
296
- try {
297
- handleAgentStatus(sessionQuery, payload && payload.agent, payload && payload.status)
298
- } catch (err) {
299
- console.error('[dsh-dock] task-track handle agent/status error', err)
300
- }
301
- }))
302
- // agent 销毁兜底归档(会话直接关闭时也产出完成记录)
303
- disposers.push(sqCtx.on('agent/disposed', (payload) => {
304
- try {
305
- const sid = payload && payload.agent && payload.agent.id ? String(payload.agent.id) : ''
306
- if (sid) finishSession(sid)
307
- } catch (err) {
308
- console.error('[dsh-dock] task-track handle agent/disposed error', err)
309
- }
310
- }))
311
- }))
312
-
313
- return {
314
- /** 当前快照(活跃任务 + 最近完成),供各功能模块的 /status 路由下发。 */
315
- snapshot(now) {
316
- const at = typeof now === 'number' ? now : Date.now()
317
- const active = []
318
- for (const [sid, s] of activeSessions) {
319
- active.push({
320
- sessionId: sid,
321
- title: s.title || truncate(s.firstPrompt, 60) || '(无标题)',
322
- startTime: s.startTime,
323
- elapsed: at - s.startTime,
324
- turns: s.turns,
325
- steps: s.steps,
326
- toolCalls: s.toolCalls,
327
- models: [...s.models],
328
- provider: s.provider,
329
- inputTokens: s.inputTokens,
330
- outputTokens: s.outputTokens,
331
- totalTokens: s.totalTokens,
332
- phase: s.phase,
333
- phaseAt: s.phaseAt,
334
- lastActivityAt: s.lastActivityAt,
335
- motionTicks: s.motionTicks,
336
- lastMotionAt: s.lastMotionAt,
337
- approvals: [...s.approvals.entries()].map(([id, v]) => ({ id, ...v })),
338
- })
339
- }
340
- return { now: at, active, recent: completedSessions.slice(0, 20) }
341
- },
342
- /** 任务结束订阅(返回退订函数);异常不会打断其他订阅者。 */
343
- onFinish(fn) {
344
- if (typeof fn !== 'function') return () => {}
345
- finishListeners.add(fn)
346
- return () => { finishListeners.delete(fn) }
347
- },
348
- /** 退订会话事件(引用计数归零时由 acquireTaskTracker 调用)。 */
349
- dispose() {
350
- finishListeners.clear()
351
- activeSessions.clear()
352
- completedSessions.length = 0
353
- while (disposers.length > 0) {
354
- const fn = disposers.pop()
355
- try { if (typeof fn === 'function') fn() } catch { /* 退订失败不阻断 */ }
356
- }
357
- },
358
- }
359
- }
1
+ // dsh-dock · 宿主共享内核:会话级任务追踪(【任务动画】【任务通知】【运行状态】共用一份数据)
2
+ //
3
+ // 为什么单独成模块:动画、通知与运行状态都需要「任务什么时候开始/结束、现在在干什么」,
4
+ // 但三者是各自独立开关的功能模块(可只开其一,也可各自提取成独立包发布)。
5
+ // 追踪逻辑放这里、用引用计数共享一份实例:三个功能都开也只有一份追踪(会话事件只订阅一次),
6
+ // 全都关则立刻退订,不留下常驻开销。
7
+ //
8
+ // 用法(features/<id>/host.js):
9
+ // const lease = acquireTaskTracker(ctx)
10
+ // disposers.push(() => lease.release())
11
+ // lease.tracker.snapshot(Date.now()) // → { now, active: [...], recent: [...] }
12
+ // const off = lease.tracker.onFinish((rec) => { ... }) // 任务结束回调(群机器人推送用)
13
+ //
14
+ // 数据形状(active/recent)由客户端各模块消费,字段改动要同时看:
15
+ // features/runstate/view.jsx、features/animation/view.jsx、features/notify/view.jsx
16
+
17
+ /** 最近完成记录保留条数(新→旧)。 */
18
+ const MAX_COMPLETED = 30
19
+
20
+ /** 当前共享实例(引用计数 > 0 时存在)。 */
21
+ let shared = null
22
+
23
+ /**
24
+ * 取得共享追踪器租约。返回 { tracker, release }:
25
+ * 每个使用方在 setup 时 acquire、在 dispose 时 release(必须成对,否则追踪不会退订)。
26
+ */
27
+ export function acquireTaskTracker(ctx) {
28
+ if (!shared) shared = { refs: 0, tracker: createTracker(ctx) }
29
+ const slot = shared
30
+ slot.refs += 1
31
+ let released = false
32
+ return {
33
+ tracker: slot.tracker,
34
+ release() {
35
+ if (released) return
36
+ released = true
37
+ slot.refs -= 1
38
+ if (slot.refs > 0) return
39
+ if (shared === slot) shared = null
40
+ try { slot.tracker.dispose() } catch { /* 退订失败不影响功能停用 */ }
41
+ },
42
+ }
43
+ }
44
+
45
+ function createTracker(ctx) {
46
+ // ===== 会话级任务追踪(agent/status 驱动开始/结束,session/event 补统计) =====
47
+ const activeSessions = new Map() // sessionId -> 追踪记录
48
+ const completedSessions = [] // 最近完成(新→旧)
49
+ const finishListeners = new Set()
50
+ const disposers = []
51
+
52
+ function num(v) { return typeof v === 'number' && Number.isFinite(v) ? v : 0 }
53
+
54
+ function extractText(content) {
55
+ if (!Array.isArray(content)) return ''
56
+ const parts = []
57
+ for (const block of content) {
58
+ if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
59
+ }
60
+ return parts.join(' ').replace(/\s+/g, ' ').trim()
61
+ }
62
+
63
+ function truncate(str, max = 160) {
64
+ if (!str) return ''
65
+ return String(str).length > max ? String(str).slice(0, max) + '…' : str
66
+ }
67
+
68
+ // 工具名 → 阶段:检索类工具 = search,其余 = code
69
+ function phaseOfToolName(name) {
70
+ const n = String(name || '').toLowerCase()
71
+ if (/web|search|fetch|grep|glob|find|read|ls$|^ls|tree|locate/.test(n)) return 'search'
72
+ return 'code'
73
+ }
74
+
75
+ // 会话标题:官方标题服务,取不到回退首条用户输入(firstPrompt)
76
+ async function getSessionTitle(sessionId, sessionQuery) {
77
+ try {
78
+ if (!sessionQuery) return ''
79
+ if (typeof sessionQuery.readTitle === 'function') {
80
+ const snap = await sessionQuery.readTitle(sessionId)
81
+ if (snap && snap.title) return snap.title
82
+ }
83
+ if (typeof sessionQuery.readSession === 'function') {
84
+ const snap = await sessionQuery.readSession(sessionId)
85
+ if (snap && snap.header && snap.header.title) return snap.header.title
86
+ }
87
+ } catch { /* 标题取不到就用 firstPrompt 兜底 */ }
88
+ return ''
89
+ }
90
+
91
+ function addActiveSession(sid, sessionQuery, extra = {}) {
92
+ if (activeSessions.has(sid)) return activeSessions.get(sid)
93
+ // 不在终端打「任务开始」日志:开始/结束状态界面(运行状态页)已实时可见
94
+ const record = {
95
+ startTime: Date.now(),
96
+ title: '',
97
+ turns: 0,
98
+ steps: 0,
99
+ toolCalls: 0,
100
+ models: new Set(),
101
+ provider: '',
102
+ inputTokens: 0,
103
+ outputTokens: 0,
104
+ totalTokens: 0,
105
+ reasoningTokens: 0,
106
+ cacheReadTokens: 0,
107
+ endReason: '',
108
+ errorMessage: '',
109
+ lastText: '',
110
+ firstPrompt: '',
111
+ // 工具等待用户确认(dsh 会话流 approval/asked):浏览器侧据此弹"需确认"通知
112
+ approvals: new Map(), // approvalId -> { toolName, reason, askedAt }
113
+ phase: 'think',
114
+ phaseAt: Date.now(),
115
+ lastActivityAt: Date.now(),
116
+ // assistant/chunk 是实时流;用轻量活动计数给浏览器侧映射动作速度,
117
+ // 不等最终 usage 落盘,避免任务正在输出时机器人仍慢速。
118
+ motionTicks: 0,
119
+ lastMotionAt: Date.now(),
120
+ ...extra,
121
+ }
122
+ activeSessions.set(sid, record)
123
+ getSessionTitle(sid, sessionQuery).then((title) => {
124
+ const s = activeSessions.get(sid)
125
+ if (s) s.title = title
126
+ })
127
+ return record
128
+ }
129
+
130
+ // 任务结束:归档 + 通知订阅者(浏览器侧通知/群机器人推送都由各自消费方决定怎么用)
131
+ function finishSession(sid) {
132
+ const session = activeSessions.get(sid)
133
+ if (!session) return
134
+ activeSessions.delete(sid)
135
+ const endTime = Date.now()
136
+ const duration = endTime - session.startTime
137
+ const record = {
138
+ sessionId: sid,
139
+ title: session.title || truncate(session.firstPrompt, 60) || '(无标题)',
140
+ duration,
141
+ turns: session.turns,
142
+ steps: session.steps,
143
+ toolCalls: session.toolCalls,
144
+ models: [...session.models],
145
+ provider: session.provider,
146
+ inputTokens: session.inputTokens,
147
+ outputTokens: session.outputTokens,
148
+ totalTokens: session.totalTokens,
149
+ reasoningTokens: session.reasoningTokens,
150
+ cacheReadTokens: session.cacheReadTokens,
151
+ endReason: session.endReason,
152
+ errorMessage: session.errorMessage,
153
+ lastText: session.lastText,
154
+ startTime: session.startTime,
155
+ endTime,
156
+ }
157
+ completedSessions.unshift(record)
158
+ if (completedSessions.length > MAX_COMPLETED) completedSessions.pop()
159
+ for (const fn of finishListeners) {
160
+ try { fn(record) } catch (e) {
161
+ console.error('[dsh-dock] task-track finish listener error:', e && e.message)
162
+ }
163
+ }
164
+ }
165
+
166
+ function handleSessionEvent(sessionQuery, session, event) {
167
+ const sid = typeof session === 'object' && session ? (session.id || '') : String(session || '')
168
+ if (!sid || !event || !event.type) return
169
+
170
+ if (event.type === 'step/start') {
171
+ const s = addActiveSession(sid, sessionQuery, { startTime: event.time || Date.now() })
172
+ s.steps++
173
+ s.phase = 'think' // 新步骤:模型正在推理下一步
174
+ s.phaseAt = event.time || Date.now()
175
+ s.lastActivityAt = s.phaseAt
176
+ s.motionTicks += 2
177
+ s.lastMotionAt = s.phaseAt
178
+ if (event.data && event.data.turn !== undefined) {
179
+ s.turns = Math.max(s.turns, num(event.data.turn) + 1)
180
+ }
181
+ return
182
+ }
183
+ const s = activeSessions.get(sid)
184
+ if (!s) return
185
+ const now = event.time || Date.now()
186
+ // 流级阶段:chunk 高频到达(每 token 一条),只做最廉价的阶段切换
187
+ if (event.type === 'assistant/chunk') {
188
+ const chunk = event.data && event.data.chunk
189
+ const ctype = chunk && chunk.type
190
+ if (ctype === 'reasoning-delta') {
191
+ s.phase = 'think'
192
+ s.phaseAt = now
193
+ } else if (ctype === 'text-delta') {
194
+ s.phase = 'write'
195
+ s.phaseAt = now
196
+ }
197
+ // 不同适配器的 delta 字段名并不完全一致。按可见文本量估算,
198
+ // 无文本时每个 chunk 也记一个脉冲,保证推理流同样有动作反馈。
199
+ if (ctype === 'reasoning-delta' || ctype === 'text-delta') {
200
+ const delta = chunk && (chunk.text || chunk.delta || chunk.content)
201
+ const units = typeof delta === 'string' ? Math.max(1, Math.ceil(delta.length / 24)) : 1
202
+ s.motionTicks += units
203
+ s.lastMotionAt = now
204
+ }
205
+ s.lastActivityAt = now
206
+ return
207
+ }
208
+ if (event.type === 'turn/end' && event.data && event.data.reason) {
209
+ const reason = event.data.reason
210
+ s.endReason = (reason.kind || 'completed')
211
+ if (reason.kind === 'error' && reason.error) {
212
+ const msg = (reason.error && (reason.error.message || reason.error.code)) || ''
213
+ s.errorMessage = typeof msg === 'string' ? msg : String(msg)
214
+ }
215
+ } else if (event.type === 'approval/asked' && event.data) {
216
+ // 工具等待用户确认/批准:挂在会话记录上,/status 下发给浏览器弹"需确认"通知
217
+ const id = typeof event.data.id === 'string' ? event.data.id : ''
218
+ if (id) {
219
+ s.approvals.set(id, {
220
+ toolName: typeof event.data.toolName === 'string' ? event.data.toolName : '',
221
+ reason: typeof event.data.reason === 'string' ? truncate(event.data.reason, 200) : '',
222
+ askedAt: event.time || Date.now(),
223
+ })
224
+ }
225
+ s.lastActivityAt = now
226
+ } else if (event.type === 'approval/decided' && event.data) {
227
+ s.approvals.delete(typeof event.data.id === 'string' ? event.data.id : '')
228
+ } else if (event.type === 'tool/call') {
229
+ s.toolCalls++
230
+ s.phase = phaseOfToolName(event.data && event.data.name)
231
+ s.phaseAt = now
232
+ // 工具启动是稀疏事件,给一个更明显的短促动作,检索/写文件也不会显得停住。
233
+ s.motionTicks += s.phase === 'search' ? 7 : 9
234
+ s.lastMotionAt = now
235
+ } else if (event.type === 'user/message') {
236
+ if (!s.firstPrompt && event.data && event.data.content) {
237
+ const text = extractText(event.data.content)
238
+ if (text) s.firstPrompt = text
239
+ }
240
+ } else if (event.type === 'assistant/message' && event.data) {
241
+ const usage = event.data.usage
242
+ if (usage) {
243
+ s.inputTokens += num(usage.inputTokens)
244
+ s.outputTokens += num(usage.outputTokens)
245
+ s.totalTokens += num(usage.inputTokens) + num(usage.outputTokens)
246
+ s.reasoningTokens += num(usage.reasoningTokens)
247
+ s.cacheReadTokens += num(usage.cacheReadTokens)
248
+ }
249
+ if (event.data.message && event.data.message.content) {
250
+ const text = extractText(event.data.message.content)
251
+ if (text) s.lastText = text
252
+ }
253
+ // 完整消息落盘后不改阶段:下一步要么 tool/call(code/search)要么收尾,
254
+ // 维持 chunk 推导出的最后状态,避免"输完还显示思考中"的回跳
255
+ } else if (event.type === 'request/context' && event.data) {
256
+ if (event.data.provider) s.provider = event.data.provider
257
+ if (event.data.model) s.models.add(event.data.model)
258
+ } else if (event.type === 'request/header' && event.data && event.data.header && event.data.header.config) {
259
+ const c = event.data.header.config
260
+ if (c.provider) s.provider = c.provider
261
+ if (c.model) s.models.add(c.model)
262
+ }
263
+ if (event.type !== 'request/header' && event.type !== 'request/context') s.lastActivityAt = now
264
+ }
265
+
266
+ function handleAgentStatus(sessionQuery, agent, status) {
267
+ const sid = agent && agent.id ? String(agent.id) : ''
268
+ if (!sid) return
269
+ if (status === 'running') {
270
+ const record = addActiveSession(sid, sessionQuery)
271
+ if (agent.options) {
272
+ if (agent.options.model) record.models.add(agent.options.model)
273
+ if (agent.options.provider) record.provider = agent.options.provider
274
+ }
275
+ } else if (status === 'idle') {
276
+ finishSession(sid)
277
+ }
278
+ }
279
+
280
+ // 不能在 setup 时软获取 sessionQuery(可能早于服务激活,见 tokenlog 模块同款注释),用 inject 等就绪
281
+ disposers.push(ctx.inject(['sessionQuery'], (sqCtx) => {
282
+ const sessionQuery = sqCtx.sessionQuery
283
+ disposers.push(sqCtx.on('session/event', (session, event) => {
284
+ try {
285
+ handleSessionEvent(sessionQuery, session, event)
286
+ } catch (err) {
287
+ console.error('[dsh-dock] task-track handle event error', err)
288
+ }
289
+ }))
290
+ disposers.push(sqCtx.on('agent/status', (payload) => {
291
+ try {
292
+ handleAgentStatus(sessionQuery, payload && payload.agent, payload && payload.status)
293
+ } catch (err) {
294
+ console.error('[dsh-dock] task-track handle agent/status error', err)
295
+ }
296
+ }))
297
+ // agent 销毁兜底归档(会话直接关闭时也产出完成记录)
298
+ disposers.push(sqCtx.on('agent/disposed', (payload) => {
299
+ try {
300
+ const sid = payload && payload.agent && payload.agent.id ? String(payload.agent.id) : ''
301
+ if (sid) finishSession(sid)
302
+ } catch (err) {
303
+ console.error('[dsh-dock] task-track handle agent/disposed error', err)
304
+ }
305
+ }))
306
+ }))
307
+
308
+ return {
309
+ /** 当前快照(活跃任务 + 最近完成),供各功能模块的 /status 路由下发。 */
310
+ snapshot(now) {
311
+ const at = typeof now === 'number' ? now : Date.now()
312
+ const active = []
313
+ for (const [sid, s] of activeSessions) {
314
+ active.push({
315
+ sessionId: sid,
316
+ title: s.title || truncate(s.firstPrompt, 60) || '(无标题)',
317
+ startTime: s.startTime,
318
+ elapsed: at - s.startTime,
319
+ turns: s.turns,
320
+ steps: s.steps,
321
+ toolCalls: s.toolCalls,
322
+ models: [...s.models],
323
+ provider: s.provider,
324
+ inputTokens: s.inputTokens,
325
+ outputTokens: s.outputTokens,
326
+ totalTokens: s.totalTokens,
327
+ phase: s.phase,
328
+ phaseAt: s.phaseAt,
329
+ lastActivityAt: s.lastActivityAt,
330
+ motionTicks: s.motionTicks,
331
+ lastMotionAt: s.lastMotionAt,
332
+ approvals: [...s.approvals.entries()].map(([id, v]) => ({ id, ...v })),
333
+ })
334
+ }
335
+ return { now: at, active, recent: completedSessions.slice(0, 20) }
336
+ },
337
+ /** 任务结束订阅(返回退订函数);异常不会打断其他订阅者。 */
338
+ onFinish(fn) {
339
+ if (typeof fn !== 'function') return () => {}
340
+ finishListeners.add(fn)
341
+ return () => { finishListeners.delete(fn) }
342
+ },
343
+ /** 退订会话事件(引用计数归零时由 acquireTaskTracker 调用)。 */
344
+ dispose() {
345
+ finishListeners.clear()
346
+ activeSessions.clear()
347
+ completedSessions.length = 0
348
+ while (disposers.length > 0) {
349
+ const fn = disposers.pop()
350
+ try { if (typeof fn === 'function') fn() } catch { /* 退订失败不阻断 */ }
351
+ }
352
+ },
353
+ }
354
+ }