wave-code 1.5.0 → 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.
Files changed (40) hide show
  1. package/bin/wave-code.js +2 -0
  2. package/dist/bundle/wave.mjs +777 -855
  3. package/package.json +7 -7
  4. package/src/cli.tsx +9 -3
  5. package/src/components/FileSelector.tsx +11 -8
  6. package/src/components/HelpView.tsx +1 -1
  7. package/src/components/InputBox.tsx +3 -5
  8. package/src/components/McpManager.tsx +26 -0
  9. package/src/components/MessageBlockItem.tsx +14 -2
  10. package/src/components/RemoteControlView.tsx +1 -1
  11. package/src/components/RewindCommand.tsx +40 -46
  12. package/src/contexts/useChat.tsx +61 -49
  13. package/src/daemon/commands.ts +266 -44
  14. package/src/daemon/daemonLog.ts +132 -0
  15. package/src/daemon/socketPath.ts +123 -0
  16. package/src/daemon-cli.ts +19 -6
  17. package/src/hooks/useInputManager.ts +27 -25
  18. package/src/index.ts +36 -4
  19. package/src/managers/inputHandlers.ts +3 -13
  20. package/src/managers/inputReducer.ts +44 -13
  21. package/src/print-cli.ts +199 -49
  22. package/src/remote/hostIdentity.ts +1 -0
  23. package/src/remote/imagePreview.ts +182 -0
  24. package/src/remote/messageWindow.ts +15 -5
  25. package/src/remote/notificationCoalescer.ts +16 -3
  26. package/src/remote/relayClient.ts +1 -1
  27. package/src/remote/remoteControlCliHost.ts +26 -4
  28. package/src/remote/remoteControlService.ts +70 -19
  29. package/src/remote/remoteNotifications.ts +5 -4
  30. package/src/remote/textPreview.ts +155 -0
  31. package/src/stdio/agentBridge.ts +450 -117
  32. package/src/stdio/daemonServer.ts +49 -33
  33. package/src/stdio/hostSlashCommands.ts +9 -4
  34. package/src/stdio/uiPayload.ts +50 -2
  35. package/src/utils/agentSelector.ts +29 -0
  36. package/src/utils/argvCompat.ts +23 -0
  37. package/src/utils/printOutput.ts +403 -0
  38. package/src/utils/usageSummary.ts +74 -24
  39. package/src/utils/worktree.ts +222 -16
  40. package/src/utils/rewindCheckpoints.ts +0 -14
package/src/print-cli.ts CHANGED
@@ -6,10 +6,18 @@ import {
6
6
  getDefaultRemoteBranch,
7
7
  validateWorktreeRemovalPath,
8
8
  setTelemetryStdoutProtected,
9
+ type Usage,
9
10
  type WorktreeSession,
10
11
  } from "wave-agent-sdk";
11
12
  import { displayUsageSummary } from "./utils/usageSummary.js";
12
- import { removeWorktree } from "./utils/worktree.js";
13
+ import { describeWorktreeSurvival, removeWorktree } from "./utils/worktree.js";
14
+ import {
15
+ drainStdout,
16
+ installStdoutGuard,
17
+ PrintOutput,
18
+ uninstallStdoutGuard,
19
+ type OutputFormat,
20
+ } from "./utils/printOutput.js";
13
21
  import { BaseAppProps } from "./types.js";
14
22
 
15
23
  export interface PrintCliOptions extends BaseAppProps {
@@ -19,9 +27,18 @@ export interface PrintCliOptions extends BaseAppProps {
19
27
  showStats?: boolean;
20
28
  /** MCP server config parsed from --mcp-config CLI argument */
21
29
  mcpServers?: Record<string, import("wave-agent-sdk").McpServerConfig>;
30
+ /** --output-format: "text" (default), "json", or "stream-json" */
31
+ outputFormat?: OutputFormat;
22
32
  }
23
33
 
24
- function displayTimingInfo(startTime: Date, showStats: boolean): void {
34
+ /** A diagnostics writer: stdout in text mode, stderr once stdout carries JSON. */
35
+ type Writer = (text: string) => void;
36
+
37
+ function displayTimingInfo(
38
+ startTime: Date,
39
+ showStats: boolean,
40
+ write: Writer,
41
+ ): void {
25
42
  // Skip timing info in test environment or if stats are disabled
26
43
  if (process.env.NODE_ENV === "test" || process.env.VITEST || !showStats) {
27
44
  return;
@@ -30,9 +47,9 @@ function displayTimingInfo(startTime: Date, showStats: boolean): void {
30
47
  const endTime = new Date();
31
48
  const duration = endTime.getTime() - startTime.getTime();
32
49
 
33
- process.stdout.write(`\n\n📅 Start time: ${startTime.toISOString()}\n`);
34
- process.stdout.write(`📅 End time: ${endTime.toISOString()}\n`);
35
- process.stdout.write(`⏱️ Duration: ${duration}ms\n`);
50
+ write(`\n\n📅 Start time: ${startTime.toISOString()}\n`);
51
+ write(`📅 End time: ${endTime.toISOString()}\n`);
52
+ write(`⏱️ Duration: ${duration}ms\n`);
36
53
  }
37
54
 
38
55
  export async function startPrintCli(options: PrintCliOptions): Promise<void> {
@@ -57,6 +74,7 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
57
74
  originalCwd,
58
75
  model,
59
76
  mcpServers,
77
+ outputFormat = "text",
60
78
  } = options;
61
79
 
62
80
  if (
@@ -70,6 +88,27 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
70
88
  process.exit(1);
71
89
  }
72
90
 
91
+ const output = new PrintOutput({ format: outputFormat });
92
+ const structured = output.structured;
93
+ // Structured modes promise stdout carries nothing but JSON, so a stray
94
+ // console.log from anywhere in the process is diverted to stderr instead of
95
+ // tearing the NDJSON stream.
96
+ if (structured) installStdoutGuard();
97
+ /** Diagnostics: stderr when stdout is the JSON channel, stdout otherwise. */
98
+ const diag: Writer = (text) => output.diag(text);
99
+ const usageWriter = structured
100
+ ? toLineWriter(process.stderr.write.bind(process.stderr))
101
+ : undefined;
102
+ // Text mode keeps the exact call shape it had (console.log); structured mode
103
+ // is the only one that redirects the summary, and it needs stderr for it.
104
+ const showUsage = (usages: Usage[], sessionFilePath: string): void => {
105
+ if (usageWriter) {
106
+ displayUsageSummary(usages, sessionFilePath, usageWriter);
107
+ } else {
108
+ displayUsageSummary(usages, sessionFilePath);
109
+ }
110
+ };
111
+
73
112
  let agent: Agent;
74
113
  let isReasoning = false;
75
114
  let isContent = false;
@@ -82,42 +121,58 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
82
121
  onAssistantMessageAdded: () => {
83
122
  isReasoning = false;
84
123
  isContent = false;
124
+ output.assistantMessageAdded();
85
125
  },
86
- onAssistantReasoningUpdated: (params: { chunk: string }) => {
126
+ onAssistantMessageDiscarded: () => {
127
+ // 流式降级的半截消息作废,不能算进 result / num_turns
128
+ output.assistantMessageDiscarded();
129
+ },
130
+ onAssistantReasoningUpdated: (params: {
131
+ messageId: string;
132
+ chunk: string;
133
+ }) => {
87
134
  if (!isReasoning) {
88
- process.stdout.write("\n💭 Reasoning:\n");
135
+ output.human("\n💭 Reasoning:\n");
89
136
  isReasoning = true;
90
137
  }
91
- process.stdout.write(params.chunk);
138
+ output.human(params.chunk);
139
+ output.reasoningDelta(params.messageId, params.chunk);
92
140
  },
93
- onAssistantContentUpdated: (params: { chunk: string }) => {
141
+ onAssistantContentUpdated: (params: {
142
+ messageId: string;
143
+ chunk: string;
144
+ }) => {
94
145
  if (!isContent) {
95
146
  if (isReasoning) {
96
- process.stdout.write("\n\n📝 Response:\n");
147
+ output.human("\n\n📝 Response:\n");
97
148
  } else {
98
- process.stdout.write("\n");
149
+ output.human("\n");
99
150
  }
100
151
  isContent = true;
101
152
  }
102
- process.stdout.write(params.chunk);
153
+ output.human(params.chunk);
154
+ output.contentDelta(params.messageId, params.chunk);
103
155
  },
104
156
 
105
157
  // Tool block callback - display tool name when tool starts
106
158
  onToolBlockUpdated: (params) => {
107
159
  // Print tool name only during 'running' stage (happens once per tool call)
108
160
  if (params.stage === "running" && params.name) {
109
- process.stdout.write(`\n🔧 ${params.name}`);
161
+ output.human(`\n🔧 ${params.name}`);
110
162
  if (params.compactParams) {
111
- process.stdout.write(` ${params.compactParams}`);
163
+ output.human(` ${params.compactParams}`);
112
164
  }
113
- process.stdout.write(`\n`);
165
+ output.human(`\n`);
114
166
  }
167
+ output.toolUpdate(params);
115
168
  },
116
169
 
117
- // Error block callback
118
- onErrorBlockAdded: (error: string) => {
119
- // Display error blocks with distinct formatting
120
- process.stdout.write(`\n❌ Error: ${error}\n`);
170
+ // Notice callback: `error` is the turn failure, `info` a hook's line for
171
+ // the human. Only the former is an error for the CLI's own output.
172
+ onNoticeAdded: ({ level, content }) => {
173
+ if (level !== "error") return;
174
+ output.error(content);
175
+ diag(`\n❌ Error: ${content}\n`);
121
176
  },
122
177
  };
123
178
 
@@ -143,6 +198,18 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
143
198
  // 保持流式模式以获得更好的命令行用户体验
144
199
  });
145
200
 
201
+ // stream-json 的首事件:把会话元信息先交代清楚,CI 才能把后面的行归到一次运行上。
202
+ // 只有这一种格式会读这些 getter,别的格式没必要为它解析一遍配置。
203
+ if (outputFormat === "stream-json") {
204
+ output.init({
205
+ sessionId: agent.sessionId,
206
+ model: agent.getModelConfig().model,
207
+ cwd: agent.currentWorkdir,
208
+ permissionMode: agent.getPermissionMode(),
209
+ tools: agent.getAvailableToolNames(),
210
+ });
211
+ }
212
+
146
213
  // Inject worktree session into this agent's container (per-session, not global)
147
214
  if (worktreeSession) {
148
215
  const session: WorktreeSession = {
@@ -160,7 +227,7 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
160
227
  // Send message if provided and not empty
161
228
  if (typeof message === "string" && message.trim() !== "") {
162
229
  await agent.sendMessage(message);
163
- process.stdout.write("\n");
230
+ output.human("\n");
164
231
  }
165
232
 
166
233
  // Wait for running background tasks/subagents to complete AND for the main
@@ -178,19 +245,29 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
178
245
  await new Promise((resolve) => setTimeout(resolve, 500));
179
246
  }
180
247
 
248
+ // Snapshot before destroy — the result envelope needs both, and the agent
249
+ // is torn down before the worktree bookkeeping below. A getter that throws
250
+ // must never turn a completed run into a failure, so it degrades to no
251
+ // usage rather than raising.
252
+ const usageSnapshot = readUsageSnapshot(agent);
253
+ let sessionId = "";
254
+ try {
255
+ sessionId = agent.sessionId;
256
+ } catch {
257
+ // Same rule as the usage snapshot: identity loss is not a run failure
258
+ }
259
+
181
260
  // Display usage summary before exit
182
- if (showStats) {
261
+ if (showStats && usageSnapshot) {
183
262
  try {
184
- const usages = agent.usages;
185
- const sessionFilePath = agent.sessionFilePath;
186
- displayUsageSummary(usages, sessionFilePath);
263
+ showUsage(usageSnapshot.usages, usageSnapshot.sessionFilePath);
187
264
  } catch {
188
265
  // Silently ignore usage summary errors
189
266
  }
190
267
  }
191
268
 
192
269
  // Display timing information
193
- displayTimingInfo(startTime, showStats);
270
+ displayTimingInfo(startTime, showStats, diag);
194
271
 
195
272
  // Decide whether the worktree is clean enough to remove
196
273
  let cleanWorktree = false;
@@ -202,7 +279,7 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
202
279
  cleanWorktree = !hasChanges && !hasCommits;
203
280
 
204
281
  if (!cleanWorktree) {
205
- process.stdout.write(
282
+ diag(
206
283
  `\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`,
207
284
  );
208
285
  }
@@ -222,34 +299,58 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
222
299
  worktreeSession.repoRoot,
223
300
  );
224
301
  }
225
- process.stdout.write("\nDeleting worktree ...\n");
226
- await removeWorktree(worktreeSession);
227
- process.stdout.write("Done.\n");
302
+ diag("\nDeleting worktree ...\n");
303
+ // "Done." only when it is: removal is best-effort, and a surviving
304
+ // directory (with its branch kept for it) needs the cleanup commands
305
+ // printed rather than a success line over the leftovers.
306
+ diag(
307
+ (await removeWorktree(worktreeSession))
308
+ ? "Done.\n"
309
+ : `\n${describeWorktreeSurvival(worktreeSession)}\n`,
310
+ );
228
311
  } catch (error) {
229
312
  // Never block print-mode exit on worktree cleanup failures
230
- process.stdout.write(
231
- `\n⚠️ Skipping worktree removal: ${(error as Error).message}\n`,
232
- );
313
+ diag(`\n⚠️ Skipping worktree removal: ${(error as Error).message}\n`);
233
314
  }
234
315
  }
235
316
 
236
- process.exit(0);
317
+ // The result line goes out last on stdout; everything above that is either
318
+ // suppressed (structured) or on stderr. The exit code reads the envelope so
319
+ // "did this run fail" has one answer, not two: a request-level failure (model
320
+ // unreachable, auth rejected) never throws out of sendMessage — it only
321
+ // leaves an error block behind.
322
+ const envelope = output.result({
323
+ sessionId,
324
+ durationMs: Date.now() - startTime.getTime(),
325
+ usages: usageSnapshot?.usages ?? [],
326
+ });
327
+ await exitWithFlushedStdout(structured, envelope.is_error ? 1 : 0);
237
328
  } catch (error) {
238
329
  console.error("Failed to send message:", error);
330
+ output.error((error as Error)?.message ?? String(error));
331
+
332
+ let usages: Usage[] = [];
333
+ let sessionId = "";
334
+
239
335
  if (agent!) {
240
336
  // Display usage summary even on error
241
- if (showStats) {
242
- try {
243
- const usages = agent.usages;
244
- const sessionFilePath = agent.sessionFilePath;
245
- displayUsageSummary(usages, sessionFilePath);
246
- } catch {
247
- // Silently ignore usage summary errors
337
+ try {
338
+ usages = agent.usages;
339
+ sessionId = agent.sessionId;
340
+ const sessionFilePath = agent.sessionFilePath;
341
+ if (showStats) {
342
+ try {
343
+ showUsage(usages, sessionFilePath);
344
+ } catch {
345
+ // Silently ignore usage summary errors
346
+ }
248
347
  }
348
+ } catch {
349
+ // Silently ignore usage access errors
249
350
  }
250
351
 
251
352
  // Display timing information even on error
252
- displayTimingInfo(startTime, showStats);
353
+ displayTimingInfo(startTime, showStats, diag);
253
354
 
254
355
  // Decide whether the worktree is clean enough to remove
255
356
  let cleanWorktree = false;
@@ -261,7 +362,7 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
261
362
  cleanWorktree = !hasChanges && !hasCommits;
262
363
 
263
364
  if (!cleanWorktree) {
264
- process.stdout.write(
365
+ diag(
265
366
  `\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`,
266
367
  );
267
368
  }
@@ -280,16 +381,65 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
280
381
  worktreeSession.repoRoot,
281
382
  );
282
383
  }
283
- process.stdout.write("\nDeleting worktree ...\n");
284
- await removeWorktree(worktreeSession);
285
- process.stdout.write("Done.\n");
286
- } catch (error) {
287
- process.stdout.write(
288
- `\n⚠️ Skipping worktree removal: ${(error as Error).message}\n`,
384
+ diag("\nDeleting worktree ...\n");
385
+ diag(
386
+ (await removeWorktree(worktreeSession))
387
+ ? "Done.\n"
388
+ : `\n${describeWorktreeSurvival(worktreeSession)}\n`,
289
389
  );
390
+ } catch (error) {
391
+ diag(`\n⚠️ Skipping worktree removal: ${(error as Error).message}\n`);
290
392
  }
291
393
  }
292
394
  }
293
- process.exit(1);
395
+
396
+ // A CI consumer must be able to read why the run failed off stdout, so a
397
+ // failed run still emits its result envelope — just without `result`
398
+ // (Claude Code's error variant does the same). This covers the case where
399
+ // the agent never came up: no usages, no session id.
400
+ const envelope = output.result({
401
+ sessionId,
402
+ durationMs: Date.now() - startTime.getTime(),
403
+ usages,
404
+ isError: true,
405
+ });
406
+ await exitWithFlushedStdout(structured, envelope.is_error ? 1 : 0);
294
407
  }
295
408
  }
409
+
410
+ /**
411
+ * The agent's usage table plus the transcript path, or `undefined` when either
412
+ * getter throws — the summary is a convenience, not part of the run's contract.
413
+ */
414
+ function readUsageSnapshot(
415
+ agent: Agent,
416
+ ): { usages: Usage[]; sessionFilePath: string } | undefined {
417
+ try {
418
+ return { usages: agent.usages, sessionFilePath: agent.sessionFilePath };
419
+ } catch {
420
+ return undefined;
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Structured modes hand stdout to a consumer reading it line by line, so the
426
+ * queued writes must land before the process goes away: a truncated last line
427
+ * is a parse error on their side. Text mode keeps its existing exit path.
428
+ */
429
+ async function exitWithFlushedStdout(
430
+ structured: boolean,
431
+ code: number,
432
+ ): Promise<void> {
433
+ if (structured) {
434
+ uninstallStdoutGuard();
435
+ await drainStdout();
436
+ }
437
+ process.exit(code);
438
+ }
439
+
440
+ /** Adapt a stream write to the line-writer shape `displayUsageSummary` takes. */
441
+ function toLineWriter(
442
+ write: (chunk: string) => unknown,
443
+ ): (line?: string) => void {
444
+ return (line) => void write((line ?? "") + "\n");
445
+ }
@@ -122,6 +122,7 @@ function readWindowsMachineId(): string | undefined {
122
122
  encoding: "utf8",
123
123
  timeout: READ_TIMEOUT_MS,
124
124
  stdio: ["ignore", "pipe", "ignore"],
125
+ windowsHide: true,
125
126
  },
126
127
  );
127
128
  return /MachineGuid\s+REG_SZ\s+(\S+)/.exec(output)?.[1];
@@ -0,0 +1,182 @@
1
+ /**
2
+ * On-demand image preview for the remote-control page.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * The phone cannot read files: message payloads have their image bytes stripped
7
+ * before they cross the relay (`stdio/uiPayload.ts`), and the page has no file
8
+ * system of its own. A screenshot an agent saved to disk (`/tmp/shot.png`) is
9
+ * therefore just a path in the conversation — readable nowhere on the phone.
10
+ * This module is the one thing that turns such a path back into pixels, on
11
+ * demand, over the relay (spec `remote/remote-web.md`「点消息里的图片路径看原图」).
12
+ *
13
+ * ## The two gates
14
+ *
15
+ * 1. **Routing** — {@link IMAGE_FILE_EXTENSIONS} decides whether the *page*
16
+ * offers a click target at all. That is a UI concern: an extension is a hint,
17
+ * never proof.
18
+ * 2. **Authorisation** — the bytes are decoded here, and only a real raster
19
+ * container is served (`ALLOWED_SOURCE_FORMATS`). A `/tmp/x.png` that is
20
+ * really a symlink to a text file dies at this gate. Nothing else in this
21
+ * path can tell the two apart, so collapsing these gates would turn a file
22
+ * name into an authorisation decision.
23
+ *
24
+ * Everything is read-only: no writes, no deletions, no path is ever echoed back
25
+ * as something callable.
26
+ *
27
+ * ## Why the budget is not the frame budget
28
+ *
29
+ * {@link IMAGE_PREVIEW_MAX_RAW_BYTES} is the *image's* budget, derived from the
30
+ * relay's single-frame limit: the frame covers a whole JSON envelope and base64
31
+ * inflates bytes by 4/3, so the image has to fit in roughly three quarters of
32
+ * it. An over-budget frame is silently dropped by the relay's 1 MiB subscriber
33
+ * buffer, which would leave the phone with nothing — hence "shrink or report",
34
+ * never "send it as-is".
35
+ */
36
+ import { readFile, stat } from "node:fs/promises";
37
+ import { shrinkImageForTransport } from "wave-agent-sdk";
38
+ import { IMAGE_FILE_EXTENSIONS } from "wave-agent-sdk/constants";
39
+ import { REMOTE_MAX_FRAME_BYTES } from "./frameBudget.js";
40
+
41
+ /** Containers the codec must report before any bytes are served. */
42
+ const ALLOWED_SOURCE_FORMATS = new Set(["png", "jpeg", "gif", "webp"]);
43
+
44
+ /**
45
+ * Refuse to pull an absurd file into memory before the codec gets a say. Well
46
+ * above any real screenshot (an 8K PNG is ~20–30 MB) and well below "a video
47
+ * file with a .png name".
48
+ */
49
+ const MAX_SOURCE_BYTES = 64 * 1024 * 1024;
50
+
51
+ /**
52
+ * Raw-byte budget for the image itself: leave a little of the frame for the
53
+ * envelope's keys and the `data:<mime>;base64,` prefix, then undo base64's 4/3.
54
+ */
55
+ export const IMAGE_PREVIEW_MAX_RAW_BYTES = Math.floor(
56
+ ((REMOTE_MAX_FRAME_BYTES - 2048) * 3) / 4,
57
+ );
58
+
59
+ /** Not an image, or not one of the containers above — the request is refused. */
60
+ export const IMAGE_PREVIEW_NOT_IMAGE = -32012;
61
+ /** Larger than the frame can carry even after every rung of the ladder. */
62
+ export const IMAGE_PREVIEW_TOO_LARGE = -32013;
63
+ /** sharp is missing (it is installed at runtime, not bundled). */
64
+ export const IMAGE_PREVIEW_CODEC_UNAVAILABLE = -32014;
65
+ /** The path could not be read (missing, permission, a directory). */
66
+ export const IMAGE_PREVIEW_READ_FAILED = -32015;
67
+
68
+ export interface ImagePreviewResult {
69
+ /** The path that was asked for, echoed for the page's error/placeholder copy. */
70
+ path: string;
71
+ /** `data:<mime>;base64,…`, ready for `<img src>`. */
72
+ dataUrl: string;
73
+ mime: string;
74
+ /** Dimensions of the bytes being returned (after any downscale). */
75
+ width: number;
76
+ height: number;
77
+ /** True when the image was downscaled to fit — the page says so out loud. */
78
+ scaled: boolean;
79
+ /** Encoded size of the returned bytes. */
80
+ bytes: number;
81
+ }
82
+
83
+ function fail(code: number, message: string): Error {
84
+ const error = new Error(message) as Error & { code?: number };
85
+ error.code = code;
86
+ return error;
87
+ }
88
+
89
+ /**
90
+ * Read one image from **this machine** and shrink it to fit a relay frame.
91
+ *
92
+ * Throws an `Error` carrying one of the `IMAGE_PREVIEW_*` codes; the remote
93
+ * error reply turns it into one visible line on the page.
94
+ */
95
+ export async function readImagePreview(
96
+ filePath: string,
97
+ ): Promise<ImagePreviewResult> {
98
+ if (typeof filePath !== "string" || filePath.trim() === "") {
99
+ throw fail(IMAGE_PREVIEW_NOT_IMAGE, "getImagePreview requires a path");
100
+ }
101
+
102
+ let size: number;
103
+ try {
104
+ const info = await stat(filePath);
105
+ if (!info.isFile()) {
106
+ throw fail(IMAGE_PREVIEW_READ_FAILED, `Not a file: ${filePath}`);
107
+ }
108
+ size = info.size;
109
+ } catch (error) {
110
+ if (
111
+ error instanceof Error &&
112
+ "code" in error &&
113
+ typeof error.code === "number"
114
+ ) {
115
+ throw error;
116
+ }
117
+ throw fail(IMAGE_PREVIEW_READ_FAILED, `Could not read ${filePath}`);
118
+ }
119
+
120
+ if (size > MAX_SOURCE_BYTES) {
121
+ throw fail(
122
+ IMAGE_PREVIEW_TOO_LARGE,
123
+ `${filePath} is ${Math.round(size / (1024 * 1024))} MB, too large to preview`,
124
+ );
125
+ }
126
+
127
+ let source: Buffer;
128
+ try {
129
+ source = await readFile(filePath);
130
+ } catch {
131
+ throw fail(IMAGE_PREVIEW_READ_FAILED, `Could not read ${filePath}`);
132
+ }
133
+
134
+ const shrunk = await shrinkImageForTransport(
135
+ source,
136
+ IMAGE_PREVIEW_MAX_RAW_BYTES,
137
+ );
138
+
139
+ if (!shrunk.ok) {
140
+ switch (shrunk.reason) {
141
+ case "no-codec":
142
+ throw fail(
143
+ IMAGE_PREVIEW_CODEC_UNAVAILABLE,
144
+ "This machine has no image codec installed, so the picture cannot be prepared for the phone",
145
+ );
146
+ case "too-large":
147
+ throw fail(
148
+ IMAGE_PREVIEW_TOO_LARGE,
149
+ "The image is still too large for the remote channel after shrinking",
150
+ );
151
+ case "unreadable":
152
+ throw fail(
153
+ IMAGE_PREVIEW_NOT_IMAGE,
154
+ `${filePath} is not an image this machine can read`,
155
+ );
156
+ }
157
+ }
158
+
159
+ // The authorisation gate: the *decoded* container, not the file name.
160
+ if (
161
+ !shrunk.sourceFormat ||
162
+ !ALLOWED_SOURCE_FORMATS.has(shrunk.sourceFormat)
163
+ ) {
164
+ throw fail(
165
+ IMAGE_PREVIEW_NOT_IMAGE,
166
+ `${filePath} is not a supported image (${shrunk.sourceFormat ?? "unknown"})`,
167
+ );
168
+ }
169
+
170
+ return {
171
+ path: filePath,
172
+ dataUrl: shrunk.dataUrl,
173
+ mime: shrunk.mime,
174
+ width: shrunk.width,
175
+ height: shrunk.height,
176
+ scaled: shrunk.scaled,
177
+ bytes: shrunk.bytes,
178
+ };
179
+ }
180
+
181
+ /** Re-exported so the remote host and the page's shim quote one list. */
182
+ export { IMAGE_FILE_EXTENSIONS };
@@ -10,11 +10,12 @@
10
10
  *
11
11
  * The list itself lives in the SDK: it starts at the last compaction, and the
12
12
  * remote page takes a `limit`-sized tail of it because of the relay's frame
13
- * budget. What is left here is the one rule a misreading caller cannot notice
14
- * on its own — a malformed parameter must fail instead of silently widening
15
- * into a full read.
13
+ * budget (a compacted session's tail is filled up to `limit` from the
14
+ * transcript, so the cap is a message count, not a byte range). What is left
15
+ * here is the one rule a misreading caller cannot notice on its own — a
16
+ * malformed parameter must fail instead of silently widening into a full read.
16
17
  *
17
- * Described in docs/specs/ui/remote-control.md (§5 白名单与增量拉取).
18
+ * Described in docs/specs/remote/remote-channel.md (§5 白名单与增量拉取).
18
19
  */
19
20
 
20
21
  import type { MessageWindow } from "wave-agent-sdk";
@@ -32,15 +33,24 @@ export function normalizeMessageWindow(
32
33
  window?: MessageWindow,
33
34
  ): MessageWindow | undefined {
34
35
  const since = window?.since;
36
+ const before = window?.before;
35
37
  const rawLimit = window?.limit;
36
38
  if (rawLimit !== undefined && (!Number.isInteger(rawLimit) || rawLimit < 1)) {
37
39
  throw new InvalidMessageWindowError(
38
40
  `getMessages limit must be a positive integer, got ${String(rawLimit)}`,
39
41
  );
40
42
  }
41
- if (since === undefined && rawLimit === undefined) return undefined;
43
+ if (since !== undefined && before !== undefined) {
44
+ throw new InvalidMessageWindowError(
45
+ "getMessages takes either `since` or `before`, not both",
46
+ );
47
+ }
48
+ if (since === undefined && before === undefined && rawLimit === undefined) {
49
+ return undefined;
50
+ }
42
51
  return {
43
52
  ...(since !== undefined ? { since } : {}),
53
+ ...(before !== undefined ? { before } : {}),
44
54
  ...(rawLimit !== undefined ? { limit: rawLimit } : {}),
45
55
  };
46
56
  }
@@ -49,8 +49,9 @@ interface MergeSpec {
49
49
  * `messageOperations.ts` accumulates `parametersChunk` by plain concatenation —
50
50
  * the same operation this class performs. A `start` / `running` / `end` frame
51
51
  * is a one-time snapshot (or the authoritative full value) and must not be
52
- * merged with anything. `btwContent` (the `/btw` answer stream) is the fourth:
53
- * one field, no stages, a delta every time.
52
+ * merged with anything. `btwContent` (the `/btw` answer stream) is the fourth,
53
+ * and the compaction summary tail (`compactionContentUpdate`) the fifth: both
54
+ * carry one delta field and no stages, so every frame is a delta.
54
55
  */
55
56
  function mergeSpec(method: string, params: unknown): MergeSpec | undefined {
56
57
  if (typeof params !== "object" || params === null) return undefined;
@@ -79,7 +80,7 @@ function mergeSpec(method: string, params: unknown): MergeSpec | undefined {
79
80
  };
80
81
  }
81
82
 
82
- // `/btw` side question (`btwContent`, spec ui/remote-control.md「Web 页也能用
83
+ // `/btw` side question (`btwContent`, spec remote/remote-web.md「Web 页也能用
83
84
  // /btw 旁路提问」场景 4). Same family as the three above: `content` is a pure
84
85
  // delta the panel accumulates, so the merged frame carries exactly the bytes
85
86
  // the individual ones would. The key carries the question **and** the channel
@@ -96,6 +97,18 @@ function mergeSpec(method: string, params: unknown): MergeSpec | undefined {
96
97
  };
97
98
  }
98
99
 
100
+ // Compaction summary streaming: since 2026-10-27 the payload is a delta of
101
+ // newly produced text (it used to carry the whole tail), so it merges exactly
102
+ // like the three above. The key needs no stream identity: one compaction runs
103
+ // one stream per session, and both its start and its end
104
+ // (`compactionStateChange`) are discrete events that flush this buffer first
105
+ // (spec: ui/stdio-transport.md「流式通知纯增量负载」场景 7, remote/remote-web.md
106
+ // 场景 10).
107
+ if (method === "compactionContentUpdate") {
108
+ if (typeof p.content !== "string") return undefined;
109
+ return { field: "content", key: "k" };
110
+ }
111
+
99
112
  return undefined;
100
113
  }
101
114
 
@@ -165,7 +165,7 @@ export interface RelayClientOptions {
165
165
  * This CLI's release version, reported so the relay can enforce the minimum
166
166
  * the served page was built against (the page itself is always the newest
167
167
  * one; the skew is handled by refusing old clients, not by keeping old
168
- * pages — see the version note in `docs/specs/ui/remote-control.md`).
168
+ * pages — see the version note in `docs/specs/remote/remote-channel.md`).
169
169
  *
170
170
  * It sits at the **top level** of the auth frame, not inside `params`: that
171
171
  * is where the relay reads it, and a version it cannot parse is silently