@modesto_ma/dsh-channels 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.
Files changed (73) hide show
  1. package/README.md +302 -2
  2. package/cordis.patch.yml +3 -0
  3. package/docs/access-control-design.md +550 -0
  4. package/docs/assets/web-settings-panel.png +0 -0
  5. package/docs/assets/web-settings-restart-pending.png +0 -0
  6. package/docs/feasibility-and-design.md +213 -0
  7. package/docs/feishu-onboarding.md +268 -0
  8. package/docs/persistence.md +199 -0
  9. package/docs/refactor-plan.md +56 -0
  10. package/docs/review-dsh-0.2.0.md +389 -0
  11. package/docs/slash-commands-implementation-plan.md +410 -0
  12. package/docs/web-display-only-surface-roadmap.md +363 -0
  13. package/lib/client.js +2226 -0
  14. package/lib/client.js.map +1 -0
  15. package/lib/index.js +10217 -0
  16. package/lib/index.js.map +1 -0
  17. package/package.json +98 -4
  18. package/src/access/cards.ts +188 -0
  19. package/src/access/controller.ts +201 -0
  20. package/src/access/index.ts +13 -0
  21. package/src/access/policy.ts +103 -0
  22. package/src/access/requests.ts +512 -0
  23. package/src/access/store.ts +220 -0
  24. package/src/approvals.ts +8 -0
  25. package/src/cards/approval.ts +114 -0
  26. package/src/cards/index.ts +11 -0
  27. package/src/cards/primitives.ts +82 -0
  28. package/src/cards/question.ts +141 -0
  29. package/src/cards/streaming.ts +563 -0
  30. package/src/cards.ts +8 -0
  31. package/src/cleanup.ts +398 -0
  32. package/src/client/api.ts +262 -0
  33. package/src/client/catalog.ts +92 -0
  34. package/src/client/index.ts +61 -0
  35. package/src/client/panel.tsx +1475 -0
  36. package/src/client/styles.ts +98 -0
  37. package/src/commands/registry.ts +155 -0
  38. package/src/commands.ts +335 -0
  39. package/src/config.ts +206 -0
  40. package/src/domain.ts +428 -0
  41. package/src/dsh-types.ts +11 -0
  42. package/src/feishu/agent-context.ts +62 -0
  43. package/src/feishu/delivery.ts +188 -0
  44. package/src/feishu/inbound-attachments.ts +436 -0
  45. package/src/feishu-channel.ts +339 -0
  46. package/src/health.ts +233 -0
  47. package/src/host/agent.ts +181 -0
  48. package/src/host/commands.ts +63 -0
  49. package/src/host/core.ts +345 -0
  50. package/src/host/index.ts +12 -0
  51. package/src/host/interaction.ts +101 -0
  52. package/src/host/services.ts +320 -0
  53. package/src/index.ts +122 -0
  54. package/src/interactions/approvals.ts +154 -0
  55. package/src/interactions/pending.ts +162 -0
  56. package/src/interactions/questions.ts +274 -0
  57. package/src/questions.ts +8 -0
  58. package/src/routing/inbound.ts +524 -0
  59. package/src/service.ts +2138 -0
  60. package/src/session/manager.ts +352 -0
  61. package/src/session/repair.ts +157 -0
  62. package/src/session/request-policy.ts +192 -0
  63. package/src/session/routes.ts +165 -0
  64. package/src/session-commands.ts +310 -0
  65. package/src/session-export.ts +112 -0
  66. package/src/settings/catalog.ts +309 -0
  67. package/src/settings/contract.ts +508 -0
  68. package/src/settings/overlay.ts +164 -0
  69. package/src/settings/routes.ts +409 -0
  70. package/src/settings/surface.ts +773 -0
  71. package/src/stats.ts +485 -0
  72. package/src/streamer.ts +1108 -0
  73. package/src/workspaces.ts +199 -0
@@ -0,0 +1,1108 @@
1
+ /**
2
+ * Per-turn streaming card state: folds live session events into card snapshots
3
+ * and patches the Feishu card on a fixed refresh interval (default 500 ms).
4
+ *
5
+ * Lifecycle per (session, user message):
6
+ * 1. `attach()` — the reply card was just sent; bind it to the session as
7
+ * segment 1.
8
+ * 2. `session/event` consumption — text deltas fill the body, reasoning
9
+ * deltas fill the fold panel, usage/header events advance the stats fold,
10
+ * and tool-call / workflow-record events feed the two transient activity
11
+ * lines: the delegation status line (子代理 / 工作流 progress, settled
12
+ * workflow outcome, heartbeat) and the tool line (the parent's in-flight
13
+ * tool call). The lines are INDEPENDENT — a later tool call never
14
+ * supersedes the settled workflow status. Subagent children live in their
15
+ * own sessions: their `subagent/descriptor` / `tool/call` / `turn/end`
16
+ * events are folded into the parent turn's status line through
17
+ * `session.header.parentSession` (status only — the child's text output
18
+ * never reaches the card).
19
+ * 3. When the body outgrows one card's budget, the overflow is split at a
20
+ * clean breakpoint and sent as a continuation segment card (segment 2, 3,
21
+ * …) into the same topic; the stream then keeps flowing into the newest
22
+ * card. Reasoning is never segmented — it stays on card 1 and is clipped
23
+ * to the leftover budget (body has priority).
24
+ * 4. `turnEnded(turn)` — finalize every segment card (`回复结束` / `回复失败`)
25
+ * and request the Typing → DONE/No reaction swap on the triggering message.
26
+ *
27
+ * The first visible text delta flips the panel title 推理中 → 回复中; the
28
+ * final patch flips it to 回复结束 or 回复失败. Patching is throttled to `refreshMs` and
29
+ * only runs when the content actually changed. Segment sends are serialized
30
+ * per session so cards arrive in order.
31
+ *
32
+ * Keep-alive: turns that produce no text deltas for a while (tool calls,
33
+ * subagent/workflow runs, provider-side work) are kept visibly alive by the
34
+ * activity lines, whose elapsed counters are re-rendered on a slow heartbeat
35
+ * (`heartbeatMs`, default 5 s) — the card never sits silently on a live turn.
36
+ *
37
+ * @module dsh-channels/streamer
38
+ */
39
+
40
+ import type { AssistantStreamFrame, Session, SessionEvent, TurnEndReason } from './dsh-types.ts'
41
+ import { buildCard, segmentCardContent } from './cards.ts'
42
+ import type { CardState, PanelPhase } from './cards.ts'
43
+ import type { FeishuChannel } from './feishu-channel.ts'
44
+ import { cacheHitPct, foldAll, foldEvent } from './stats.ts'
45
+ import type { HeaderSample, StatsFold, UsageSample } from './stats.ts'
46
+
47
+ /**
48
+ * Compact build marker printed by the service at startup so a deployment's
49
+ * console can confirm which build of the activity status line is loaded.
50
+ * Bump on every behavior change to this feature.
51
+ */
52
+ export const ACTIVITY_BUILD_MARKER = 'activity-status-v6'
53
+
54
+ /** How a segment card reached Feishu. */
55
+ export interface SegmentDelivery {
56
+ messageId: string
57
+ /** `card` segments stream via `updateCard`; `text` messages are final
58
+ * (delivered as a plain-text fallback and never patched afterwards). */
59
+ mode: 'card' | 'text'
60
+ }
61
+
62
+ /** Card figures read from host projections (all optional: unit may be unmounted). */
63
+ export interface ProjectedStats {
64
+ inputTokens?: number
65
+ outputTokens?: number
66
+ cacheHitPct?: number
67
+ contextWindow?: number
68
+ }
69
+
70
+ export interface StreamerHooks {
71
+ /** Fetch current context pressure for one session (may be expensive → cached by caller). */
72
+ measureContext(session: Session): { contextTokens?: number; contextWindow?: number }
73
+ /**
74
+ * Host-projection-backed figures for one session. When present these win
75
+ * over the local incremental fold (the host units fold the durable embedded
76
+ * assistant streams the plugin cannot see as live events).
77
+ */
78
+ projectStats?(session: Session): ProjectedStats | undefined
79
+ /** Resolve the session id label shown on the card. */
80
+ sessionLabel(sessionId: string): string
81
+ /** Log a non-fatal streaming error. */
82
+ onError(sessionId: string, error: unknown): void
83
+ /**
84
+ * Invoked once when a turn's card reaches its final state. Carries the main
85
+ * card's message id so the caller can retire the turn's in-flight repair
86
+ * snapshot (the id is the snapshot's storage key).
87
+ */
88
+ onTurnFinalized(sessionId: string, turn: number, mainCardMessageId: string): void
89
+ /**
90
+ * Invoked when a continuation segment's message id resolves (delivery
91
+ * succeeded). Lets the caller fold the resolved id into the turn's repair
92
+ * snapshot. `segmentIndex` is the card index within the turn (`0` is the
93
+ * main card; never reported).
94
+ */
95
+ onSegmentDelivered?(
96
+ mainCardMessageId: string,
97
+ segmentIndex: number,
98
+ messageId: string,
99
+ mode: SegmentDelivery['mode'],
100
+ ): void
101
+ /**
102
+ * Send one continuation segment card for a turn (calls arrive in segment
103
+ * order per session). Implementations may fall back to a plain-text message
104
+ * (reporting `mode: 'text'`) when the card is rejected — e.g. Feishu's
105
+ * per-card table limit (error 11310) — so the content always reaches the
106
+ * user. `fallbackText` carries the raw segment body for that fallback.
107
+ */
108
+ sendSegmentCard(
109
+ sessionId: string,
110
+ segment: number,
111
+ card: object,
112
+ fallbackText: string,
113
+ ): Promise<SegmentDelivery>
114
+ }
115
+
116
+ /** One segment card of a turn. Card 1 is the attached reply card; further
117
+ * cards are created lazily when the body overflows the previous card. */
118
+ interface SegmentCard {
119
+ /** Feishu message id; undefined until the segment send resolves. */
120
+ messageId: string | undefined
121
+ /** Body accumulated for this segment (frozen once a later segment exists). */
122
+ body: string
123
+ /** True when the card has unsent content for its message id. */
124
+ dirty: boolean
125
+ /** True once this card stopped accepting body growth. */
126
+ frozen: boolean
127
+ /** Delivery mode once known; `text` segments are never patched. */
128
+ mode: SegmentDelivery['mode'] | undefined
129
+ /** True when delivery failed permanently (logged; never retried again). */
130
+ failed: boolean
131
+ /** True while a segment send is enqueued/in flight (prevents duplicate sends). */
132
+ sending: boolean
133
+ /** Consecutive update failures (drives the retry backoff). */
134
+ failures: number
135
+ /** Earliest timestamp for the next update attempt. */
136
+ nextRetryAt: number
137
+ }
138
+
139
+ interface TurnState {
140
+ /** Turn number once known; undefined until the first `step/start`. */
141
+ turn: number | undefined
142
+ /** Message whose reaction carries Typing → DONE/No. */
143
+ triggerMessageId: string
144
+ /** Reaction id of the Typing reaction (undefined when the add failed). */
145
+ reactionId: string | undefined
146
+ phase: PanelPhase
147
+ reasoning: string
148
+ /** Request header produced during THIS turn; never inherited from history. */
149
+ header: HeaderSample | undefined
150
+ /** Provider usage produced during THIS turn; never inherited from history. */
151
+ lastUsage: UsageSample | undefined
152
+ /** Structured terminal failure for this turn, when `turn/end.kind=error`. */
153
+ failure: { message: string; code?: string } | undefined
154
+ /** Segment cards in order; `cards[0]` is the attached reply card. */
155
+ cards: SegmentCard[]
156
+ dirty: boolean
157
+ lastPatchAt: number
158
+ timer: ReturnType<typeof setTimeout> | undefined
159
+ finalized: boolean
160
+ /** Set by `detach()`; pending sends then stop scheduling further patches. */
161
+ disposed: boolean
162
+ /** Unix ms when the turn was attached (heartbeat elapsed base). */
163
+ turnStartedAt: number
164
+ /** The in-flight tool call, if any (cleared by its `tool/result`). */
165
+ toolActivity: { name: string; label: string; since: number } | undefined
166
+ /** Live workflow-run folds projected by the workflow tool into this session. */
167
+ workflowRuns: Map<string, WorkflowRun>
168
+ /** The last settled workflow run (kept visible until the next activity). */
169
+ workflowEndStatus: { name: string; stopReason: string } | undefined
170
+ /**
171
+ * Live subagent child sessions folded into this turn (child session id →
172
+ * status). Children are discovered through `session.header.parentSession`
173
+ * and removed when their own turn ends.
174
+ */
175
+ children: Map<string, ChildActivity>
176
+ /**
177
+ * Labels of subagent children that FINISHED during this turn (capped). They
178
+ * leave the live `children` fold at their turn/end but are summarized on
179
+ * the settled card: `🤖 子代理完成:a、b`.
180
+ */
181
+ settled: string[]
182
+ /** Currently rendered delegation status line (recomputed on every snapshot). */
183
+ status: string
184
+ /** Kind of the status line currently logged (change-detection only). */
185
+ statusKind: string
186
+ /** Currently rendered tool-call line (recomputed on every snapshot). */
187
+ tool: string
188
+ /** Kind of the tool line currently logged (change-detection only). */
189
+ toolKind: string
190
+ /** Slow keep-alive timer re-rendering the activity elapsed counter. */
191
+ heartbeatTimer: ReturnType<typeof setTimeout> | undefined
192
+ /**
193
+ * Rollback marks for live stream attempts (`attemptId` → lengths at the
194
+ * attempt's `start` frame). An `abandoned` attempt's deltas never reach the
195
+ * durable model history, so the card must drop them again.
196
+ */
197
+ attempts: Map<string, AttemptMark>
198
+ }
199
+
200
+ /** Card/body lengths captured at one stream attempt's `start` frame. */
201
+ interface AttemptMark {
202
+ cardCount: number
203
+ bodyLengths: number[]
204
+ reasoningLength: number
205
+ }
206
+
207
+ /** One live `tool-workflow/run-*` fold: run identity and member progress. */
208
+ interface WorkflowRun {
209
+ name: string
210
+ startedAt: number
211
+ /** Members started so far. */
212
+ started: number
213
+ /** Members settled so far. */
214
+ ended: number
215
+ /** Still-running members (started minus ended), in start order. */
216
+ active: Array<{ seq: number; label: string }>
217
+ /** Latest phase declared by a member (`phase()` in the workflow script). */
218
+ phase?: string
219
+ /**
220
+ * Child session ids of this run's members — their own events are already
221
+ * aggregated by the workflow line and must not be folded again as generic
222
+ * children.
223
+ */
224
+ memberIds: Set<string>
225
+ }
226
+
227
+ /** One running subagent child's displayable status. */
228
+ interface ChildActivity {
229
+ /** Delegation description (subagent/descriptor label), default 子代理. */
230
+ label: string
231
+ /** The child's latest tool call, if any. */
232
+ tool?: string
233
+ /** When the child was first observed (elapsed base). */
234
+ since: number
235
+ }
236
+
237
+ /** Hard cap on the activity line so it can never threaten the card budget. */
238
+ const ACTIVITY_MAX_CHARS = 64
239
+
240
+ /** Trim an activity line to `ACTIVITY_MAX_CHARS` (ellipsis counts as one char). */
241
+ function clipActivity(line: string): string {
242
+ return line.length <= ACTIVITY_MAX_CHARS ? line : `${line.slice(0, ACTIVITY_MAX_CHARS - 1)}…`
243
+ }
244
+
245
+ /** `已运行 45s` / `已运行 2分5s` — the activity line's elapsed counter. */
246
+ function fmtElapsed(ms: number): string {
247
+ const total = Math.max(0, Math.floor(ms / 1000))
248
+ if (total < 60) return `已运行 ${total}s`
249
+ return `已运行 ${Math.floor(total / 60)}分${total % 60}s`
250
+ }
251
+
252
+ /**
253
+ * Human label for one tool call: the subagent tool contributes its
254
+ * `description`, the workflow tool its `meta.name`; anything else uses the
255
+ * bare tool name. Arguments are model-produced JSON — parsing failures fall
256
+ * back to the tool name instead of failing the fold.
257
+ */
258
+ function toolActivityLabel(name: string, rawArguments: string): string {
259
+ if (name !== 'subagent' && name !== 'workflow') return name
260
+ try {
261
+ const args = JSON.parse(rawArguments) as Record<string, unknown>
262
+ if (name === 'subagent' && typeof args.description === 'string' && args.description !== '') {
263
+ return args.description
264
+ }
265
+ if (name === 'workflow') {
266
+ const meta = args.meta as Record<string, unknown> | undefined
267
+ if (meta !== undefined && typeof meta.name === 'string' && meta.name !== '') return meta.name
268
+ }
269
+ } catch {
270
+ // Unparseable arguments: fall back to the bare tool name.
271
+ }
272
+ return name
273
+ }
274
+
275
+ /** `completed`/`failed`/`cancelled` → the Chinese line fragment. */
276
+ const WORKFLOW_OUTCOMES: Record<string, string> = {
277
+ completed: '完成',
278
+ failed: '失败',
279
+ cancelled: '已取消',
280
+ error: '失败',
281
+ }
282
+
283
+ export class TurnStreamer {
284
+ private readonly channel: FeishuChannel
285
+ private readonly refreshMs: number
286
+ private readonly hooks: StreamerHooks
287
+ /** Slow keep-alive interval for the activity line's elapsed counter. */
288
+ private readonly heartbeatMs: number
289
+ /** sessionId → queue of turn states (FIFO consumption of the driver). */
290
+ private readonly bySession = new Map<string, TurnState[]>()
291
+ /** sessionId → session-log stats fold (session cumulative). */
292
+ private readonly folds = new Map<string, StatsFold>()
293
+ /** sessionId → the Session object for context measurement. */
294
+ private readonly sessions = new Map<string, Session>()
295
+ /** sessionId → context pressure cache (refreshed every N patches). */
296
+ private readonly contextCache = new Map<string, { contextTokens?: number; contextWindow?: number }>()
297
+ /** sessionId → serialized segment-send chain (keeps card order). */
298
+ private readonly segmentChains = new Map<string, Promise<void>>()
299
+
300
+ constructor(channel: FeishuChannel, refreshMs: number, hooks: StreamerHooks, heartbeatMs: number = 5_000) {
301
+ this.channel = channel
302
+ this.refreshMs = refreshMs
303
+ this.hooks = hooks
304
+ this.heartbeatMs = heartbeatMs
305
+ }
306
+
307
+ /** Bind a freshly sent reply card to a session; seeds the stats fold from the log. */
308
+ attach(session: Session, cardMessageId: string, triggerMessageId: string, reactionId: string | undefined): void {
309
+ this.sessions.set(session.id, session)
310
+ let fold = this.folds.get(session.id)
311
+ if (fold === undefined) {
312
+ fold = foldAll(session.snapshotEvents())
313
+ this.folds.set(session.id, fold)
314
+ }
315
+ const queue = this.bySession.get(session.id) ?? []
316
+ queue.push({
317
+ turn: undefined,
318
+ triggerMessageId,
319
+ reactionId,
320
+ phase: 'thinking',
321
+ reasoning: '',
322
+ header: undefined,
323
+ lastUsage: undefined,
324
+ failure: undefined,
325
+ cards: [{
326
+ messageId: cardMessageId,
327
+ body: '',
328
+ dirty: true,
329
+ frozen: false,
330
+ mode: 'card',
331
+ failed: false,
332
+ sending: false,
333
+ failures: 0,
334
+ nextRetryAt: 0,
335
+ }],
336
+ dirty: true,
337
+ lastPatchAt: 0,
338
+ timer: undefined,
339
+ finalized: false,
340
+ disposed: false,
341
+ turnStartedAt: Date.now(),
342
+ toolActivity: undefined,
343
+ workflowRuns: new Map(),
344
+ workflowEndStatus: undefined,
345
+ children: new Map(),
346
+ settled: [],
347
+ status: '',
348
+ statusKind: '',
349
+ tool: '',
350
+ toolKind: '',
351
+ heartbeatTimer: undefined,
352
+ attempts: new Map(),
353
+ })
354
+ this.bySession.set(session.id, queue)
355
+ const state = queue[queue.length - 1]!
356
+ this.scheduleHeartbeat(session.id, state)
357
+ }
358
+
359
+ /** Drop all state for a session (agent disposed / route removed). */
360
+ detach(sessionId: string): void {
361
+ const queue = this.bySession.get(sessionId) ?? []
362
+ for (const state of queue) {
363
+ state.disposed = true
364
+ if (state.timer !== undefined) clearTimeout(state.timer)
365
+ if (state.heartbeatTimer !== undefined) clearTimeout(state.heartbeatTimer)
366
+ }
367
+ this.bySession.delete(sessionId)
368
+ this.folds.delete(sessionId)
369
+ this.sessions.delete(sessionId)
370
+ this.contextCache.delete(sessionId)
371
+ this.segmentChains.delete(sessionId)
372
+ }
373
+
374
+ hasSession(sessionId: string): boolean {
375
+ return this.bySession.has(sessionId)
376
+ }
377
+
378
+ /** The front (oldest unfinished) turn state of a session. */
379
+ private front(sessionId: string): TurnState | undefined {
380
+ const queue = this.bySession.get(sessionId)
381
+ if (queue === undefined || queue.length === 0) return undefined
382
+ for (const state of queue) {
383
+ if (!state.finalized) return state
384
+ }
385
+ return undefined
386
+ }
387
+
388
+ /** Consume one appended session event. */
389
+ onSessionEvent(session: Session, event: SessionEvent): void {
390
+ const fold = this.folds.get(session.id)
391
+ if (fold !== undefined) foldEvent(fold, event)
392
+ this.sessions.set(session.id, session)
393
+
394
+ // Subagent children live in their OWN sessions (header.parentSession →
395
+ // caller). Their events never touch the parent's log, so fold their
396
+ // status into the parent turn's activity line here.
397
+ const parentId = session.header.parentSession
398
+ if (parentId !== undefined) {
399
+ const parentState = this.front(parentId)
400
+ if (parentState !== undefined) {
401
+ this.foldChildEvent(parentId, parentState, session, event)
402
+ return
403
+ }
404
+ }
405
+
406
+ const state = this.front(session.id)
407
+ if (state === undefined) return
408
+
409
+ switch (event.type) {
410
+ case 'step/start': {
411
+ if (state.turn === undefined) {
412
+ state.turn = event.data.turn
413
+ this.markDirty(session.id, state)
414
+ }
415
+ break
416
+ }
417
+ case 'assistant/message': {
418
+ if (state.turn === undefined) state.turn = event.data.turn
419
+ if (event.data.usage !== undefined) {
420
+ state.lastUsage = {
421
+ usage: event.data.usage,
422
+ turn: event.data.turn,
423
+ step: event.data.step,
424
+ }
425
+ this.markDirty(session.id, state)
426
+ }
427
+ break
428
+ }
429
+ case 'request/header': {
430
+ const config = event.data.header.config
431
+ // Parse this exact event instead of copying the session fold's latest
432
+ // sample, so malformed/current events can never expose prior history.
433
+ state.header = {
434
+ provider: typeof config.provider === 'string' ? config.provider : '',
435
+ model: typeof config.model === 'string' ? config.model : '',
436
+ effort: typeof config.reasoningEffort === 'string' ? config.reasoningEffort : undefined,
437
+ }
438
+ this.markDirty(session.id, state)
439
+ break
440
+ }
441
+ case 'tool/call': {
442
+ if (state.turn === undefined) state.turn = event.data.turn
443
+ state.toolActivity = {
444
+ name: event.data.name,
445
+ label: toolActivityLabel(event.data.name, event.data.arguments),
446
+ since: Date.now(),
447
+ }
448
+ // The tool line is independent: it never supersedes the settled
449
+ // workflow end status on the delegation line (both render together).
450
+ this.markDirty(session.id, state)
451
+ break
452
+ }
453
+ case 'tool/result': {
454
+ if (state.turn === undefined) state.turn = event.data.turn
455
+ state.toolActivity = undefined
456
+ this.markDirty(session.id, state)
457
+ break
458
+ }
459
+ case 'tool-workflow/run-start': {
460
+ // Workflow record events carry no turn/step — they belong to the
461
+ // front (oldest unfinished) turn by construction.
462
+ state.workflowRuns.set(event.data.runId, {
463
+ name: event.data.name,
464
+ startedAt: Date.now(),
465
+ started: 0,
466
+ ended: 0,
467
+ active: [],
468
+ memberIds: new Set(),
469
+ })
470
+ state.workflowEndStatus = undefined
471
+ this.markDirty(session.id, state)
472
+ break
473
+ }
474
+ case 'tool-workflow/agent-start': {
475
+ const run = state.workflowRuns.get(event.data.runId)
476
+ if (run !== undefined) {
477
+ run.started++
478
+ run.active.push({ seq: event.data.seq, label: event.data.label })
479
+ run.memberIds.add(event.data.childId)
480
+ if (event.data.phase !== undefined) run.phase = event.data.phase
481
+ this.markDirty(session.id, state)
482
+ }
483
+ break
484
+ }
485
+ case 'tool-workflow/agent-end': {
486
+ const run = state.workflowRuns.get(event.data.runId)
487
+ if (run !== undefined) {
488
+ run.ended++
489
+ const index = run.active.findIndex(member => member.seq === event.data.seq)
490
+ if (index >= 0) run.active.splice(index, 1)
491
+ this.markDirty(session.id, state)
492
+ }
493
+ break
494
+ }
495
+ case 'tool-workflow/run-end': {
496
+ const run = state.workflowRuns.get(event.data.runId)
497
+ if (run !== undefined) {
498
+ state.workflowRuns.delete(event.data.runId)
499
+ state.workflowEndStatus = {
500
+ name: run.name,
501
+ stopReason: WORKFLOW_OUTCOMES[event.data.stopReason] ?? event.data.stopReason,
502
+ }
503
+ this.markDirty(session.id, state)
504
+ }
505
+ break
506
+ }
507
+ case 'turn/end': {
508
+ // The ended turn belongs to the front state: assign it if a turn was
509
+ // rejected/emptied before any step started.
510
+ if (state.turn === undefined) state.turn = event.data.turn
511
+ this.finalize(session.id, state, turnFailure(event.data.reason))
512
+ break
513
+ }
514
+ default:
515
+ break
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Consume one live assistant-stream frame (`agent/assistant-stream`).
521
+ *
522
+ * DSH stopped emitting durable `assistant/chunk` events (commit
523
+ * `f99b06eaed`), so this transient agent-scoped publication is the only
524
+ * live delta source. `start` records rollback marks; `end` keeps the deltas
525
+ * only when the attempt committed to the durable log — an `abandoned`
526
+ * attempt's text is not model-visible history and must leave the card again.
527
+ */
528
+ onAssistantFrame(session: Session, frame: AssistantStreamFrame): void {
529
+ this.sessions.set(session.id, session)
530
+ const state = this.front(session.id)
531
+ if (state === undefined || state.disposed) return
532
+ switch (frame.type) {
533
+ case 'start': {
534
+ state.attempts.set(frame.attemptId, this.attemptMark(state))
535
+ if (state.turn === undefined) state.turn = frame.turn
536
+ break
537
+ }
538
+ case 'chunk': {
539
+ const chunk = frame.chunk
540
+ if (chunk.type === 'text-delta') {
541
+ const last = state.cards[state.cards.length - 1]
542
+ if (last !== undefined && !last.frozen && chunk.text !== '') {
543
+ last.body += chunk.text
544
+ if (state.phase === 'thinking') state.phase = 'replying'
545
+ this.markDirty(session.id, state)
546
+ }
547
+ } else if (chunk.type === 'reasoning-delta' && chunk.text !== '') {
548
+ state.reasoning += chunk.text
549
+ this.markDirty(session.id, state)
550
+ }
551
+ break
552
+ }
553
+ case 'end': {
554
+ const mark = state.attempts.get(frame.attemptId)
555
+ state.attempts.delete(frame.attemptId)
556
+ if (frame.outcome.kind === 'abandoned' && mark !== undefined) {
557
+ this.rollbackAttempt(state, mark)
558
+ this.markDirty(session.id, state)
559
+ }
560
+ break
561
+ }
562
+ }
563
+ }
564
+
565
+ /** Capture the card/body lengths an attempt's deltas start from. */
566
+ private attemptMark(state: TurnState): AttemptMark {
567
+ return {
568
+ cardCount: state.cards.length,
569
+ bodyLengths: state.cards.map(card => card.body.length),
570
+ reasoningLength: state.reasoning.length,
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Drop one abandoned attempt's deltas. The attempt's text is a contiguous
576
+ * suffix of the logical body: segments created during the attempt are
577
+ * removed, the segment that was last at `start` is truncated, and the
578
+ * reasoning tail is cut. Already-delivered plain-text segments cannot be
579
+ * retracted and stop the removal.
580
+ */
581
+ private rollbackAttempt(state: TurnState, mark: AttemptMark): void {
582
+ const lastIndex = mark.cardCount - 1
583
+ for (let index = state.cards.length - 1; index > lastIndex; index -= 1) {
584
+ const card = state.cards[index]
585
+ if (card === undefined) continue
586
+ if (card.mode === 'text' && card.messageId !== undefined) break
587
+ state.cards.splice(index, 1)
588
+ }
589
+ const tail = state.cards[lastIndex]
590
+ const length = mark.bodyLengths[lastIndex]
591
+ if (tail !== undefined && length !== undefined
592
+ && !(tail.mode === 'text' && tail.messageId !== undefined)) {
593
+ tail.body = tail.body.slice(0, length)
594
+ tail.dirty = true
595
+ }
596
+ state.reasoning = state.reasoning.slice(0, mark.reasoningLength)
597
+ }
598
+
599
+ private markDirty(sessionId: string, state: TurnState): void {
600
+ if (state.disposed) return
601
+ state.dirty = true
602
+ if (state.timer !== undefined) return
603
+ const wait = state.lastPatchAt === 0
604
+ ? this.refreshMs
605
+ : Math.max(0, this.refreshMs - (Date.now() - state.lastPatchAt))
606
+ state.timer = setTimeout(() => {
607
+ state.timer = undefined
608
+ this.flush(sessionId, state)
609
+ }, wait)
610
+ }
611
+
612
+ private statsFor(sessionId: string, state: TurnState): CardState['stats'] {
613
+ const fold = this.folds.get(sessionId)
614
+ const lastUsage = state.lastUsage
615
+ const header = state.header
616
+ const cached = this.contextCache.get(sessionId)
617
+ const session = this.sessions.get(sessionId)
618
+ let context = cached
619
+ if (session !== undefined) {
620
+ try {
621
+ context = this.hooks.measureContext(session)
622
+ this.contextCache.set(sessionId, context)
623
+ } catch {
624
+ context = cached
625
+ }
626
+ }
627
+ // Host projections are authoritative when mounted: they fold the durable
628
+ // embedded assistant streams the plugin can no longer see as live events.
629
+ const projected = session === undefined ? undefined : this.hooks.projectStats?.(session)
630
+ return {
631
+ sessionId: this.hooks.sessionLabel(sessionId),
632
+ inputTokens: projected?.inputTokens ?? fold?.totals.inputTokens ?? 0,
633
+ outputTokens: projected?.outputTokens ?? fold?.totals.outputTokens ?? 0,
634
+ cacheHitPct: projected?.cacheHitPct ?? cacheHitPct(lastUsage),
635
+ contextTokens: context?.contextTokens,
636
+ // The context-token count is session-wide, but the model window belongs
637
+ // to the current request. Suppress it when this turn failed before a
638
+ // request header was emitted, just like model/effort/cache.
639
+ contextWindow: header === undefined
640
+ ? undefined
641
+ : context?.contextWindow ?? projected?.contextWindow,
642
+ model: header?.model,
643
+ effort: header?.effort,
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Fold one event from a subagent child session into its parent turn's
649
+ * activity state. Only identity/progress signals are kept — the child's
650
+ * text output stays out of the user-facing card (the parent model receives
651
+ * it as the tool result and composes the final reply itself).
652
+ *
653
+ * Workflow members are skipped here: their childId is recorded on the run
654
+ * fold and the workflow line already aggregates them.
655
+ */
656
+ private foldChildEvent(parentId: string, state: TurnState, child: Session, event: SessionEvent): void {
657
+ for (const run of state.workflowRuns.values()) {
658
+ if (run.memberIds.has(child.id)) return
659
+ }
660
+ switch (event.type) {
661
+ case 'subagent/descriptor': {
662
+ const label = typeof event.data.label === 'string' && event.data.label !== ''
663
+ ? event.data.label
664
+ : '子代理'
665
+ const existing = state.children.get(child.id)
666
+ if (existing === undefined) {
667
+ state.children.set(child.id, { label, tool: undefined, since: Date.now() })
668
+ } else if (existing.label === '子代理') {
669
+ existing.label = label
670
+ }
671
+ this.markDirty(parentId, state)
672
+ break
673
+ }
674
+ case 'tool/call': {
675
+ const existing = state.children.get(child.id)
676
+ if (existing === undefined) {
677
+ state.children.set(child.id, { label: '子代理', tool: event.data.name, since: Date.now() })
678
+ } else {
679
+ existing.tool = event.data.name
680
+ }
681
+ this.markDirty(parentId, state)
682
+ break
683
+ }
684
+ case 'tool/result': {
685
+ const existing = state.children.get(child.id)
686
+ if (existing !== undefined && existing.tool !== undefined) {
687
+ existing.tool = undefined
688
+ this.markDirty(parentId, state)
689
+ }
690
+ break
691
+ }
692
+ case 'turn/end': {
693
+ const entry = state.children.get(child.id)
694
+ if (entry === undefined) break
695
+ state.children.delete(child.id)
696
+ // A finished child leaves the live fold but is summarized on the
697
+ // settled card (capped; placeholder labels are skipped).
698
+ if (entry.label !== '子代理' && state.settled.length < 3) {
699
+ state.settled.push(entry.label)
700
+ }
701
+ this.markDirty(parentId, state)
702
+ break
703
+ }
704
+ default:
705
+ break
706
+ }
707
+ }
708
+
709
+ /**
710
+ * The delegation status line, by priority:
711
+ * 1. the most recently started live workflow run (member progress + phase);
712
+ * 2. running subagent children (label + current tool + elapsed);
713
+ * 3. the last settled workflow run's outcome — it persists across later
714
+ * tool calls and text streaming (only a new run-start replaces it), so
715
+ * the workflow conclusion stays visible;
716
+ * 4. heartbeat fallback — a thinking turn with no output yet still shows
717
+ * an elapsed counter so the card never sits silently.
718
+ * Once the turn is finalized, the line switches to the SETTLED form
719
+ * (workflow conclusion + finished-subagent summary) — the final card keeps
720
+ * the delegation status; only the tool line disappears.
721
+ */
722
+ private delegationLine(state: TurnState, now: number): string {
723
+ if (state.failure !== undefined) {
724
+ const suffix = state.failure.code === undefined ? '' : ` · ${state.failure.code}`
725
+ return clipActivity(`🔴 本轮运行失败${suffix}`)
726
+ }
727
+ if (state.finalized) return this.settledLine(state)
728
+
729
+ let latest: WorkflowRun | undefined
730
+ for (const run of state.workflowRuns.values()) {
731
+ if (latest === undefined || run.startedAt > latest.startedAt) latest = run
732
+ }
733
+ if (latest !== undefined) {
734
+ let line = `🧵 工作流 ${latest.name} · ${latest.ended}/${latest.started} 子代理完成`
735
+ if (latest.phase !== undefined) line += ` · 阶段 ${latest.phase}`
736
+ const labels = latest.active.slice(0, 3).map(member => member.label)
737
+ if (labels.length > 0) line += ` · ${labels.join('、')}`
738
+ line += ` · ${fmtElapsed(now - latest.startedAt)}`
739
+ return clipActivity(line)
740
+ }
741
+
742
+ const children = [...state.children.values()]
743
+ if (children.length > 0) {
744
+ const listed = children.slice(0, 2).map(child => {
745
+ const tool = child.tool === undefined ? '' : ` (${child.tool})`
746
+ return `${child.label}${tool}`
747
+ })
748
+ const head = children.length === 1
749
+ ? `🤖 子代理执行中:${listed[0]}`
750
+ : `🤖 ${children.length} 个子代理执行中:${listed.join('、')}${children.length > 2 ? '、…' : ''}`
751
+ const oldest = Math.min(...children.map(child => child.since))
752
+ return clipActivity(`${head} · ${fmtElapsed(now - oldest)}`)
753
+ }
754
+
755
+ if (state.workflowEndStatus !== undefined) {
756
+ const { name, stopReason } = state.workflowEndStatus
757
+ return clipActivity(`🧵 工作流 ${name} 结束(${stopReason})`)
758
+ }
759
+
760
+ const empty = state.cards.every(card => card.body === '')
761
+ // The tool line already shows liveness while a call is in flight, so the
762
+ // heartbeat fallback stays out of the way.
763
+ if (state.phase === 'thinking' && empty && state.toolActivity === undefined) {
764
+ return clipActivity(`⏳ 处理中 · ${fmtElapsed(now - state.turnStartedAt)}`)
765
+ }
766
+ return ''
767
+ }
768
+
769
+ /**
770
+ * The settled-card form of the delegation status line: the workflow
771
+ * conclusion and/or the finished-subagent summary, without elapsed counters.
772
+ * Still-running (background) children are noted factually. Empty when the
773
+ * turn delegated nothing.
774
+ */
775
+ private settledLine(state: TurnState): string {
776
+ const parts: string[] = []
777
+ if (state.workflowEndStatus !== undefined) {
778
+ const { name, stopReason } = state.workflowEndStatus
779
+ parts.push(`🧵 工作流 ${name} 结束(${stopReason})`)
780
+ }
781
+ if (state.settled.length > 0) {
782
+ parts.push(`🤖 子代理完成:${state.settled.join('、')}`)
783
+ }
784
+ if (parts.length > 0) return clipActivity(parts.join(' · '))
785
+
786
+ const children = [...state.children.values()]
787
+ if (children.length > 0) {
788
+ const listed = children.slice(0, 2).map(child =>
789
+ `${child.label}${child.tool === undefined ? '' : ` (${child.tool})`}`)
790
+ const head = children.length === 1
791
+ ? `🤖 子代理执行中:${listed[0]}`
792
+ : `🤖 ${children.length} 个子代理执行中:${listed.join('、')}${children.length > 2 ? '、…' : ''}`
793
+ return clipActivity(head)
794
+ }
795
+ return ''
796
+ }
797
+
798
+ /**
799
+ * The tool-call line: the parent agent's in-flight tool call, independent
800
+ * of the delegation status line — workflow/subagent status never hides it
801
+ * and it never hides them. Cleared by the tool's `tool/result`; always
802
+ * empty on the settled card.
803
+ */
804
+ private toolLine(state: TurnState, now: number): string {
805
+ if (state.finalized) return ''
806
+ const tool = state.toolActivity
807
+ if (tool === undefined) return ''
808
+ const prefix = tool.name === 'subagent'
809
+ ? '🤖 调用子代理:'
810
+ : tool.name === 'workflow'
811
+ ? '🧵 调用工作流:'
812
+ : '🔧 调用工具:'
813
+ return clipActivity(`${prefix}${tool.label} · ${fmtElapsed(now - tool.since)}`)
814
+ }
815
+
816
+ /**
817
+ * Slow keep-alive: recompute both activity lines and patch only when their
818
+ * rendered text actually changed (the elapsed counter ticking). Reschedules
819
+ * itself while the turn is pending; `finalize`/`detach` cancel it.
820
+ */
821
+ private scheduleHeartbeat(sessionId: string, state: TurnState): void {
822
+ if (state.disposed || state.finalized || state.heartbeatTimer !== undefined) return
823
+ state.heartbeatTimer = setTimeout(() => {
824
+ state.heartbeatTimer = undefined
825
+ if (state.disposed || state.finalized) return
826
+ const now = Date.now()
827
+ if (this.delegationLine(state, now) !== state.status
828
+ || this.toolLine(state, now) !== state.tool) {
829
+ this.markDirty(sessionId, state)
830
+ }
831
+ this.scheduleHeartbeat(sessionId, state)
832
+ }, this.heartbeatMs)
833
+ }
834
+
835
+ private cardState(sessionId: string, state: TurnState, body: string): CardState {
836
+ // The snapshot point recomputes (and remembers) both activity lines so
837
+ // every patch — including heartbeat-driven ones — carries the current
838
+ // status and tool call.
839
+ const now = Date.now()
840
+ const status = this.delegationLine(state, now)
841
+ if (status !== state.status) {
842
+ state.status = status
843
+ const kind = this.noteKind('status', status, state.statusKind)
844
+ if (kind !== undefined) state.statusKind = kind
845
+ }
846
+ const tool = this.toolLine(state, now)
847
+ if (tool !== state.tool) {
848
+ state.tool = tool
849
+ const kind = this.noteKind('tool', tool, state.toolKind)
850
+ if (kind !== undefined) state.toolKind = kind
851
+ }
852
+ return {
853
+ phase: state.phase,
854
+ reasoning: state.reasoning,
855
+ body,
856
+ status: state.status,
857
+ tool: state.tool,
858
+ stats: this.statsFor(sessionId, state),
859
+ }
860
+ }
861
+
862
+ /**
863
+ * Diagnostic: log activity-type transitions only (first segment of the
864
+ * line, stable across elapsed/count ticks) so a live test can confirm
865
+ * exactly which status the card is being patched with.
866
+ */
867
+ private noteKind(slot: 'status' | 'tool', line: string, current: string): string | undefined {
868
+ const kind = line === '' ? '' : (line.split('·')[0] ?? '').trim()
869
+ if (kind === current) return undefined
870
+ console.info(`[dsh-channels] activity ${slot} →`, kind === '' ? '(收起)' : kind)
871
+ return kind
872
+ }
873
+
874
+ /**
875
+ * Freeze the newest card once its body stops fitting its budget (bytes or
876
+ * the per-card table cap) and move the overflow into fresh segment cards
877
+ * (their sends are chained in order).
878
+ */
879
+ private splitOverflow(sessionId: string, state: TurnState): void {
880
+ for (;;) {
881
+ const index = state.cards.length - 1
882
+ const card = state.cards[index]
883
+ if (card === undefined || card.frozen || card.mode === 'text') return
884
+
885
+ const options = index === 0 ? {} : { panel: false, segment: index + 1 }
886
+ const split = segmentCardContent(this.cardState(sessionId, state, card.body), options)
887
+ if (split.head === '') {
888
+ // Degenerate budget (cannot happen with the real fixed overhead):
889
+ // keep streaming into this card; buildCard's absolute clip still
890
+ // guards the card size.
891
+ return
892
+ }
893
+ if (split.tail === '') {
894
+ card.body = split.head
895
+ card.dirty = true
896
+ // A card with content but no message id yet (e.g. the continuation of
897
+ // a text-fallback segment) needs its own send, not just a patch.
898
+ if (card.messageId === undefined && !card.sending && !card.failed && card.body !== '') {
899
+ this.sendSegment(sessionId, state, index)
900
+ }
901
+ return
902
+ }
903
+
904
+ card.body = split.head
905
+ card.frozen = true
906
+ card.dirty = true
907
+ const next: SegmentCard = this.freshSegment(split.tail)
908
+ state.cards.push(next)
909
+ this.sendSegment(sessionId, state, state.cards.length - 1)
910
+ }
911
+ }
912
+
913
+ private freshSegment(body: string): SegmentCard {
914
+ return {
915
+ messageId: undefined,
916
+ body,
917
+ dirty: true,
918
+ frozen: false,
919
+ mode: undefined,
920
+ failed: false,
921
+ sending: false,
922
+ failures: 0,
923
+ nextRetryAt: 0,
924
+ }
925
+ }
926
+
927
+ private delay(ms: number): Promise<void> {
928
+ return new Promise(resolve => setTimeout(resolve, ms))
929
+ }
930
+
931
+ /**
932
+ * Serialize one continuation-card send per session (order on Feishu), with
933
+ * up to two retries (1s / 2s backoff) for transient failures. When delivery
934
+ * resolves as a text fallback the segment is frozen and further growth
935
+ * continues in a new segment — text messages cannot be patched afterwards.
936
+ */
937
+ private sendSegment(sessionId: string, state: TurnState, index: number): void {
938
+ const card = state.cards[index]
939
+ if (card === undefined || card.failed || card.sending || state.disposed) return
940
+ card.sending = true
941
+
942
+ const previous = this.segmentChains.get(sessionId) ?? Promise.resolve()
943
+ const next = previous.then(async () => {
944
+ let lastError: unknown
945
+ for (let attempt = 0; attempt < 3; attempt++) {
946
+ try {
947
+ const snapshot = buildCard(this.cardState(sessionId, state, card.body), {
948
+ panel: false,
949
+ segment: index + 1,
950
+ })
951
+ const delivery = await this.hooks.sendSegmentCard(sessionId, index + 1, snapshot, card.body)
952
+ card.sending = false
953
+ card.messageId = delivery.messageId
954
+ card.mode = delivery.mode
955
+ this.hooks.onSegmentDelivered?.(
956
+ state.cards[0]?.messageId ?? '',
957
+ index,
958
+ delivery.messageId,
959
+ delivery.mode,
960
+ )
961
+ if (delivery.mode === 'card') {
962
+ card.dirty = true
963
+ this.markDirty(sessionId, state)
964
+ } else if (!card.frozen && !state.disposed) {
965
+ // Text fallback: this card is final; keep streaming into a new one.
966
+ card.frozen = true
967
+ if (state.cards[state.cards.length - 1] === card) {
968
+ state.cards.push(this.freshSegment(''))
969
+ }
970
+ this.markDirty(sessionId, state)
971
+ }
972
+ return
973
+ } catch (error) {
974
+ lastError = error
975
+ if (attempt >= 2 || state.disposed) break
976
+ await this.delay(1_000 * 2 ** attempt)
977
+ }
978
+ }
979
+ card.sending = false
980
+ card.failed = true
981
+ this.hooks.onError(sessionId, lastError)
982
+ })
983
+ this.segmentChains.set(sessionId, next.then(() => {}, () => {}))
984
+ }
985
+
986
+ /** Patch one card when due; failed updates retry with exponential backoff
987
+ * (logged on the first failure and every 10th — no per-flush spam).
988
+ *
989
+ * The patch gate covers both per-card body growth (`card.dirty`) and
990
+ * turn-scoped snapshot changes (`turnDirty`: phase, reasoning, activity
991
+ * line, session stats), so activity-only events still reach the card. */
992
+ private patchCard(sessionId: string, state: TurnState, index: number, force: boolean, turnDirty: boolean): void {
993
+ const card = state.cards[index]
994
+ if (card === undefined || card.messageId === undefined || card.mode === 'text' || card.failed) return
995
+ const now = Date.now()
996
+ if (!force && !card.dirty && !turnDirty) return
997
+ if (!force && card.nextRetryAt > now) return
998
+ card.dirty = false
999
+ const options = index === 0 ? {} : { panel: false, segment: index + 1 }
1000
+ const snapshot = buildCard(this.cardState(sessionId, state, card.body), options)
1001
+ this.channel.updateCard(card.messageId, snapshot).catch((error: unknown) => {
1002
+ card.dirty = true
1003
+ card.failures++
1004
+ card.nextRetryAt = Date.now() + Math.min(1_000 * 2 ** Math.min(card.failures, 6), 60_000)
1005
+ if (card.failures === 1 || card.failures % 10 === 0) {
1006
+ this.hooks.onError(sessionId, error)
1007
+ }
1008
+ })
1009
+ }
1010
+
1011
+ private flush(sessionId: string, state: TurnState): void {
1012
+ state.lastPatchAt = Date.now()
1013
+ const turnDirty = state.dirty
1014
+ if (state.dirty) {
1015
+ state.dirty = false
1016
+ this.splitOverflow(sessionId, state)
1017
+ }
1018
+ for (const [index] of state.cards.entries()) {
1019
+ this.patchCard(sessionId, state, index, false, turnDirty)
1020
+ }
1021
+ }
1022
+
1023
+ private finalize(
1024
+ sessionId: string,
1025
+ state: TurnState,
1026
+ failure: { message: string; code?: string } | undefined,
1027
+ ): void {
1028
+ if (state.finalized) return
1029
+ state.finalized = true
1030
+ state.failure = failure
1031
+ state.phase = failure === undefined ? 'done' : 'error'
1032
+ if (failure !== undefined) {
1033
+ const code = failure.code === undefined ? '' : `\n\n错误码:\`${failure.code}\``
1034
+ const notice = `❌ **本轮运行失败**\n\n${failure.message}${code}`
1035
+ const tail = state.cards[state.cards.length - 1]
1036
+ if (tail !== undefined) {
1037
+ if (tail.mode === 'text' || tail.failed) {
1038
+ // An already-delivered plain-text fallback cannot be patched, and a
1039
+ // permanently failed card cannot surface new content. Put the
1040
+ // terminal diagnostic in its own continuation card instead.
1041
+ state.cards.push(this.freshSegment(notice))
1042
+ } else {
1043
+ tail.body = tail.body.trim() === '' ? notice : `${tail.body}\n\n---\n\n${notice}`
1044
+ tail.dirty = true
1045
+ }
1046
+ }
1047
+ }
1048
+ if (state.timer !== undefined) {
1049
+ clearTimeout(state.timer)
1050
+ state.timer = undefined
1051
+ }
1052
+ // The settled card keeps the delegation status (workflow conclusion /
1053
+ // subagent summary — `delegationLine` renders its settled form once
1054
+ // `finalized` is set); only the transient tool line disappears. The
1055
+ // heartbeat stops.
1056
+ if (state.heartbeatTimer !== undefined) {
1057
+ clearTimeout(state.heartbeatTimer)
1058
+ state.heartbeatTimer = undefined
1059
+ }
1060
+ state.tool = ''
1061
+ // Freeze the tail and spawn any remaining segment cards.
1062
+ this.splitOverflow(sessionId, state)
1063
+
1064
+ // Final patches with the settled content: every patchable card flips to
1065
+ // 回复结束 / 回复失败 (backoff is bypassed once so the final state always lands;
1066
+ // late-arriving sends are patched when they resolve).
1067
+ for (const [index] of state.cards.entries()) {
1068
+ this.patchCard(sessionId, state, index, true, false)
1069
+ }
1070
+
1071
+ // Reaction swap: Typing → DONE on success, Typing → No on failure.
1072
+ void this.swapReaction(state, failure !== undefined)
1073
+
1074
+ // Prune the finalized state lazily (kept until the next attach so the
1075
+ // front-scan skips it, then removed).
1076
+ void Promise.resolve().then(() => {
1077
+ const queue = this.bySession.get(sessionId)
1078
+ if (queue === undefined) return
1079
+ const index = queue.indexOf(state)
1080
+ if (index >= 0) queue.splice(index, 1)
1081
+ })
1082
+
1083
+ this.hooks.onTurnFinalized(sessionId, state.turn ?? 0, state.cards[0]?.messageId ?? '')
1084
+ }
1085
+
1086
+ private async swapReaction(state: TurnState, failed: boolean): Promise<void> {
1087
+ try {
1088
+ if (state.reactionId !== undefined) {
1089
+ await this.channel.removeReaction(state.triggerMessageId, state.reactionId)
1090
+ }
1091
+ await this.channel.addReaction(state.triggerMessageId, failed ? 'No' : 'DONE')
1092
+ } catch (error) {
1093
+ // Reaction failures are cosmetic: never surface them to the user.
1094
+ this.hooks.onError(state.triggerMessageId, error)
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ /** Extract the user-visible failure from the current Harness turn-end union. */
1100
+ function turnFailure(reason: TurnEndReason): { message: string; code?: string } | undefined {
1101
+ if (typeof reason !== 'object' || reason === null || reason.kind !== 'error') return undefined
1102
+ const error = reason.error
1103
+ const message = typeof error?.message === 'string' && error.message !== ''
1104
+ ? error.message
1105
+ : '模型请求失败'
1106
+ const code = typeof error?.code === 'string' && error.code !== '' ? error.code : undefined
1107
+ return { message, code }
1108
+ }