@rynx-ai/plugin-channel-lark 0.1.9

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 (41) hide show
  1. package/dist/cli-mirror/mirror-index.d.ts +46 -0
  2. package/dist/cli-mirror/mirror-index.js +48 -0
  3. package/dist/cli-mirror/rollout-mirror.d.ts +85 -0
  4. package/dist/cli-mirror/rollout-mirror.js +333 -0
  5. package/dist/cli-mirror/rollout-watcher.d.ts +109 -0
  6. package/dist/cli-mirror/rollout-watcher.js +347 -0
  7. package/dist/host-adapters.d.ts +82 -0
  8. package/dist/host-adapters.js +404 -0
  9. package/dist/index.d.ts +7 -0
  10. package/dist/index.js +7 -0
  11. package/dist/lark-card-stream.d.ts +363 -0
  12. package/dist/lark-card-stream.js +1335 -0
  13. package/dist/lark-content.d.ts +30 -0
  14. package/dist/lark-content.js +138 -0
  15. package/dist/lark-conversation-directory.d.ts +65 -0
  16. package/dist/lark-conversation-directory.js +106 -0
  17. package/dist/lark-diff-card.d.ts +25 -0
  18. package/dist/lark-diff-card.js +58 -0
  19. package/dist/lark-fork-transcript.d.ts +16 -0
  20. package/dist/lark-fork-transcript.js +141 -0
  21. package/dist/lark-resume-card.d.ts +31 -0
  22. package/dist/lark-resume-card.js +105 -0
  23. package/dist/lark-sdk/client.d.ts +8 -0
  24. package/dist/lark-sdk/client.js +32 -0
  25. package/dist/lark-sdk/index.d.ts +1 -0
  26. package/dist/lark-sdk/index.js +1 -0
  27. package/dist/lark-sender.d.ts +114 -0
  28. package/dist/lark-sender.js +323 -0
  29. package/dist/lark-session-store.d.ts +13 -0
  30. package/dist/lark-session-store.js +14 -0
  31. package/dist/lark-settings-card.d.ts +55 -0
  32. package/dist/lark-settings-card.js +158 -0
  33. package/dist/lark-setup.d.ts +17 -0
  34. package/dist/lark-setup.js +86 -0
  35. package/dist/lark.d.ts +500 -0
  36. package/dist/lark.js +2010 -0
  37. package/dist/runtime.d.ts +16 -0
  38. package/dist/runtime.js +157 -0
  39. package/dist/runtime.mjs +130863 -0
  40. package/package.json +35 -0
  41. package/rynx-plugin.json +42 -0
@@ -0,0 +1,1335 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { randomUUID } from "node:crypto";
3
+ const OUTPUT_MAX_BYTES = 2048;
4
+ const REASONING_MAX_BYTES = 4096;
5
+ const AGENT_MESSAGE_MAX_BYTES = 8192;
6
+ const DIFF_MAX_BYTES = 6144;
7
+ export function createInitialCardState({ localThreadId, model, runtime, resumeShortId, effort, startedAt = new Date().toISOString(), }) {
8
+ return {
9
+ status: "starting",
10
+ statusReason: null,
11
+ plan: null,
12
+ reasoningSummary: "",
13
+ toolCalls: [],
14
+ turnDiff: null,
15
+ agentMessage: "",
16
+ finalAnswer: null,
17
+ errorMessage: null,
18
+ localThreadId,
19
+ codexThreadId: null,
20
+ resumeShortId: resumeShortId ?? null,
21
+ turnId: null,
22
+ runtime: runtime ?? null,
23
+ model,
24
+ effort: effort ?? null,
25
+ usage: null,
26
+ startedAt,
27
+ };
28
+ }
29
+ export function applyEventToCardState(state, event) {
30
+ switch (event.type) {
31
+ case "meta":
32
+ return state;
33
+ case "session_bound":
34
+ return {
35
+ ...state,
36
+ codexThreadId: event.runtimeSessionId ?? state.codexThreadId,
37
+ status: state.status === "starting" ? "running" : state.status,
38
+ };
39
+ case "turn_started":
40
+ return { ...state, turnId: event.turnId ?? state.turnId, status: "running" };
41
+ case "turn_completed":
42
+ return {
43
+ ...state,
44
+ status: state.status === "failed" || state.status === "cancelled" ? state.status : "completed",
45
+ usage: event.usage ?? state.usage,
46
+ };
47
+ case "session_notice":
48
+ return event.kind === "session.expired"
49
+ ? { ...state, statusReason: "session.expired" }
50
+ : state;
51
+ case "message_completed":
52
+ return event.text ? { ...state, agentMessage: event.text } : state;
53
+ case "reasoning_delta": {
54
+ if (!event.text) {
55
+ return state;
56
+ }
57
+ return {
58
+ ...state,
59
+ reasoningSummary: tailBytes(state.reasoningSummary + event.text, REASONING_MAX_BYTES),
60
+ };
61
+ }
62
+ case "plan":
63
+ return { ...state, plan: event.steps ?? null };
64
+ case "turn_diff":
65
+ return event.diff.trim()
66
+ ? { ...state, turnDiff: tailBytes(event.diff, DIFF_MAX_BYTES) }
67
+ : state;
68
+ case "file_change": {
69
+ // Per-item patches also arrive aggregated as `turn_diff`; only use these
70
+ // as a fallback when no turn diff exists.
71
+ if (state.turnDiff) {
72
+ return state;
73
+ }
74
+ const composed = event.changes
75
+ .filter((change) => typeof change.diff === "string" && change.diff)
76
+ .map((change) => change.diff)
77
+ .join("\n");
78
+ return composed ? { ...state, turnDiff: tailBytes(composed, DIFF_MAX_BYTES) } : state;
79
+ }
80
+ case "status":
81
+ case "reasoning_completed":
82
+ case "runtime_debug":
83
+ return state;
84
+ case "token": {
85
+ const text = event.text ?? "";
86
+ if (!text) {
87
+ return state;
88
+ }
89
+ const next = state.agentMessage + text;
90
+ return {
91
+ ...state,
92
+ status: state.status === "starting" ? "running" : state.status,
93
+ agentMessage: tailBytes(next, AGENT_MESSAGE_MAX_BYTES),
94
+ };
95
+ }
96
+ case "tool":
97
+ return applyToolEvent(state, event);
98
+ case "tool_output_delta": {
99
+ if (!event.delta) {
100
+ return state;
101
+ }
102
+ return mergeToolCall(state, event.callId, (tool) => ({
103
+ ...tool,
104
+ output: tailBytes((tool.output ?? "") + event.delta, OUTPUT_MAX_BYTES),
105
+ }));
106
+ }
107
+ case "final":
108
+ return {
109
+ ...state,
110
+ finalAnswer: event.answer || state.agentMessage || null,
111
+ };
112
+ case "done":
113
+ return state;
114
+ default:
115
+ return state;
116
+ }
117
+ }
118
+ function applyToolEvent(state, event) {
119
+ const name = event.name ?? "tool";
120
+ const kind = classifyToolKind(name);
121
+ const data = event.data;
122
+ const itemId = event.input?.id ??
123
+ event.output?.id ??
124
+ data?.item?.id ??
125
+ `${kind}:${state.toolCalls.length}`;
126
+ if (event.event === "on_tool_start") {
127
+ const command = event.input?.command ?? undefined;
128
+ const label = command ?? deriveToolLabel(kind, event.input);
129
+ const exists = state.toolCalls.find((tool) => tool.itemId === itemId);
130
+ if (exists) {
131
+ return mergeToolCall(state, itemId, (tool) => ({
132
+ ...tool,
133
+ status: "inProgress",
134
+ }));
135
+ }
136
+ return {
137
+ ...state,
138
+ toolCalls: [
139
+ ...state.toolCalls,
140
+ {
141
+ itemId,
142
+ kind,
143
+ label,
144
+ status: "inProgress",
145
+ command,
146
+ output: undefined,
147
+ },
148
+ ],
149
+ };
150
+ }
151
+ if (event.event === "on_tool_end") {
152
+ const output = event.output;
153
+ const status = output?.status === "completed"
154
+ ? "completed"
155
+ : output?.status === "failed"
156
+ ? "failed"
157
+ : output?.status === "declined"
158
+ ? "declined"
159
+ : "completed";
160
+ return mergeToolCall(state, itemId, (tool) => ({
161
+ ...tool,
162
+ status,
163
+ command: tool.command ?? output?.command,
164
+ output: output?.aggregatedOutput
165
+ ? tailBytes(output.aggregatedOutput, OUTPUT_MAX_BYTES)
166
+ : tool.output,
167
+ exitCode: output?.exitCode ?? null,
168
+ }));
169
+ }
170
+ return state;
171
+ }
172
+ function classifyToolKind(name) {
173
+ if (name === "command_execution")
174
+ return "command_execution";
175
+ if (name === "file_change")
176
+ return "file_change";
177
+ if (name.startsWith("mcp:"))
178
+ return "mcp_tool_call";
179
+ if (name.startsWith("dynamic:"))
180
+ return "dynamic_tool_call";
181
+ if (name === "web_search")
182
+ return "web_search";
183
+ return "other";
184
+ }
185
+ function deriveToolLabel(kind, input) {
186
+ switch (kind) {
187
+ case "command_execution":
188
+ return "shell";
189
+ case "file_change":
190
+ return "file change";
191
+ case "mcp_tool_call":
192
+ return "mcp tool";
193
+ case "dynamic_tool_call":
194
+ return "tool";
195
+ case "web_search":
196
+ return input?.query ?? "web search";
197
+ default:
198
+ return "tool";
199
+ }
200
+ }
201
+ function mergeToolCall(state, itemId, mutator) {
202
+ const index = state.toolCalls.findIndex((tool) => tool.itemId === itemId);
203
+ if (index < 0) {
204
+ return {
205
+ ...state,
206
+ toolCalls: [
207
+ ...state.toolCalls,
208
+ mutator({
209
+ itemId,
210
+ kind: "other",
211
+ label: "tool",
212
+ status: "inProgress",
213
+ }),
214
+ ],
215
+ };
216
+ }
217
+ const updated = [...state.toolCalls];
218
+ updated[index] = mutator(updated[index]);
219
+ return { ...state, toolCalls: updated };
220
+ }
221
+ function tailBytes(value, maxBytes) {
222
+ if (Buffer.byteLength(value, "utf8") <= maxBytes) {
223
+ return value;
224
+ }
225
+ let bytes = 0;
226
+ let cut = value.length;
227
+ for (let i = value.length - 1; i >= 0; i--) {
228
+ const ch = value.charCodeAt(i);
229
+ bytes += ch < 0x80 ? 1 : ch < 0x800 ? 2 : 3;
230
+ if (bytes > maxBytes) {
231
+ cut = i + 1;
232
+ break;
233
+ }
234
+ }
235
+ return `…${value.slice(cut)}`;
236
+ }
237
+ const STATUS_LABELS = {
238
+ starting: "正在准备",
239
+ running: "正在处理",
240
+ completed: "已完成",
241
+ failed: "失败",
242
+ cancelled: "已停止",
243
+ };
244
+ const STATUS_EMOJI = {
245
+ starting: "⏳",
246
+ running: "⏳",
247
+ completed: "✅",
248
+ failed: "❌",
249
+ cancelled: "⏹",
250
+ };
251
+ const ELEMENT_IDS = {
252
+ // Top status row is a column_set: left column holds the status markdown,
253
+ // optional right column holds the stop button. Inner markdown is updated
254
+ // via partial_update_element on `status` so the row container stays put.
255
+ status_row: "status_row",
256
+ status: "status",
257
+ stop_column: "stop_column",
258
+ plan: "plan",
259
+ // Container panels appear at the body level; their inner markdowns
260
+ // (reasoning, tools, output) live inside them and are only ever updated
261
+ // via partial_update_element on the inner element_id (so the user's
262
+ // expand/collapse state stays put).
263
+ reasoning_panel: "reasoning_panel",
264
+ reasoning: "reasoning",
265
+ tools_panel: "tools_panel",
266
+ tools: "tools",
267
+ output: "output",
268
+ diff_panel: "diff_panel",
269
+ diff: "diff",
270
+ message: "message",
271
+ error: "error",
272
+ };
273
+ /** For each container id, the inner element ids whose content should be
274
+ * diffed via partial_update_element instead of replacing the container. */
275
+ const PANEL_INNERS = {
276
+ status_row: ["status"],
277
+ reasoning_panel: ["reasoning"],
278
+ tools_panel: ["tools", "output"],
279
+ diff_panel: ["diff"],
280
+ };
281
+ function isCollapsiblePanelId(id) {
282
+ return id === "reasoning_panel" || id === "tools_panel" || id === "diff_panel";
283
+ }
284
+ export function renderStatusMarkdown(state) {
285
+ const head = `${STATUS_EMOJI[state.status]} **${STATUS_LABELS[state.status]}**`;
286
+ const meta = [];
287
+ if (state.runtime) {
288
+ meta.push(state.runtime);
289
+ }
290
+ if (state.model) {
291
+ meta.push(state.model);
292
+ }
293
+ if (state.effort) {
294
+ meta.push(state.effort);
295
+ }
296
+ if (state.resumeShortId) {
297
+ // The conversation's stable `/resume <id>` short id — shown so that what's
298
+ // on the card is exactly what the user types to jump back here. (Derived
299
+ // from the conversation key, so it survives restarts and session
300
+ // rotations, unlike the volatile codex thread id.) Kept muted grey so it
301
+ // doesn't draw the user's eye.
302
+ meta.push(state.resumeShortId);
303
+ }
304
+ if (meta.length === 0) {
305
+ return head;
306
+ }
307
+ return `${head} <font color='grey'>· ${meta.join(" · ")}</font>`;
308
+ }
309
+ export function renderPlanMarkdown(plan) {
310
+ if (!plan || plan.length === 0) {
311
+ return "";
312
+ }
313
+ return plan
314
+ .map((step) => {
315
+ const icon = step.status === "completed"
316
+ ? "✅"
317
+ : step.status === "inProgress"
318
+ ? "⏳"
319
+ : "⏸";
320
+ return `${icon} ${step.step}`;
321
+ })
322
+ .join("\n");
323
+ }
324
+ export function renderReasoningMarkdown(summary) {
325
+ return summary.trim();
326
+ }
327
+ export function renderToolsMarkdown(toolCalls) {
328
+ if (toolCalls.length === 0) {
329
+ return "";
330
+ }
331
+ return toolCalls
332
+ .map((tool) => {
333
+ const icon = tool.status === "completed"
334
+ ? "✅"
335
+ : tool.status === "failed"
336
+ ? "❌"
337
+ : tool.status === "declined"
338
+ ? "🚫"
339
+ : "⏳";
340
+ const head = `${icon} \`${tool.kind}\` ${truncate(tool.label, 200)}`;
341
+ return head;
342
+ })
343
+ .join("\n");
344
+ }
345
+ export function renderOutputMarkdown(toolCalls) {
346
+ const tail = [...toolCalls]
347
+ .reverse()
348
+ .find((tool) => tool.output && tool.output.length > 0);
349
+ if (!tail || !tail.output) {
350
+ return "";
351
+ }
352
+ const fence = "```";
353
+ return `${fence}\n${tail.output}\n${fence}`;
354
+ }
355
+ export function renderDiffMarkdown(turnDiff) {
356
+ if (!turnDiff || !turnDiff.trim()) {
357
+ return "";
358
+ }
359
+ const fence = "```";
360
+ return `${fence}diff\n${turnDiff}\n${fence}`;
361
+ }
362
+ export function renderMessageMarkdown(state) {
363
+ const content = state.finalAnswer ?? state.agentMessage;
364
+ return content.trim() ? content : "";
365
+ }
366
+ export function renderErrorMarkdown(state) {
367
+ if (!state.errorMessage) {
368
+ return "";
369
+ }
370
+ return `> ${state.errorMessage}`;
371
+ }
372
+ function renderElement(elementId, state) {
373
+ switch (elementId) {
374
+ case "status":
375
+ return renderStatusMarkdown(state);
376
+ case "plan":
377
+ return renderPlanMarkdown(state.plan);
378
+ case "reasoning":
379
+ return renderReasoningMarkdown(state.reasoningSummary);
380
+ case "tools":
381
+ return renderToolsMarkdown(state.toolCalls);
382
+ case "output":
383
+ return renderOutputMarkdown(state.toolCalls);
384
+ case "diff":
385
+ return renderDiffMarkdown(state.turnDiff);
386
+ case "message":
387
+ return renderMessageMarkdown(state);
388
+ case "error":
389
+ return renderErrorMarkdown(state);
390
+ case "status_row":
391
+ case "stop_column":
392
+ case "reasoning_panel":
393
+ case "tools_panel":
394
+ case "diff_panel":
395
+ // Container elements don't have a flat markdown content — diff
396
+ // iterates their inner ids instead. Returning "" here is a safety
397
+ // net for callers that go through the generic renderer.
398
+ return "";
399
+ }
400
+ }
401
+ function shouldMountElement(elementId, state) {
402
+ switch (elementId) {
403
+ case "status_row":
404
+ return true;
405
+ case "message": {
406
+ const content = state.finalAnswer ?? state.agentMessage;
407
+ return content.trim().length > 0;
408
+ }
409
+ case "plan":
410
+ return Boolean(state.plan && state.plan.length > 0);
411
+ case "reasoning_panel":
412
+ return state.reasoningSummary.trim().length > 0;
413
+ case "tools_panel":
414
+ return state.toolCalls.length > 0;
415
+ case "diff_panel":
416
+ return Boolean(state.turnDiff && state.turnDiff.trim().length > 0);
417
+ case "error":
418
+ return Boolean(state.errorMessage);
419
+ // Inner ids are mounted as part of their parent container, not at the
420
+ // body level — diffCardActions treats them via partial_update_element.
421
+ case "status":
422
+ case "stop_column":
423
+ case "reasoning":
424
+ case "tools":
425
+ case "output":
426
+ case "diff":
427
+ return false;
428
+ }
429
+ }
430
+ /** Body-level order. Inner markdowns (status/reasoning/tools/output) are
431
+ * not here because they only ever appear inside their parent container. */
432
+ const ELEMENT_ORDER = [
433
+ ELEMENT_IDS.status_row,
434
+ ELEMENT_IDS.plan,
435
+ ELEMENT_IDS.reasoning_panel,
436
+ ELEMENT_IDS.tools_panel,
437
+ ELEMENT_IDS.diff_panel,
438
+ ELEMENT_IDS.message,
439
+ ELEMENT_IDS.error,
440
+ ];
441
+ function findInsertTarget(elementId, mounted) {
442
+ if (elementId === ELEMENT_IDS.error) {
443
+ return { type: "append" };
444
+ }
445
+ const index = ELEMENT_ORDER.indexOf(elementId);
446
+ for (let i = index + 1; i < ELEMENT_ORDER.length; i++) {
447
+ const candidate = ELEMENT_ORDER[i];
448
+ if (mounted.has(candidate)) {
449
+ return { type: "insert_before", target_element_id: candidate };
450
+ }
451
+ }
452
+ return { type: "append" };
453
+ }
454
+ function buildPanelElement(id, state) {
455
+ if (id === "diff_panel") {
456
+ return {
457
+ tag: "collapsible_panel",
458
+ element_id: "diff_panel",
459
+ // Diffs can be long; start collapsed so they don't dominate the card.
460
+ expanded: false,
461
+ header: {
462
+ title: { tag: "plain_text", content: "📝 代码改动" },
463
+ vertical_align: "center",
464
+ icon: { tag: "standard_icon", token: "down-small-ccm_outlined" },
465
+ },
466
+ elements: [
467
+ {
468
+ tag: "markdown",
469
+ element_id: "diff",
470
+ content: renderDiffMarkdown(state.turnDiff),
471
+ },
472
+ ],
473
+ };
474
+ }
475
+ if (id === "reasoning_panel") {
476
+ return {
477
+ tag: "collapsible_panel",
478
+ element_id: "reasoning_panel",
479
+ expanded: true,
480
+ header: {
481
+ title: { tag: "plain_text", content: "🧠 思考过程" },
482
+ vertical_align: "center",
483
+ icon: { tag: "standard_icon", token: "down-small-ccm_outlined" },
484
+ },
485
+ elements: [
486
+ {
487
+ tag: "markdown",
488
+ element_id: "reasoning",
489
+ content: renderReasoningMarkdown(state.reasoningSummary),
490
+ },
491
+ ],
492
+ };
493
+ }
494
+ return {
495
+ tag: "collapsible_panel",
496
+ element_id: "tools_panel",
497
+ expanded: true,
498
+ header: {
499
+ title: { tag: "plain_text", content: "🔧 工具调用" },
500
+ vertical_align: "center",
501
+ icon: { tag: "standard_icon", token: "down-small-ccm_outlined" },
502
+ },
503
+ // Both inner markdowns are mounted up-front so subsequent updates can
504
+ // target them via partial_update_element by element_id. Empty content
505
+ // is fine — markdown elements with empty content render as nothing.
506
+ elements: [
507
+ {
508
+ tag: "markdown",
509
+ element_id: "tools",
510
+ content: renderToolsMarkdown(state.toolCalls),
511
+ },
512
+ {
513
+ tag: "markdown",
514
+ element_id: "output",
515
+ content: renderOutputMarkdown(state.toolCalls),
516
+ },
517
+ ],
518
+ };
519
+ }
520
+ function buildStatusRowElement(state, stopButton) {
521
+ const columns = [
522
+ {
523
+ tag: "column",
524
+ width: "weighted",
525
+ weight: 1,
526
+ vertical_align: "center",
527
+ elements: [
528
+ {
529
+ tag: "markdown",
530
+ element_id: "status",
531
+ content: renderStatusMarkdown(state),
532
+ },
533
+ ],
534
+ },
535
+ ];
536
+ if (stopButton) {
537
+ columns.push({
538
+ tag: "column",
539
+ element_id: "stop_column",
540
+ width: "auto",
541
+ vertical_align: "center",
542
+ elements: [buildStopButtonElement(stopButton)],
543
+ });
544
+ }
545
+ return {
546
+ tag: "column_set",
547
+ element_id: "status_row",
548
+ flex_mode: "none",
549
+ horizontal_align: "left",
550
+ columns,
551
+ };
552
+ }
553
+ function buildBodyElement(id, state, ctx = {}) {
554
+ if (id === "status_row") {
555
+ return buildStatusRowElement(state, ctx.stopButton);
556
+ }
557
+ if (isCollapsiblePanelId(id)) {
558
+ return buildPanelElement(id, state);
559
+ }
560
+ return {
561
+ tag: "markdown",
562
+ element_id: id,
563
+ content: renderElement(id, state),
564
+ };
565
+ }
566
+ function buildStopButtonElement(opts) {
567
+ return {
568
+ tag: "button",
569
+ type: "text",
570
+ size: "small",
571
+ icon: {
572
+ tag: "standard_icon",
573
+ token: "stop_outlined",
574
+ },
575
+ hover_tips: {
576
+ tag: "plain_text",
577
+ content: opts.hoverTip ?? "停止",
578
+ },
579
+ behaviors: [
580
+ {
581
+ type: "callback",
582
+ value: { kind: "stop_turn", sessionKey: opts.sessionKey },
583
+ },
584
+ ],
585
+ };
586
+ }
587
+ export function createInitialCardJson(state, opts = {}) {
588
+ const elements = [];
589
+ const mounted = new Set();
590
+ for (const id of ELEMENT_ORDER) {
591
+ if (!shouldMountElement(id, state)) {
592
+ continue;
593
+ }
594
+ elements.push(buildBodyElement(id, state, { stopButton: opts.stopButton }));
595
+ mounted.add(id);
596
+ }
597
+ const card = {
598
+ schema: "2.0",
599
+ config: {
600
+ streaming_mode: true,
601
+ summary: { content: "" },
602
+ streaming_config: {
603
+ print_frequency_ms: { default: 70 },
604
+ print_step: { default: 1 },
605
+ print_strategy: "fast",
606
+ },
607
+ },
608
+ body: { elements },
609
+ };
610
+ return { cardJson: JSON.stringify(card), mounted };
611
+ }
612
+ export function buildTerminalSettingsAction() {
613
+ return {
614
+ action: "partial_update_setting",
615
+ params: {
616
+ settings: {
617
+ config: { streaming_mode: false },
618
+ },
619
+ },
620
+ };
621
+ }
622
+ export function buildStopColumnDeleteAction() {
623
+ return {
624
+ action: "delete_elements",
625
+ params: { element_ids: [ELEMENT_IDS.stop_column] },
626
+ };
627
+ }
628
+ export function diffCardActions(prev, next, mounted) {
629
+ const actions = [];
630
+ const nextMounted = new Set(mounted);
631
+ for (const id of ELEMENT_ORDER) {
632
+ const wantMounted = shouldMountElement(id, next);
633
+ if (!nextMounted.has(id)) {
634
+ if (!wantMounted) {
635
+ continue;
636
+ }
637
+ const target = findInsertTarget(id, nextMounted);
638
+ actions.push({
639
+ action: "add_elements",
640
+ params: {
641
+ type: target.type,
642
+ ...(target.target_element_id
643
+ ? { target_element_id: target.target_element_id }
644
+ : {}),
645
+ elements: [buildBodyElement(id, next)],
646
+ },
647
+ });
648
+ nextMounted.add(id);
649
+ continue;
650
+ }
651
+ // Already mounted. Container elements (status_row, panels) diff their
652
+ // inner ids individually so we never replace the container itself
653
+ // (which would either reset expand/collapse state or wipe the embedded
654
+ // stop button). Flat markdowns: diff content as before.
655
+ const innerIds = PANEL_INNERS[id];
656
+ if (innerIds) {
657
+ for (const innerId of innerIds) {
658
+ const prevContent = renderElement(innerId, prev);
659
+ const nextContent = renderElement(innerId, next);
660
+ if (prevContent !== nextContent) {
661
+ actions.push({
662
+ action: "partial_update_element",
663
+ params: {
664
+ element_id: innerId,
665
+ partial_element: { content: nextContent },
666
+ },
667
+ });
668
+ }
669
+ }
670
+ continue;
671
+ }
672
+ const prevContent = renderElement(id, prev);
673
+ const nextContent = renderElement(id, next);
674
+ if (prevContent !== nextContent) {
675
+ actions.push({
676
+ action: "partial_update_element",
677
+ params: { element_id: id, partial_element: { content: nextContent } },
678
+ });
679
+ }
680
+ }
681
+ return { actions, mounted: nextMounted };
682
+ }
683
+ function truncate(value, max) {
684
+ if (value.length <= max) {
685
+ return value;
686
+ }
687
+ return `${value.slice(0, max - 1)}…`;
688
+ }
689
+ export class LarkTurnCardController {
690
+ sender;
691
+ session;
692
+ throttleMs;
693
+ logger;
694
+ stopButton;
695
+ state;
696
+ cardId = null;
697
+ sequence = 0;
698
+ flushTimer = null;
699
+ flushChain = Promise.resolve();
700
+ lastFlushedState;
701
+ mounted = new Set();
702
+ dirty = false;
703
+ closed = false;
704
+ terminal = false;
705
+ stopButtonMounted = false;
706
+ constructor({ sender, session, initialState, throttleMs = 250, logger, stopButton, }) {
707
+ this.sender = sender;
708
+ this.session = session;
709
+ this.state = initialState;
710
+ this.lastFlushedState = initialState;
711
+ this.throttleMs = throttleMs;
712
+ this.logger = logger;
713
+ this.stopButton = stopButton ?? null;
714
+ }
715
+ async start() {
716
+ if (!this.session.messageId) {
717
+ // No inbound message to quote — fall back to a standalone send.
718
+ return this.startStandalone();
719
+ }
720
+ const { cardJson, mounted } = createInitialCardJson(this.state, {
721
+ stopButton: this.stopButton ?? undefined,
722
+ });
723
+ this.mounted = mounted;
724
+ this.stopButtonMounted = Boolean(this.stopButton);
725
+ const { cardId } = await this.sender.createCard({ cardJson });
726
+ this.cardId = cardId;
727
+ const uuid = randomUUID();
728
+ // Always reply to the user's message so the card is quoted under their @
729
+ // (reply in thread only when the message is itself in a topic thread).
730
+ await this.sender.replyInteractive({
731
+ messageId: this.session.messageId,
732
+ cardId,
733
+ uuid,
734
+ replyInThread: Boolean(this.session.threadId),
735
+ });
736
+ this.lastFlushedState = this.state;
737
+ }
738
+ /**
739
+ * Post the card as a standalone chat message instead of a quote-reply. Used
740
+ * to mirror a CLI-driven turn, where there is no inbound Feishu message to
741
+ * reply to. `sendInteractive` targets the chat (no thread-targeting param),
742
+ * so for topic-thread conversations the card lands at chat level.
743
+ */
744
+ async startStandalone() {
745
+ const { cardJson, mounted } = createInitialCardJson(this.state, {
746
+ stopButton: this.stopButton ?? undefined,
747
+ });
748
+ this.mounted = mounted;
749
+ this.stopButtonMounted = Boolean(this.stopButton);
750
+ const { cardId } = await this.sender.createCard({ cardJson });
751
+ this.cardId = cardId;
752
+ await this.sender.sendInteractive({
753
+ chatId: this.session.chatId,
754
+ cardId,
755
+ uuid: randomUUID(),
756
+ });
757
+ this.lastFlushedState = this.state;
758
+ }
759
+ async ingestEvent(event) {
760
+ if (this.closed || !this.cardId) {
761
+ return;
762
+ }
763
+ this.state = applyEventToCardState(this.state, event);
764
+ this.scheduleFlush();
765
+ }
766
+ async finalize(opts) {
767
+ if (this.closed) {
768
+ return;
769
+ }
770
+ this.state = {
771
+ ...this.state,
772
+ status: opts.status,
773
+ finalAnswer: opts.finalAnswer ?? this.state.finalAnswer ?? this.state.agentMessage ?? null,
774
+ errorMessage: opts.error ?? this.state.errorMessage,
775
+ };
776
+ if (this.flushTimer) {
777
+ clearTimeout(this.flushTimer);
778
+ this.flushTimer = null;
779
+ }
780
+ this.terminal = true;
781
+ this.dirty = true;
782
+ await this.flush();
783
+ this.closed = true;
784
+ }
785
+ getState() {
786
+ return this.state;
787
+ }
788
+ scheduleFlush() {
789
+ this.dirty = true;
790
+ if (this.flushTimer) {
791
+ return;
792
+ }
793
+ this.flushTimer = setTimeout(() => {
794
+ this.flushTimer = null;
795
+ void this.flush();
796
+ }, this.throttleMs);
797
+ }
798
+ flush() {
799
+ const cardId = this.cardId;
800
+ if (!cardId || !this.dirty) {
801
+ return this.flushChain;
802
+ }
803
+ const nextState = this.state;
804
+ const { actions, mounted } = diffCardActions(this.lastFlushedState, nextState, this.mounted);
805
+ if (this.terminal) {
806
+ if (this.stopButtonMounted) {
807
+ actions.push(buildStopColumnDeleteAction());
808
+ this.stopButtonMounted = false;
809
+ }
810
+ actions.push(buildTerminalSettingsAction());
811
+ }
812
+ this.dirty = false;
813
+ if (actions.length === 0) {
814
+ return this.flushChain;
815
+ }
816
+ const sequence = ++this.sequence;
817
+ this.lastFlushedState = nextState;
818
+ this.mounted = mounted;
819
+ this.flushChain = this.flushChain
820
+ .catch(() => undefined)
821
+ .then(async () => {
822
+ try {
823
+ await this.sender.batchUpdateCard({
824
+ cardId,
825
+ sequence,
826
+ actions: JSON.stringify(actions),
827
+ uuid: randomUUID(),
828
+ });
829
+ }
830
+ catch (error) {
831
+ this.logger?.log({
832
+ event: "card.batch_update_failed",
833
+ cardId,
834
+ sequence,
835
+ error: error instanceof Error ? error.message : String(error),
836
+ });
837
+ }
838
+ });
839
+ return this.flushChain;
840
+ }
841
+ }
842
+ const TOOL_ICON = {
843
+ running: "⏳",
844
+ completed: "✅",
845
+ failed: "❌",
846
+ };
847
+ function itemContainerId(item) {
848
+ return item.elId;
849
+ }
850
+ /** The inner element ids + content for an item (what the diff updates in place). */
851
+ function itemInners(item) {
852
+ const container = itemContainerId(item);
853
+ switch (item.kind) {
854
+ case "message":
855
+ return [{ id: container, content: item.text }];
856
+ case "error":
857
+ return [{ id: container, content: item.message ? `> ${item.message}` : "" }];
858
+ case "reasoning":
859
+ return [{ id: `${container}_b`, content: item.summary.trim() }];
860
+ case "tool": {
861
+ const head = `${TOOL_ICON[item.status]} \`${item.name || "tool"}\`${item.label ? ` ${truncate(item.label, 200)}` : ""}`;
862
+ const out = item.output ? `\`\`\`\n${item.output}\n\`\`\`` : "";
863
+ return [
864
+ { id: `${container}_h`, content: head },
865
+ { id: `${container}_o`, content: out },
866
+ ];
867
+ }
868
+ }
869
+ }
870
+ function renderSessionStatus(status, meta, note) {
871
+ const head = `${STATUS_EMOJI[status]} **${STATUS_LABELS[status]}**`;
872
+ // A transient note (e.g. an API-retry banner) is shown in amber so a stalled
873
+ // turn reads as "still working / retrying" rather than frozen.
874
+ const noteSuffix = note ? ` <font color='orange'>· ${note}</font>` : "";
875
+ const parts = [meta.runtime, meta.model, meta.effort, meta.resumeShortId].filter((p) => Boolean(p));
876
+ const metaSuffix = parts.length ? ` <font color='grey'>· ${parts.join(" · ")}</font>` : "";
877
+ return `${head}${noteSuffix}${metaSuffix}`;
878
+ }
879
+ function buildSessionStatusRow(statusContent, stopButton) {
880
+ const columns = [
881
+ {
882
+ tag: "column",
883
+ width: "weighted",
884
+ weight: 1,
885
+ vertical_align: "center",
886
+ elements: [{ tag: "markdown", element_id: ELEMENT_IDS.status, content: statusContent }],
887
+ },
888
+ ];
889
+ if (stopButton) {
890
+ columns.push({
891
+ tag: "column",
892
+ element_id: ELEMENT_IDS.stop_column,
893
+ width: "auto",
894
+ vertical_align: "center",
895
+ elements: [buildStopButtonElement(stopButton)],
896
+ });
897
+ }
898
+ return {
899
+ tag: "column_set",
900
+ element_id: ELEMENT_IDS.status_row,
901
+ flex_mode: "none",
902
+ horizontal_align: "left",
903
+ columns,
904
+ };
905
+ }
906
+ function buildItemBodyElement(item) {
907
+ const container = itemContainerId(item);
908
+ const inners = itemInners(item);
909
+ if (item.kind === "message" || item.kind === "error") {
910
+ return { tag: "markdown", element_id: container, content: inners[0].content };
911
+ }
912
+ const title = item.kind === "reasoning" ? "🧠 思考过程" : "🔧 工具调用";
913
+ return {
914
+ tag: "collapsible_panel",
915
+ element_id: container,
916
+ expanded: true,
917
+ header: {
918
+ title: { tag: "plain_text", content: title },
919
+ vertical_align: "center",
920
+ icon: { tag: "standard_icon", token: "down-small-ccm_outlined" },
921
+ },
922
+ elements: inners.map((inner) => ({ tag: "markdown", element_id: inner.id, content: inner.content })),
923
+ };
924
+ }
925
+ function contentText(parts) {
926
+ return parts.map((p) => p.text).join("");
927
+ }
928
+ /** A compact label for a tool head, derived from its JSON arguments. */
929
+ function toolLabel(argumentsJson) {
930
+ let args = {};
931
+ try {
932
+ const parsed = JSON.parse(argumentsJson);
933
+ if (parsed && typeof parsed === "object")
934
+ args = parsed;
935
+ }
936
+ catch {
937
+ return "";
938
+ }
939
+ const pick = (key) => {
940
+ const value = args[key];
941
+ return typeof value === "string" && value.trim() ? value : undefined;
942
+ };
943
+ return (pick("command") ??
944
+ pick("query") ??
945
+ pick("file_path") ??
946
+ pick("path") ??
947
+ pick("pattern") ??
948
+ pick("url") ??
949
+ pick("prompt") ??
950
+ "");
951
+ }
952
+ const SESSION_CARD_CONFIG = {
953
+ schema: "2.0",
954
+ config: {
955
+ streaming_mode: true,
956
+ summary: { content: "" },
957
+ streaming_config: {
958
+ print_frequency_ms: { default: 70 },
959
+ print_step: { default: 1 },
960
+ print_strategy: "fast",
961
+ },
962
+ },
963
+ };
964
+ /**
965
+ * Renders ONE response as a streaming Feishu card: a status row plus an ordered
966
+ * list of item blocks appended as they arrive. Keeps the legacy controller's
967
+ * throttle / sequence / serialized-flush machinery; only the state model + diff
968
+ * differ (an append-mostly ordered list rather than fixed panels).
969
+ */
970
+ export class LarkResponseCardController {
971
+ sender;
972
+ chatId;
973
+ meta;
974
+ throttleMs;
975
+ logger;
976
+ stopButton;
977
+ cardId = null;
978
+ sequence = 0;
979
+ flushTimer = null;
980
+ flushChain = Promise.resolve();
981
+ dirty = false;
982
+ terminal = false;
983
+ closed = false;
984
+ stopButtonMounted = false;
985
+ status = "running";
986
+ /** Transient status banner (e.g. API-retry); cleared once progress resumes. */
987
+ statusNote = null;
988
+ elIdSeq = 0;
989
+ items = [];
990
+ byItemId = new Map();
991
+ byCallId = new Map();
992
+ mounted = new Set();
993
+ lastRendered = new Map();
994
+ constructor(opts) {
995
+ this.sender = opts.sender;
996
+ this.chatId = opts.chatId;
997
+ this.meta = opts.meta;
998
+ this.throttleMs = opts.throttleMs ?? 250;
999
+ this.logger = opts.logger;
1000
+ this.stopButton = opts.stopButton ?? null;
1001
+ }
1002
+ /** Create the card (status row only) and reply/send it. Returns the posted
1003
+ * message id so the orchestrator can chain the next response's card under it. */
1004
+ async start(replyTo) {
1005
+ const statusRow = buildSessionStatusRow(renderSessionStatus("running", this.meta), this.stopButton ?? undefined);
1006
+ const card = { ...SESSION_CARD_CONFIG, body: { elements: [statusRow] } };
1007
+ const { cardId } = await this.sender.createCard({ cardJson: JSON.stringify(card) });
1008
+ this.cardId = cardId;
1009
+ this.mounted.add(ELEMENT_IDS.status_row);
1010
+ this.stopButtonMounted = Boolean(this.stopButton);
1011
+ this.lastRendered.set(ELEMENT_IDS.status, renderSessionStatus("running", this.meta));
1012
+ const uuid = randomUUID();
1013
+ if (replyTo.messageId) {
1014
+ const { messageId } = await this.sender.replyInteractive({
1015
+ messageId: replyTo.messageId,
1016
+ cardId,
1017
+ uuid,
1018
+ replyInThread: replyTo.replyInThread,
1019
+ });
1020
+ return { messageId };
1021
+ }
1022
+ const { messageId } = await this.sender.sendInteractive({ chatId: this.chatId, cardId, uuid });
1023
+ return { messageId };
1024
+ }
1025
+ applyEvent(event) {
1026
+ if (this.closed || !this.cardId) {
1027
+ return;
1028
+ }
1029
+ switch (event.type) {
1030
+ case "response.output_text.delta": {
1031
+ this.clearStatusNote();
1032
+ const item = this.ensureMessage(event.itemId);
1033
+ item.text = tailBytes(item.text + event.delta, AGENT_MESSAGE_MAX_BYTES);
1034
+ this.scheduleFlush();
1035
+ break;
1036
+ }
1037
+ case "response.reasoning_summary_text.delta": {
1038
+ this.clearStatusNote();
1039
+ const item = this.ensureReasoning(event.itemId);
1040
+ item.summary = tailBytes(item.summary + event.delta, REASONING_MAX_BYTES);
1041
+ this.scheduleFlush();
1042
+ break;
1043
+ }
1044
+ case "response.output_item.done": {
1045
+ this.clearStatusNote();
1046
+ this.applyItem(event.item);
1047
+ this.scheduleFlush();
1048
+ break;
1049
+ }
1050
+ default:
1051
+ // lifecycle events are handled by the orchestrator (start / finalize)
1052
+ break;
1053
+ }
1054
+ }
1055
+ /** Set/replace the transient status banner (amber note in the status row). */
1056
+ setStatusNote(note) {
1057
+ if (this.closed || this.statusNote === note) {
1058
+ return;
1059
+ }
1060
+ this.statusNote = note;
1061
+ this.scheduleFlush();
1062
+ }
1063
+ clearStatusNote() {
1064
+ if (this.statusNote !== null) {
1065
+ this.statusNote = null;
1066
+ }
1067
+ }
1068
+ async finalize(status) {
1069
+ if (this.closed) {
1070
+ return;
1071
+ }
1072
+ this.status = status;
1073
+ this.statusNote = null; // terminal card: drop any pending retry banner
1074
+ if (this.flushTimer) {
1075
+ clearTimeout(this.flushTimer);
1076
+ this.flushTimer = null;
1077
+ }
1078
+ this.terminal = true;
1079
+ this.dirty = true;
1080
+ await this.flush();
1081
+ this.closed = true;
1082
+ }
1083
+ applyItem(item) {
1084
+ switch (item.type) {
1085
+ case "message": {
1086
+ this.ensureMessage(item.id).text = tailBytes(contentText(item.data.content), AGENT_MESSAGE_MAX_BYTES);
1087
+ break;
1088
+ }
1089
+ case "reasoning": {
1090
+ this.ensureReasoning(item.id).summary = tailBytes(contentText(item.data.summary), REASONING_MAX_BYTES);
1091
+ break;
1092
+ }
1093
+ case "function_call": {
1094
+ const tool = this.ensureTool(item.data.callId);
1095
+ tool.name = item.data.name;
1096
+ tool.label = toolLabel(item.data.arguments);
1097
+ if (tool.status !== "completed" && tool.status !== "failed") {
1098
+ tool.status = "running";
1099
+ }
1100
+ break;
1101
+ }
1102
+ case "function_call_output": {
1103
+ const tool = this.ensureTool(item.data.callId);
1104
+ tool.status = item.data.status === "failed" ? "failed" : "completed";
1105
+ tool.output = tailBytes(item.data.output, OUTPUT_MAX_BYTES);
1106
+ break;
1107
+ }
1108
+ case "error": {
1109
+ this.items.push({ kind: "error", id: item.id, elId: this.nextElId("e"), message: item.data.message });
1110
+ break;
1111
+ }
1112
+ }
1113
+ }
1114
+ /** A fresh, Feishu-legal element_id (letter-prefixed, ≤ 20 chars). */
1115
+ nextElId(prefix) {
1116
+ return `${prefix}${(this.elIdSeq += 1)}`;
1117
+ }
1118
+ ensureMessage(id) {
1119
+ const existing = this.byItemId.get(id);
1120
+ if (existing && existing.kind === "message") {
1121
+ return existing;
1122
+ }
1123
+ const item = { kind: "message", id, elId: this.nextElId("m"), text: "" };
1124
+ this.byItemId.set(id, item);
1125
+ this.items.push(item);
1126
+ return item;
1127
+ }
1128
+ ensureReasoning(id) {
1129
+ const existing = this.byItemId.get(id);
1130
+ if (existing && existing.kind === "reasoning") {
1131
+ return existing;
1132
+ }
1133
+ const item = { kind: "reasoning", id, elId: this.nextElId("r"), summary: "" };
1134
+ this.byItemId.set(id, item);
1135
+ this.items.push(item);
1136
+ return item;
1137
+ }
1138
+ ensureTool(callId) {
1139
+ const existing = this.byCallId.get(callId);
1140
+ if (existing) {
1141
+ return existing;
1142
+ }
1143
+ const item = {
1144
+ kind: "tool",
1145
+ id: callId,
1146
+ elId: this.nextElId("t"),
1147
+ callId,
1148
+ name: "",
1149
+ label: "",
1150
+ status: "running",
1151
+ output: "",
1152
+ };
1153
+ this.byCallId.set(callId, item);
1154
+ this.items.push(item);
1155
+ return item;
1156
+ }
1157
+ scheduleFlush() {
1158
+ this.dirty = true;
1159
+ if (this.flushTimer) {
1160
+ return;
1161
+ }
1162
+ this.flushTimer = setTimeout(() => {
1163
+ this.flushTimer = null;
1164
+ void this.flush();
1165
+ }, this.throttleMs);
1166
+ }
1167
+ flush() {
1168
+ const cardId = this.cardId;
1169
+ if (!cardId || !this.dirty) {
1170
+ return this.flushChain;
1171
+ }
1172
+ const actions = [];
1173
+ const statusContent = renderSessionStatus(this.status, this.meta, this.statusNote);
1174
+ if (this.lastRendered.get(ELEMENT_IDS.status) !== statusContent) {
1175
+ actions.push({
1176
+ action: "partial_update_element",
1177
+ params: { element_id: ELEMENT_IDS.status, partial_element: { content: statusContent } },
1178
+ });
1179
+ this.lastRendered.set(ELEMENT_IDS.status, statusContent);
1180
+ }
1181
+ for (const item of this.items) {
1182
+ const container = itemContainerId(item);
1183
+ const inners = itemInners(item);
1184
+ if (!this.mounted.has(container)) {
1185
+ actions.push({ action: "add_elements", params: { type: "append", elements: [buildItemBodyElement(item)] } });
1186
+ this.mounted.add(container);
1187
+ for (const inner of inners) {
1188
+ this.lastRendered.set(inner.id, inner.content);
1189
+ }
1190
+ continue;
1191
+ }
1192
+ for (const inner of inners) {
1193
+ if (this.lastRendered.get(inner.id) !== inner.content) {
1194
+ actions.push({
1195
+ action: "partial_update_element",
1196
+ params: { element_id: inner.id, partial_element: { content: inner.content } },
1197
+ });
1198
+ this.lastRendered.set(inner.id, inner.content);
1199
+ }
1200
+ }
1201
+ }
1202
+ if (this.terminal) {
1203
+ if (this.stopButtonMounted) {
1204
+ actions.push({ action: "delete_elements", params: { element_ids: [ELEMENT_IDS.stop_column] } });
1205
+ this.stopButtonMounted = false;
1206
+ }
1207
+ actions.push({ action: "partial_update_setting", params: { settings: { config: { streaming_mode: false } } } });
1208
+ }
1209
+ this.dirty = false;
1210
+ if (actions.length === 0) {
1211
+ return this.flushChain;
1212
+ }
1213
+ const sequence = ++this.sequence;
1214
+ this.flushChain = this.flushChain
1215
+ .catch(() => undefined)
1216
+ .then(async () => {
1217
+ try {
1218
+ await this.sender.batchUpdateCard({
1219
+ cardId,
1220
+ sequence,
1221
+ actions: JSON.stringify(actions),
1222
+ uuid: randomUUID(),
1223
+ });
1224
+ }
1225
+ catch (error) {
1226
+ this.logger?.log({
1227
+ event: "card.batch_update_failed",
1228
+ cardId,
1229
+ sequence,
1230
+ error: error instanceof Error ? error.message : String(error),
1231
+ });
1232
+ }
1233
+ });
1234
+ return this.flushChain;
1235
+ }
1236
+ }
1237
+ /**
1238
+ * Orchestrates a turn's {@link SessionEvent} stream into a sequence of per-
1239
+ * response cards. Each `response.created` opens a new card (replied under the
1240
+ * previous one, forming a thread); item/lifecycle events route by `responseId`.
1241
+ */
1242
+ export class LarkSessionCardController {
1243
+ opts;
1244
+ cards = new Map();
1245
+ current = null;
1246
+ anchorMessageId;
1247
+ replyInThread;
1248
+ notedRuntimeSession = false;
1249
+ started = false;
1250
+ /** Accumulated assistant text, used only as a chat fallback if no card opened. */
1251
+ fallback = "";
1252
+ constructor(opts) {
1253
+ this.opts = opts;
1254
+ this.anchorMessageId = opts.session.messageId;
1255
+ this.replyInThread = Boolean(opts.session.threadId);
1256
+ }
1257
+ /** Whether at least one response card was successfully posted. */
1258
+ get cardStarted() {
1259
+ return this.started;
1260
+ }
1261
+ /** The assistant text (for a plain-text reply when no card could be shown). */
1262
+ get fallbackText() {
1263
+ return this.fallback;
1264
+ }
1265
+ async ingest(event) {
1266
+ switch (event.type) {
1267
+ case "response.created": {
1268
+ const card = new LarkResponseCardController({
1269
+ sender: this.opts.sender,
1270
+ chatId: this.opts.session.chatId,
1271
+ meta: this.opts.meta,
1272
+ throttleMs: this.opts.throttleMs,
1273
+ logger: this.opts.logger,
1274
+ stopButton: this.opts.stopButton,
1275
+ });
1276
+ this.cards.set(event.responseId, card);
1277
+ this.current = card;
1278
+ try {
1279
+ const { messageId } = await card.start({
1280
+ messageId: this.anchorMessageId,
1281
+ replyInThread: this.replyInThread,
1282
+ });
1283
+ if (messageId) {
1284
+ this.anchorMessageId = messageId;
1285
+ }
1286
+ this.started = true;
1287
+ }
1288
+ catch (error) {
1289
+ // A failed card post must not abandon the run: keep draining the
1290
+ // stream (so the runtime turn completes) and fall back to a text reply.
1291
+ this.current = null;
1292
+ this.opts.logger?.log({
1293
+ event: "card.start_failed",
1294
+ error: error instanceof Error ? error.message : String(error),
1295
+ });
1296
+ }
1297
+ break;
1298
+ }
1299
+ case "session.status": {
1300
+ if (event.runtimeSessionId && !this.notedRuntimeSession) {
1301
+ this.notedRuntimeSession = true;
1302
+ this.opts.onRuntimeSession?.(event.runtimeSessionId);
1303
+ }
1304
+ if (event.note !== undefined) {
1305
+ this.current?.setStatusNote(event.note);
1306
+ }
1307
+ break;
1308
+ }
1309
+ case "response.output_item.done":
1310
+ if (event.item.type === "message") {
1311
+ this.fallback = contentText(event.item.data.content);
1312
+ }
1313
+ (this.cards.get(event.responseId) ?? this.current)?.applyEvent(event);
1314
+ break;
1315
+ case "response.output_text.delta":
1316
+ case "response.reasoning_summary_text.delta":
1317
+ (this.cards.get(event.responseId) ?? this.current)?.applyEvent(event);
1318
+ break;
1319
+ case "response.completed":
1320
+ await (this.cards.get(event.responseId) ?? this.current)?.finalize("completed");
1321
+ break;
1322
+ case "response.failed":
1323
+ await (this.cards.get(event.responseId) ?? this.current)?.finalize(event.error.code === "codex_cancelled" ? "cancelled" : "failed");
1324
+ break;
1325
+ case "session.input.consumed":
1326
+ break;
1327
+ }
1328
+ }
1329
+ /** Finalize any still-open cards (safety net on stream end / throw). */
1330
+ async finalizeAll(status) {
1331
+ for (const card of this.cards.values()) {
1332
+ await card.finalize(status);
1333
+ }
1334
+ }
1335
+ }