wave-code 1.0.7 → 1.0.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.
@@ -59,6 +59,8 @@ export class AgentBridge {
59
59
  return this.getSessionInfo(sessionId);
60
60
  case "listPendingPermissions":
61
61
  return this.listPendingPermissions();
62
+ case "listDaemonSessions":
63
+ return this.listDaemonSessions();
62
64
  case "updateConfig":
63
65
  return this.updateConfig(p, sessionId);
64
66
  case "getConfiguredModels":
@@ -474,9 +476,39 @@ export class AgentBridge {
474
476
  catch {
475
477
  // Best-effort; don't block message sending on history save failure
476
478
  }
477
- await entry.agent.sendMessage(params.text, params.images);
479
+ await entry.agent.sendMessage(params.text, this.persistDataUrlImages(params.images));
478
480
  return null;
479
481
  }
482
+ /**
483
+ * Webview hosts (desktop/vscode/jetbrains) send pasted images as inline
484
+ * data URLs — there is no local file behind them. Persist each to a temp
485
+ * file so the model gets a real path it can reference with tools (aligned
486
+ * with Claude Code's `[Image source: <path>]` metadata). Real paths pass
487
+ * through untouched; unparseable data URLs pass through as-is and are
488
+ * skipped by the SDK rather than blocking the message.
489
+ */
490
+ persistDataUrlImages(images) {
491
+ if (!images || images.length === 0)
492
+ return images;
493
+ return images.map((img) => {
494
+ if (!img.path.startsWith("data:"))
495
+ return img;
496
+ const match = /^data:([^;,]+);base64,(.*)$/s.exec(img.path);
497
+ if (!match)
498
+ return img;
499
+ try {
500
+ const mimeType = match[1];
501
+ const ext = mimeType.split("/")[1]?.replace("jpeg", "jpg") || "png";
502
+ const filePath = join(tmpdir(), `wave-image-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`);
503
+ writeFileSync(filePath, Buffer.from(match[2], "base64"));
504
+ return { path: filePath, mimeType };
505
+ }
506
+ catch (error) {
507
+ logger.warn("Failed to persist pasted image to temp file:", error);
508
+ return img;
509
+ }
510
+ });
511
+ }
480
512
  async bang(command, sessionId) {
481
513
  const entry = this.requireSession(sessionId);
482
514
  await entry.agent.bang(command);
@@ -511,7 +543,10 @@ export class AgentBridge {
511
543
  async rewindToMessage(messageId, sessionId) {
512
544
  const entry = this.requireSession(sessionId);
513
545
  const { messages } = await entry.agent.getFullMessageThread();
514
- const index = messages.findIndex((m) => m.id === messageId);
546
+ // 压缩是 append-only:同 id 消息(压缩前历史 + 压缩后 append 的重复)会
547
+ // 在磁盘完整线程中出现多次。用户看到的折叠视图对应最后一次出现,
548
+ // 因此匹配最后一个而非第一个,避免回滚时连压缩摘要一起删掉。
549
+ const index = messages.map((m) => m.id).lastIndexOf(messageId);
515
550
  if (index === -1) {
516
551
  throw new RpcError(PROTOCOL_INTERNAL_ERROR, `Message not found: ${messageId}`);
517
552
  }
@@ -523,13 +558,19 @@ export class AgentBridge {
523
558
  async listRewindCheckpoints(sessionId) {
524
559
  const entry = this.requireSession(sessionId);
525
560
  const { messages } = await entry.agent.getFullMessageThread();
526
- const checkpoints = messages
527
- .filter((m) => isUserCheckpointMessage(m) && m.id)
528
- .map((m) => ({
529
- id: m.id,
530
- content: getMessageContent(m).replace(/\s+/g, " ").trim(),
531
- }));
532
- return { checkpoints };
561
+ // 压缩 append-only 后同 id 消息在磁盘完整线程中重复出现(压缩前历史 +
562
+ // 压缩后 append 的重复)。按 id 去重并保留最后一次出现(与折叠后的
563
+ // UI/内存视图一致),避免弹窗把同一条用户消息显示两遍。
564
+ const checkpointMap = new Map();
565
+ for (const m of messages) {
566
+ if (isUserCheckpointMessage(m) && m.id) {
567
+ checkpointMap.set(m.id, {
568
+ id: m.id,
569
+ content: getMessageContent(m).replace(/\s+/g, " ").trim(),
570
+ });
571
+ }
572
+ }
573
+ return { checkpoints: Array.from(checkpointMap.values()) };
533
574
  }
534
575
  deleteQueuedMessage(index, sessionId) {
535
576
  const entry = this.requireSession(sessionId);
@@ -540,7 +581,7 @@ export class AgentBridge {
540
581
  const entry = this.requireSession(sessionId);
541
582
  const ok = entry.agent.updateQueuedMessageById(id, {
542
583
  content: text,
543
- images,
584
+ images: this.persistDataUrlImages(images),
544
585
  });
545
586
  return { ok };
546
587
  }
@@ -698,6 +739,18 @@ export class AgentBridge {
698
739
  })),
699
740
  };
700
741
  }
742
+ /** Daemon list: expose the in-memory session registry (live sessions only,
743
+ * not disk-scanning). Registration order is preserved. */
744
+ listDaemonSessions() {
745
+ return {
746
+ sessions: [...this.sessions.entries()].map(([sessionId, entry]) => ({
747
+ sessionId,
748
+ workingDirectory: entry.agent.workingDirectory,
749
+ isLoading: entry.agent.isLoading,
750
+ messageCount: entry.agent.messages.length,
751
+ })),
752
+ };
753
+ }
701
754
  // ── Auth (global) ────────────────────────────────────────────
702
755
  async getAuthStatus() {
703
756
  const authService = AuthService.getInstance();
@@ -34,7 +34,7 @@ export declare const INVALID_REQUEST = -32600;
34
34
  export declare const METHOD_NOT_FOUND = -32601;
35
35
  export declare const INVALID_PARAMS = -32602;
36
36
  export declare const INTERNAL_ERROR = -32603;
37
- export type RequestMethod = "initialize" | "destroy" | "restoreSession" | "listSessions" | "getSessionInfo" | "sendMessage" | "bang" | "askBtw" | "abortMessage" | "clearMessages" | "rewindToMessage" | "listRewindCheckpoints" | "deleteQueuedMessage" | "updateQueuedMessage" | "deleteQueuedMessageById" | "getMessages" | "getFullMessageThread" | "setPermissionMode" | "getPermissionMode" | "getMcpServers" | "connectMcpServer" | "disconnectMcpServer" | "getSlashCommands" | "searchFiles" | "writeArtifactFile" | "getPromptHistory" | "searchPromptHistory" | "updateConfig" | "getConfiguredModels" | "setModel" | "listPendingPermissions" | "getAuthStatus" | "login" | "logout" | "listPlugins" | "installPlugin" | "uninstallPlugin" | "enablePlugin" | "disablePlugin" | "updatePlugin" | "listMarketplaces" | "addMarketplace" | "removeMarketplace" | "updateMarketplace" | "compact" | "getBackgroundTaskOutput" | "stopBackgroundTask" | "getWorkflowRuns" | "stopWorkflowRun" | "listGitBranches" | "createWorktree" | "removeWorktree";
37
+ export type RequestMethod = "initialize" | "destroy" | "restoreSession" | "listSessions" | "getSessionInfo" | "sendMessage" | "bang" | "askBtw" | "abortMessage" | "clearMessages" | "rewindToMessage" | "listRewindCheckpoints" | "deleteQueuedMessage" | "updateQueuedMessage" | "deleteQueuedMessageById" | "getMessages" | "getFullMessageThread" | "setPermissionMode" | "getPermissionMode" | "getMcpServers" | "connectMcpServer" | "disconnectMcpServer" | "getSlashCommands" | "searchFiles" | "writeArtifactFile" | "getPromptHistory" | "searchPromptHistory" | "updateConfig" | "getConfiguredModels" | "setModel" | "listPendingPermissions" | "listDaemonSessions" | "getAuthStatus" | "login" | "logout" | "listPlugins" | "installPlugin" | "uninstallPlugin" | "enablePlugin" | "disablePlugin" | "updatePlugin" | "listMarketplaces" | "addMarketplace" | "removeMarketplace" | "updateMarketplace" | "compact" | "getBackgroundTaskOutput" | "stopBackgroundTask" | "getWorkflowRuns" | "stopWorkflowRun" | "listGitBranches" | "createWorktree" | "removeWorktree";
38
38
  export type ClientNotificationMethod = "permissionResponse";
39
39
  export type ServerNotificationMethod = "userMessageAdded" | "assistantMessageAdded" | "assistantContentUpdated" | "assistantReasoningUpdated" | "toolBlockUpdated" | "errorBlockAdded" | "loadingChange" | "commandRunningChange" | "queuedMessagesChange" | "tasksChange" | "sessionIdChange" | "permissionModeChange" | "mcpServersChange" | "workdirChange" | "bangMessageAdded" | "bangMessageUpdated" | "bangMessageCompleted" | "notificationMessageAdded" | "permissionRequest" | "authUrl" | "compactBlockAdded" | "compactionStateChange" | "backgroundTasksChange" | "btwContent";
40
40
  export declare function isRequest(msg: unknown): msg is JsonRpcRequest;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-code",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "CLI-based code assistant powered by AI, built with React and Ink",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,7 +43,7 @@
43
43
  "wrap-ansi": "^10.0.0",
44
44
  "yargs": "^17.7.2",
45
45
  "zod": "^3.23.8",
46
- "wave-agent-sdk": "1.0.7"
46
+ "wave-agent-sdk": "1.0.9"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/react": "^19.1.8",
@@ -142,6 +142,7 @@ export const ChatInterface: React.FC = () => {
142
142
  toolName={confirmingTool!.name}
143
143
  toolInput={confirmingTool!.input}
144
144
  planContent={confirmingTool!.planContent}
145
+ warning={confirmingTool!.warning}
145
146
  isExpanded={isExpanded}
146
147
  />
147
148
  )}
@@ -151,6 +152,7 @@ export const ChatInterface: React.FC = () => {
151
152
  toolName={confirmingTool!.name}
152
153
  toolInput={confirmingTool!.input}
153
154
  planContent={confirmingTool!.planContent}
155
+ warning={confirmingTool!.warning}
154
156
  isExpanded={isExpanded}
155
157
  />
156
158
  )}
@@ -7,6 +7,7 @@ import {
7
7
  EXIT_PLAN_MODE_TOOL_NAME,
8
8
  ENTER_PLAN_MODE_TOOL_NAME,
9
9
  ASK_USER_QUESTION_TOOL_NAME,
10
+ ARTIFACT_TOOL_NAME,
10
11
  } from "wave-agent-sdk";
11
12
  import { DiffDisplay } from "./DiffDisplay.js";
12
13
  import { PlanDisplay } from "./PlanDisplay.js";
@@ -34,6 +35,8 @@ const getActionDescription = (
34
35
  return "Enter plan mode for complex task planning";
35
36
  case ASK_USER_QUESTION_TOOL_NAME:
36
37
  return "Answer questions to clarify intent";
38
+ case ARTIFACT_TOOL_NAME:
39
+ return `Publish file: ${toolInput.file_path || "unknown file"}`;
37
40
  default:
38
41
  return "Execute operation";
39
42
  }
@@ -43,6 +46,7 @@ export interface ConfirmationDetailsProps {
43
46
  toolName: string;
44
47
  toolInput?: Record<string, unknown>;
45
48
  planContent?: string;
49
+ warning?: string;
46
50
  isExpanded?: boolean;
47
51
  }
48
52
 
@@ -50,6 +54,7 @@ export const ConfirmationDetails: React.FC<ConfirmationDetailsProps> = ({
50
54
  toolName,
51
55
  toolInput,
52
56
  planContent,
57
+ warning,
53
58
  isExpanded = false,
54
59
  }) => {
55
60
  const startLineNumber =
@@ -69,6 +74,7 @@ export const ConfirmationDetails: React.FC<ConfirmationDetailsProps> = ({
69
74
  Tool: {toolName}
70
75
  </Text>
71
76
  <Text color="yellow">{getActionDescription(toolName, toolInput)}</Text>
77
+ {warning && <Text color="red">⚠ {warning}</Text>}
72
78
 
73
79
  <DiffDisplay
74
80
  toolName={toolName}
@@ -34,10 +34,16 @@ export const RewindCommand: React.FC<RewindCommandProps> = ({
34
34
  }, [getFullMessageThread]);
35
35
 
36
36
  // Filter user messages as checkpoints, excluding meta messages and
37
- // system-generated user-role messages (task notifications, hook injections)
38
- const checkpoints = messages
39
- .map((msg, index) => ({ msg, index }))
40
- .filter(({ msg }) => isUserCheckpointMessage(msg));
37
+ // system-generated user-role messages (task notifications, hook injections).
38
+ // Compaction is append-only: the same message id appears twice on the full
39
+ // thread (pre-compact history + post-compact append), so dedupe by id and
40
+ // keep the last occurrence (matching the folded view the user sees).
41
+ const checkpointMap = new Map<string, { msg: Message; index: number }>();
42
+ messages.forEach((msg, index) => {
43
+ if (!isUserCheckpointMessage(msg)) return;
44
+ checkpointMap.set(msg.id ?? `index:${index}`, { msg, index });
45
+ });
46
+ const checkpoints = Array.from(checkpointMap.values());
41
47
 
42
48
  const MAX_VISIBLE_ITEMS = 3;
43
49
 
@@ -31,7 +31,6 @@ import {
31
31
  extractLatestTotalTokens,
32
32
  } from "wave-agent-sdk";
33
33
  import { logger } from "../utils/logger.js";
34
- import { throttle } from "../utils/throttle.js";
35
34
  import { displayUsageSummary } from "../utils/usageSummary.js";
36
35
  import { expandLongTextPlaceholders } from "../managers/inputHandlers.js";
37
36
 
@@ -112,6 +111,7 @@ export interface ChatContextType {
112
111
  hidePersistentOption?: boolean;
113
112
  planContent?: string;
114
113
  permissionMode?: PermissionMode;
114
+ warning?: string;
115
115
  };
116
116
  showConfirmation: (
117
117
  toolName: string,
@@ -120,6 +120,7 @@ export interface ChatContextType {
120
120
  hidePersistentOption?: boolean,
121
121
  planContent?: string,
122
122
  permissionMode?: PermissionMode,
123
+ warning?: string,
123
124
  ) => Promise<PermissionDecision>;
124
125
  hideConfirmation: () => void;
125
126
  handleConfirmationDecision: (decision: PermissionDecision) => void;
@@ -167,6 +168,22 @@ interface StreamingUpdateParams {
167
168
  stage: "streaming" | "end";
168
169
  }
169
170
 
171
+ /**
172
+ * Snapshot a SDK message for consumer state. The SDK mutates its internal
173
+ * message blocks in-place BEFORE firing the delta callback (it writes the full
174
+ * accumulated value to the shared block, then computes the chunk delta by
175
+ * slicing the new value). A consumer that pushed the SDK message object by
176
+ * live reference would read the already-updated block and append the delta
177
+ * again — the first delta is double-counted ("LetLet me think..."), affecting
178
+ * reasoning and text content alike. See docs/specs/core/stream-content-updates.md.
179
+ * The clone must be at least one layer deep (message + blocks) so the in-place
180
+ * block mutation never leaks into consumer state.
181
+ */
182
+ const snapshotMessage = (message: Message): Message => ({
183
+ ...message,
184
+ blocks: message.blocks.map((block) => ({ ...block })),
185
+ });
186
+
170
187
  /**
171
188
  * Window-concat throttle for pure-delta streaming updates: chunks arriving
172
189
  * within the cooldown window are merged so no delta is lost (a dropped delta
@@ -239,6 +256,99 @@ function createStreamingWindowThrottle(
239
256
  return throttled;
240
257
  }
241
258
 
259
+ /**
260
+ * Per-tool window-concat throttle for pure-delta tool parameter streaming:
261
+ * `parametersChunk` deltas are accumulated independently per tool block id
262
+ * within the cooldown window, so interleaved multi-tool streams lose no delta
263
+ * (a plain throttle's single last-args slot would drop every earlier tool's
264
+ * deltas, leaving the first tool without streaming parameters). `start` /
265
+ * `running` apply immediately (one-shot snapshots); `end` flushes pending
266
+ * streaming deltas first, then applies the authoritative parameters/result.
267
+ */
268
+ export function createToolStreamingThrottle(
269
+ fn: (params: ToolBlockUpdateCallbackParams) => void,
270
+ wait: number,
271
+ ): {
272
+ (params: ToolBlockUpdateCallbackParams): void;
273
+ cancel: () => void;
274
+ flush: () => void;
275
+ } {
276
+ let timer: NodeJS.Timeout | null = null;
277
+ let pending: { messageId: string; chunks: Map<string, string> } | null = null;
278
+
279
+ const fire = () => {
280
+ if (pending && pending.chunks.size > 0) {
281
+ const { messageId, chunks } = pending;
282
+ pending = null;
283
+ for (const [id, chunk] of chunks) {
284
+ fn({ messageId, id, parametersChunk: chunk, stage: "streaming" });
285
+ }
286
+ }
287
+ };
288
+
289
+ const throttled = (params: ToolBlockUpdateCallbackParams) => {
290
+ if (params.stage === "end") {
291
+ // Flush any deltas still pending inside the cooldown window first
292
+ if (timer) {
293
+ clearTimeout(timer);
294
+ timer = null;
295
+ }
296
+ fire();
297
+ fn(params);
298
+ return;
299
+ }
300
+ if (params.stage === "streaming") {
301
+ if (!pending) {
302
+ pending = { messageId: params.messageId, chunks: new Map() };
303
+ }
304
+ const prev = pending.chunks.get(params.id) || "";
305
+ pending.chunks.set(params.id, prev + (params.parametersChunk || ""));
306
+ if (!timer) {
307
+ timer = setTimeout(() => {
308
+ timer = null;
309
+ fire();
310
+ }, wait);
311
+ }
312
+ return;
313
+ }
314
+ // start / running — one-shot snapshots applied immediately. Drop this
315
+ // tool's buffered streaming deltas first: start/running carry the
316
+ // authoritative parameters, and a pending timer would otherwise fire late
317
+ // with a stale `streaming` event, regressing this tool block's stage back
318
+ // to streaming (yellow dot -> gray) mid-execution. Other tools' in-flight
319
+ // chunks are kept so interleaved multi-tool streaming still accumulates.
320
+ if (pending) {
321
+ pending.chunks.delete(params.id);
322
+ if (pending.chunks.size === 0) {
323
+ pending = null;
324
+ if (timer) {
325
+ clearTimeout(timer);
326
+ timer = null;
327
+ }
328
+ }
329
+ }
330
+ fn(params);
331
+ };
332
+
333
+ throttled.cancel = () => {
334
+ if (timer) {
335
+ clearTimeout(timer);
336
+ timer = null;
337
+ }
338
+ pending = null;
339
+ };
340
+
341
+ throttled.flush = () => {
342
+ if (timer) {
343
+ clearTimeout(timer);
344
+ timer = null;
345
+ }
346
+ fire();
347
+ };
348
+
349
+ return throttled;
350
+ }
351
+
242
352
  export const ChatProvider: React.FC<ChatProviderProps> = ({
243
353
  children,
244
354
  bypassPermissions,
@@ -345,8 +455,13 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
345
455
 
346
456
  const throttledToolBlockUpdate = useMemo(
347
457
  () =>
348
- throttle((params: ToolBlockUpdateCallbackParams) => {
349
- const { messageId, id: toolBlockId, ...updates } = params;
458
+ createToolStreamingThrottle((params) => {
459
+ const {
460
+ messageId,
461
+ id: toolBlockId,
462
+ parametersChunk,
463
+ ...updates
464
+ } = params;
350
465
  setMessages((prev) =>
351
466
  prev.map((m) => {
352
467
  if (m.id !== messageId) return m;
@@ -363,7 +478,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
363
478
  id: toolBlockId,
364
479
  name: updates.name || "",
365
480
  stage: updates.stage || "start",
366
- parameters: updates.parameters || "",
481
+ parameters:
482
+ (updates.parameters || "") + (parametersChunk || ""),
367
483
  result: updates.result || "",
368
484
  ...updates,
369
485
  },
@@ -374,7 +490,18 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
374
490
  ...m,
375
491
  blocks: m.blocks.map((b, idx) =>
376
492
  idx === toolBlockIndex && b.type === "tool"
377
- ? { ...b, ...updates }
493
+ ? {
494
+ ...b,
495
+ ...updates,
496
+ // Streaming carries only the delta; append it to the
497
+ // accumulated parameters. start/running/end carry the
498
+ // authoritative value and replace wholesale.
499
+ parameters: parametersChunk
500
+ ? (b.parameters || "") + parametersChunk
501
+ : updates.parameters !== undefined
502
+ ? updates.parameters
503
+ : b.parameters,
504
+ }
378
505
  : b,
379
506
  ),
380
507
  };
@@ -443,6 +570,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
443
570
  hidePersistentOption?: boolean;
444
571
  planContent?: string;
445
572
  permissionMode?: PermissionMode;
573
+ warning?: string;
446
574
  }
447
575
  | undefined
448
576
  >();
@@ -454,6 +582,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
454
582
  hidePersistentOption?: boolean;
455
583
  planContent?: string;
456
584
  permissionMode?: PermissionMode;
585
+ warning?: string;
457
586
  resolver: (decision: PermissionDecision) => void;
458
587
  reject: () => void;
459
588
  }>
@@ -465,6 +594,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
465
594
  hidePersistentOption?: boolean;
466
595
  planContent?: string;
467
596
  permissionMode?: PermissionMode;
597
+ warning?: string;
468
598
  resolver: (decision: PermissionDecision) => void;
469
599
  reject: () => void;
470
600
  } | null>(null);
@@ -491,7 +621,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
491
621
  // the incremental callbacks in initializeAgent below.
492
622
  const refreshMessages = useCallback(() => {
493
623
  if (!isExpandedRef.current && agentRef.current) {
494
- const msgs = [...agentRef.current.messages];
624
+ const msgs = agentRef.current.messages.map(snapshotMessage);
495
625
  setMessages(msgs);
496
626
  setLatestTotalTokens(extractLatestTotalTokens(msgs));
497
627
  }
@@ -506,6 +636,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
506
636
  hidePersistentOption?: boolean,
507
637
  planContent?: string,
508
638
  permissionMode?: PermissionMode,
639
+ warning?: string,
509
640
  ): Promise<PermissionDecision> => {
510
641
  return new Promise<PermissionDecision>((resolve, reject) => {
511
642
  const queueItem = {
@@ -515,6 +646,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
515
646
  hidePersistentOption,
516
647
  planContent,
517
648
  permissionMode,
649
+ warning,
518
650
  resolver: resolve,
519
651
  reject,
520
652
  };
@@ -540,7 +672,9 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
540
672
  const last = msgs[msgs.length - 1];
541
673
  if (!last || last.role !== "user") return;
542
674
  setMessages((prev) =>
543
- prev.some((m) => m.id === last.id) ? prev : [...prev, last],
675
+ prev.some((m) => m.id === last.id)
676
+ ? prev
677
+ : [...prev, snapshotMessage(last)],
544
678
  );
545
679
  },
546
680
  onAssistantMessageAdded: (messageId: string) => {
@@ -548,7 +682,9 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
548
682
  const msg = agentRef.current.messages.find((m) => m.id === messageId);
549
683
  if (!msg) return;
550
684
  setMessages((prev) =>
551
- prev.some((m) => m.id === messageId) ? prev : [...prev, msg],
685
+ prev.some((m) => m.id === messageId)
686
+ ? prev
687
+ : [...prev, snapshotMessage(msg)],
552
688
  );
553
689
  },
554
690
  onAssistantContentUpdated: (params) => {
@@ -723,6 +859,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
723
859
  context.hidePersistentOption,
724
860
  context.planContent,
725
861
  context.permissionMode,
862
+ context.warning,
726
863
  );
727
864
  } catch {
728
865
  // If confirmation was cancelled or failed, deny the operation
@@ -772,9 +909,10 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
772
909
  agent.setWorktreeSession(session);
773
910
  }
774
911
 
775
- // Get initial state
912
+ // Get initial state — snapshot the SDK messages (never hold live
913
+ // references; see snapshotMessage)
776
914
  setSessionId(agent.sessionId);
777
- setMessages(agent.messages);
915
+ setMessages(agent.messages.map(snapshotMessage));
778
916
  setIsLoading(agent.isLoading);
779
917
  setLatestTotalTokens(extractLatestTotalTokens(agent.messages));
780
918
  setIsCommandRunning(agent.isCommandRunning);
@@ -1070,6 +1208,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1070
1208
  hidePersistentOption: next.hidePersistentOption,
1071
1209
  planContent: next.planContent,
1072
1210
  permissionMode: next.permissionMode,
1211
+ warning: next.warning,
1073
1212
  });
1074
1213
  setIsConfirmationVisible(true);
1075
1214
  setConfirmationQueue((prev) => prev.slice(1));