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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon socket addressing — one default, two platform shapes.
|
|
3
|
+
*
|
|
4
|
+
* POSIX keeps the file-socket it has always used (`~/.wave/daemon.sock`). On
|
|
5
|
+
* Windows libuv only accepts the `\\.\pipe\` shape: handing `net.Server.listen`
|
|
6
|
+
* a filesystem path fails with `EACCES` and the `.sock` file is never created,
|
|
7
|
+
* so the daemon could not start at all there. The default is therefore mapped to
|
|
8
|
+
* a named pipe on Windows.
|
|
9
|
+
*
|
|
10
|
+
* Two constraints shape the mapping:
|
|
11
|
+
* - the pipe namespace is **machine-global** (every OS user shares `\\.\pipe\`),
|
|
12
|
+
* so the name must carry a per-user component — otherwise one user's daemon
|
|
13
|
+
* occupies another's default socket;
|
|
14
|
+
* - a home path cannot be embedded verbatim, because `\` and `:` are illegal in
|
|
15
|
+
* pipe names. Hence a stable digest of the default path, not the path itself.
|
|
16
|
+
*
|
|
17
|
+
* The mapping applies to **the default socket only**, and it is applied on both
|
|
18
|
+
* sides (client and `--daemon` server) by this one function. A name that is
|
|
19
|
+
* already a pipe, or any other path, is used verbatim: rewriting what the caller
|
|
20
|
+
* named would silently point the daemon somewhere else.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
/** The default daemon socket as a file path — the shape POSIX listens on. */
|
|
28
|
+
export const DEFAULT_DAEMON_SOCKET_FILE = path.join(
|
|
29
|
+
os.homedir(),
|
|
30
|
+
".wave",
|
|
31
|
+
"daemon.sock",
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/** libuv's Windows named-pipe prefixes (`\\.\pipe\` is the documented form). */
|
|
35
|
+
const WINDOWS_PIPE_PREFIXES = ["\\\\.\\pipe\\", "\\\\?\\pipe\\"] as const;
|
|
36
|
+
|
|
37
|
+
/** True when `socketPath` is already a Windows named-pipe name. */
|
|
38
|
+
export function isWindowsPipeName(socketPath: string): boolean {
|
|
39
|
+
return WINDOWS_PIPE_PREFIXES.some((prefix) => socketPath.startsWith(prefix));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Derive the pipe name for a file-path socket. The digest is stable per path, so
|
|
44
|
+
* the same user always lands on the same name (a re-launched daemon finds and
|
|
45
|
+
* refuses to double-bind it), while different home directories — different OS
|
|
46
|
+
* users on one machine — land on different names.
|
|
47
|
+
*/
|
|
48
|
+
export function windowsPipeNameFor(socketFilePath: string): string {
|
|
49
|
+
const digest = createHash("sha256")
|
|
50
|
+
.update(socketFilePath)
|
|
51
|
+
.digest("hex")
|
|
52
|
+
.slice(0, 32);
|
|
53
|
+
return `\\\\.\\pipe\\wave-daemon-${digest}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Same file, allowing for how it was spelled — separator style, `.`/`..`
|
|
58
|
+
* segments, and (on Windows) letter case. A user who writes the default socket
|
|
59
|
+
* out by hand rarely matches `path.join`'s spelling byte for byte
|
|
60
|
+
* (`C:/Users/me/.wave/daemon.sock` vs `C:\Users\me\.wave\daemon.sock`), and
|
|
61
|
+
* missing that match would land the server on an `EACCES` instead of the pipe the
|
|
62
|
+
* client derives.
|
|
63
|
+
*/
|
|
64
|
+
function isSameFilePath(
|
|
65
|
+
a: string,
|
|
66
|
+
b: string,
|
|
67
|
+
platform: NodeJS.Platform,
|
|
68
|
+
): boolean {
|
|
69
|
+
if (a === b) return true;
|
|
70
|
+
const resolvedA = path.resolve(a);
|
|
71
|
+
const resolvedB = path.resolve(b);
|
|
72
|
+
return platform === "win32"
|
|
73
|
+
? resolvedA.toLowerCase() === resolvedB.toLowerCase()
|
|
74
|
+
: resolvedA === resolvedB;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the socket a caller means: identity on POSIX, and on Windows the pipe
|
|
79
|
+
* form of the default socket (anything else — an explicit path or a name that is
|
|
80
|
+
* already a pipe — is returned unchanged).
|
|
81
|
+
*/
|
|
82
|
+
export function resolveDaemonSocket(
|
|
83
|
+
platform: NodeJS.Platform,
|
|
84
|
+
socketPath: string,
|
|
85
|
+
): string {
|
|
86
|
+
if (platform !== "win32") return socketPath;
|
|
87
|
+
if (isWindowsPipeName(socketPath)) return socketPath;
|
|
88
|
+
return isSameFilePath(socketPath, DEFAULT_DAEMON_SOCKET_FILE, platform)
|
|
89
|
+
? // Digest the canonical spelling so every way of writing the default socket
|
|
90
|
+
// derives the same name — the pipe is a shared address, not a per-caller one.
|
|
91
|
+
windowsPipeNameFor(path.resolve(socketPath))
|
|
92
|
+
: socketPath;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The fixed default socket every `wave daemon` subcommand talks to (spec:
|
|
97
|
+
* 默认 socket 固定,形态随平台). No `--socket` override exists, so this is the
|
|
98
|
+
* only address the client side ever uses.
|
|
99
|
+
*/
|
|
100
|
+
export const DEFAULT_DAEMON_SOCKET = resolveDaemonSocket(
|
|
101
|
+
process.platform,
|
|
102
|
+
DEFAULT_DAEMON_SOCKET_FILE,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Translate a `listen` failure on a daemon address. `EADDRINUSE` is the shape a
|
|
107
|
+
* second daemon takes when the first one already holds the name — on Windows that
|
|
108
|
+
* is the *only* shape, since a pipe name has no stale file to probe first (the
|
|
109
|
+
* POSIX path detects the same situation by connecting to the socket file). The
|
|
110
|
+
* raw errno tells a user nothing actionable, so it becomes the same sentence both
|
|
111
|
+
* platforms report.
|
|
112
|
+
*/
|
|
113
|
+
export function describeListenError(
|
|
114
|
+
socketPath: string,
|
|
115
|
+
err: NodeJS.ErrnoException,
|
|
116
|
+
): Error {
|
|
117
|
+
if (err.code === "EADDRINUSE") {
|
|
118
|
+
return new Error(
|
|
119
|
+
`Another wave daemon is already listening on ${socketPath}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return err;
|
|
123
|
+
}
|
package/src/daemon-cli.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* daemon-cli.ts — Entry point for `wave --daemon <socket-path>` mode.
|
|
3
3
|
*
|
|
4
|
-
* Starts a DaemonServer that serves JSON-RPC over a
|
|
5
|
-
*
|
|
6
|
-
* back with `ssh -L`;
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Starts a DaemonServer that serves JSON-RPC over a local socket (a unix domain
|
|
5
|
+
* socket on POSIX, a named pipe on Windows). The desktop app launches this on a
|
|
6
|
+
* remote host via nohup/setsid and tunnels the socket back with `ssh -L`;
|
|
7
|
+
* multiple attach/detach cycles share one process, so sessions and pending
|
|
8
|
+
* permissions survive client disconnects. The net server keeps the process
|
|
9
|
+
* alive — there is no stdin to wait on.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { ensureRuntimeDeps } from "wave-agent-sdk";
|
|
12
13
|
import { DaemonServer } from "./stdio/daemonServer.js";
|
|
13
14
|
import { createRemoteControlSinkFactory } from "./remote/remoteControlSink.js";
|
|
15
|
+
import { resolveDaemonSocket } from "./daemon/socketPath.js";
|
|
14
16
|
|
|
15
17
|
export async function startDaemonCli(socketPath: string): Promise<void> {
|
|
16
18
|
// Install the on-demand runtime dependencies (image codec + ripgrep) before
|
|
@@ -19,10 +21,21 @@ export async function startDaemonCli(socketPath: string): Promise<void> {
|
|
|
19
21
|
// since the host reports "daemon ready" on this socket.
|
|
20
22
|
await ensureRuntimeDeps();
|
|
21
23
|
|
|
24
|
+
// Resolve the address exactly like the client does. The default path is the
|
|
25
|
+
// one name both sides must agree on, and on Windows it is reachable only in
|
|
26
|
+
// its pipe form — a caller that spells it out (`wave --daemon
|
|
27
|
+
// ~/.wave/daemon.sock`) must land on the same pipe the client derives, not on
|
|
28
|
+
// an EACCES. An explicit pipe name, or any other path, is used verbatim so a
|
|
29
|
+
// wrong address still fails loudly instead of being silently renamed.
|
|
30
|
+
const resolvedSocketPath = resolveDaemonSocket(process.platform, socketPath);
|
|
31
|
+
|
|
22
32
|
// The resident daemon owns the remote sessions, so it is the process that
|
|
23
33
|
// serves a paired phone. Off (undefined) unless the switch says otherwise.
|
|
24
34
|
const createRemoteSink = createRemoteControlSinkFactory(process.cwd());
|
|
25
|
-
const server = new DaemonServer({
|
|
35
|
+
const server = new DaemonServer({
|
|
36
|
+
socketPath: resolvedSocketPath,
|
|
37
|
+
createRemoteSink,
|
|
38
|
+
});
|
|
26
39
|
await server.start();
|
|
27
40
|
// Ready — any error that follows goes to the daemon log via stderr.
|
|
28
41
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,12 @@ import { createWorktree, type WorktreeSession } from "./utils/worktree.js";
|
|
|
11
11
|
import path from "path";
|
|
12
12
|
import { pathToFileURL } from "url";
|
|
13
13
|
import { readNearestPackageJson } from "./utils/readPackageJson.js";
|
|
14
|
+
import { ensurePlainNodeArgv } from "./utils/argvCompat.js";
|
|
15
|
+
|
|
16
|
+
// Must run before `main()` builds its yargs instance (see argvCompat.ts):
|
|
17
|
+
// when a host runs this bundle directly under Electron RUN_AS_NODE, yargs would
|
|
18
|
+
// otherwise treat the script path as an unknown positional and exit 1.
|
|
19
|
+
ensurePlainNodeArgv();
|
|
14
20
|
|
|
15
21
|
const version = readNearestPackageJson().version;
|
|
16
22
|
|
package/src/print-cli.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type WorktreeSession,
|
|
11
11
|
} from "wave-agent-sdk";
|
|
12
12
|
import { displayUsageSummary } from "./utils/usageSummary.js";
|
|
13
|
-
import { removeWorktree } from "./utils/worktree.js";
|
|
13
|
+
import { describeWorktreeSurvival, removeWorktree } from "./utils/worktree.js";
|
|
14
14
|
import {
|
|
15
15
|
drainStdout,
|
|
16
16
|
installStdoutGuard,
|
|
@@ -167,11 +167,12 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
|
|
|
167
167
|
output.toolUpdate(params);
|
|
168
168
|
},
|
|
169
169
|
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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`);
|
|
175
176
|
},
|
|
176
177
|
};
|
|
177
178
|
|
|
@@ -299,8 +300,14 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
|
|
|
299
300
|
);
|
|
300
301
|
}
|
|
301
302
|
diag("\nDeleting worktree ...\n");
|
|
302
|
-
|
|
303
|
-
|
|
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
|
+
);
|
|
304
311
|
} catch (error) {
|
|
305
312
|
// Never block print-mode exit on worktree cleanup failures
|
|
306
313
|
diag(`\n⚠️ Skipping worktree removal: ${(error as Error).message}\n`);
|
|
@@ -375,8 +382,11 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
|
|
|
375
382
|
);
|
|
376
383
|
}
|
|
377
384
|
diag("\nDeleting worktree ...\n");
|
|
378
|
-
|
|
379
|
-
|
|
385
|
+
diag(
|
|
386
|
+
(await removeWorktree(worktreeSession))
|
|
387
|
+
? "Done.\n"
|
|
388
|
+
: `\n${describeWorktreeSurvival(worktreeSession)}\n`,
|
|
389
|
+
);
|
|
380
390
|
} catch (error) {
|
|
381
391
|
diag(`\n⚠️ Skipping worktree removal: ${(error as Error).message}\n`);
|
|
382
392
|
}
|
|
@@ -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";
|
|
@@ -80,7 +80,7 @@ function mergeSpec(method: string, params: unknown): MergeSpec | undefined {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
// `/btw` side question (`btwContent`, spec
|
|
83
|
+
// `/btw` side question (`btwContent`, spec remote/remote-web.md「Web 页也能用
|
|
84
84
|
// /btw 旁路提问」场景 4). Same family as the three above: `content` is a pure
|
|
85
85
|
// delta the panel accumulates, so the merged frame carries exactly the bytes
|
|
86
86
|
// the individual ones would. The key carries the question **and** the channel
|
|
@@ -102,7 +102,7 @@ function mergeSpec(method: string, params: unknown): MergeSpec | undefined {
|
|
|
102
102
|
// like the three above. The key needs no stream identity: one compaction runs
|
|
103
103
|
// one stream per session, and both its start and its end
|
|
104
104
|
// (`compactionStateChange`) are discrete events that flush this buffer first
|
|
105
|
-
// (spec: ui/stdio-transport.md「流式通知纯增量负载」场景 7,
|
|
105
|
+
// (spec: ui/stdio-transport.md「流式通知纯增量负载」场景 7, remote/remote-web.md
|
|
106
106
|
// 场景 10).
|
|
107
107
|
if (method === "compactionContentUpdate") {
|
|
108
108
|
if (typeof p.content !== "string") return undefined;
|
|
@@ -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
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* white list and the page need no special case.
|
|
11
11
|
*
|
|
12
12
|
* Two consequences of "one session, and it is the one on screen", both
|
|
13
|
-
* deliberate (docs/specs/
|
|
13
|
+
* deliberate (docs/specs/remote/remote-web.md, 前台 CLI 故事):
|
|
14
14
|
*
|
|
15
15
|
* - Both ways of asking for the session list (`listDaemonSessions`, which the
|
|
16
16
|
* page reads, and `listSessions`) answer with exactly this session. The
|
|
@@ -42,6 +42,8 @@ import {
|
|
|
42
42
|
hostSlashCommandsFor,
|
|
43
43
|
toSlashCommandInfo,
|
|
44
44
|
} from "../stdio/hostSlashCommands.js";
|
|
45
|
+
import { readImagePreview } from "./imagePreview.js";
|
|
46
|
+
import { readTextPreview } from "./textPreview.js";
|
|
45
47
|
import { messagesForUi } from "../stdio/uiPayload.js";
|
|
46
48
|
import {
|
|
47
49
|
InvalidMessageWindowError,
|
|
@@ -163,6 +165,17 @@ export class RemoteControlCliHost implements RemoteControlHost {
|
|
|
163
165
|
// 会落到「这台电脑上的会话不支持这个操作」——白名单放行 ≠ 宿主实现。
|
|
164
166
|
case "getRecentWorkdirs":
|
|
165
167
|
return { dirs: readRecentWorkdirs() };
|
|
168
|
+
// 远控页按需取图(spec「点消息里的图片路径看原图」)。与 daemon 那条
|
|
169
|
+
// 共用 `imagePreview.ts`:判型按解码后的真实容器、压到单帧预算内。
|
|
170
|
+
// 不实现的话页面会落到 hostUnsupported,而「白名单放行 ≠ 宿主实现」
|
|
171
|
+
// 这份规格已经踩过一次(见场景 7)。
|
|
172
|
+
case "getImagePreview":
|
|
173
|
+
return readImagePreview(typeof p.path === "string" ? p.path : "");
|
|
174
|
+
case "getTextPreview":
|
|
175
|
+
// 与取图同一条按需读取通道(spec remote/remote-web.md「点消息里的文件
|
|
176
|
+
// 路径看内容」);前台 CLI 会话同样要能回,见图片故事场景 7 那条
|
|
177
|
+
// 「白名单放行 ≠ 宿主实现」。
|
|
178
|
+
return readTextPreview(typeof p.path === "string" ? p.path : "");
|
|
166
179
|
case "rememberRecentWorkdir":
|
|
167
180
|
rememberRecentWorkdir(typeof p.workdir === "string" ? p.workdir : "");
|
|
168
181
|
return null;
|
|
@@ -203,11 +216,20 @@ export class RemoteControlCliHost implements RemoteControlHost {
|
|
|
203
216
|
// business travelling over a relay (and would be dropped silently,
|
|
204
217
|
// leaving the frame's shape to chance). The host-local half comes
|
|
205
218
|
// from the one table, filtered for this entry (spec
|
|
206
|
-
//
|
|
207
|
-
// the daemon returns,
|
|
219
|
+
// commands/slash-commands.md「弹窗里的宿主命令只有一份表」) — the same
|
|
220
|
+
// bytes the daemon returns, with one exception: `/resume` is dropped
|
|
221
|
+
// here, because this host holds exactly one session slot. Restoring a
|
|
222
|
+
// transcript from disk would mean replacing the session the user is
|
|
223
|
+
// watching in the terminal (and `initialize` is not implemented here
|
|
224
|
+
// either), so the row would be one that does nothing when picked —
|
|
225
|
+
// the exact defect the one-table rule forbids. The table declares the
|
|
226
|
+
// entry's ceiling; the host narrows it to what it can actually run
|
|
227
|
+
// (spec commands/slash-commands.md 场景 4).
|
|
208
228
|
commands: [
|
|
209
229
|
...this.session.slashCommands().map(toSlashCommandInfo),
|
|
210
|
-
...hostSlashCommandsFor("remote-page")
|
|
230
|
+
...hostSlashCommandsFor("remote-page").filter(
|
|
231
|
+
(command) => command.id !== "resume",
|
|
232
|
+
),
|
|
211
233
|
],
|
|
212
234
|
};
|
|
213
235
|
default:
|