dsh-plugin-taskflow 0.0.0-stage → 0.1.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/lib/index.js ADDED
@@ -0,0 +1,1421 @@
1
+ /**
2
+ * TaskFlow — host half (DeepSeek Harness plugin).
3
+ *
4
+ * Two jobs:
5
+ * 1. Keep a per-session timeline of the agent's todo list, so the browser can
6
+ * show how long every task has been running / took to finish.
7
+ * 2. When a turn ends, condense that turn (tool calls, files, commands, the
8
+ * assistant's own closing prose) into a short "segment" summary with one
9
+ * lightweight model call, then serve it to the browser over HTTP.
10
+ *
11
+ * Zero package dependencies on purpose: the plugin only touches Node builtins
12
+ * and the cordis services the host already provides (`session` events, `llm`,
13
+ * `webServer`, `commands`, `sessions`). Nothing here imports a @deepseek-ai
14
+ * package, so the plugin keeps working when the profile's module links move.
15
+ *
16
+ * Data lives in `$DSH_HOME/taskflow/<session>.json` (DSH_HOME defaults to
17
+ * ~/.dsh). It is display-only state: losing it costs timings and summaries,
18
+ * never session correctness.
19
+ */
20
+
21
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import { homedir } from 'node:os'
24
+ import { randomUUID } from 'node:crypto'
25
+
26
+ export const name = 'taskflow'
27
+
28
+ const VERSION = '0.1.0'
29
+ const MAX_SEGMENTS = 60
30
+ const MAX_HISTORY = 40
31
+ const MAX_DIGEST_CHARS = 14_000
32
+ const SAVE_DEBOUNCE_MS = 400
33
+
34
+ const DEFAULT_CONFIG = {
35
+ /** Master switch: false keeps the strip but never calls a model. */
36
+ enabled: true,
37
+ /** Summarize turns with at least this many tool calls ... */
38
+ minToolCalls: 1,
39
+ /** ... or with at least this much assistant prose (whitespace-trimmed). */
40
+ minAssistantChars: 240,
41
+ /** Hard cap for the digest handed to the summarizer. */
42
+ maxDigestChars: MAX_DIGEST_CHARS,
43
+ /** Output budget of the summarization call. */
44
+ maxOutputTokens: 600,
45
+ /** Per-call watchdog. */
46
+ timeoutMs: 45_000,
47
+ /** Concurrent summarization calls across all sessions. */
48
+ maxConcurrent: 2,
49
+ /** Optional explicit route; empty means "whatever the session last used". */
50
+ provider: '',
51
+ model: '',
52
+ /** Retain at most this many segments per session. */
53
+ maxSegments: MAX_SEGMENTS,
54
+ /** Delegated child sessions are skipped by default: their turns are not the user's. */
55
+ includeSubagents: false,
56
+ /** Add the task-list policy to the model's system prompt. */
57
+ taskListPolicy: true,
58
+ /** Add the parallel-decomposition policy (absorbed from the SDK parallel-agents SOP). */
59
+ parallelPolicy: true,
60
+ }
61
+
62
+ const TASK_PROMPT = [
63
+ '你是一名工程进度记录员。输入是 agent 完成某个任务期间的真实动作记录(工具调用、文件、耗时)。',
64
+ '请把「这个任务到底做了什么」压缩成一句话中文小结,规则:',
65
+ '- 一句话,不超过 60 个字,动词开头;',
66
+ '- 只写做了什么、改了什么、结论是什么;保留关键文件名/命令/错误码;',
67
+ '- 不要写「已完成」「成功完成」这类状态词,不要评价,不要分点,不要换行;',
68
+ '- 如果记录里几乎没有有效动作,只输出「无有效动作」。',
69
+ ].join('\n')
70
+
71
+ const SYSTEM_PROMPT = [
72
+ '你是一名严谨的工程进度记录员。输入是一段 agent 会话回合的原始记录,包含用户请求、工具调用、文件改动与助手回复。',
73
+ '请把这一段压缩成中文小结,严格遵循下列格式,不要输出任何额外解释:',
74
+ '第一行:不超过 24 个字的标题,动词开头,不要句号、不要引号。',
75
+ '随后 1-3 行,每行以「- 」开头,写关键动作、产物或结论;保留具体的文件路径、命令、错误要点;',
76
+ '不要复述用户原话,不要写「本次」「我们」这类空话,不要评价工作质量。',
77
+ '如果这段没有实质产出(被中断、只有时间戳/元信息、没有动作也没有结论),只输出一行「(无实质产出)」,不要编造过程。',
78
+ '只输出小结本身。',
79
+ ].join('\n')
80
+
81
+ /**
82
+ * Resolve the on-disk state root once, so a later DSH_HOME change cannot split
83
+ * one session's records across two directories.
84
+ */
85
+ function stateRoot() {
86
+ const home = process.env.DSH_HOME && process.env.DSH_HOME.length > 0 ? process.env.DSH_HOME : join(homedir(), '.dsh')
87
+ return join(home, 'taskflow')
88
+ }
89
+
90
+ /** Encode a session id into a portable file name. */
91
+ function stateFile(sessionId) {
92
+ return join(stateRoot(), `${encodeURIComponent(sessionId)}.json`)
93
+ }
94
+
95
+ function log(ctx, level, message) {
96
+ try {
97
+ const logger = typeof ctx.logger === 'function' ? ctx.logger('taskflow') : ctx.logger
98
+ const fn = logger?.[level]
99
+ if (typeof fn === 'function') fn.call(logger, message)
100
+ } catch {
101
+ // Logging must never take the plugin down.
102
+ }
103
+ }
104
+
105
+ /** Concatenate the text blocks of one message payload. */
106
+ function textOf(message) {
107
+ const content = message?.content
108
+ if (!Array.isArray(content)) return ''
109
+ const parts = []
110
+ for (const block of content) {
111
+ if (block && block.type === 'text' && typeof block.text === 'string' && block.text.length > 0) parts.push(block.text)
112
+ }
113
+ return parts.join('\n')
114
+ }
115
+
116
+ /** One-line, length-bounded rendering of arbitrary tool arguments. */
117
+ function describeArgs(name, args) {
118
+ // Tool-call arguments arrive either as an object or as the raw JSON string.
119
+ let value = args
120
+ if (typeof value === 'string') {
121
+ try {
122
+ value = JSON.parse(value)
123
+ } catch {
124
+ return clip(value.replace(/\s+/g, ' ').trim(), 160)
125
+ }
126
+ }
127
+ args = value
128
+ if (!args || typeof args !== 'object') return ''
129
+ const first = (...keys) => {
130
+ for (const key of keys) {
131
+ const value = args[key]
132
+ if (typeof value === 'string' && value.length > 0) return value
133
+ }
134
+ return ''
135
+ }
136
+ const direct = first('command', 'file_path', 'filePath', 'path', 'pattern', 'query', 'url', 'name', 'prompt')
137
+ if (direct.length > 0) return clip(direct.replace(/\s+/g, ' ').trim(), 160)
138
+ try {
139
+ return clip(JSON.stringify(args), 160)
140
+ } catch {
141
+ return ''
142
+ }
143
+ }
144
+
145
+ function clip(text, limit) {
146
+ const value = String(text)
147
+ return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`
148
+ }
149
+
150
+ /** Collect the file paths a tool call touched, when it names any. */
151
+ function pathsOf(args) {
152
+ const out = []
153
+ if (typeof args === 'string') {
154
+ try {
155
+ args = JSON.parse(args)
156
+ } catch {
157
+ return out
158
+ }
159
+ }
160
+ if (!args || typeof args !== 'object') return out
161
+ for (const key of ['file_path', 'filePath', 'path', 'paths', 'target', 'destination']) {
162
+ const value = args[key]
163
+ if (typeof value === 'string' && value.length > 0) out.push(value)
164
+ else if (Array.isArray(value)) for (const item of value) if (typeof item === 'string' && item.length > 0) out.push(item)
165
+ }
166
+ return out
167
+ }
168
+
169
+ /**
170
+ * Fold one turn's events into the compact text a summarizer can read.
171
+ * @returns {{ digest: string, toolCalls: number, assistantChars: number, files: string[], commands: string[] }}
172
+ */
173
+ function buildDigest(events, cwd) {
174
+ const lines = []
175
+ const files = new Set()
176
+ const commands = []
177
+ let toolCalls = 0
178
+ let assistantChars = 0
179
+ let userText = ''
180
+ let assistantText = ''
181
+
182
+ for (const event of events) {
183
+ switch (event.type) {
184
+ case 'user/message': {
185
+ const text = textOf(event.data)
186
+ if (text.length > 0) userText = text
187
+ break
188
+ }
189
+ case 'assistant/message': {
190
+ const text = textOf(event.data?.message)
191
+ if (text.length > 0) {
192
+ assistantText = assistantText.length > 0 ? `${assistantText}\n${text}` : text
193
+ assistantChars += text.trim().length
194
+ }
195
+ break
196
+ }
197
+ case 'tool/call': {
198
+ toolCalls += 1
199
+ const toolName = String(event.data?.name ?? 'tool')
200
+ const args = event.data?.arguments
201
+ for (const path of pathsOf(args)) files.add(path)
202
+ if (toolName === 'bash' || toolName === 'pwsh') {
203
+ const command = typeof args?.command === 'string' ? args.command.trim() : ''
204
+ if (command.length > 0) commands.push(clip(command.split('\n')[0], 140))
205
+ }
206
+ lines.push(`- ${toolName}: ${describeArgs(toolName, args)}`)
207
+ break
208
+ }
209
+ case 'tool/result': {
210
+ const error = event.data?.error
211
+ if (error && typeof error.message === 'string') lines.push(` ! 失败: ${clip(error.message.replace(/\s+/g, ' '), 160)}`)
212
+ break
213
+ }
214
+ case 'todo/write': {
215
+ const todos = Array.isArray(event.data?.todos) ? event.data.todos : []
216
+ if (todos.length > 0) {
217
+ const done = todos.filter((item) => item.status === 'completed').length
218
+ const active = todos.find((item) => item.status === 'in_progress')
219
+ lines.push(`- 任务清单: ${done}/${todos.length} 完成${active ? `,进行中「${active.content}」` : ''}`)
220
+ }
221
+ break
222
+ }
223
+ default:
224
+ break
225
+ }
226
+ }
227
+
228
+ const tail = assistantText.trim()
229
+ const head = [
230
+ userText.trim().length > 0 ? `用户请求:${clip(userText.trim(), 600)}` : '',
231
+ cwd ? `工作目录:${cwd}` : '',
232
+ lines.length > 0 ? `执行过程:\n${lines.slice(0, 60).join('\n')}` : '',
233
+ tail.length > 0 ? `助手收尾:${clip(tail, 900)}` : '',
234
+ ].filter((part) => part.length > 0)
235
+
236
+ return {
237
+ digest: head.join('\n\n'),
238
+ toolCalls,
239
+ assistantChars,
240
+ files: [...files].slice(0, 16),
241
+ commands: commands.slice(0, 8),
242
+ }
243
+ }
244
+
245
+ /** Split the model's answer into a title and bullet lines. */
246
+ function parseSummary(raw) {
247
+ const lines = String(raw)
248
+ .split(/\r?\n/)
249
+ .map((line) => line.trim())
250
+ .filter((line) => line.length > 0)
251
+ if (lines.length === 0) return null
252
+ let title = lines[0].replace(/^#+\s*/, '').replace(/^["'「『]|["'」』]$/g, '').trim()
253
+ title = clip(title, 60)
254
+ const bullets = []
255
+ for (const line of lines.slice(1)) {
256
+ if (/^[-*·•]/.test(line)) bullets.push(clip(line.replace(/^[-*·•]\s*/, ''), 220))
257
+ }
258
+ if (bullets.length === 0 && lines.length > 1) bullets.push(clip(lines.slice(1).join(' '), 220))
259
+ return { title, bullets: bullets.slice(0, 4) }
260
+ }
261
+
262
+ /** Deterministic summary used when no model route exists or the call fails. */
263
+ function mechanicalSummary(input) {
264
+ const { digestParts } = input
265
+ const titleSource = digestParts.userText
266
+ || (digestParts.files && digestParts.files[0])
267
+ || (digestParts.commands && digestParts.commands[0])
268
+ || `第 ${input.turn} 段`
269
+ const bullets = []
270
+ if (digestParts.toolCalls > 0) bullets.push(`${digestParts.toolCalls} 次工具调用:${digestParts.toolNames.slice(0, 5).join('、')}`)
271
+ if (digestParts.files.length > 0) bullets.push(`改动文件:${digestParts.files.slice(0, 4).join('、')}`)
272
+ if (digestParts.commands.length > 0) bullets.push(`命令:${digestParts.commands.slice(0, 3).join(';')}`)
273
+ if (bullets.length === 0 && digestParts.assistantTail) bullets.push(clip(digestParts.assistantTail, 180))
274
+ return { title: clip(titleSource, 40), bullets: bullets.slice(0, 3) }
275
+ }
276
+
277
+ export function apply(ctx, rawConfig) {
278
+ const config = { ...DEFAULT_CONFIG, ...(rawConfig && typeof rawConfig === 'object' ? rawConfig : {}) }
279
+ /** @type {Map<string, object>} */
280
+ const states = new Map()
281
+ /** @type {Array<() => Promise<void>>} */
282
+ const queue = []
283
+ /** @type {Set<string>} */
284
+ const pending = new Set()
285
+ /** sessionId → Set<ServerResponse> of live SSE listeners. */
286
+ const streams = new Map()
287
+ /** Debounced per-session broadcast, so a burst of events costs one frame. */
288
+ const streamTimers = new Map()
289
+
290
+ function streamSubscribers(sessionId) {
291
+ const set = streams.get(sessionId)
292
+ return set === undefined ? [] : [...set]
293
+ }
294
+
295
+ function pushFrame(response, state) {
296
+ try {
297
+ response.write(`event: state\ndata: ${JSON.stringify(payload(state))}\n\n`)
298
+ } catch {
299
+ // A dead socket is cleaned up by its own close handler.
300
+ }
301
+ }
302
+
303
+ /** Send the current state to every listener of one session, at most every 80 ms. */
304
+ function broadcast(sessionId) {
305
+ if (!streams.has(sessionId)) return
306
+ const existing = streamTimers.get(sessionId)
307
+ if (existing) return
308
+ streamTimers.set(sessionId, setTimeout(() => {
309
+ streamTimers.delete(sessionId)
310
+ const state = states.get(sessionId)
311
+ if (!state) return
312
+ for (const response of streamSubscribers(sessionId)) pushFrame(response, state)
313
+ }, 80))
314
+ }
315
+
316
+ /** childSessionId → { parentId, inTurn } for delegated subagents that are running. */
317
+ const childSessions = new Map()
318
+ /** Every session id we have seen an event for, with its meta origin (diagnostics). */
319
+ const seenSessions = new Map()
320
+ const saveTimers = new Map()
321
+ /** Contents completed by the current todo/write call, drained by recordTodos. */
322
+ const pendingTaskSummaries = []
323
+ /** Queue for per-task summaries, independent from the per-turn segment queue. */
324
+ const taskQueue = []
325
+ /** @type {Set<string>} */
326
+ const taskPending = new Set()
327
+ let taskRunning = 0
328
+ let running = 0
329
+ let llm = null
330
+ // Liveness counters: they answer "did the browser half actually run and is it
331
+ // talking to me?" from /taskflow/health, without a screenshot.
332
+ let stateHits = 0
333
+ let lastStateAt = 0
334
+ let summaryRequests = 0
335
+ let clientHello = null
336
+ let policyRegistered = false
337
+ let parallelPolicyRegistered = false
338
+ /** Last measured geometry per rendered surface, reported by the browser half. */
339
+ const renders = {}
340
+
341
+ /** Lazily load (or create) one session's display state. */
342
+ function stateOf(sessionId) {
343
+ const cached = states.get(sessionId)
344
+ if (cached) return cached
345
+ let loaded = null
346
+ try {
347
+ loaded = JSON.parse(readFileSync(stateFile(sessionId), 'utf8'))
348
+ } catch {
349
+ loaded = null
350
+ }
351
+ const state = {
352
+ version: VERSION,
353
+ sessionId,
354
+ updatedAt: 0,
355
+ open: null,
356
+ lastTurn: typeof loaded?.lastTurn === 'number' ? loaded.lastTurn : 0,
357
+ skipped: Array.isArray(loaded?.skipped) ? loaded.skipped : [],
358
+ turns: {},
359
+ segments: Array.isArray(loaded?.segments) ? loaded.segments : [],
360
+ tasks: {
361
+ items: loaded?.tasks && typeof loaded.tasks.items === 'object' && loaded.tasks.items !== null ? loaded.tasks.items : {},
362
+ history: Array.isArray(loaded?.tasks?.history) ? loaded.tasks.history : [],
363
+ updatedAt: loaded?.tasks?.updatedAt ?? 0,
364
+ },
365
+ }
366
+ states.set(sessionId, state)
367
+ return state
368
+ }
369
+
370
+ /** Debounced atomic write of one session's display state. */
371
+ function scheduleSave(state) {
372
+ state.updatedAt = Date.now()
373
+ const existing = saveTimers.get(state.sessionId)
374
+ if (existing) clearTimeout(existing)
375
+ saveTimers.set(state.sessionId, setTimeout(() => {
376
+ saveTimers.delete(state.sessionId)
377
+ try {
378
+ mkdirSync(stateRoot(), { recursive: true })
379
+ const target = stateFile(state.sessionId)
380
+ const temp = `${target}.${process.pid}.tmp`
381
+ writeFileSync(temp, JSON.stringify(state))
382
+ renameSync(temp, target)
383
+ } catch (error) {
384
+ log(ctx, 'warn', `[taskflow] failed to persist ${state.sessionId}: ${error?.message ?? error}`)
385
+ }
386
+ }, SAVE_DEBOUNCE_MS))
387
+ }
388
+
389
+ /** Track todo transitions so the strip can show per-task timing. */
390
+ function recordTodos(session, state, event, turn) {
391
+ const todos = Array.isArray(event.data?.todos) ? event.data.todos : []
392
+ pendingTaskSummaries.length = 0
393
+ const now = typeof event.time === 'number' ? event.time : Date.now()
394
+ const seen = new Set()
395
+ for (const todo of todos) {
396
+ if (!todo || typeof todo.content !== 'string') continue
397
+ const content = todo.content
398
+ seen.add(content)
399
+ const item = state.tasks.items[content] ?? (state.tasks.items[content] = { content, firstSeen: now })
400
+ item.status = todo.status
401
+ item.lastSeen = now
402
+ if (todo.status === 'in_progress' && !item.startedAt) item.startedAt = now
403
+ if (todo.status === 'completed' && !item.completedAt) {
404
+ item.completedAt = now
405
+ item.summaryPending = true
406
+ pendingTaskSummaries.push(content)
407
+ }
408
+ if (todo.status !== 'completed') item.completedAt = null
409
+ delete item.dropped
410
+ }
411
+ for (const [content, item] of Object.entries(state.tasks.items)) {
412
+ if (!seen.has(content) && item.status !== 'completed') item.dropped = true
413
+ }
414
+ const running = todos.find((todo) => todo && todo.status === 'in_progress')
415
+ state.currentTask = running ? running.content : null
416
+ state.tasks.history.push({ at: now, turn: turn ?? null, todos })
417
+ if (state.tasks.history.length > MAX_HISTORY) state.tasks.history.splice(0, state.tasks.history.length - MAX_HISTORY)
418
+ state.tasks.updatedAt = now
419
+ // Authoritative current list, scoped to the turn it belongs to: the client
420
+ // uses it only when the `todos` projection is unavailable.
421
+ state.currentList = todos.map((todo) => ({ content: todo.content, status: todo.status }))
422
+ state.currentListTurn = typeof turn === 'number' ? turn : (typeof state.openTurn === 'number' ? state.openTurn : null)
423
+ for (const content of pendingTaskSummaries) queueTaskSummary(session, state, content)
424
+ scheduleSave(state)
425
+ }
426
+
427
+ /** Route used for one summarization call: the session's own last request wins. */
428
+ function resolveRoute(session) {
429
+ if (config.provider && config.model) return { provider: config.provider, model: config.model }
430
+ try {
431
+ const context = session.requestContext?.()
432
+ if (context?.provider && context?.model) return { provider: context.provider, model: context.model }
433
+ } catch {
434
+ // fall through
435
+ }
436
+ try {
437
+ const headerConfig = session.requestHeader?.()?.config
438
+ if (headerConfig?.provider && headerConfig?.model) return { provider: headerConfig.provider, model: headerConfig.model }
439
+ } catch {
440
+ // fall through
441
+ }
442
+ return null
443
+ }
444
+
445
+ /** Stream one auxiliary completion and return its text plus terminal facts. */
446
+ async function streamCompletion(route, system, userText, sessionId) {
447
+ if (!llm) throw new Error('llm service is not available')
448
+ const controller = new AbortController()
449
+ const timer = setTimeout(() => controller.abort(new Error('taskflow: summarization timed out')), config.timeoutMs)
450
+ let text = ''
451
+ let finish = null
452
+ let usage
453
+ try {
454
+ const options = {
455
+ provider: route.provider,
456
+ model: route.model,
457
+ system,
458
+ messages: [{
459
+ id: randomUUID(),
460
+ role: 'user',
461
+ source: { kind: 'plugin', plugin: 'dsh-plugin-taskflow' },
462
+ content: [{ type: 'text', text: userText }],
463
+ }],
464
+ maxTokens: config.maxOutputTokens,
465
+ sessionId,
466
+ signal: controller.signal,
467
+ }
468
+ for await (const chunk of llm.stream(options)) {
469
+ if (!chunk || typeof chunk !== 'object') continue
470
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
471
+ else if (chunk.type === 'finish') finish = chunk.reason ?? chunk
472
+ else if (chunk.type === 'usage') usage = chunk.usage
473
+ }
474
+ } finally {
475
+ clearTimeout(timer)
476
+ }
477
+ // A truncated answer is still an answer: reasoning tokens or a long opening
478
+ // line can eat the budget before the model writes its last bullet. Only an
479
+ // empty body counts as a failure.
480
+ if (finish && finish.kind !== 'stop' && finish.kind !== 'max-tokens') {
481
+ const reason = finish.kind === 'error' || finish.kind === 'aborted' ? (finish.failure?.message ?? finish.kind) : finish.kind
482
+ throw new Error(`taskflow: summarization finished with ${reason}`)
483
+ }
484
+ const trimmed = text.trim()
485
+ if (trimmed.length === 0) throw new Error(`taskflow: summarization produced no text (${finish?.kind ?? 'unknown finish'})`)
486
+ return { text: trimmed, usage, truncated: finish?.kind === 'max-tokens' }
487
+ }
488
+
489
+ function pump() {
490
+ while (running < Math.max(1, config.maxConcurrent) && queue.length > 0) {
491
+ const task = queue.shift()
492
+ running += 1
493
+ Promise.resolve()
494
+ .then(task)
495
+ .catch((error) => log(ctx, 'warn', `[taskflow] summarization failed: ${error?.message ?? error}`))
496
+ .finally(() => {
497
+ running -= 1
498
+ pump()
499
+ })
500
+ }
501
+ }
502
+
503
+ /** Summarize one completed turn and publish the segment. */
504
+ function summarizeTurn(session, state, input) {
505
+ const key = `${session.id}:${input.turn}`
506
+ if (pending.has(key)) return Promise.resolve({ queued: false, reason: 'already running' })
507
+ pending.add(key)
508
+ let settle
509
+ const done = new Promise((resolve) => {
510
+ settle = resolve
511
+ })
512
+ queue.push(async () => {
513
+ const outcome = { queued: true, turn: input.turn }
514
+ try {
515
+ const route = resolveRoute(session)
516
+ let parsed = null
517
+ let usedRoute = null
518
+ let usage
519
+ let summaryMs = 0
520
+ let truncated = false
521
+ let failure = null
522
+ if (!config.enabled) failure = 'summarization disabled by config'
523
+ else if (!route) failure = 'no provider/model route: the session has no request context and no config override'
524
+ else {
525
+ try {
526
+ const callStarted = Date.now()
527
+ const result = await streamCompletion(route, SYSTEM_PROMPT, input.digest, session.id)
528
+ summaryMs = Date.now() - callStarted
529
+ truncated = result.truncated === true
530
+ parsed = parseSummary(result.text)
531
+ if (!parsed) failure = 'model returned no usable text'
532
+ else {
533
+ usedRoute = route
534
+ usage = result.usage
535
+ }
536
+ } catch (error) {
537
+ failure = String(error?.message ?? error)
538
+ log(ctx, 'warn', `[taskflow] model summary fell back to mechanical text: ${failure}`)
539
+ }
540
+ }
541
+ outcome.route = route
542
+ outcome.failure = failure
543
+ if (parsed && parsed.title.includes('无实质产出')) {
544
+ markSkipped(state, input.turn)
545
+ scheduleSave(state)
546
+ outcome.skipped = true
547
+ settle(outcome)
548
+ return
549
+ }
550
+ if (!parsed) parsed = mechanicalSummary(input)
551
+ const segment = {
552
+ turn: input.turn,
553
+ at: input.endedAt,
554
+ startedAt: input.startedAt,
555
+ durationMs: Math.max(0, input.endedAt - input.startedAt),
556
+ title: parsed.title,
557
+ bullets: parsed.bullets,
558
+ toolCalls: input.toolCalls,
559
+ files: input.files,
560
+ commands: input.commands,
561
+ provider: usedRoute?.provider ?? null,
562
+ model: usedRoute?.model ?? null,
563
+ usage: usage ?? null,
564
+ summaryMs,
565
+ truncated: truncated || undefined,
566
+ mechanical: usedRoute === null,
567
+ failure,
568
+ }
569
+ state.skipped = state.skipped.filter((turn) => turn !== input.turn)
570
+ state.segments = state.segments.filter((item) => item.turn !== input.turn)
571
+ state.segments.push(segment)
572
+ state.segments.sort((a, b) => a.turn - b.turn)
573
+ if (state.segments.length > Math.max(1, config.maxSegments)) state.segments.splice(0, state.segments.length - config.maxSegments)
574
+ state.lastSummary = { turn: input.turn, at: Date.now(), route: route ?? null, failure, mechanical: segment.mechanical }
575
+ scheduleSave(state)
576
+ broadcast(state.sessionId)
577
+ outcome.segment = segment
578
+ settle(outcome)
579
+ } catch (error) {
580
+ outcome.failure = String(error?.message ?? error)
581
+ settle(outcome)
582
+ } finally {
583
+ pending.delete(key)
584
+ }
585
+ })
586
+ pump()
587
+ return done
588
+ }
589
+
590
+ /** Generate one task's own one-line summary from what happened while it ran. */
591
+ function queueTaskSummary(session, state, content) {
592
+ const key = `${session.id}:${content}`
593
+ if (taskPending.has(key)) return
594
+ const item = state.tasks.items[content]
595
+ if (!item || item.summary) return
596
+ taskPending.add(key)
597
+ item.summaryPending = true
598
+ taskQueue.push(async () => {
599
+ try {
600
+ const current = state.tasks.items[content]
601
+ if (!current || current.summary) return
602
+ const route = resolveRoute(session)
603
+ const steps = (current.steps || []).slice(-10)
604
+ const digest = [
605
+ `任务:${content}`,
606
+ current.startedAt && current.completedAt ? `耗时:${Math.round((current.completedAt - current.startedAt) / 1000)} 秒` : '',
607
+ steps.length > 0 ? `动作:\n${steps.map((step) => `- ${step.n}: ${step.a || ''}`).join('\n')}` : '动作:(无工具调用记录)',
608
+ current.files && current.files.length > 0 ? `文件:${current.files.join('、')}` : '',
609
+ ].filter((line) => line.length > 0).join('\n')
610
+ let text = ''
611
+ if (config.enabled && route) {
612
+ try {
613
+ const result = await streamCompletion(route, TASK_PROMPT, clip(digest, 4000), session.id)
614
+ text = result.text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0)[0] || ''
615
+ } catch (error) {
616
+ log(ctx, 'warn', `[taskflow] task summary fell back to mechanical: ${error?.message ?? error}`)
617
+ }
618
+ }
619
+ if (text.length === 0 || text.includes('无有效动作')) {
620
+ // Nothing worth calling a summary: leave it without one, so the UI keeps
621
+ // the row non-clickable instead of opening an empty panel.
622
+ delete current.summaryPending
623
+ scheduleSave(state)
624
+ return
625
+ }
626
+ current.summary = clip(text, 160)
627
+ current.summaryAt = Date.now()
628
+ delete current.summaryPending
629
+ scheduleSave(state)
630
+ broadcast(state.sessionId)
631
+ } finally {
632
+ taskPending.delete(key)
633
+ }
634
+ })
635
+ pumpTaskQueue()
636
+ }
637
+
638
+ function pumpTaskQueue() {
639
+ if (taskRunning >= 1 || taskQueue.length === 0) return
640
+ const run = taskQueue.shift()
641
+ taskRunning += 1
642
+ Promise.resolve()
643
+ .then(run)
644
+ .catch((error) => log(ctx, 'warn', `[taskflow] task summary failed: ${error?.message ?? error}`))
645
+ .finally(() => {
646
+ taskRunning -= 1
647
+ pumpTaskQueue()
648
+ })
649
+ }
650
+
651
+ /** Recover the open turn after a mid-turn mount or restart: the last boundary wins. */
652
+ function deriveOpenTurn(session) {
653
+ try {
654
+ const events = session.snapshotEvents ? session.snapshotEvents() : []
655
+ for (let index = events.length - 1; index >= 0; index -= 1) {
656
+ const event = events[index]
657
+ if (event.type === 'turn/end') return null
658
+ if (event.type === 'turn/start') return typeof event.data?.turn === 'number' ? event.data.turn : null
659
+ }
660
+ } catch {
661
+ // treat as unknown
662
+ }
663
+ return undefined
664
+ }
665
+
666
+ /** Whether a session is a delegated child whose turns should not be summarized. */
667
+ function isSubagentSession(session) {
668
+ const meta = session.meta
669
+ if (!meta || typeof meta !== 'object') return false
670
+ return meta.origin === 'subagent' || (typeof meta.delegationDepth === 'number' && meta.delegationDepth > 0)
671
+ }
672
+
673
+ /** Remember which subagent sessions exist and whether each is working right now. */
674
+ function noteChildSession(session, event) {
675
+ const header = session?.header ?? {}
676
+ const meta = session?.meta ?? {}
677
+ // The spawn metadata lands on the session header, not on `meta`.
678
+ const origin = meta.origin ?? header.origin
679
+ const parentId = meta.parentSession ?? header.parentSession
680
+ if (session && typeof session.id === 'string') {
681
+ const known = seenSessions.get(session.id)
682
+ const current = {
683
+ origin: origin ?? null,
684
+ parent: parentId ?? null,
685
+ turn: known?.turn ?? false,
686
+ }
687
+ if (event.type === 'turn/start') current.turn = true
688
+ else if (event.type === 'turn/end') current.turn = false
689
+ seenSessions.set(session.id, current)
690
+ }
691
+ if (origin !== 'subagent' || typeof parentId !== 'string' || parentId.length === 0) return
692
+ let entry = childSessions.get(session.id)
693
+ if (!entry) {
694
+ entry = { parentId, inTurn: false }
695
+ childSessions.set(session.id, entry)
696
+ }
697
+ entry.parentId = parentId
698
+ if (event.type === 'turn/start') entry.inTurn = true
699
+ else if (event.type === 'turn/end') entry.inTurn = false
700
+ }
701
+
702
+ function handleEvent(session, event) {
703
+ noteChildSession(session, event)
704
+ const state = stateOf(session.id)
705
+ const turn = typeof event.data?.turn === 'number' ? event.data.turn : null
706
+ switch (event.type) {
707
+ case 'turn/start': {
708
+ state.open = { turn, seq: event.seq, time: event.time }
709
+ state.openTurn = turn
710
+ // Work resumes: clear any "waiting for the human" stamp.
711
+ for (const item of Object.values(state.tasks.items)) {
712
+ if (item.pausedAt && item.status === 'in_progress') delete item.pausedAt
713
+ }
714
+ if (turn !== null) state.turns[turn] = { startSeq: event.seq, endedAt: null }
715
+ break
716
+ }
717
+ case 'turn/end': {
718
+ const open = state.open && (turn === null || state.open.turn === turn) ? state.open : null
719
+ state.open = null
720
+ state.openTurn = null
721
+ // The turn ended while something was still in progress: nobody is working
722
+ // on it now, so freeze it as "waiting for confirmation".
723
+ for (const item of Object.values(state.tasks.items)) {
724
+ if (item.status === 'in_progress' && !item.pausedAt) item.pausedAt = event.time
725
+ }
726
+ const opening = locateTurnStart(session, state, turn, open)
727
+ if (turn !== null) {
728
+ state.turns[turn] = { startSeq: opening.seq ?? state.turns[turn]?.startSeq ?? event.seq, endedAt: event.time }
729
+ state.lastTurn = Math.max(state.lastTurn, turn)
730
+ }
731
+ // An interrupted or failed turn has no story worth spending a model call
732
+ // on: it usually contains half a tool call and no conclusion.
733
+ const reasonKind = event.data?.reason?.kind
734
+ if (reasonKind === 'aborted' || reasonKind === 'error') {
735
+ markSkipped(state, turn)
736
+ scheduleSave(state)
737
+ break
738
+ }
739
+ let events = []
740
+ try {
741
+ events = session.snapshotEvents(opening.seq ?? Math.max(0, event.seq - 400), event.seq + 1)
742
+ } catch {
743
+ events = []
744
+ }
745
+ const digest = buildDigest(events, session.meta?.cwd)
746
+ if (turn === null || digest.digest.length === 0) break
747
+ const substantial = digest.toolCalls >= Math.max(0, config.minToolCalls)
748
+ || digest.assistantChars >= Math.max(0, config.minAssistantChars)
749
+ if (!substantial) {
750
+ markSkipped(state, turn)
751
+ scheduleSave(state)
752
+ break
753
+ }
754
+ const existing = state.segments.find((item) => item.turn === turn)
755
+ if (existing) break
756
+ if (!config.includeSubagents && isSubagentSession(session)) {
757
+ markSkipped(state, turn)
758
+ scheduleSave(state)
759
+ break
760
+ }
761
+ pruneTurns(state)
762
+ summarizeTurn(session, state, {
763
+ turn,
764
+ startedAt: opening.time ?? event.time,
765
+ endedAt: event.time,
766
+ digest: clip(digest.digest, Math.max(1000, config.maxDigestChars)),
767
+ toolCalls: digest.toolCalls,
768
+ files: digest.files,
769
+ commands: digest.commands,
770
+ digestParts: {
771
+ userText: firstUserText(events),
772
+ firstTool: digest.toolCalls > 0 ? '工具调用' : '',
773
+ toolCalls: digest.toolCalls,
774
+ toolNames: toolNamesOf(events),
775
+ files: digest.files,
776
+ commands: digest.commands,
777
+ assistantTail: lastAssistantText(events),
778
+ },
779
+ })
780
+ break
781
+ }
782
+ case 'tool/call': {
783
+ // Attribute the work to whatever task was in progress at that moment, so
784
+ // the detail view can say what a task actually involved — not just its
785
+ // status and duration.
786
+ const active = state.currentTask
787
+ const item = active ? state.tasks.items[active] : undefined
788
+ const bookkeeping = event.data?.name === 'todo_write' || event.data?.name === 'present'
789
+ if (item && !bookkeeping) {
790
+ const toolName = String(event.data?.name ?? 'tool')
791
+ const args = event.data?.arguments
792
+ item.steps = [...(item.steps || []), {
793
+ n: toolName,
794
+ a: describeArgs(toolName, args),
795
+ at: event.time,
796
+ }].slice(-12)
797
+ const touched = pathsOf(args)
798
+ if (touched.length > 0) item.files = [...new Set([...(item.files || []), ...touched])].slice(0, 10)
799
+ scheduleSave(state)
800
+ }
801
+ break
802
+ }
803
+ case 'todo/write': {
804
+ recordTodos(session, state, event, turn ?? state.open?.turn ?? null)
805
+ break
806
+ }
807
+ default:
808
+ break
809
+ }
810
+ }
811
+
812
+ ctx.on('session/event', (session, event) => {
813
+ try {
814
+ handleEvent(session, event)
815
+ if (event.type === 'todo/write' || event.type === 'turn/start' || event.type === 'turn/end') broadcast(session.id)
816
+ const child = childSessions.get(session.id)
817
+ if (child && (event.type === 'turn/start' || event.type === 'turn/end')) broadcast(child.parentId)
818
+ } catch (error) {
819
+ log(ctx, 'warn', `[taskflow] event handler failed on ${event?.type}: ${error?.message ?? error}`)
820
+ }
821
+ })
822
+
823
+ // The model route is optional: without it the plugin still records turns and
824
+ // publishes mechanical summaries.
825
+ ctx.inject(['llm'], (host) => {
826
+ llm = host.llm
827
+ return () => {
828
+ llm = null
829
+ }
830
+ })
831
+
832
+ /** Build the JSON payload the browser polls. */
833
+ /** Tasks without their heavy step/file history: fetched on demand instead. */
834
+ function lightTasks(state) {
835
+ const items = {}
836
+ for (const [content, item] of Object.entries(state.tasks.items)) {
837
+ const { steps, files, ...rest } = item
838
+ items[content] = { ...rest, stepCount: Array.isArray(steps) ? steps.length : 0, fileCount: Array.isArray(files) ? files.length : 0 }
839
+ }
840
+ return { items, updatedAt: state.tasks.updatedAt }
841
+ }
842
+
843
+ /**
844
+ * Compact state frame. History and full segment/step bodies are omitted: the
845
+ * client asks for them only when a surface actually shows them, which keeps
846
+ * the push frame small enough to send on every change.
847
+ */
848
+ function payload(state) {
849
+ const latest = state.segments.length > 0 ? state.segments[state.segments.length - 1] : null
850
+ return {
851
+ ok: true,
852
+ version: VERSION,
853
+ sessionId: state.sessionId,
854
+ enabled: config.enabled,
855
+ latestSegment: latest,
856
+ segmentCount: state.segments.length,
857
+ tasks: lightTasks(state),
858
+ currentList: Array.isArray(state.currentList) ? state.currentList : null,
859
+ currentListTurn: typeof state.currentListTurn === 'number' ? state.currentListTurn : null,
860
+ lastTurn: state.lastTurn,
861
+ openTurn: typeof state.openTurn === 'number' ? state.openTurn : null,
862
+ delegates: [...childSessions.values()].filter((child) => child.parentId === state.sessionId && child.inTurn).length,
863
+ inTurn: state.openTurn === undefined ? null : state.openTurn !== null,
864
+ skipped: state.skipped,
865
+ pending: [...pending].some((key) => key.startsWith(`${state.sessionId}:`)),
866
+ lastSummary: state.lastSummary ?? null,
867
+ updatedAt: state.updatedAt,
868
+ }
869
+ }
870
+
871
+ function sendJson(response, status, body) {
872
+ const text = JSON.stringify(body)
873
+ response.writeHead(status, {
874
+ 'content-type': 'application/json; charset=utf-8',
875
+ 'cache-control': 'no-store',
876
+ 'content-length': Buffer.byteLength(text),
877
+ })
878
+ response.end(text)
879
+ }
880
+
881
+ function readBody(request) {
882
+ return new Promise((resolve, reject) => {
883
+ let size = 0
884
+ const chunks = []
885
+ request.on('data', (chunk) => {
886
+ size += chunk.length
887
+ if (size > 64 * 1024) {
888
+ reject(new Error('body too large'))
889
+ request.destroy()
890
+ return
891
+ }
892
+ chunks.push(chunk)
893
+ })
894
+ request.on('end', () => {
895
+ try {
896
+ resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')))
897
+ } catch (error) {
898
+ reject(error)
899
+ }
900
+ })
901
+ request.on('error', reject)
902
+ })
903
+ }
904
+
905
+ ctx.inject(['webServer'], (host) => {
906
+ host.effect(() => {
907
+ const disposers = [
908
+ host.webServer.register({
909
+ kind: 'exact',
910
+ path: '/taskflow/state',
911
+ handler: (request, response) => {
912
+ stateHits += 1
913
+ lastStateAt = Date.now()
914
+ const url = new URL(request.url ?? '/', 'http://localhost')
915
+ const sessionId = url.searchParams.get('session') ?? ''
916
+ if (sessionId.length === 0) {
917
+ sendJson(response, 400, { ok: false, error: 'missing session' })
918
+ return
919
+ }
920
+ const state = stateOf(sessionId)
921
+ // Always re-derive from the live log: a missed turn/end (plugin
922
+ // reload mid-turn) must not leave a finished task looking active.
923
+ const liveSession = typeof ctx.get === 'function' ? ctx.get('sessions')?.get?.(sessionId) : undefined
924
+ if (liveSession) {
925
+ const derived = deriveOpenTurn(liveSession)
926
+ if (derived !== undefined) state.openTurn = derived
927
+ }
928
+ sendJson(response, 200, payload(state))
929
+ },
930
+ }),
931
+ host.webServer.register({
932
+ kind: 'exact',
933
+ path: '/taskflow/summarize',
934
+ handler: async (request, response) => {
935
+ if (request.method !== 'POST') {
936
+ sendJson(response, 405, { ok: false, error: 'method not allowed' })
937
+ return
938
+ }
939
+ summaryRequests += 1
940
+ let body = {}
941
+ try {
942
+ body = await readBody(request)
943
+ } catch (error) {
944
+ sendJson(response, 400, { ok: false, error: String(error?.message ?? error) })
945
+ return
946
+ }
947
+ const sessionId = typeof body?.session === 'string' ? body.session : ''
948
+ const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : undefined
949
+ const session = sessionId.length > 0 ? sessions?.get?.(sessionId) : undefined
950
+ if (!session) {
951
+ sendJson(response, 404, { ok: false, error: 'session is not live' })
952
+ return
953
+ }
954
+ try {
955
+ const state = stateOf(sessionId)
956
+ const force = body?.force === true
957
+ const run = summarizeLatestTurn(session, state, body?.turn, force)
958
+ if (!run) {
959
+ sendJson(response, 200, { ok: true, queued: false, reason: 'nothing to summarize (already summarized or no completed turn)' })
960
+ return
961
+ }
962
+ const outcome = await run
963
+ sendJson(response, 200, { ok: true, queued: true, ...outcome })
964
+ } catch (error) {
965
+ sendJson(response, 500, { ok: false, error: String(error?.message ?? error) })
966
+ }
967
+ },
968
+ }),
969
+ host.webServer.register({
970
+ kind: 'exact',
971
+ path: '/taskflow/health',
972
+ handler: (_request, response) => {
973
+ sendJson(response, 200, {
974
+ ok: true,
975
+ version: VERSION,
976
+ sessions: states.size,
977
+ queue: queue.length,
978
+ client: clientHello,
979
+ stateHits,
980
+ lastStateAgoMs: lastStateAt === 0 ? null : Date.now() - lastStateAt,
981
+ summaryRequests,
982
+ renders,
983
+ streams: streams.size,
984
+ streamSessions: [...streams.keys()].map((id) => id.slice(-8)),
985
+ policy: policyRegistered,
986
+ seen: [...seenSessions.entries()].slice(-6).map(([id, info]) => ({ id: id.slice(-8), ...info })),
987
+ parallelPolicy: parallelPolicyRegistered,
988
+ })
989
+ },
990
+ }),
991
+ // Full segment list, fetched only when the sidebar is open.
992
+ host.webServer.register({
993
+ kind: 'exact',
994
+ path: '/taskflow/segments',
995
+ handler: (request, response) => {
996
+ const url = new URL(request.url ?? '/', 'http://localhost')
997
+ const sessionId = url.searchParams.get('session') ?? ''
998
+ if (!sessionId) {
999
+ sendJson(response, 400, { ok: false, error: 'missing session' })
1000
+ return
1001
+ }
1002
+ const state = stateOf(sessionId)
1003
+ sendJson(response, 200, { ok: true, sessionId, segments: state.segments })
1004
+ },
1005
+ }),
1006
+ // One task's detail (steps, files, summary), fetched when a row is clicked.
1007
+ host.webServer.register({
1008
+ kind: 'exact',
1009
+ path: '/taskflow/task',
1010
+ handler: (request, response) => {
1011
+ const url = new URL(request.url ?? '/', 'http://localhost')
1012
+ const sessionId = url.searchParams.get('session') ?? ''
1013
+ const content = url.searchParams.get('content') ?? ''
1014
+ const item = stateOf(sessionId).tasks.items[content]
1015
+ if (!item) {
1016
+ sendJson(response, 404, { ok: false, error: 'task not found' })
1017
+ return
1018
+ }
1019
+ sendJson(response, 200, { ok: true, item })
1020
+ },
1021
+ }),
1022
+ // Live push: one long-lived connection per open session. Falls back to
1023
+ // polling on the client whenever this stream cannot be established.
1024
+ host.webServer.register({
1025
+ kind: 'exact',
1026
+ path: '/taskflow/events',
1027
+ handler: (request, response) => {
1028
+ const url = new URL(request.url ?? '/', 'http://localhost')
1029
+ const sessionId = url.searchParams.get('session') ?? ''
1030
+ if (sessionId.length === 0) {
1031
+ sendJson(response, 400, { ok: false, error: 'missing session' })
1032
+ return
1033
+ }
1034
+ response.writeHead(200, {
1035
+ 'content-type': 'text/event-stream; charset=utf-8',
1036
+ 'cache-control': 'no-cache, no-transform',
1037
+ connection: 'keep-alive',
1038
+ 'x-accel-buffering': 'no',
1039
+ })
1040
+ response.write(': connected\n\n')
1041
+ const state = stateOf(sessionId)
1042
+ const live = typeof ctx.get === 'function' ? ctx.get('sessions')?.get?.(sessionId) : undefined
1043
+ if (live) {
1044
+ const derived = deriveOpenTurn(live)
1045
+ if (derived !== undefined) state.openTurn = derived
1046
+ }
1047
+ pushFrame(response, state)
1048
+ let set = streams.get(sessionId)
1049
+ if (!set) {
1050
+ set = new Set()
1051
+ streams.set(sessionId, set)
1052
+ }
1053
+ if (set.size >= 4) {
1054
+ response.write('event: error\ndata: {"reason":"too many listeners"}\n\n')
1055
+ response.end()
1056
+ return
1057
+ }
1058
+ set.add(response)
1059
+ const heartbeat = setInterval(() => {
1060
+ try {
1061
+ response.write(': ping\n\n')
1062
+ } catch {
1063
+ // closed socket; the close handler cleans up
1064
+ }
1065
+ }, 20000)
1066
+ const cleanup = () => {
1067
+ clearInterval(heartbeat)
1068
+ set.delete(response)
1069
+ if (set.size === 0) streams.delete(sessionId)
1070
+ }
1071
+ request.on('close', cleanup)
1072
+ response.on('close', cleanup)
1073
+ },
1074
+ }),
1075
+ // Handshake from the browser half: proves the bundle executed in the
1076
+ // client and tells us which build is live.
1077
+ host.webServer.register({
1078
+ kind: 'exact',
1079
+ path: '/taskflow/client-ready',
1080
+ handler: async (request, response) => {
1081
+ if (request.method !== 'POST') {
1082
+ sendJson(response, 405, { ok: false, error: 'method not allowed' })
1083
+ return
1084
+ }
1085
+ let body = {}
1086
+ try {
1087
+ body = await readBody(request)
1088
+ } catch {
1089
+ body = {}
1090
+ }
1091
+ const build = typeof body?.build === 'string' ? body.build : 'unknown'
1092
+ // A new browser build invalidates every geometry report from the old one.
1093
+ if (!clientHello || clientHello.build !== build) {
1094
+ for (const key of Object.keys(renders)) delete renders[key]
1095
+ }
1096
+ clientHello = {
1097
+ at: Date.now(),
1098
+ build,
1099
+ href: typeof body?.href === 'string' ? clip(body.href, 200) : '',
1100
+ ua: typeof body?.ua === 'string' ? clip(body.ua, 200) : '',
1101
+ }
1102
+ log(ctx, 'info', `[taskflow] client half connected (build=${clientHello.build})`)
1103
+ sendJson(response, 200, { ok: true, version: VERSION })
1104
+ },
1105
+ }),
1106
+ // Geometry handshake: the browser half measures what it actually
1107
+ // painted, so "is the UI there?" has an answer without a screenshot.
1108
+ host.webServer.register({
1109
+ kind: 'exact',
1110
+ path: '/taskflow/client-report',
1111
+ handler: async (request, response) => {
1112
+ if (request.method !== 'POST') {
1113
+ sendJson(response, 405, { ok: false, error: 'method not allowed' })
1114
+ return
1115
+ }
1116
+ let body = {}
1117
+ try {
1118
+ body = await readBody(request)
1119
+ } catch {
1120
+ body = {}
1121
+ }
1122
+ const part = typeof body?.part === 'string' ? clip(body.part, 40) : 'unknown'
1123
+ renders[part] = {
1124
+ at: Date.now(),
1125
+ build: typeof body?.build === 'string' ? body.build : 'unknown',
1126
+ mounted: body?.mounted === true,
1127
+ visible: body?.visible === true,
1128
+ width: typeof body?.width === 'number' ? Math.round(body.width) : 0,
1129
+ height: typeof body?.height === 'number' ? Math.round(body.height) : 0,
1130
+ detail: typeof body?.detail === 'string' ? clip(body.detail, 2000) : '',
1131
+ }
1132
+ sendJson(response, 200, { ok: true })
1133
+ },
1134
+ }),
1135
+ // End-to-end prompt check: does the assembled system prompt really carry
1136
+ // our policy? Reads the session log instead of guessing.
1137
+ host.webServer.register({
1138
+ kind: 'exact',
1139
+ path: '/taskflow/prompt-check',
1140
+ handler: (request, response) => {
1141
+ const url = new URL(request.url ?? '/', 'http://localhost')
1142
+ const sessionId = url.searchParams.get('session') ?? ''
1143
+ const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : undefined
1144
+ const session = sessionId.length > 0 ? sessions?.get?.(sessionId) : undefined
1145
+ if (!session) {
1146
+ sendJson(response, 404, { ok: false, error: 'session is not live' })
1147
+ return
1148
+ }
1149
+ let last = ''
1150
+ let count = 0
1151
+ try {
1152
+ for (const event of session.snapshotEvents()) {
1153
+ if (event.type !== 'system/message') continue
1154
+ const text = textOf(event.data?.message) || textOf(event.data)
1155
+ if (text.length > 0) {
1156
+ last = text
1157
+ count += 1
1158
+ }
1159
+ }
1160
+ } catch (error) {
1161
+ sendJson(response, 500, { ok: false, error: String(error?.message ?? error) })
1162
+ return
1163
+ }
1164
+ const marker = '任务清单规范'
1165
+ const parallel = '复杂任务:并行拆解'
1166
+ const at = last.indexOf(marker)
1167
+ sendJson(response, 200, {
1168
+ ok: true,
1169
+ systemMessages: count,
1170
+ promptChars: last.length,
1171
+ hasTaskPolicy: at >= 0,
1172
+ hasParallelPolicy: last.includes(parallel),
1173
+ excerpt: at >= 0 ? clip(last.slice(at, at + 120), 200) : clip(last.slice(-200), 200),
1174
+ })
1175
+ },
1176
+ }),
1177
+ // Deployment self-check: did the browser half get discovered, and which
1178
+ // revisioned bundle URL is the shell expected to load?
1179
+ host.webServer.register({
1180
+ kind: 'exact',
1181
+ path: '/taskflow/diagnostics',
1182
+ handler: async (_request, response) => {
1183
+ const registry = typeof ctx.get === 'function' ? ctx.get('clientModules') : undefined
1184
+ let clientPath = null
1185
+ let entry = null
1186
+ try {
1187
+ clientPath = registry?.clientPath?.('dsh-plugin-taskflow') ?? null
1188
+ } catch {
1189
+ clientPath = null
1190
+ }
1191
+ try {
1192
+ const graph = registry?.graph?.()
1193
+ entry = graph?.entries?.find((row) => row.id === 'dsh-plugin-taskflow') ?? null
1194
+ } catch {
1195
+ entry = null
1196
+ }
1197
+ const url = new URL(_request.url ?? '/', 'http://localhost')
1198
+ if (url.searchParams.get('llm') !== '1') {
1199
+ sendJson(response, 200, { ok: true, version: VERSION, clientPath, entry })
1200
+ return
1201
+ }
1202
+ // Opt-in live check: one tiny call proves the profile can summarize.
1203
+ const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : undefined
1204
+ const live = sessions?.list?.() ?? []
1205
+ const probe = live.find((candidate) => !isSubagentSession(candidate)) ?? live[0]
1206
+ const route = probe ? resolveRoute(probe) : null
1207
+ if (!llm || !route) {
1208
+ sendJson(response, 200, { ok: false, version: VERSION, reason: !llm ? 'llm service is not available' : 'no provider/model route', clientPath, entry })
1209
+ return
1210
+ }
1211
+ const started = Date.now()
1212
+ try {
1213
+ const result = await streamCompletion(route, '你只回复四个字:链路正常', '请回复链路正常四字', probe?.id)
1214
+ sendJson(response, 200, { ok: true, version: VERSION, route, text: result.text, ms: Date.now() - started, clientPath, entry })
1215
+ } catch (error) {
1216
+ sendJson(response, 200, { ok: false, version: VERSION, route, error: String(error?.message ?? error), ms: Date.now() - started, clientPath, entry })
1217
+ }
1218
+ },
1219
+ }),
1220
+ ]
1221
+ return () => {
1222
+ for (const dispose of disposers) {
1223
+ try {
1224
+ dispose()
1225
+ } catch {
1226
+ // ignore
1227
+ }
1228
+ }
1229
+ }
1230
+ }, 'taskflow: http routes')
1231
+ })
1232
+
1233
+ /** Manual trigger: summarize the newest turn that has no segment yet. */
1234
+ function summarizeLatestTurn(session, state, requestedTurn, force = false) {
1235
+ const all = session.snapshotEvents?.() ?? []
1236
+ let startIndex = -1
1237
+ let endIndex = -1
1238
+ for (let index = all.length - 1; index >= 0; index -= 1) {
1239
+ const event = all[index]
1240
+ if (event.type === 'turn/end' && (requestedTurn === undefined || requestedTurn === null || event.data?.turn === requestedTurn)) {
1241
+ endIndex = index
1242
+ for (let start = index - 1; start >= 0; start -= 1) {
1243
+ if (all[start].type === 'turn/start' && all[start].data?.turn === event.data?.turn) {
1244
+ startIndex = start
1245
+ break
1246
+ }
1247
+ }
1248
+ break
1249
+ }
1250
+ }
1251
+ if (endIndex < 0 || startIndex < 0) return null
1252
+ const turn = all[endIndex].data?.turn
1253
+ if (state.segments.some((item) => item.turn === turn)) {
1254
+ if (!force) return null
1255
+ state.segments = state.segments.filter((item) => item.turn !== turn)
1256
+ }
1257
+ const events = all.slice(startIndex, endIndex + 1)
1258
+ const digest = buildDigest(events, session.meta?.cwd)
1259
+ if (digest.digest.length === 0) return null
1260
+ return summarizeTurn(session, state, {
1261
+ turn,
1262
+ startedAt: all[startIndex].time,
1263
+ endedAt: all[endIndex].time,
1264
+ digest: clip(digest.digest, Math.max(1000, config.maxDigestChars)),
1265
+ toolCalls: digest.toolCalls,
1266
+ files: digest.files,
1267
+ commands: digest.commands,
1268
+ digestParts: {
1269
+ userText: firstUserText(events),
1270
+ toolCalls: digest.toolCalls,
1271
+ toolNames: toolNamesOf(events),
1272
+ files: digest.files,
1273
+ commands: digest.commands,
1274
+ assistantTail: lastAssistantText(events),
1275
+ },
1276
+ })
1277
+ }
1278
+
1279
+ // Complex work decomposes into parallel tracks; absorbed from the SDK
1280
+ // workflows/parallel-agents SOP, compressed to what the model can act on.
1281
+ if (config.parallelPolicy) {
1282
+ ctx.inject(['systemPrompt'], (host) => {
1283
+ host.effect(() => host.systemPrompt.section({
1284
+ name: 'taskflow:parallel-policy',
1285
+ order: 9010,
1286
+ text: [
1287
+ '## 复杂任务:并行拆解',
1288
+ '- 触发:出现 2 个以上可独立回答的未知,或 2 个以上可隔离的产物/模块。单文件单步不硬拆。',
1289
+ '- 粒度:按「可独立消费的产物」切,每条写清产物、消费者与停止条件;不按文件数量机械切分。',
1290
+ '- 执行:独立项用 subagent 或 workflow 并发派出(数量按宿主槽位减一),主代理同时推进不依赖它们的关键路径;有依赖的串行交同一 agent 续作。',
1291
+ '- 清单:并行项同时标 in_progress 并在文本前加「∥」;被依赖项保持 pending,依赖解除后再置 in_progress。',
1292
+ '- 诚实:以真实工具调用作为派发证据;派发失败要保留该条并记录原因,继续推进可串行的工作。',
1293
+ ].join('\n'),
1294
+ }), 'taskflow: parallel policy')
1295
+ parallelPolicyRegistered = true
1296
+ log(ctx, 'info', '[taskflow] parallel policy registered')
1297
+ })
1298
+ }
1299
+
1300
+ // Always decompose, in Chinese, short items — enforced where the model reads it.
1301
+ if (config.taskListPolicy) {
1302
+ ctx.inject(['systemPrompt'], (host) => {
1303
+ host.effect(() => host.systemPrompt.section({
1304
+ name: 'taskflow:task-list-policy',
1305
+ order: 9000,
1306
+ text: [
1307
+ '## 任务清单规范',
1308
+ '- 每次收到用户请求,第一个动作必须是 todo_write:先拆成可执行条目再动手,哪怕只有一条,也要写。',
1309
+ '- 禁止先干活再补清单:不允许把已经做完的事批量写成 completed 列表(那等于事后编造计划)。',
1310
+ '- 每条用中文书写,动词开头,最多 35 个词(中文约 60 字);一条只做一件事,但要把对象和判据写全。',
1311
+ '- 写清对象(文件、命令、结论),不要用「处理一下」「继续优化」这类空话。',
1312
+ '- 同一时间只保留一个 in_progress;做完一项立刻勾掉,不要攒到最后批量改。',
1313
+ ].join('\n'),
1314
+ }), 'taskflow: task list policy')
1315
+
1316
+ // Runtime context is appended after the sections on every request, so the
1317
+ // reminder stays adjacent to the user turn instead of scrolling away.
1318
+ host.effect(() => host.systemPrompt.context({
1319
+ name: 'taskflow:plan-first',
1320
+ order: 20000,
1321
+ text: '执行前先建任务清单:本轮若还没调用过 todo_write,先调用它列出要做的事(哪怕只有一条),再开始动手。',
1322
+ }), 'taskflow: plan-first reminder')
1323
+ policyRegistered = true
1324
+ log(ctx, 'info', '[taskflow] task-list policy registered')
1325
+ })
1326
+ }
1327
+
1328
+ ctx.inject(['commands'], (host) => {
1329
+ host.effect(() => host.commands.register({
1330
+ name: 'taskflow',
1331
+ description: '把刚结束的一段工作整理成小结(TaskFlow)',
1332
+ input: { hint: '[now]', attachments: false },
1333
+ handler: (invocation) => {
1334
+ const session = invocation?.agent?.session
1335
+ if (!session) return { kind: 'error', text: 'taskflow: 没有可用的会话' }
1336
+ const queued = summarizeLatestTurn(session, stateOf(session.id))
1337
+ return queued
1338
+ ? { kind: 'success', text: 'taskflow: 已开始整理本段小结,稍后出现在对话与侧栏。' }
1339
+ : { kind: 'success', text: 'taskflow: 最近一段已有小结,或内容太少无需总结。' }
1340
+ },
1341
+ }), 'taskflow: /taskflow command')
1342
+ })
1343
+
1344
+ ctx.effect(() => {
1345
+ log(ctx, 'info', `[taskflow] v${VERSION} ready (enabled=${config.enabled})`)
1346
+ return () => {
1347
+ for (const timer of saveTimers.values()) clearTimeout(timer)
1348
+ saveTimers.clear()
1349
+ }
1350
+ }, 'taskflow: lifecycle')
1351
+ }
1352
+
1353
+ /**
1354
+ * Where the turn actually began. The in-memory marker is authoritative; when the
1355
+ * plugin mounted mid-turn (or after a reload) fall back to the log, scanning for
1356
+ * the matching `turn/start` instead of guessing a fixed event window — a long
1357
+ * turn would otherwise lose its user request and produce a useless summary.
1358
+ * @returns {{seq: number|undefined, time: number|undefined}} opening coordinates
1359
+ */
1360
+ function locateTurnStart(session, state, turn, open) {
1361
+ if (open) return { seq: open.seq, time: open.time }
1362
+ const known = turn === null ? undefined : state.turns[turn]?.startSeq
1363
+ if (typeof known === 'number') return { seq: known, time: undefined }
1364
+ if (turn === null || typeof session.snapshotEvents !== 'function') return { seq: undefined, time: undefined }
1365
+ try {
1366
+ const all = session.snapshotEvents()
1367
+ for (let index = all.length - 1; index >= 0; index -= 1) {
1368
+ const event = all[index]
1369
+ if (event.type === 'turn/start' && event.data?.turn === turn) return { seq: event.seq, time: event.time }
1370
+ }
1371
+ } catch {
1372
+ // fall through to the bounded window
1373
+ }
1374
+ return { seq: undefined, time: undefined }
1375
+ }
1376
+
1377
+ /** Keep the per-turn index bounded; it only serves manual summaries. */
1378
+ function pruneTurns(state) {
1379
+ const turns = Object.keys(state.turns)
1380
+ if (turns.length <= 200) return
1381
+ turns.sort((a, b) => Number(a) - Number(b))
1382
+ for (const turn of turns.slice(0, turns.length - 200)) delete state.turns[turn]
1383
+ }
1384
+
1385
+ /** Remember that a turn deliberately has no summary, so the UI stops waiting. */
1386
+ function markSkipped(state, turn) {
1387
+ if (!state.skipped.includes(turn)) state.skipped.push(turn)
1388
+ if (state.skipped.length > 200) state.skipped.splice(0, state.skipped.length - 200)
1389
+ }
1390
+
1391
+ /** First human request in a turn's events, for the mechanical fallback title. */
1392
+ function firstUserText(events) {
1393
+ for (const event of events) {
1394
+ if (event.type !== 'user/message') continue
1395
+ const text = textOf(event.data).trim()
1396
+ if (text.length > 0) return clip(text.replace(/\s+/g, ' '), 60)
1397
+ }
1398
+ return ''
1399
+ }
1400
+
1401
+ /** Distinct tool names used in a turn. */
1402
+ function toolNamesOf(events) {
1403
+ const names = []
1404
+ for (const event of events) {
1405
+ if (event.type !== 'tool/call') continue
1406
+ const toolName = String(event.data?.name ?? 'tool')
1407
+ if (!names.includes(toolName)) names.push(toolName)
1408
+ }
1409
+ return names
1410
+ }
1411
+
1412
+ /** Last assistant prose in a turn. */
1413
+ function lastAssistantText(events) {
1414
+ let text = ''
1415
+ for (const event of events) {
1416
+ if (event.type !== 'assistant/message') continue
1417
+ const value = textOf(event.data?.message).trim()
1418
+ if (value.length > 0) text = value
1419
+ }
1420
+ return text
1421
+ }