wave-code 1.5.1 → 1.6.1

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 (39) hide show
  1. package/README.md +3 -2
  2. package/bin/wave-code.js +2 -0
  3. package/dist/bundle/wave.mjs +735 -686
  4. package/package.json +8 -10
  5. package/src/cli.tsx +59 -3
  6. package/src/components/App.tsx +13 -13
  7. package/src/components/ChatInterface.tsx +4 -2
  8. package/src/components/InputBox.tsx +6 -3
  9. package/src/components/McpManager.tsx +26 -0
  10. package/src/components/MessageBlockItem.tsx +14 -2
  11. package/src/components/Notifications.tsx +6 -3
  12. package/src/components/RemoteControlView.tsx +1 -1
  13. package/src/contexts/useChat.tsx +88 -43
  14. package/src/daemon/commands.ts +266 -44
  15. package/src/daemon/daemonLog.ts +132 -0
  16. package/src/daemon/socketPath.ts +123 -0
  17. package/src/daemon-cli.ts +19 -6
  18. package/src/hooks/useExitSignals.ts +83 -0
  19. package/src/hooks/useRemoteControl.ts +16 -4
  20. package/src/index.ts +6 -0
  21. package/src/print-cli.ts +20 -10
  22. package/src/remote/hostIdentity.ts +1 -0
  23. package/src/remote/imagePreview.ts +182 -0
  24. package/src/remote/messageWindow.ts +5 -4
  25. package/src/remote/notificationCoalescer.ts +2 -2
  26. package/src/remote/relayClient.ts +1 -1
  27. package/src/remote/remoteControlCliHost.ts +46 -8
  28. package/src/remote/remoteControlService.ts +75 -18
  29. package/src/remote/remoteNotifications.ts +31 -9
  30. package/src/remote/textPreview.ts +155 -0
  31. package/src/stdio/agentBridge.ts +407 -72
  32. package/src/stdio/daemonServer.ts +49 -33
  33. package/src/stdio/hostSlashCommands.ts +9 -4
  34. package/src/stdio/startupStage.ts +22 -0
  35. package/src/stdio/uiPayload.ts +49 -0
  36. package/src/stdio-cli.ts +9 -1
  37. package/src/utils/argvCompat.ts +23 -0
  38. package/src/utils/printOutput.ts +1 -1
  39. package/src/utils/worktree.ts +222 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-code",
3
- "version": "1.5.1",
3
+ "version": "1.6.1",
4
4
  "description": "CLI-based code assistant powered by AI, built with React and Ink",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,9 +29,7 @@
29
29
  ],
30
30
  "dependencies": {
31
31
  "@anthropic-ai/sandbox-runtime": "^0.0.81",
32
- "@vscode/ripgrep": "^1.18.0",
33
- "qrcode": "^1.5.4",
34
- "sharp": "^0.35.4"
32
+ "qrcode": "^1.5.4"
35
33
  },
36
34
  "devDependencies": {
37
35
  "@types/qrcode": "^1.5.6",
@@ -59,7 +57,7 @@
59
57
  "wrap-ansi": "^10.0.0",
60
58
  "yargs": "^17.7.2",
61
59
  "zod": "^3.23.8",
62
- "wave-agent-sdk": "1.5.1"
60
+ "wave-agent-sdk": "1.6.1"
63
61
  },
64
62
  "engines": {
65
63
  "node": ">=22"
@@ -69,11 +67,11 @@
69
67
  "wave": "tsx --tsconfig tsconfig.dev.json src/index.ts",
70
68
  "build": "rimraf dist && node scripts/bundle.mjs",
71
69
  "type-check": "tsc --noEmit --incremental",
72
- "test": "vitest run --reporter=dot",
73
- "test:coverage": "vitest run --coverage --reporter=dot",
74
- "test:unit": "vitest run --reporter=dot --exclude 'tests/integration/**' --exclude '**/*.integration.test.ts'",
75
- "test:unit:coverage": "vitest run --coverage --reporter=dot --exclude 'tests/integration/**' --exclude '**/*.integration.test.ts'",
76
- "test:integration": "vitest run --reporter=dot tests/integration .integration.test",
70
+ "test": "vitest run",
71
+ "test:coverage": "vitest run --coverage",
72
+ "test:unit": "vitest run --exclude 'tests/integration/**' --exclude '**/*.integration.test.ts'",
73
+ "test:unit:coverage": "vitest run --coverage --exclude 'tests/integration/**' --exclude '**/*.integration.test.ts'",
74
+ "test:integration": "vitest run tests/integration .integration.test",
77
75
  "lint": "oxlint",
78
76
  "format": "prettier --write ."
79
77
  }
package/src/cli.tsx CHANGED
@@ -2,10 +2,51 @@ import React from "react";
2
2
  import { render } from "ink";
3
3
  import { ensureRuntimeDeps } from "wave-agent-sdk";
4
4
  import { App } from "./components/App.js";
5
+ import { getPendingAgentDestroy } from "./contexts/useChat.js";
5
6
  import { cleanupLogs } from "./utils/logger.js";
6
- import { removeWorktree } from "./utils/worktree.js";
7
+ import { describeWorktreeSurvival, removeWorktree } from "./utils/worktree.js";
7
8
  import { BaseAppProps } from "./types.js";
8
9
 
10
+ /**
11
+ * Upper bound on waiting for `Agent.destroy()` after the UI is gone.
12
+ *
13
+ * The teardown is bounded on its own side (its async-work drain has a timeout),
14
+ * but a wedged MCP child or LSP server must not turn closing the window into a
15
+ * process that never exits. Past this point the orphan risk is accepted in
16
+ * exchange for a terminal that actually comes back.
17
+ */
18
+ const AGENT_DESTROY_TIMEOUT_MS = 5_000;
19
+
20
+ /**
21
+ * Await the teardown that the unmount cleanup started.
22
+ *
23
+ * React effect cleanups are synchronous, so unmounting only *begins*
24
+ * `Agent.destroy()`; without this wait, `process.exit()` below terminates the
25
+ * process while the bash process trees, MCP children and LSP servers that
26
+ * `destroy()` was about to signal are still alive. Detached bash children then
27
+ * outlive their parent as launchd-reparented orphans.
28
+ */
29
+ async function awaitAgentDestroy(): Promise<void> {
30
+ const pending = getPendingAgentDestroy();
31
+ if (!pending) return;
32
+
33
+ let timeoutHandle: NodeJS.Timeout | undefined;
34
+ const timeout = new Promise<void>((resolve) => {
35
+ timeoutHandle = setTimeout(() => {
36
+ console.warn(
37
+ "Agent teardown timed out; exiting without waiting further.",
38
+ );
39
+ resolve();
40
+ }, AGENT_DESTROY_TIMEOUT_MS);
41
+ });
42
+
43
+ try {
44
+ await Promise.race([pending, timeout]);
45
+ } finally {
46
+ if (timeoutHandle) clearTimeout(timeoutHandle);
47
+ }
48
+ }
49
+
9
50
  export interface CliOptions extends BaseAppProps {
10
51
  restoreSessionId?: string;
11
52
  continueLastSession?: boolean;
@@ -40,8 +81,13 @@ export async function startCli(options: CliOptions): Promise<void> {
40
81
 
41
82
  // Continue with ink-based UI for normal mode
42
83
  let shouldRemoveWorktree = false;
84
+ let isExiting = false;
43
85
 
44
86
  const handleExit = (shouldRemove: boolean) => {
87
+ // A single close can arrive twice (SIGHUP plus the TTY-revocation poll, or
88
+ // an outside kill racing the keypress). Only the first one may unmount.
89
+ if (isExiting) return;
90
+ isExiting = true;
45
91
  shouldRemoveWorktree = shouldRemove;
46
92
  unmount();
47
93
  };
@@ -83,6 +129,10 @@ export async function startCli(options: CliOptions): Promise<void> {
83
129
  // Wait for the app to finish unmounting
84
130
  await waitUntilExit();
85
131
 
132
+ // ...which only *started* the agent teardown. Let it finish before exiting,
133
+ // otherwise the bash trees it was about to signal survive as orphans.
134
+ await awaitAgentDestroy();
135
+
86
136
  try {
87
137
  // Clean up old log files
88
138
  await cleanupLogs().catch((error) => {
@@ -96,8 +146,14 @@ export async function startCli(options: CliOptions): Promise<void> {
96
146
  if (shouldRemoveWorktree && worktreeSession) {
97
147
  process.stdout.write("\nDeleting worktree ...\n");
98
148
  process.chdir(worktreeSession.repoRoot);
99
- await removeWorktree(worktreeSession);
100
- process.stdout.write("Done.\n");
149
+ // "Done." only when it is: removal is best-effort, and a surviving
150
+ // directory (with its branch kept for it) needs the cleanup commands
151
+ // printed rather than a success line over the leftovers.
152
+ process.stdout.write(
153
+ (await removeWorktree(worktreeSession))
154
+ ? "Done.\n"
155
+ : `\n${describeWorktreeSurvival(worktreeSession)}\n`,
156
+ );
101
157
  }
102
158
 
103
159
  process.exit(0);
@@ -1,9 +1,10 @@
1
- import React, { useState, useEffect, useCallback } from "react";
1
+ import React, { useState, useCallback } from "react";
2
2
  import { useInput } from "ink";
3
3
  import { ChatInterface } from "./ChatInterface.js";
4
4
  import { ChatProvider } from "../contexts/useChat.js";
5
5
  import { AppProvider } from "../contexts/useAppConfig.js";
6
6
  import { WorktreeExitPrompt } from "./WorktreeExitPrompt.js";
7
+ import { useExitSignals } from "../hooks/useExitSignals.js";
7
8
  import {
8
9
  hasUncommittedChanges,
9
10
  hasNewCommits,
@@ -58,18 +59,11 @@ const ChatWithExitPrompt: React.FC<{
58
59
  }
59
60
  });
60
61
 
61
- useEffect(() => {
62
- const onSigInt = () => handleSignal();
63
- const onSigTerm = () => handleSignal();
64
-
65
- process.on("SIGINT", onSigInt);
66
- process.on("SIGTERM", onSigTerm);
67
-
68
- return () => {
69
- process.off("SIGINT", onSigInt);
70
- process.off("SIGTERM", onSigTerm);
71
- };
72
- }, [handleSignal]);
62
+ // Terminal close / outside kill: the worktree prompt still applies, because
63
+ // the same uncommitted-changes check has to run for an exit nobody typed.
64
+ useExitSignals(() => {
65
+ void handleSignal();
66
+ });
73
67
 
74
68
  if (isExiting && worktreeStatus) {
75
69
  return (
@@ -116,6 +110,12 @@ const AppWithProviders: React.FC<AppWithProvidersProps> = ({
116
110
  }
117
111
  });
118
112
 
113
+ // An outside kill or a terminal close never reaches `useInput` — it arrives
114
+ // as a signal, or on macOS as nothing at all. With a worktree, the prompt
115
+ // component owns that handler (it has uncommitted changes to check first), so
116
+ // this registration stays out of the way.
117
+ useExitSignals(() => onExit(false), !worktreeSession);
118
+
119
119
  if (worktreeSession) {
120
120
  return (
121
121
  <ChatProvider
@@ -26,7 +26,8 @@ export const ChatInterface: React.FC = () => {
26
26
  isExpanded,
27
27
  sessionId,
28
28
  latestTotalTokens,
29
- maxInputTokens,
29
+ contextWindow,
30
+ maxOutputTokens,
30
31
  slashCommands,
31
32
  hasSlashCommand,
32
33
  hooks,
@@ -112,7 +113,8 @@ export const ChatInterface: React.FC = () => {
112
113
  hasSlashCommand={hasSlashCommand}
113
114
  hooks={hooks}
114
115
  latestTotalTokens={latestTotalTokens}
115
- maxInputTokens={maxInputTokens}
116
+ contextWindow={contextWindow}
117
+ maxOutputTokens={maxOutputTokens}
116
118
  showLoginHint={showLoginHint}
117
119
  />
118
120
  </>
@@ -63,7 +63,8 @@ export interface InputBoxProps {
63
63
  >;
64
64
  // Token usage
65
65
  latestTotalTokens?: number;
66
- maxInputTokens?: number;
66
+ contextWindow?: number;
67
+ maxOutputTokens?: number;
67
68
  // Login hint
68
69
  showLoginHint?: boolean;
69
70
  }
@@ -81,7 +82,8 @@ export const InputBox: React.FC<InputBoxProps> = ({
81
82
  hasSlashCommand = () => false,
82
83
  hooks = {},
83
84
  latestTotalTokens = 0,
84
- maxInputTokens = 200000,
85
+ contextWindow = 200000,
86
+ maxOutputTokens = 32000,
85
87
  showLoginHint = false,
86
88
  }) => {
87
89
  const {
@@ -509,7 +511,8 @@ export const InputBox: React.FC<InputBoxProps> = ({
509
511
  />
510
512
  <Notifications
511
513
  latestTotalTokens={latestTotalTokens}
512
- maxInputTokens={maxInputTokens}
514
+ contextWindow={contextWindow}
515
+ maxOutputTokens={maxOutputTokens}
513
516
  showLoginHint={showLoginHint}
514
517
  pluginHint={pluginHint}
515
518
  />
@@ -157,6 +157,26 @@ export const McpManager: React.FC<McpManagerProps> = ({
157
157
  </Text>
158
158
  </Box>
159
159
  )}
160
+ {/* Not dialed yet (lazy dial): the tool list comes from the local
161
+ snapshot of the last successful connection, and says so. */}
162
+ {selectedServer.status !== "connected" &&
163
+ selectedServer.toolsFromCache && (
164
+ <Box>
165
+ <Text>
166
+ <Text color="blue">Tools:</Text>{" "}
167
+ {selectedServer.toolsFromCache.length} tools{" "}
168
+ <Text color="gray" dimColor>
169
+ (cached
170
+ {selectedServer.toolsCachedAt
171
+ ? ` · last connected ${formatTime(
172
+ selectedServer.toolsCachedAt,
173
+ )}`
174
+ : ""}
175
+ )
176
+ </Text>
177
+ </Text>
178
+ </Box>
179
+ )}
160
180
  {selectedServer.capabilities && (
161
181
  <Box>
162
182
  <Text>
@@ -270,6 +290,12 @@ export const McpManager: React.FC<McpManagerProps> = ({
270
290
  {server.status === "connected" && server.toolCount && (
271
291
  <Text color="green"> · {server.toolCount} tools</Text>
272
292
  )}
293
+ {server.status !== "connected" && server.toolsFromCache && (
294
+ <Text color="gray" dimColor>
295
+ {" "}
296
+ · {server.toolsFromCache.length} tools (cached)
297
+ </Text>
298
+ )}
273
299
  </Text>
274
300
  {index === state.selectedIndex && (
275
301
  <Box marginLeft={4} flexDirection="column">
@@ -41,9 +41,21 @@ export const MessageBlockItem = ({
41
41
  </Box>
42
42
  )}
43
43
 
44
- {block.type === "error" && (
44
+ {/* A line for the human, never sent to the model (see NoticeBlock in the
45
+ SDK): a failed turn (`error`, red) or a hook's status line (`info`,
46
+ muted). */}
47
+ {block.type === "notice" && (
45
48
  <Box>
46
- <Text color="red">Error: {block.content}</Text>
49
+ {block.level === "error" ? (
50
+ <Text color="red">Error: {block.content}</Text>
51
+ ) : (
52
+ <Text
53
+ color={block.level === "warning" ? "yellow" : "gray"}
54
+ dimColor
55
+ >
56
+ {block.content}
57
+ </Text>
58
+ )}
47
59
  </Box>
48
60
  )}
49
61
 
@@ -1,9 +1,11 @@
1
1
  import React from "react";
2
2
  import { Box, Text } from "ink";
3
+ import { percentOf } from "../remote/remoteNotifications.js";
3
4
 
4
5
  export interface NotificationsProps {
5
6
  latestTotalTokens?: number;
6
- maxInputTokens?: number;
7
+ contextWindow?: number;
8
+ maxOutputTokens?: number;
7
9
  showLoginHint?: boolean;
8
10
  /**
9
11
  * 一次性中性提示(插件变更待应用 / 重载完成)。由宿主显式设置并在数秒后清除,
@@ -14,13 +16,14 @@ export interface NotificationsProps {
14
16
 
15
17
  export const Notifications: React.FC<NotificationsProps> = ({
16
18
  latestTotalTokens = 0,
17
- maxInputTokens = 200000,
19
+ contextWindow = 200000,
20
+ maxOutputTokens = 32000,
18
21
  showLoginHint = false,
19
22
  pluginHint = null,
20
23
  }) => {
21
24
  const percentage =
22
25
  latestTotalTokens > 0
23
- ? Math.min(Math.round((latestTotalTokens / maxInputTokens) * 100), 100)
26
+ ? percentOf(latestTotalTokens, contextWindow, maxOutputTokens)
24
27
  : 0;
25
28
 
26
29
  const contextColor =
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * RemoteControlView — `/remote-control` 的覆盖层(把这条前台会话交给手机)。
3
3
  *
4
- * 规格:docs/specs/ui/remote-control.md「前台 CLI 会话也能被手机接管」。
4
+ * 规格:docs/specs/remote/remote-web.md「前台 CLI 会话也能被手机接管」。
5
5
  *
6
6
  * **这个斜杠命令不带参数**:没有 `on` / `off` / `status` 子命令,也不收机器名。
7
7
  * 判据是「状态自己决定动作」——`off` / `error` 时唯一合法动作是开启,`connecting`
@@ -14,6 +14,7 @@ import {
14
14
  } from "../hooks/useRemoteControl.js";
15
15
  import type {
16
16
  Message,
17
+ MessageBlock,
17
18
  McpServerStatus,
18
19
  BackgroundTask,
19
20
  Task,
@@ -100,7 +101,8 @@ export interface ChatContextType {
100
101
  recallQueuedMessage: () => QueuedMessage | null;
101
102
  removeQueuedMessageById: (id: string) => boolean;
102
103
  latestTotalTokens: number;
103
- maxInputTokens: number;
104
+ contextWindow: number;
105
+ maxOutputTokens: number;
104
106
  // Model functionality
105
107
  currentModel: string;
106
108
  configuredModels: string[];
@@ -201,6 +203,23 @@ export interface ChatContextType {
201
203
 
202
204
  const ChatContext = createContext<ChatContextType | null>(null);
203
205
 
206
+ /**
207
+ * Handoff for the agent teardown that React unmount can only *start*.
208
+ *
209
+ * A React effect cleanup is synchronous: it cannot await `Agent.destroy()`,
210
+ * whose teardown (bash process trees, MCP children, LSP, async work drain) is
211
+ * async. `cli.tsx` used to call `process.exit(0)` the moment `waitUntilExit()`
212
+ * resolved, which cut that teardown short — the detached bash process group
213
+ * never received its SIGTERM and survived as an orphan. The promise is parked
214
+ * here so the exit path can await it before exiting.
215
+ */
216
+ let pendingAgentDestroy: Promise<void> | null = null;
217
+
218
+ /** The in-flight teardown started by the unmount cleanup, if any. */
219
+ export function getPendingAgentDestroy(): Promise<void> | null {
220
+ return pendingAgentDestroy;
221
+ }
222
+
204
223
  export const useChat = () => {
205
224
  const context = useContext(ChatContext);
206
225
  if (!context) {
@@ -363,6 +382,38 @@ const applyToolBlockUpdate = (
363
382
  });
364
383
  };
365
384
 
385
+ /**
386
+ * Append a block to the last message — only when it is an assistant message
387
+ * (the current turn's in-flight reply). If the last message is a user message,
388
+ * the block belongs BELOW it: create a new assistant message instead of
389
+ * polluting the stale assistant from an earlier turn. Same rule the webview
390
+ * reducer applies (APPEND_NOTICE_BLOCK).
391
+ *
392
+ * Both the error and the system line use this: the SDK appends them to the
393
+ * same place (`appendBlockToLastAssistant`), so the CLI must not invent a
394
+ * second placement rule.
395
+ */
396
+ const appendBlockToLastAssistant = (
397
+ prev: Message[],
398
+ block: MessageBlock,
399
+ ): Message[] => {
400
+ const last = prev[prev.length - 1];
401
+ if (last && last.role === "assistant") {
402
+ return prev.map((m, idx) =>
403
+ idx === prev.length - 1 ? { ...m, blocks: [...m.blocks, block] } : m,
404
+ );
405
+ }
406
+ return [
407
+ ...prev,
408
+ {
409
+ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
410
+ role: "assistant",
411
+ timestamp: new Date().toISOString(),
412
+ blocks: [block],
413
+ },
414
+ ];
415
+ };
416
+
366
417
  export const ChatProvider: React.FC<ChatProviderProps> = ({
367
418
  children,
368
419
  bypassPermissions,
@@ -392,7 +443,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
392
443
 
393
444
  const [messages, setMessages] = useState<Message[]>([]);
394
445
  const [latestTotalTokens, setLatestTotalTokens] = useState(0);
395
- const [maxInputTokens, setMaxInputTokens] = useState(200000);
446
+ const [contextWindow, setContextWindow] = useState(200000);
447
+ const [maxOutputTokens, setMaxOutputTokens] = useState(32000);
396
448
 
397
449
  // All message-state updates apply IMMEDIATELY via setMessages — no data-layer
398
450
  // throttling (removed 2026-10-15 after observing fully-immediate rendering in
@@ -726,39 +778,19 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
726
778
  // `end` carries the authoritative final values.
727
779
  setMessages((prev) => applyToolBlockUpdate(prev, params));
728
780
  },
729
- onErrorBlockAdded: (error: string) => {
730
- remoteStream?.onErrorBlockAdded(error);
781
+ onNoticeAdded: ({ level, content }) => {
782
+ remoteStream?.onNoticeAdded({ level, content });
731
783
  if (isExpandedRef.current) return;
732
- // One-shot structural update — applied directly (errors must not be
733
- // delayed by the tool-running window).
734
- setMessages((prev) => {
735
- // Append to the LAST message only if it is an assistant message
736
- // (the current turn's in-flight reply). If the last message is a
737
- // user message, the error belongs BELOW it — create a new
738
- // assistant message instead of polluting the stale assistant from
739
- // an earlier turn (which surfaced the error above the latest
740
- // user message and accumulated it there).
741
- const last = prev[prev.length - 1];
742
- if (last && last.role === "assistant") {
743
- return prev.map((m, idx) =>
744
- idx === prev.length - 1
745
- ? {
746
- ...m,
747
- blocks: [...m.blocks, { type: "error", content: error }],
748
- }
749
- : m,
750
- );
751
- }
752
- return [
753
- ...prev,
754
- {
755
- id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
756
- role: "assistant",
757
- timestamp: new Date().toISOString(),
758
- blocks: [{ type: "error", content: error }],
759
- },
760
- ];
761
- });
784
+ // One-shot structural update — applied directly (a failure must not
785
+ // be delayed by the tool-running window; a hook's round line must
786
+ // not wait for the next `refreshMessages`).
787
+ setMessages((prev) =>
788
+ appendBlockToLastAssistant(prev, {
789
+ type: "notice",
790
+ level,
791
+ content,
792
+ }),
793
+ );
762
794
  },
763
795
  onLatestTotalTokensChange: (tokens) => {
764
796
  setLatestTotalTokens(tokens);
@@ -908,7 +940,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
908
940
  setWorkingDirectory(agent.currentWorkdir);
909
941
  setCurrentModelState(agent.getModelConfig().model || "");
910
942
  setConfiguredModels(agent.getConfiguredModels().map((m) => m.name));
911
- setMaxInputTokens(agent.getMaxInputTokens());
943
+ setContextWindow(agent.getContextWindow());
944
+ setMaxOutputTokens(agent.getMaxOutputTokens?.() ?? 32_000);
912
945
 
913
946
  // Get initial MCP servers state
914
947
  const initialMcpServers = agent.getMcpServers?.() || [];
@@ -1017,16 +1050,23 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1017
1050
  useEffect(() => {
1018
1051
  return () => {
1019
1052
  if (agentRef.current) {
1053
+ const agent = agentRef.current;
1020
1054
  try {
1021
1055
  // Display usage summary before cleanup
1022
- const usages = agentRef.current.usages;
1023
- const sessionFilePath = agentRef.current.sessionFilePath;
1056
+ const usages = agent.usages;
1057
+ const sessionFilePath = agent.sessionFilePath;
1024
1058
  displayUsageSummary(usages, sessionFilePath);
1025
1059
  } catch {
1026
1060
  // Silently ignore usage summary errors during cleanup
1027
1061
  }
1028
1062
 
1029
- agentRef.current.destroy();
1063
+ // Park the promise instead of dropping it: the exit path in `cli.tsx`
1064
+ // awaits it before `process.exit()` so the teardown actually runs
1065
+ // (see `pendingAgentDestroy`). Failures are swallowed so a broken
1066
+ // teardown cannot turn a clean exit into an unhandled rejection.
1067
+ pendingAgentDestroy = agent.destroy().catch((error: unknown) => {
1068
+ logger.warn("Agent teardown failed during exit:", error);
1069
+ });
1030
1070
  }
1031
1071
  };
1032
1072
  }, []);
@@ -1213,7 +1253,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1213
1253
  timestamp: new Date().toISOString(),
1214
1254
  blocks: [
1215
1255
  {
1216
- type: "error",
1256
+ type: "notice",
1257
+ level: "error",
1217
1258
  content: `Failed to resume conversation: ${
1218
1259
  error instanceof Error ? error.message : String(error)
1219
1260
  }`,
@@ -1442,7 +1483,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1442
1483
  isLoading,
1443
1484
  permissionMode,
1444
1485
  latestTotalTokens,
1445
- maxInputTokens,
1486
+ contextWindow,
1487
+ maxOutputTokens,
1446
1488
  // 手机侧会话列表的标题材料:`customTitle` 不走 React state(agent 自己存着,
1447
1489
  // 重命名后即刻生效),所以按「取当前值」的函数递给门面。
1448
1490
  customTitle: () => agentRef.current?.customTitle,
@@ -1467,8 +1509,10 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1467
1509
  renameSession: (title) => agentRef.current?.renameSession(title),
1468
1510
  compact: (instructions) => compact(instructions),
1469
1511
  slashCommands: () => agentRef.current?.getSlashCommands() ?? [],
1470
- // 手机页取首屏尾段 / 补差都读 agent 手上那份列表(压缩后的上下文);它这一侧
1471
- // 不提供上翻,所以不会带来 `before` 锚点。
1512
+ // 手机页取数都读 agent 手上那份列表(压缩后的上下文):首屏尾段(`limit`)、
1513
+ // 重连补差(`since`)、滚到顶往回翻(`before` + `limit`)三种窗口原样交给
1514
+ // SDK,切片与 `hasMore` / `anchorLost` 都由它判。分页是 2026-11-06 起远控页
1515
+ // 也有的能力,所以这里不能只认前两种。
1472
1516
  messagePage: (window) =>
1473
1517
  agentRef.current?.getMessagePage(window) ??
1474
1518
  Promise.resolve({ messages: [] }),
@@ -1589,7 +1633,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1589
1633
  recallQueuedMessage,
1590
1634
  removeQueuedMessageById,
1591
1635
  latestTotalTokens,
1592
- maxInputTokens,
1636
+ contextWindow,
1637
+ maxOutputTokens,
1593
1638
  currentModel,
1594
1639
  configuredModels,
1595
1640
  getConfiguredModels,