wave-code 1.6.0 → 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.
- package/README.md +3 -2
- package/dist/bundle/wave.mjs +656 -642
- package/package.json +3 -5
- package/src/cli.tsx +50 -0
- package/src/components/App.tsx +13 -13
- package/src/components/ChatInterface.tsx +4 -2
- package/src/components/InputBox.tsx +6 -3
- package/src/components/Notifications.tsx +6 -3
- package/src/contexts/useChat.tsx +41 -10
- package/src/hooks/useExitSignals.ts +83 -0
- package/src/hooks/useRemoteControl.ts +16 -4
- package/src/remote/remoteControlCliHost.ts +20 -4
- package/src/remote/remoteControlService.ts +8 -2
- package/src/remote/remoteNotifications.ts +26 -5
- package/src/stdio/agentBridge.ts +107 -7
- package/src/stdio/startupStage.ts +22 -0
- package/src/stdio-cli.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wave-code",
|
|
3
|
-
"version": "1.6.
|
|
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
|
-
"
|
|
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.6.
|
|
60
|
+
"wave-agent-sdk": "1.6.1"
|
|
63
61
|
},
|
|
64
62
|
"engines": {
|
|
65
63
|
"node": ">=22"
|
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
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) => {
|
package/src/components/App.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import React, { useState,
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
514
|
+
contextWindow={contextWindow}
|
|
515
|
+
maxOutputTokens={maxOutputTokens}
|
|
513
516
|
showLoginHint={showLoginHint}
|
|
514
517
|
pluginHint={pluginHint}
|
|
515
518
|
/>
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
26
|
+
? percentOf(latestTotalTokens, contextWindow, maxOutputTokens)
|
|
24
27
|
: 0;
|
|
25
28
|
|
|
26
29
|
const contextColor =
|
package/src/contexts/useChat.tsx
CHANGED
|
@@ -101,7 +101,8 @@ export interface ChatContextType {
|
|
|
101
101
|
recallQueuedMessage: () => QueuedMessage | null;
|
|
102
102
|
removeQueuedMessageById: (id: string) => boolean;
|
|
103
103
|
latestTotalTokens: number;
|
|
104
|
-
|
|
104
|
+
contextWindow: number;
|
|
105
|
+
maxOutputTokens: number;
|
|
105
106
|
// Model functionality
|
|
106
107
|
currentModel: string;
|
|
107
108
|
configuredModels: string[];
|
|
@@ -202,6 +203,23 @@ export interface ChatContextType {
|
|
|
202
203
|
|
|
203
204
|
const ChatContext = createContext<ChatContextType | null>(null);
|
|
204
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
|
+
|
|
205
223
|
export const useChat = () => {
|
|
206
224
|
const context = useContext(ChatContext);
|
|
207
225
|
if (!context) {
|
|
@@ -425,7 +443,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
425
443
|
|
|
426
444
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
427
445
|
const [latestTotalTokens, setLatestTotalTokens] = useState(0);
|
|
428
|
-
const [
|
|
446
|
+
const [contextWindow, setContextWindow] = useState(200000);
|
|
447
|
+
const [maxOutputTokens, setMaxOutputTokens] = useState(32000);
|
|
429
448
|
|
|
430
449
|
// All message-state updates apply IMMEDIATELY via setMessages — no data-layer
|
|
431
450
|
// throttling (removed 2026-10-15 after observing fully-immediate rendering in
|
|
@@ -921,7 +940,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
921
940
|
setWorkingDirectory(agent.currentWorkdir);
|
|
922
941
|
setCurrentModelState(agent.getModelConfig().model || "");
|
|
923
942
|
setConfiguredModels(agent.getConfiguredModels().map((m) => m.name));
|
|
924
|
-
|
|
943
|
+
setContextWindow(agent.getContextWindow());
|
|
944
|
+
setMaxOutputTokens(agent.getMaxOutputTokens?.() ?? 32_000);
|
|
925
945
|
|
|
926
946
|
// Get initial MCP servers state
|
|
927
947
|
const initialMcpServers = agent.getMcpServers?.() || [];
|
|
@@ -1030,16 +1050,23 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1030
1050
|
useEffect(() => {
|
|
1031
1051
|
return () => {
|
|
1032
1052
|
if (agentRef.current) {
|
|
1053
|
+
const agent = agentRef.current;
|
|
1033
1054
|
try {
|
|
1034
1055
|
// Display usage summary before cleanup
|
|
1035
|
-
const usages =
|
|
1036
|
-
const sessionFilePath =
|
|
1056
|
+
const usages = agent.usages;
|
|
1057
|
+
const sessionFilePath = agent.sessionFilePath;
|
|
1037
1058
|
displayUsageSummary(usages, sessionFilePath);
|
|
1038
1059
|
} catch {
|
|
1039
1060
|
// Silently ignore usage summary errors during cleanup
|
|
1040
1061
|
}
|
|
1041
1062
|
|
|
1042
|
-
|
|
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
|
+
});
|
|
1043
1070
|
}
|
|
1044
1071
|
};
|
|
1045
1072
|
}, []);
|
|
@@ -1456,7 +1483,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1456
1483
|
isLoading,
|
|
1457
1484
|
permissionMode,
|
|
1458
1485
|
latestTotalTokens,
|
|
1459
|
-
|
|
1486
|
+
contextWindow,
|
|
1487
|
+
maxOutputTokens,
|
|
1460
1488
|
// 手机侧会话列表的标题材料:`customTitle` 不走 React state(agent 自己存着,
|
|
1461
1489
|
// 重命名后即刻生效),所以按「取当前值」的函数递给门面。
|
|
1462
1490
|
customTitle: () => agentRef.current?.customTitle,
|
|
@@ -1481,8 +1509,10 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1481
1509
|
renameSession: (title) => agentRef.current?.renameSession(title),
|
|
1482
1510
|
compact: (instructions) => compact(instructions),
|
|
1483
1511
|
slashCommands: () => agentRef.current?.getSlashCommands() ?? [],
|
|
1484
|
-
//
|
|
1485
|
-
//
|
|
1512
|
+
// 手机页取数都读 agent 手上那份列表(压缩后的上下文):首屏尾段(`limit`)、
|
|
1513
|
+
// 重连补差(`since`)、滚到顶往回翻(`before` + `limit`)三种窗口原样交给
|
|
1514
|
+
// SDK,切片与 `hasMore` / `anchorLost` 都由它判。分页是 2026-11-06 起远控页
|
|
1515
|
+
// 也有的能力,所以这里不能只认前两种。
|
|
1486
1516
|
messagePage: (window) =>
|
|
1487
1517
|
agentRef.current?.getMessagePage(window) ??
|
|
1488
1518
|
Promise.resolve({ messages: [] }),
|
|
@@ -1603,7 +1633,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1603
1633
|
recallQueuedMessage,
|
|
1604
1634
|
removeQueuedMessageById,
|
|
1605
1635
|
latestTotalTokens,
|
|
1606
|
-
|
|
1636
|
+
contextWindow,
|
|
1637
|
+
maxOutputTokens,
|
|
1607
1638
|
currentModel,
|
|
1608
1639
|
configuredModels,
|
|
1609
1640
|
getConfiguredModels,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Process-level exit detection for the interactive CLI.
|
|
5
|
+
*
|
|
6
|
+
* Ink runs the terminal in raw mode, so Ctrl-C normally arrives as a keypress
|
|
7
|
+
* (`useInput`) rather than a signal — but the signals still arrive when the
|
|
8
|
+
* process is killed from outside, and the terminal itself sends one when the
|
|
9
|
+
* user closes the window. Every one of those paths has to unwind through React
|
|
10
|
+
* unmount, because that is what triggers `Agent.destroy()`; without a handler
|
|
11
|
+
* Node applies the default action and terminates on the spot, leaving the
|
|
12
|
+
* agent's async teardown — and every child process it owns — unreaped.
|
|
13
|
+
*
|
|
14
|
+
* `SIGHUP` is the one that actually bit us. The Bash tool spawns its shell with
|
|
15
|
+
* `detached: true`, which makes the child the leader of its own process group
|
|
16
|
+
* and therefore immune to the terminal's SIGHUP broadcast. If nothing reaps it,
|
|
17
|
+
* the shell and everything under it survive as launchd-reparented orphans
|
|
18
|
+
* (observed: one `zsh -> ping -> tail` group alive for almost three days).
|
|
19
|
+
* Claude Code registers the same signal for the same reason.
|
|
20
|
+
*
|
|
21
|
+
* The polling branch covers macOS specifically: closing a terminal there
|
|
22
|
+
* revokes the TTY file descriptors *instead of* delivering a signal, leaving
|
|
23
|
+
* the process alive but unable to read or write. Nothing else would ever
|
|
24
|
+
* notice, so the stdio handles are probed on the same 30s cadence Claude Code
|
|
25
|
+
* uses in `gracefulShutdown`.
|
|
26
|
+
*/
|
|
27
|
+
const TTY_REVOCATION_CHECK_INTERVAL_MS = 30_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Invoke `onExitSignal` on SIGINT / SIGTERM / SIGHUP, and when the controlling
|
|
31
|
+
* TTY is revoked out from under the process.
|
|
32
|
+
*
|
|
33
|
+
* `onExitSignal` is held in a ref, so callers do not need to memoize it and a
|
|
34
|
+
* re-render does not churn the signal registrations. Callers are responsible
|
|
35
|
+
* for making the teardown itself idempotent: a single terminal close can arrive
|
|
36
|
+
* as a signal *and* as a revoked TTY.
|
|
37
|
+
*
|
|
38
|
+
* @param enabled - set false to skip registration entirely (e.g. a wrapper
|
|
39
|
+
* component that renders a child which owns the handler).
|
|
40
|
+
*/
|
|
41
|
+
export function useExitSignals(onExitSignal: () => void, enabled = true): void {
|
|
42
|
+
const handlerRef = useRef(onExitSignal);
|
|
43
|
+
handlerRef.current = onExitSignal;
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!enabled) return;
|
|
47
|
+
|
|
48
|
+
const handle = () => handlerRef.current();
|
|
49
|
+
|
|
50
|
+
// Windows has no SIGHUP: the event never fires there, so registering it
|
|
51
|
+
// would only imply a coverage the platform cannot provide.
|
|
52
|
+
const signals: NodeJS.Signals[] =
|
|
53
|
+
process.platform === "win32"
|
|
54
|
+
? ["SIGINT", "SIGTERM"]
|
|
55
|
+
: ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
56
|
+
for (const signal of signals) {
|
|
57
|
+
process.on(signal, handle);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Only meaningful for an interactive terminal; piping either stream out
|
|
61
|
+
// means the handles legitimately stop being readable/writable.
|
|
62
|
+
let ttyRevocationPoll: NodeJS.Timeout | undefined;
|
|
63
|
+
if (process.stdin.isTTY) {
|
|
64
|
+
ttyRevocationPoll = setInterval(() => {
|
|
65
|
+
if (!process.stdout.writable || !process.stdin.readable) {
|
|
66
|
+
// Stop polling before handing off: the revoked TTY stays revoked, so
|
|
67
|
+
// a surviving interval would re-fire the teardown every 30s.
|
|
68
|
+
if (ttyRevocationPoll) clearInterval(ttyRevocationPoll);
|
|
69
|
+
handle();
|
|
70
|
+
}
|
|
71
|
+
}, TTY_REVOCATION_CHECK_INTERVAL_MS);
|
|
72
|
+
// Never keep the process alive just to poll for its own death.
|
|
73
|
+
ttyRevocationPoll.unref();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
for (const signal of signals) {
|
|
78
|
+
process.off(signal, handle);
|
|
79
|
+
}
|
|
80
|
+
if (ttyRevocationPoll) clearInterval(ttyRevocationPoll);
|
|
81
|
+
};
|
|
82
|
+
}, [enabled]);
|
|
83
|
+
}
|
|
@@ -61,7 +61,8 @@ export interface UseRemoteControlOptions {
|
|
|
61
61
|
isLoading: boolean;
|
|
62
62
|
permissionMode: PermissionMode;
|
|
63
63
|
latestTotalTokens: number;
|
|
64
|
-
|
|
64
|
+
contextWindow: number;
|
|
65
|
+
maxOutputTokens: number;
|
|
65
66
|
/**
|
|
66
67
|
* The title `/rename` sets, read live off the agent — the page's session list
|
|
67
68
|
* labels the row with it (and the page itself may be the one that renamed it).
|
|
@@ -194,10 +195,20 @@ export function useRemoteControl(
|
|
|
194
195
|
if (!sink || !sessionId) return;
|
|
195
196
|
sink.sendNotification(
|
|
196
197
|
"contextUsage",
|
|
197
|
-
remoteWire.contextUsage(
|
|
198
|
+
remoteWire.contextUsage(
|
|
199
|
+
latestTotalTokens,
|
|
200
|
+
options.contextWindow,
|
|
201
|
+
options.maxOutputTokens,
|
|
202
|
+
),
|
|
198
203
|
sessionId,
|
|
199
204
|
);
|
|
200
|
-
}, [
|
|
205
|
+
}, [
|
|
206
|
+
sink,
|
|
207
|
+
sessionId,
|
|
208
|
+
latestTotalTokens,
|
|
209
|
+
options.contextWindow,
|
|
210
|
+
options.maxOutputTokens,
|
|
211
|
+
]);
|
|
201
212
|
|
|
202
213
|
// Approvals are announced once, when they appear. The page answers with
|
|
203
214
|
// `permissionResponse`, which the host routes back into the same promise the
|
|
@@ -318,7 +329,8 @@ function adapterOf(latest: {
|
|
|
318
329
|
contextUsagePercent: () =>
|
|
319
330
|
percentOf(
|
|
320
331
|
latest.current.latestTotalTokens,
|
|
321
|
-
latest.current.
|
|
332
|
+
latest.current.contextWindow,
|
|
333
|
+
latest.current.maxOutputTokens,
|
|
322
334
|
),
|
|
323
335
|
latestTotalTokens: () => latest.current.latestTotalTokens,
|
|
324
336
|
permissionMode: () => latest.current.permissionMode,
|
|
@@ -249,10 +249,19 @@ export class RemoteControlCliHost implements RemoteControlHost {
|
|
|
249
249
|
decision?: unknown;
|
|
250
250
|
};
|
|
251
251
|
if (typeof p.requestId !== "string" || !p.decision) return;
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
)
|
|
252
|
+
const decision = p.decision as PermissionDecision;
|
|
253
|
+
this.session.respondPermission(p.requestId, decision);
|
|
254
|
+
// 不带反馈的拒绝 = 用户在页面上关掉了确认卡(关闭键 / Esc),本轮就此结束:
|
|
255
|
+
// 与桌面端 / VS Code / JetBrains 的 `denied → abortMessage()`、以及 Claude
|
|
256
|
+
// Code 的 `cancelAndAbort`(无 feedback 直接 abort 整轮)同一条语义。少了这
|
|
257
|
+
// 一步,手机上关掉提问弹窗后模型会拿着「用户拒绝了操作」继续往下做,而同一
|
|
258
|
+
// 张卡在桌面端关掉就停(spec ui/confirm-ui.md 场景 2 / 5、remote-web.md
|
|
259
|
+
// 场景 24)。**带 message 的拒绝不中断**——那是用户给出理由后拒绝这一次工具
|
|
260
|
+
// 调用,模型要照反馈继续或改问。
|
|
261
|
+
const rejectionFeedback = decision.message?.trim();
|
|
262
|
+
if (decision.behavior === "deny" && !rejectionFeedback) {
|
|
263
|
+
this.session.abort();
|
|
264
|
+
}
|
|
256
265
|
}
|
|
257
266
|
|
|
258
267
|
/**
|
|
@@ -388,10 +397,17 @@ export class RemoteControlCliHost implements RemoteControlHost {
|
|
|
388
397
|
messages: Message[];
|
|
389
398
|
contextUsagePercent: number;
|
|
390
399
|
reset?: boolean;
|
|
400
|
+
anchorLost?: boolean;
|
|
401
|
+
hasMore?: boolean;
|
|
391
402
|
}> {
|
|
392
403
|
const contextUsagePercent = this.session.contextUsagePercent();
|
|
393
404
|
const window: MessageWindow = {};
|
|
394
405
|
if (typeof p.since === "string") window.since = p.since;
|
|
406
|
+
// 上翻取更早的锚点(spec ui/message-rendering-system.md「首屏一页,上翻按需
|
|
407
|
+
// 取更早」,远控页自 2026-11-06 起也翻)。少这一行,`{before, limit}` 会被当成
|
|
408
|
+
// 「只要一页尾巴」——回的是同一段尾部、`hasMore` 仍是 true,页面每次滚到顶都
|
|
409
|
+
// 白要一次,永远翻不动。
|
|
410
|
+
if (typeof p.before === "string") window.before = p.before;
|
|
395
411
|
if (p.limit !== undefined) window.limit = Number(p.limit);
|
|
396
412
|
|
|
397
413
|
let normalized: MessageWindow | undefined;
|
|
@@ -696,7 +696,7 @@ export class RemoteControlService implements RemoteControlController {
|
|
|
696
696
|
// Not even one message fits: there is nothing to send that the phone
|
|
697
697
|
// could render, and an empty list would only make it re-ask forever.
|
|
698
698
|
if (kept > 0) {
|
|
699
|
-
this.send(pageFrame(replyId, page, kept));
|
|
699
|
+
this.send(pageFrame(replyId, page, kept, kept < page.messages.length));
|
|
700
700
|
return;
|
|
701
701
|
}
|
|
702
702
|
}
|
|
@@ -859,13 +859,19 @@ function pageFrame(
|
|
|
859
859
|
replyId: number | string,
|
|
860
860
|
page: MessagePage,
|
|
861
861
|
count: number,
|
|
862
|
+
droppedEarlier = false,
|
|
862
863
|
): RelayEnvelope {
|
|
864
|
+
// 裁掉的是**最早**那几条,所以只要真裁过,「更早的还在」就必然成立——哪怕
|
|
865
|
+
// 宿主那一页说的 `hasMore: false`(上翻取到这条会话最早那一页时正是如此)。
|
|
866
|
+
// 不在这里改这个字段,页面会照着 false 把上翻开关关掉,被裁掉的几条就再也
|
|
867
|
+
// 翻不回来了;页大小因此只能是「大概几条」,**条数永远不是终止判据**。
|
|
868
|
+
const rest = droppedEarlier ? { ...page.rest, hasMore: true } : page.rest;
|
|
863
869
|
return {
|
|
864
870
|
id: replyId,
|
|
865
871
|
method: "result",
|
|
866
872
|
params: {
|
|
867
873
|
result: {
|
|
868
|
-
...
|
|
874
|
+
...rest,
|
|
869
875
|
messages: page.messages.slice(-count),
|
|
870
876
|
},
|
|
871
877
|
},
|
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
ToolBlockUpdateCallbackParams,
|
|
29
29
|
ToolPermissionContext,
|
|
30
30
|
} from "wave-agent-sdk";
|
|
31
|
+
import { getAutoCompactThreshold } from "wave-agent-sdk";
|
|
31
32
|
import { toolBlockUpdateForUi } from "../stdio/uiPayload.js";
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -45,9 +46,25 @@ type StreamDelta = Parameters<
|
|
|
45
46
|
* push and its `getSessionInfo` read). 0 when the cap is unknown, so a caller
|
|
46
47
|
* that has one reads a number rather than a special case.
|
|
47
48
|
*/
|
|
48
|
-
export function percentOf(
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
export function percentOf(
|
|
50
|
+
tokens: number,
|
|
51
|
+
contextWindow: number,
|
|
52
|
+
maxOutputTokens = 32_000,
|
|
53
|
+
): number {
|
|
54
|
+
if (!Number.isFinite(contextWindow) || contextWindow <= 0) return 0;
|
|
55
|
+
const calculatedThreshold = getAutoCompactThreshold(
|
|
56
|
+
contextWindow,
|
|
57
|
+
Number.isFinite(maxOutputTokens) && maxOutputTokens > 0
|
|
58
|
+
? maxOutputTokens
|
|
59
|
+
: 32_000,
|
|
60
|
+
);
|
|
61
|
+
// Some host tests auto-mock the SDK barrel wholesale. Keep the wire helper
|
|
62
|
+
// usable in that environment without changing production's shared formula.
|
|
63
|
+
const threshold =
|
|
64
|
+
Number.isFinite(calculatedThreshold) && calculatedThreshold > 0
|
|
65
|
+
? calculatedThreshold
|
|
66
|
+
: contextWindow;
|
|
67
|
+
return Math.min(100, Math.round((tokens / threshold) * 100));
|
|
51
68
|
}
|
|
52
69
|
|
|
53
70
|
/** The shapes both hosts put on the wire, keyed by the method they ride under. */
|
|
@@ -95,8 +112,12 @@ export const remoteWire = {
|
|
|
95
112
|
...(latestTotalTokens === undefined ? {} : { latestTotalTokens }),
|
|
96
113
|
}),
|
|
97
114
|
|
|
98
|
-
contextUsage: (
|
|
99
|
-
|
|
115
|
+
contextUsage: (
|
|
116
|
+
latestTotalTokens: number,
|
|
117
|
+
contextWindow: number,
|
|
118
|
+
maxOutputTokens = 32_000,
|
|
119
|
+
) => ({
|
|
120
|
+
percent: percentOf(latestTotalTokens, contextWindow, maxOutputTokens),
|
|
100
121
|
}),
|
|
101
122
|
|
|
102
123
|
permissionModeChange: (mode: PermissionMode) => ({ mode }),
|