wave-code 1.5.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/wave-code.js +2 -0
- package/dist/bundle/wave.mjs +697 -662
- package/package.json +7 -7
- package/src/cli.tsx +9 -3
- package/src/components/McpManager.tsx +26 -0
- package/src/components/MessageBlockItem.tsx +14 -2
- package/src/components/RemoteControlView.tsx +1 -1
- package/src/contexts/useChat.tsx +47 -33
- package/src/daemon/commands.ts +266 -44
- package/src/daemon/daemonLog.ts +132 -0
- package/src/daemon/socketPath.ts +123 -0
- package/src/daemon-cli.ts +19 -6
- package/src/index.ts +6 -0
- package/src/print-cli.ts +20 -10
- package/src/remote/hostIdentity.ts +1 -0
- package/src/remote/imagePreview.ts +182 -0
- package/src/remote/messageWindow.ts +5 -4
- package/src/remote/notificationCoalescer.ts +2 -2
- package/src/remote/relayClient.ts +1 -1
- package/src/remote/remoteControlCliHost.ts +26 -4
- package/src/remote/remoteControlService.ts +67 -16
- package/src/remote/remoteNotifications.ts +5 -4
- package/src/remote/textPreview.ts +155 -0
- package/src/stdio/agentBridge.ts +300 -65
- package/src/stdio/daemonServer.ts +49 -33
- package/src/stdio/hostSlashCommands.ts +9 -4
- package/src/stdio/uiPayload.ts +49 -0
- package/src/utils/argvCompat.ts +23 -0
- package/src/utils/printOutput.ts +1 -1
- package/src/utils/worktree.ts +222 -16
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
type RelaySocketFactory,
|
|
50
50
|
} from "./relayClient.js";
|
|
51
51
|
import type { RemoteControlEndpoint } from "./remoteControlConfig.js";
|
|
52
|
+
import { resultForRemote } from "../stdio/uiPayload.js";
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
55
|
* Methods the remote channel accepts.
|
|
@@ -131,7 +132,7 @@ export const REMOTE_ALLOWED_METHODS: ReadonlySet<string> = new Set([
|
|
|
131
132
|
"renameSession",
|
|
132
133
|
"compact",
|
|
133
134
|
"getSlashCommands",
|
|
134
|
-
// 发起新会话(spec
|
|
135
|
+
// 发起新会话(spec remote/remote-web.md「Web 端也能发起新会话」):白名单
|
|
135
136
|
// 从「只碰已存在的会话」显式扩到「可在选定目录建会话」。initialize 在下方
|
|
136
137
|
// 收敛入参——放行的是「建一条会话」这个能力,不是它的整张参数表。
|
|
137
138
|
"initialize",
|
|
@@ -151,14 +152,17 @@ export const REMOTE_ALLOWED_METHODS: ReadonlySet<string> = new Set([
|
|
|
151
152
|
// 排队消息三动作里的两条(「立即发送」= sendMessage{force} + 删这条)。
|
|
152
153
|
"updateQueuedMessage",
|
|
153
154
|
"deleteQueuedMessageById",
|
|
154
|
-
// 后台任务与工作流:读输出 /
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
155
|
+
// 后台任务与工作流:读输出 / 停止 / 把本轮转到后台(不毁文件,回执即可)。
|
|
156
|
+
// `backgroundCurrentTask` 曾经因为「页面上唯一的入口是 Ctrl/Cmd+B,手机键盘
|
|
157
|
+
// 没有这个键」而不在此列:放行一个页面根本调不到的方法,只是把暴露面放大,
|
|
158
|
+
// 与本清单「跟页面能力一致」的判据相反。2026-10-30 助手消息上出现了后台按钮
|
|
159
|
+
// (spec: task-background-execution.md「前台工具后台化」场景 3–6),页面上有了
|
|
160
|
+
// 调用方,这条判据随之满足。
|
|
158
161
|
"getBackgroundTaskOutput",
|
|
159
162
|
"stopBackgroundTask",
|
|
160
163
|
"getWorkflowRuns",
|
|
161
164
|
"stopWorkflowRun",
|
|
165
|
+
"backgroundCurrentTask",
|
|
162
166
|
// `@` 文件建议与历史提示词:都只读,锚在信封 sessionId 的 workdir 上。
|
|
163
167
|
"searchFiles",
|
|
164
168
|
"getPromptHistory",
|
|
@@ -184,6 +188,33 @@ export const REMOTE_ALLOWED_METHODS: ReadonlySet<string> = new Set([
|
|
|
184
188
|
// 的任何东西(那个能力本来就在 `initialize` 里)。
|
|
185
189
|
"getRecentWorkdirs",
|
|
186
190
|
"rememberRecentWorkdir",
|
|
191
|
+
// ── 第六批:点消息里的图片路径看原图(spec「点消息里的图片路径看原图」)──
|
|
192
|
+
// 读:`getImagePreview` 把**一个路径**变成一张压缩到单帧预算内的图。
|
|
193
|
+
// 这是第一个收调用方 path 的读方法,所以边界是显式的三道而不是目录前缀:
|
|
194
|
+
// ① 页面只对图片扩展名给点击入口,路径来自对话内容(没有手输入口);
|
|
195
|
+
// ② 宿主解码后按**真实容器**判型(`imagePreview.ts` 的 ALLOWED_SOURCE_FORMATS),
|
|
196
|
+
// 扩展名只是路由提示,指向文本文件的软链在这一道被挡下;
|
|
197
|
+
// ③ 只读、不写不删,回包体积被压缩档位限住。
|
|
198
|
+
// **不按目录限制**是刻意的:真机截图落在 `/tmp`,限目录等于打掉主场景
|
|
199
|
+
// (见规格边界情况那条「边界画在内容类型上,不画在目录上」)。
|
|
200
|
+
"getImagePreview",
|
|
201
|
+
// 读:`getTextPreview` 是同一个形态的第二条(spec「点消息里的文件路径看内容」)
|
|
202
|
+
// ——把**一个路径**变成它开头那段文本。它**不设扩展名白名单**:判据是「读出来
|
|
203
|
+
// 是不是文本」(含 NUL 或大部分解不出即拒),不是「名字像不像文本」。
|
|
204
|
+
// 为什么这条通道不必也不该加路径限制:`sendMessage` + `permissionResponse` 已在
|
|
205
|
+
// 白名单里,持页面者让模型 Read 同一个路径拿到的就是同一份内容——任何这里加的
|
|
206
|
+
// 目录/扩展名限制都是假防护,只挡住更好用的入口(见规格边界情况那条)。
|
|
207
|
+
"getTextPreview",
|
|
208
|
+
// ── 第七批:`/resume`,接着磁盘上的历史会话聊(spec remote/remote-web.md
|
|
209
|
+
// 「手机端也能接着磁盘上的历史会话聊」)──
|
|
210
|
+
// 读:`listAllSessions` 枚举这台机器**所有项目目录**下的主会话(跨工作目录),
|
|
211
|
+
// 页面按最近活跃时间取前 200 条(帧预算是硬约束,见规格场景 2)。
|
|
212
|
+
// **`restoreSession` 仍然不放行**:它是「原地载入」——把承接进程里某条正在跑的
|
|
213
|
+
// 会话换成另一条(在桌面端就等于换掉用户正看着的那个 pane),与「手机只当控制
|
|
214
|
+
// 面」相冲。页面打开一条磁盘会话走的是**新建**路径
|
|
215
|
+
// (`initialize { workdir, restoreSessionId }`):恢复出来的是一条新会话,没有
|
|
216
|
+
// 任何已有会话被替换,见 `pickInitializeParams` 的注释。
|
|
217
|
+
"listAllSessions",
|
|
187
218
|
]);
|
|
188
219
|
|
|
189
220
|
/**
|
|
@@ -206,10 +237,22 @@ export const REMOTE_HOST_UNSUPPORTED = -32011;
|
|
|
206
237
|
|
|
207
238
|
/**
|
|
208
239
|
* The only `initialize` shape the remote channel may request: create a session
|
|
209
|
-
* in a chosen directory, with an optional permission mode
|
|
210
|
-
*
|
|
211
|
-
* restoreSessionId
|
|
212
|
-
*
|
|
240
|
+
* in a chosen directory, with an optional permission mode, and — since
|
|
241
|
+
* `/resume` (spec remote/remote-web.md「手机端也能接着磁盘上的历史会话聊」) — an
|
|
242
|
+
* optional `restoreSessionId` that seeds that **new** session from a transcript
|
|
243
|
+
* already on disk. Everything else in its parameter surface (model / apiKey /
|
|
244
|
+
* tools / plugins / mcpServers …) is dropped, not validated — a whitelisted
|
|
245
|
+
* method must not smuggle in knobs the whitelist never approved.
|
|
246
|
+
*
|
|
247
|
+
* Why `restoreSessionId` belongs here and not on the old "no re-attach" rule:
|
|
248
|
+
* that rule was about **switching** between live sessions (the page does that
|
|
249
|
+
* with `getMessages` on its own `sessionId`, never by re-binding the host's
|
|
250
|
+
* agent). `/resume` is a different act — the target is a transcript nobody is
|
|
251
|
+
* running, which the process cannot serve until a session exists for it. The
|
|
252
|
+
* write is therefore "start a new session seeded from this history", exactly
|
|
253
|
+
* the same capability as `initialize { workdir }` (which the page has had since
|
|
254
|
+
* 「Web 端也能发起新会话」), and the session it creates is a fresh one: no live
|
|
255
|
+
* session is replaced.
|
|
213
256
|
*
|
|
214
257
|
* `entrypoint` is stamped here rather than picked: it describes the CHANNEL, not
|
|
215
258
|
* the caller's request, and must not be spoofable (the page cannot claim to be
|
|
@@ -224,6 +267,9 @@ function pickInitializeParams(params: unknown): Record<string, unknown> {
|
|
|
224
267
|
if (typeof p.permissionMode === "string" && p.permissionMode) {
|
|
225
268
|
picked.permissionMode = p.permissionMode;
|
|
226
269
|
}
|
|
270
|
+
if (typeof p.restoreSessionId === "string" && p.restoreSessionId) {
|
|
271
|
+
picked.restoreSessionId = p.restoreSessionId;
|
|
272
|
+
}
|
|
227
273
|
return picked;
|
|
228
274
|
}
|
|
229
275
|
|
|
@@ -579,13 +625,15 @@ export class RemoteControlService implements RemoteControlController {
|
|
|
579
625
|
}
|
|
580
626
|
|
|
581
627
|
// `initialize` is whitelisted for exactly one shape: create a session in a
|
|
582
|
-
// chosen directory
|
|
628
|
+
// chosen directory, optionally set its permission mode, and optionally seed
|
|
629
|
+
// it from a transcript on disk (`restoreSessionId`, `/resume`). Its full
|
|
583
630
|
// parameter surface — apiKey / baseURL / model / tools / plugins /
|
|
584
631
|
// mcpServers … — would let the page reshape the machine-side agent, so
|
|
585
|
-
// everything outside
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
632
|
+
// everything outside those three keys is dropped here rather than trusted to
|
|
633
|
+
// the caller (spec 「Web 端也能发起新会话」场景 5、「手机端也能接着磁盘上
|
|
634
|
+
// 的历史会话聊」场景 4). Switching between *live* sessions still goes
|
|
635
|
+
// through `getMessages` on the page's own `sessionId`, never by re-binding
|
|
636
|
+
// an existing host agent.
|
|
589
637
|
const requestParams =
|
|
590
638
|
frame.method === "initialize"
|
|
591
639
|
? pickInitializeParams(frame.params)
|
|
@@ -624,10 +672,13 @@ export class RemoteControlService implements RemoteControlController {
|
|
|
624
672
|
method: string,
|
|
625
673
|
result: unknown,
|
|
626
674
|
): void {
|
|
675
|
+
// The page's copy: user image bytes never go over the relay
|
|
676
|
+
// (spec remote/remote-channel.md「远端通道只开一个白名单」场景 10).
|
|
677
|
+
const forRemote = resultForRemote(result);
|
|
627
678
|
const envelope: RelayEnvelope = {
|
|
628
679
|
id: replyId,
|
|
629
680
|
method: "result",
|
|
630
|
-
params: { result },
|
|
681
|
+
params: { result: forRemote },
|
|
631
682
|
};
|
|
632
683
|
const bytes = frameBytes(envelope);
|
|
633
684
|
if (bytes <= REMOTE_MAX_FRAME_BYTES) {
|
|
@@ -635,7 +686,7 @@ export class RemoteControlService implements RemoteControlController {
|
|
|
635
686
|
return;
|
|
636
687
|
}
|
|
637
688
|
|
|
638
|
-
const page = asMessagePage(
|
|
689
|
+
const page = asMessagePage(forRemote);
|
|
639
690
|
if (page) {
|
|
640
691
|
const kept = trailingCountThatFits(
|
|
641
692
|
page.messages.length,
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
AgentCallbacks,
|
|
24
24
|
BackgroundTask,
|
|
25
25
|
BackgroundTaskSummary,
|
|
26
|
+
NoticeLevel,
|
|
26
27
|
PermissionMode,
|
|
27
28
|
ToolBlockUpdateCallbackParams,
|
|
28
29
|
ToolPermissionContext,
|
|
@@ -87,7 +88,7 @@ export const remoteWire = {
|
|
|
87
88
|
return rest;
|
|
88
89
|
},
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
noticeAdded: (notice: { level: NoticeLevel; content: string }) => notice,
|
|
91
92
|
|
|
92
93
|
loadingChange: (loading: boolean, latestTotalTokens?: number) => ({
|
|
93
94
|
loading,
|
|
@@ -174,7 +175,7 @@ export function remoteNotifications(
|
|
|
174
175
|
| "onAssistantContentUpdated"
|
|
175
176
|
| "onAssistantReasoningUpdated"
|
|
176
177
|
| "onToolBlockUpdated"
|
|
177
|
-
| "
|
|
178
|
+
| "onNoticeAdded"
|
|
178
179
|
>
|
|
179
180
|
> {
|
|
180
181
|
return {
|
|
@@ -193,8 +194,8 @@ export function remoteNotifications(
|
|
|
193
194
|
onToolBlockUpdated: (params) => {
|
|
194
195
|
notify("toolBlockUpdated", remoteWire.toolBlockUpdated(params));
|
|
195
196
|
},
|
|
196
|
-
|
|
197
|
-
notify("
|
|
197
|
+
onNoticeAdded: (notice) => {
|
|
198
|
+
notify("noticeAdded", remoteWire.noticeAdded(notice));
|
|
198
199
|
},
|
|
199
200
|
};
|
|
200
201
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand text preview for the remote-control page.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* Same missing channel as {@link readImagePreview}: the phone has no file
|
|
7
|
+
* system, so "the agent wrote it to `/tmp/build.log`" is a path the user can
|
|
8
|
+
* read nowhere. The image channel solved that for pictures; this one solves it
|
|
9
|
+
* for everything else (spec `remote/remote-web.md`「点消息里的文件路径看内容」).
|
|
10
|
+
*
|
|
11
|
+
* ## The one gate
|
|
12
|
+
*
|
|
13
|
+
* The image channel is authorised by decoding: only a real raster container is
|
|
14
|
+
* served, so a `/tmp/x.png` that is really a text file dies before any bytes
|
|
15
|
+
* leave. Text has no equivalent — "it is text" is close to "anything can be
|
|
16
|
+
* read" — so by decision (2026-11-01) this channel does **not** filter by
|
|
17
|
+
* extension. What is left is: judge by *content* (a NUL byte or a mostly
|
|
18
|
+
* undecodable payload means binary), never write, never delete, and cap what
|
|
19
|
+
* one reply can carry. The consequences are spelled out in the spec's
|
|
20
|
+
* 「文本预览不设扩展名白名单」 boundary note — read that before widening this.
|
|
21
|
+
*
|
|
22
|
+
* ## Why the cap is not the frame budget
|
|
23
|
+
*
|
|
24
|
+
* {@link TEXT_PREVIEW_MAX_BYTES} is 256 KiB: a single frame could carry roughly
|
|
25
|
+
* twice that, but one screen of phone text is a few KiB, so anything past this
|
|
26
|
+
* only makes the overlay slower to render. The cap is applied as "read this
|
|
27
|
+
* much, then say you truncated", never as "refuse the file".
|
|
28
|
+
*/
|
|
29
|
+
import { open, stat } from "node:fs/promises";
|
|
30
|
+
|
|
31
|
+
/** Never read more than this for one preview; the rest is reported as cut. */
|
|
32
|
+
export const TEXT_PREVIEW_MAX_BYTES = 256 * 1024;
|
|
33
|
+
|
|
34
|
+
/** The bytes are not text (NUL byte, or mostly undecodable as UTF-8). */
|
|
35
|
+
export const TEXT_PREVIEW_NOT_TEXT = -32016;
|
|
36
|
+
/** The path could not be read (missing, permission, a directory). */
|
|
37
|
+
export const TEXT_PREVIEW_READ_FAILED = -32017;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Share of U+FFFD above which the payload is treated as binary rather than as
|
|
41
|
+
* text with a bad encoding. A GBK log that is otherwise readable stays under
|
|
42
|
+
* this; a JPEG/ELF renamed to `.log` lands far above it.
|
|
43
|
+
*/
|
|
44
|
+
const MAX_REPLACEMENT_RATIO = 0.1;
|
|
45
|
+
|
|
46
|
+
export interface TextPreviewResult {
|
|
47
|
+
/** The path that was asked for, echoed for the page's header. */
|
|
48
|
+
path: string;
|
|
49
|
+
content: string;
|
|
50
|
+
/** Size of the file on disk, for the "只显示前 X" line. */
|
|
51
|
+
bytes: number;
|
|
52
|
+
/** Bytes actually decoded (≤ {@link TEXT_PREVIEW_MAX_BYTES}). */
|
|
53
|
+
returnedBytes: number;
|
|
54
|
+
/** True when the file is larger than what was returned. */
|
|
55
|
+
truncated: boolean;
|
|
56
|
+
/** True when the decoded text contains replacement characters. */
|
|
57
|
+
encodingIssue: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fail(code: number, message: string): Error {
|
|
61
|
+
const error = new Error(message) as Error & { code?: number };
|
|
62
|
+
error.code = code;
|
|
63
|
+
return error;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read the head of one file from **this machine** and decode it as UTF-8.
|
|
68
|
+
*
|
|
69
|
+
* Throws an `Error` carrying one of the `TEXT_PREVIEW_*` codes; the remote
|
|
70
|
+
* error reply turns it into one visible line on the page.
|
|
71
|
+
*/
|
|
72
|
+
export async function readTextPreview(
|
|
73
|
+
filePath: string,
|
|
74
|
+
): Promise<TextPreviewResult> {
|
|
75
|
+
if (typeof filePath !== "string" || filePath.trim() === "") {
|
|
76
|
+
throw fail(TEXT_PREVIEW_READ_FAILED, "getTextPreview requires a path");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let size: number;
|
|
80
|
+
try {
|
|
81
|
+
const info = await stat(filePath);
|
|
82
|
+
if (!info.isFile()) {
|
|
83
|
+
throw fail(TEXT_PREVIEW_READ_FAILED, `Not a file: ${filePath}`);
|
|
84
|
+
}
|
|
85
|
+
size = info.size;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (
|
|
88
|
+
error instanceof Error &&
|
|
89
|
+
"code" in error &&
|
|
90
|
+
typeof error.code === "number"
|
|
91
|
+
) {
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
throw fail(TEXT_PREVIEW_READ_FAILED, `Could not read ${filePath}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const readBytes = Math.min(size, TEXT_PREVIEW_MAX_BYTES);
|
|
98
|
+
let source: Buffer;
|
|
99
|
+
try {
|
|
100
|
+
const handle = await open(filePath, "r");
|
|
101
|
+
try {
|
|
102
|
+
const buffer = Buffer.allocUnsafe(readBytes);
|
|
103
|
+
let filled = 0;
|
|
104
|
+
// `read` may return short; loop until the head we asked for is in hand.
|
|
105
|
+
while (filled < readBytes) {
|
|
106
|
+
const { bytesRead } = await handle.read(
|
|
107
|
+
buffer,
|
|
108
|
+
filled,
|
|
109
|
+
readBytes - filled,
|
|
110
|
+
filled,
|
|
111
|
+
);
|
|
112
|
+
if (bytesRead === 0) break;
|
|
113
|
+
filled += bytesRead;
|
|
114
|
+
}
|
|
115
|
+
source = buffer.subarray(0, filled);
|
|
116
|
+
} finally {
|
|
117
|
+
await handle.close();
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
throw fail(TEXT_PREVIEW_READ_FAILED, `Could not read ${filePath}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The gate: content, not the name. UTF-16 files carry NUL bytes and are
|
|
124
|
+
// rejected here too — this channel promises UTF-8, and saying so is better
|
|
125
|
+
// than showing a screen of interleaved NULs.
|
|
126
|
+
if (source.includes(0)) {
|
|
127
|
+
throw fail(TEXT_PREVIEW_NOT_TEXT, `${filePath} is not a text file`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const truncated = size > source.length;
|
|
131
|
+
// With `stream: true` the decoder holds back an incomplete trailing sequence
|
|
132
|
+
// instead of turning it into U+FFFD, so a cut never produces a broken last
|
|
133
|
+
// character (场景 5:按字节截断、退回最近的字符边界).
|
|
134
|
+
const content = new TextDecoder("utf-8").decode(source, {
|
|
135
|
+
stream: truncated,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
let replacements = 0;
|
|
139
|
+
for (const char of content) {
|
|
140
|
+
if (char === "\uFFFD") replacements++;
|
|
141
|
+
}
|
|
142
|
+
const length = content.length;
|
|
143
|
+
if (length > 0 && replacements / length > MAX_REPLACEMENT_RATIO) {
|
|
144
|
+
throw fail(TEXT_PREVIEW_NOT_TEXT, `${filePath} is not a text file`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
path: filePath,
|
|
149
|
+
content,
|
|
150
|
+
bytes: size,
|
|
151
|
+
returnedBytes: source.length,
|
|
152
|
+
truncated,
|
|
153
|
+
encodingIssue: replacements > 0,
|
|
154
|
+
};
|
|
155
|
+
}
|