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.
- package/bin/wave-code.js +2 -0
- package/dist/bundle/wave.mjs +777 -855
- package/package.json +7 -7
- package/src/cli.tsx +9 -3
- package/src/components/FileSelector.tsx +11 -8
- package/src/components/HelpView.tsx +1 -1
- package/src/components/InputBox.tsx +3 -5
- package/src/components/McpManager.tsx +26 -0
- package/src/components/MessageBlockItem.tsx +14 -2
- package/src/components/RemoteControlView.tsx +1 -1
- package/src/components/RewindCommand.tsx +40 -46
- package/src/contexts/useChat.tsx +61 -49
- 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/hooks/useInputManager.ts +27 -25
- package/src/index.ts +36 -4
- package/src/managers/inputHandlers.ts +3 -13
- package/src/managers/inputReducer.ts +44 -13
- package/src/print-cli.ts +199 -49
- package/src/remote/hostIdentity.ts +1 -0
- package/src/remote/imagePreview.ts +182 -0
- package/src/remote/messageWindow.ts +15 -5
- package/src/remote/notificationCoalescer.ts +16 -3
- package/src/remote/relayClient.ts +1 -1
- package/src/remote/remoteControlCliHost.ts +26 -4
- package/src/remote/remoteControlService.ts +70 -19
- package/src/remote/remoteNotifications.ts +5 -4
- package/src/remote/textPreview.ts +155 -0
- package/src/stdio/agentBridge.ts +450 -117
- package/src/stdio/daemonServer.ts +49 -33
- package/src/stdio/hostSlashCommands.ts +9 -4
- package/src/stdio/uiPayload.ts +50 -2
- package/src/utils/agentSelector.ts +29 -0
- package/src/utils/argvCompat.ts +23 -0
- package/src/utils/printOutput.ts +403 -0
- package/src/utils/usageSummary.ts +74 -24
- package/src/utils/worktree.ts +222 -16
- 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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
+
output.human("\n💭 Reasoning:\n");
|
|
89
136
|
isReasoning = true;
|
|
90
137
|
}
|
|
91
|
-
|
|
138
|
+
output.human(params.chunk);
|
|
139
|
+
output.reasoningDelta(params.messageId, params.chunk);
|
|
92
140
|
},
|
|
93
|
-
onAssistantContentUpdated: (params: {
|
|
141
|
+
onAssistantContentUpdated: (params: {
|
|
142
|
+
messageId: string;
|
|
143
|
+
chunk: string;
|
|
144
|
+
}) => {
|
|
94
145
|
if (!isContent) {
|
|
95
146
|
if (isReasoning) {
|
|
96
|
-
|
|
147
|
+
output.human("\n\n📝 Response:\n");
|
|
97
148
|
} else {
|
|
98
|
-
|
|
149
|
+
output.human("\n");
|
|
99
150
|
}
|
|
100
151
|
isContent = true;
|
|
101
152
|
}
|
|
102
|
-
|
|
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
|
-
|
|
161
|
+
output.human(`\n🔧 ${params.name}`);
|
|
110
162
|
if (params.compactParams) {
|
|
111
|
-
|
|
163
|
+
output.human(` ${params.compactParams}`);
|
|
112
164
|
}
|
|
113
|
-
|
|
165
|
+
output.human(`\n`);
|
|
114
166
|
}
|
|
167
|
+
output.toolUpdate(params);
|
|
115
168
|
},
|
|
116
169
|
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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/
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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/
|
|
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
|