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.
@@ -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 unix socket. The desktop
5
- * app launches this on a remote host via nohup/setsid and tunnels the socket
6
- * back with `ssh -L`; multiple attach/detach cycles share one process, so
7
- * sessions and pending permissions survive client disconnects. The net server
8
- * keeps the process alive — there is no stdin to wait on.
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({ socketPath, createRemoteSink });
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
- // Error block callback
171
- onErrorBlockAdded: (error: string) => {
172
- // Display error blocks with distinct formatting
173
- output.error(error);
174
- diag(`\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`);
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
- await removeWorktree(worktreeSession);
303
- diag("Done.\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
+ );
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
- await removeWorktree(worktreeSession);
379
- diag("Done.\n");
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
  }
@@ -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";
@@ -80,7 +80,7 @@ function mergeSpec(method: string, params: unknown): MergeSpec | undefined {
80
80
  };
81
81
  }
82
82
 
83
- // `/btw` side question (`btwContent`, spec ui/remote-control.md「Web 页也能用
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, ui/remote-control.md
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/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
@@ -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/ui/remote-control.md, 前台 CLI 故事):
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
- // ui/slash-commands.md「弹窗里的宿主命令只有一份表」) — the same bytes
207
- // the daemon returns, so the phone sees one list either way.
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: