wave-code 1.2.0 → 1.4.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 (64) hide show
  1. package/dist/bundle/wave.mjs +1738 -893
  2. package/package.json +6 -3
  3. package/src/cli.tsx +9 -0
  4. package/src/commands/plugin/install.ts +6 -7
  5. package/src/commands/plugin/uninstall.ts +11 -5
  6. package/src/commands/update.ts +62 -22
  7. package/src/components/BackgroundTaskManager.tsx +76 -54
  8. package/src/components/ChatInterface.tsx +1 -0
  9. package/src/components/ConfirmationDetails.tsx +51 -16
  10. package/src/components/ConfirmationSelector.tsx +39 -0
  11. package/src/components/InputBox.tsx +41 -2
  12. package/src/components/Markdown.tsx +21 -8
  13. package/src/components/MarketplaceDetail.tsx +10 -27
  14. package/src/components/MarketplaceView.tsx +17 -3
  15. package/src/components/MessageBlockItem.tsx +0 -6
  16. package/src/components/Notifications.tsx +7 -0
  17. package/src/components/PlanView.tsx +22 -17
  18. package/src/components/PluginManagerShell.tsx +3 -3
  19. package/src/components/PluginManagerTypes.ts +2 -1
  20. package/src/components/ResumeCommand.tsx +84 -0
  21. package/src/components/ScrolledContent.tsx +71 -0
  22. package/src/components/SessionSelector.tsx +184 -6
  23. package/src/components/SessionSelectorFlow.tsx +246 -0
  24. package/src/components/WorkflowManager.tsx +38 -138
  25. package/src/components/WorkflowRunDetail.tsx +193 -0
  26. package/src/constants/commands.ts +24 -0
  27. package/src/contexts/useChat.tsx +517 -155
  28. package/src/daemon/commands.ts +582 -50
  29. package/src/daemon-cli.ts +12 -1
  30. package/src/hooks/useInputManager.ts +20 -0
  31. package/src/hooks/usePluginManager.ts +104 -78
  32. package/src/hooks/useRemoteControl.ts +324 -0
  33. package/src/index.ts +97 -8
  34. package/src/managers/inputHandlers.ts +16 -9
  35. package/src/managers/inputReducer.ts +30 -9
  36. package/src/print-cli.ts +5 -0
  37. package/src/reducers/confirmationReducer.ts +20 -1
  38. package/src/reducers/selectorReducer.ts +3 -0
  39. package/src/reducers/workflowManagerReducer.ts +21 -2
  40. package/src/remote/frameBudget.ts +45 -0
  41. package/src/remote/hostIdentity.ts +128 -0
  42. package/src/remote/messageWindow.ts +97 -0
  43. package/src/remote/notificationCoalescer.ts +174 -0
  44. package/src/remote/relayClient.ts +668 -0
  45. package/src/remote/remoteControlCliHost.ts +391 -0
  46. package/src/remote/remoteControlCommands.ts +197 -0
  47. package/src/remote/remoteControlConfig.ts +334 -0
  48. package/src/remote/remoteControlService.ts +799 -0
  49. package/src/remote/remoteControlSink.ts +77 -0
  50. package/src/remote/remoteNotifications.ts +194 -0
  51. package/src/session-selector-cli.tsx +9 -255
  52. package/src/stdio/agentBridge.ts +1001 -165
  53. package/src/stdio/daemonServer.ts +30 -0
  54. package/src/stdio/jsonRpcConnection.ts +11 -2
  55. package/src/stdio/protocol.ts +14 -106
  56. package/src/stdio/stdioServer.ts +33 -2
  57. package/src/stdio-cli.ts +16 -1
  58. package/src/utils/gitCredentialHelper.ts +106 -0
  59. package/src/utils/logger.ts +5 -2
  60. package/src/utils/rewindCheckpoints.ts +2 -4
  61. package/src/utils/sessionOwnership.ts +62 -0
  62. package/src/utils/workspaceDiff.ts +620 -0
  63. package/src/utils/worktree.ts +97 -0
  64. package/src/components/PlanDisplay.tsx +0 -18
@@ -0,0 +1,391 @@
1
+ /**
2
+ * RemoteControlCliHost — serves the **interactive CLI's own session** to a
3
+ * phone.
4
+ *
5
+ * The daemon and each GUI host's `--stdio` child serve their sessions through
6
+ * `AgentBridge`, which owns a registry of them. The front end the user is
7
+ * typing in has no RPC server at all: one session, held in React state. Rather
8
+ * than pretend otherwise, this host implements the same three-method
9
+ * `RemoteControlHost` contract against that single session, so the relay, the
10
+ * white list and the page need no special case.
11
+ *
12
+ * Two consequences of "one session, and it is the one on screen", both
13
+ * deliberate (docs/specs/ui/remote-control.md, 前台 CLI 故事):
14
+ *
15
+ * - Both ways of asking for the session list (`listDaemonSessions`, which the
16
+ * page reads, and `listSessions`) answer with exactly this session. The
17
+ * phone's list is a navigation surface, and there is nothing else to navigate
18
+ * to — scanning disk for older conversations would offer rows that cannot be
19
+ * opened.
20
+ * - The host dies with its process. Nothing here survives the TUI exiting, and
21
+ * that is the point: a session nobody holds cannot be served, only resumed
22
+ * (as a new session).
23
+ */
24
+
25
+ import type {
26
+ BackgroundTaskSummary,
27
+ Message,
28
+ PermissionDecision,
29
+ PermissionMode,
30
+ QueuedMessage,
31
+ SessionMetadata,
32
+ SlashCommand,
33
+ Task,
34
+ ToolPermissionContext,
35
+ } from "wave-agent-sdk";
36
+ import { listSessions } from "wave-agent-sdk";
37
+ import {
38
+ InvalidMessageWindowError,
39
+ sliceMessageWindow,
40
+ type MessageWindow,
41
+ type MessageWindowResult,
42
+ } from "./messageWindow.js";
43
+ import {
44
+ REMOTE_HOST_UNSUPPORTED,
45
+ type RemoteControlHost,
46
+ } from "./remoteControlService.js";
47
+
48
+ /**
49
+ * What this host needs from the front end. Supplied by the chat hook, which
50
+ * owns all of it; every getter must return the **current** value (the host is
51
+ * long-lived and the session underneath it is not).
52
+ */
53
+ export interface RemoteControlCliSession {
54
+ /** Empty until the first turn mints one — there is nothing to serve yet. */
55
+ sessionId(): string;
56
+ workdir(): string;
57
+ displayMessages(): Message[];
58
+ contextUsagePercent(): number;
59
+ latestTotalTokens(): number;
60
+ permissionMode(): PermissionMode;
61
+ /** User-set title (the one `/rename` sets); undefined when it has none. */
62
+ customTitle(): string | undefined;
63
+ availableTools(): string[];
64
+ isLoading(): boolean;
65
+ hasRunningBackgroundWork(): boolean;
66
+ hasPendingMessages(): boolean;
67
+ /** The todo list (TaskCreate/TaskUpdate) — a *state*, the page re-reads it. */
68
+ tasks(): Task[];
69
+ isCompacting(): boolean;
70
+ isCommandRunning(): boolean;
71
+ queuedMessages(): QueuedMessage[];
72
+ /**
73
+ * Background tasks (shell / subagent / workflow) as the TUI holds them. Same
74
+ * role as `tasks`: the page's `/tasks` dialog reads the current set on its
75
+ * alignment paths, because the change notification only reports changes.
76
+ */
77
+ backgroundTasks(): BackgroundTaskSummary[];
78
+ /** Approvals waiting on the user right now (terminal or phone). */
79
+ pendingPermissions(): Array<{
80
+ requestId: string;
81
+ context: ToolPermissionContext;
82
+ }>;
83
+ sendMessage(text: string, force?: boolean): void | Promise<void>;
84
+ abort(): void;
85
+ setPermissionMode(mode: PermissionMode): void;
86
+ /** Resolve a pending approval, whichever side asked for it. */
87
+ respondPermission(requestId: string, decision: PermissionDecision): void;
88
+ /** Set this session's custom title (the value `wave -r` lists). */
89
+ renameSession(title: string): void | Promise<void>;
90
+ /** Compact this conversation, optionally with custom instructions. */
91
+ compact(customInstructions?: string): void | Promise<void>;
92
+ /**
93
+ * The slash commands this session offers (skills / custom commands). The
94
+ * page renders these as a menu; the SDK's own list is exactly what a phone
95
+ * can run, since host-local commands (`/plan`, `/config`, …) live outside it.
96
+ */
97
+ slashCommands(): SlashCommand[];
98
+ }
99
+
100
+ /** The JSON-RPC code for a rejected parameter (matches the daemon's answer). */
101
+ const INVALID_PARAMS = -32602;
102
+
103
+ function invalidParams(message: string): Error {
104
+ const err = new Error(message) as Error & { code?: number };
105
+ err.code = INVALID_PARAMS;
106
+ return err;
107
+ }
108
+
109
+ /**
110
+ * "The channel carries this method, this host does not." The white list is
111
+ * channel-wide (a daemon, a GUI's `--stdio` child and this foreground session all
112
+ * read the same set), while the three hosts implement different amounts of it:
113
+ * `AgentBridge` serves a registry of conversations, this host serves the one on
114
+ * screen and is wired to a subset. Answering `-32601` here would tell the page the
115
+ * method is unavailable over remote control at all — i.e. "upgrade the machine" —
116
+ * when the truth is "this machine can, this session cannot".
117
+ */
118
+ function hostUnsupported(method: string): Error {
119
+ const err = new Error(
120
+ `Not implemented by a foreground CLI session: ${method}`,
121
+ ) as Error & { code?: number };
122
+ err.code = REMOTE_HOST_UNSUPPORTED;
123
+ return err;
124
+ }
125
+
126
+ function firstUserText(messages: Message[]): string | undefined {
127
+ const user = messages.find((message) => message.role === "user");
128
+ if (!user) return undefined;
129
+ for (const block of user.blocks) {
130
+ if (block.type === "text" && typeof block.content === "string") {
131
+ const text = block.content.trim();
132
+ if (text) return text;
133
+ }
134
+ }
135
+ return undefined;
136
+ }
137
+
138
+ export class RemoteControlCliHost implements RemoteControlHost {
139
+ constructor(private readonly session: RemoteControlCliSession) {}
140
+
141
+ async handleRequest(method: string, params: unknown): Promise<unknown> {
142
+ const p = (params ?? {}) as Record<string, unknown>;
143
+ switch (method) {
144
+ case "listSessions":
145
+ return this.listSessions();
146
+ case "listDaemonSessions":
147
+ return this.listDaemonSessions();
148
+ case "getSessionInfo":
149
+ return this.getSessionInfo();
150
+ case "getMessages":
151
+ return this.getMessages(p);
152
+ case "listPendingPermissions":
153
+ return this.listPendingPermissions();
154
+ case "sendMessage":
155
+ await this.session.sendMessage(
156
+ typeof p.text === "string" ? p.text : "",
157
+ p.force === true,
158
+ );
159
+ return null;
160
+ case "abortMessage":
161
+ this.session.abort();
162
+ return null;
163
+ case "setPermissionMode":
164
+ this.session.setPermissionMode(p.mode as PermissionMode);
165
+ return null;
166
+ case "renameSession": {
167
+ // The daemon's AgentBridge takes the target session from params (a host
168
+ // serves many). This host serves exactly one, so the title is the only
169
+ // input that means anything here — an empty one is a caller bug.
170
+ const title = typeof p.title === "string" ? p.title.trim() : "";
171
+ if (!title) throw invalidParams("renameSession requires a title");
172
+ await this.session.renameSession(title);
173
+ return null;
174
+ }
175
+ case "compact":
176
+ await this.session.compact(
177
+ typeof p.customInstructions === "string"
178
+ ? p.customInstructions
179
+ : undefined,
180
+ );
181
+ return null;
182
+ case "getSlashCommands":
183
+ return {
184
+ // Narrowed to what the page renders, like the desktop host does: the
185
+ // SDK's SlashCommand carries a `handler` function, which has no
186
+ // business travelling over a relay (and would be dropped silently,
187
+ // leaving the frame's shape to chance).
188
+ commands: this.session.slashCommands().map((command) => ({
189
+ id: command.id,
190
+ name: command.name,
191
+ description: command.description,
192
+ ...(command.skillSource
193
+ ? { skillSource: command.skillSource }
194
+ : {}),
195
+ })),
196
+ };
197
+ default:
198
+ // The white list already rejected everything outside it, so anything
199
+ // arriving here is a method the *channel* carries and this host does not
200
+ // (rewind, `clearMessages`, background tasks, `searchFiles`, … — see
201
+ // `REMOTE_HOST_UNSUPPORTED`). Said as exactly that, not as a bad
202
+ // parameter and not as silence: the page turns it into one visible line.
203
+ throw hostUnsupported(method);
204
+ }
205
+ }
206
+
207
+ handleNotification(method: string, params: unknown): void {
208
+ if (method !== "permissionResponse") return;
209
+ const p = (params ?? {}) as {
210
+ requestId?: unknown;
211
+ decision?: unknown;
212
+ };
213
+ if (typeof p.requestId !== "string" || !p.decision) return;
214
+ this.session.respondPermission(
215
+ p.requestId,
216
+ p.decision as PermissionDecision,
217
+ );
218
+ }
219
+
220
+ /**
221
+ * One row, one word. The relay turns a transition *into* `requires_action`
222
+ * into a Web Push, so this is what makes a phone ring when the terminal is
223
+ * waiting on an approval.
224
+ */
225
+ listSessionStatuses(): Array<{
226
+ sessionId: string;
227
+ status: "running" | "idle" | "requires_action";
228
+ }> {
229
+ const sessionId = this.session.sessionId();
230
+ if (!sessionId) return [];
231
+ if (this.session.pendingPermissions().length > 0) {
232
+ return [{ sessionId, status: "requires_action" }];
233
+ }
234
+ const busy =
235
+ this.session.isLoading() || this.session.hasRunningBackgroundWork();
236
+ return [{ sessionId, status: busy ? "running" : "idle" }];
237
+ }
238
+
239
+ private async listSessions(): Promise<{ sessions: SessionMetadata[] }> {
240
+ const sessionId = this.session.sessionId();
241
+ if (!sessionId) return { sessions: [] };
242
+ // Prefer the real record from disk — it carries the title, timestamps and
243
+ // token count the page renders. A session that has not been flushed yet
244
+ // has no record, and an empty list would read on the phone as "this
245
+ // machine has nothing to control", so synthesize the row instead.
246
+ try {
247
+ const persisted = await listSessions(this.session.workdir());
248
+ const mine = persisted.find((entry) => entry.id === sessionId);
249
+ if (mine) return { sessions: [mine] };
250
+ } catch {
251
+ // Unreadable project directory: the synthesized row is still the truth.
252
+ }
253
+ return { sessions: [this.synthesizedMetadata(sessionId)] };
254
+ }
255
+
256
+ private synthesizedMetadata(sessionId: string): SessionMetadata {
257
+ const now = new Date();
258
+ const firstMessage = firstUserText(this.session.displayMessages());
259
+ const customTitle = this.session.customTitle();
260
+ return {
261
+ id: sessionId,
262
+ sessionType: "main",
263
+ workdir: this.session.workdir(),
264
+ createdAt: now,
265
+ lastActiveAt: now,
266
+ latestTotalTokens: this.session.latestTotalTokens(),
267
+ ...(customTitle ? { customTitle } : {}),
268
+ ...(firstMessage ? { firstMessage } : {}),
269
+ };
270
+ }
271
+
272
+ /**
273
+ * The session list the page actually reads. A daemon has a real registry of
274
+ * them (`AgentBridge.listDaemonSessions`); this host holds exactly one, so its
275
+ * registry is that one — same shape, one row, and no disk read (the page can
276
+ * only open sessions that are live in the serving process, and this is the
277
+ * only one that is; see 规格 remote-control「边界情况」).
278
+ *
279
+ * Not `listSessions`: that reads the transcripts and would answer with rows
280
+ * the page cannot open — including this machine's older, unloaded ones.
281
+ */
282
+ private listDaemonSessions(): {
283
+ sessions: Array<{
284
+ sessionId: string;
285
+ workingDirectory: string;
286
+ isLoading: boolean;
287
+ hasRunningBackgroundWork: boolean;
288
+ hasPendingMessages: boolean;
289
+ messageCount: number;
290
+ /** User-set title; wins over `firstMessage` in the UI. */
291
+ customTitle?: string;
292
+ /** First real user message — the label when no custom title is set. */
293
+ firstMessage?: string;
294
+ /** Last display message's timestamp: the session list's ordering key. */
295
+ lastActiveAt?: string;
296
+ }>;
297
+ } {
298
+ const sessionId = this.session.sessionId();
299
+ if (!sessionId) return { sessions: [] };
300
+ const messages = this.session.displayMessages();
301
+ const customTitle = this.session.customTitle();
302
+ const firstMessage = firstUserText(messages);
303
+ const lastActiveAt = messages[messages.length - 1]?.timestamp;
304
+ return {
305
+ sessions: [
306
+ {
307
+ sessionId,
308
+ workingDirectory: this.session.workdir(),
309
+ isLoading: this.session.isLoading(),
310
+ hasRunningBackgroundWork: this.session.hasRunningBackgroundWork(),
311
+ hasPendingMessages: this.session.hasPendingMessages(),
312
+ messageCount: messages.length,
313
+ ...(customTitle ? { customTitle } : {}),
314
+ ...(firstMessage ? { firstMessage } : {}),
315
+ ...(lastActiveAt ? { lastActiveAt } : {}),
316
+ },
317
+ ],
318
+ };
319
+ }
320
+
321
+ private getSessionInfo(): {
322
+ sessionId: string;
323
+ workingDirectory: string;
324
+ latestTotalTokens: number;
325
+ permissionMode: PermissionMode;
326
+ availableTools: string[];
327
+ tasks: Task[];
328
+ isCompacting: boolean;
329
+ isCommandRunning: boolean;
330
+ queuedMessages: QueuedMessage[];
331
+ backgroundTasks: BackgroundTaskSummary[];
332
+ } {
333
+ return {
334
+ sessionId: this.session.sessionId(),
335
+ workingDirectory: this.session.workdir(),
336
+ latestTotalTokens: this.session.latestTotalTokens(),
337
+ permissionMode: this.session.permissionMode(),
338
+ availableTools: this.session.availableTools(),
339
+ // The session-state family the page re-reads (spec remote-control
340
+ // 「Web 页作为第五种宿主」场景 26): current values, not transitions.
341
+ tasks: this.session.tasks(),
342
+ isCompacting: this.session.isCompacting(),
343
+ isCommandRunning: this.session.isCommandRunning(),
344
+ queuedMessages: this.session.queuedMessages(),
345
+ backgroundTasks: this.session.backgroundTasks(),
346
+ };
347
+ }
348
+
349
+ private getMessages(p: Record<string, unknown>): {
350
+ messages: Message[];
351
+ contextUsagePercent: number;
352
+ hasMore?: boolean;
353
+ reset?: boolean;
354
+ total?: number;
355
+ } {
356
+ const all = this.session.displayMessages();
357
+ const contextUsagePercent = this.session.contextUsagePercent();
358
+ const window: MessageWindow = {};
359
+ if (typeof p.before === "string") window.before = p.before;
360
+ if (typeof p.since === "string") window.since = p.since;
361
+ if (p.limit !== undefined) window.limit = Number(p.limit);
362
+
363
+ let sliced: MessageWindowResult | undefined;
364
+ try {
365
+ sliced = sliceMessageWindow(all, window);
366
+ } catch (err) {
367
+ if (err instanceof InvalidMessageWindowError)
368
+ throw invalidParams(err.message);
369
+ throw err;
370
+ }
371
+ if (!sliced) return { messages: all, contextUsagePercent };
372
+ return { ...sliced, contextUsagePercent };
373
+ }
374
+
375
+ private listPendingPermissions(): {
376
+ requests: Array<{
377
+ requestId: string;
378
+ sessionId?: string;
379
+ context: ToolPermissionContext;
380
+ }>;
381
+ } {
382
+ const sessionId = this.session.sessionId();
383
+ return {
384
+ requests: this.session.pendingPermissions().map((pending) => ({
385
+ requestId: pending.requestId,
386
+ ...(sessionId ? { sessionId } : {}),
387
+ context: pending.context,
388
+ })),
389
+ };
390
+ }
391
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `wave remote-control` — the operator's handle on the relay connection.
3
+ *
4
+ * These subcommands serve the **daemon**: it is the resident host (it owns
5
+ * sessions across client detach/attach and keeps generating while nothing is
6
+ * attached), and it is the process whose emit fan-out the relay subscribes to.
7
+ * They are therefore a client of the daemon socket, exactly like `wave daemon …`
8
+ * — `on` starts a daemon on demand, `off`/`status` never do.
9
+ *
10
+ * A session that lives in the *foreground* CLI process is out of reach from
11
+ * here; that process serves itself through the TUI's `/remote-control` command
12
+ * (see `remoteControlCliHost.ts`). The switch can be on for both at once without
13
+ * either one covering the other's sessions.
14
+ *
15
+ * The page address a phone opens is assembled from this machine's channel key,
16
+ * which the daemon got by registering with the relay (an HTTP call) — nothing is
17
+ * pushed down the WebSocket for it. So `on` may have to wait for a registration
18
+ * round trip, and everything it learned stays on the daemon, where `status` can
19
+ * pick it up.
20
+ *
21
+ * All subcommands are non-interactive (stdout for results, stderr for
22
+ * diagnostics, exit code set here) — yargs would otherwise fall through to the
23
+ * Ink TUI.
24
+ */
25
+
26
+ import { toString as qrToString } from "qrcode";
27
+ import type { RemoteControlSnapshot } from "wave-agent-sdk";
28
+ import {
29
+ DEFAULT_DAEMON_SOCKET,
30
+ connectDaemonIfRunning,
31
+ connectDaemonOrExit,
32
+ } from "../daemon/commands.js";
33
+ import type { SocketClient } from "../daemon/socketClient.js";
34
+
35
+ /** How long `on` waits for the page address to come back from the daemon. */
36
+ export const PAGE_WAIT_MS = 5_000;
37
+ const PAGE_POLL_MS = 500;
38
+
39
+ function describe(err: unknown): string {
40
+ return err instanceof Error ? err.message : String(err);
41
+ }
42
+
43
+ /**
44
+ * The switch lives in settings, so a daemon started before it was turned on
45
+ * cannot serve — and the error it returns says which key to set but not that
46
+ * the running daemon has to be replaced for the change to take effect.
47
+ */
48
+ function hintFor(message: string): string | undefined {
49
+ if (!message.includes("enableRemoteControl")) return undefined;
50
+ return 'Set "enableRemoteControl": true in ~/.wave/settings.json (or .wave/settings.json in the project), then run `wave daemon restart` — a daemon that was already running keeps its old settings.';
51
+ }
52
+
53
+ function printEnableHint(message: string): void {
54
+ console.error(message);
55
+ const hint = hintFor(message);
56
+ if (hint) console.error(hint);
57
+ }
58
+
59
+ /**
60
+ * Print the page the phone should open, and a QR of that page.
61
+ *
62
+ * The options are Claude Code's bridge-dialog pair (`type: "utf8"`,
63
+ * `errorCorrectionLevel: "L"`) minus its `small: true`, which is inert here:
64
+ * node-qrcode reads `small` only in the *terminal* renderer, and `utf8` is
65
+ * already the compact half-block form (`▀▄█`) with no ANSI — so the code stays
66
+ * copyable out of a terminal. Encoding is the only thing that can fail (and only
67
+ * for absurd input), and the address printed above is the real contract — a
68
+ * missing QR is not worth failing the command.
69
+ */
70
+ async function printPageUrl(pageUrl: string): Promise<void> {
71
+ console.log("");
72
+ console.log(`Web 页:${pageUrl}`);
73
+ console.log(
74
+ "手机:用相机扫码或在 Safari 中打开,再「添加到主屏幕」(控制中心的扫码器打开的容器加不了主屏)。",
75
+ );
76
+ try {
77
+ console.log(
78
+ await qrToString(pageUrl, {
79
+ type: "utf8",
80
+ errorCorrectionLevel: "L",
81
+ }),
82
+ );
83
+ } catch {
84
+ // Nothing to add: the line above already carries the address.
85
+ }
86
+ }
87
+
88
+ /** Poll the daemon until the registration round trip hands back a page address. */
89
+ async function waitForPageUrl(
90
+ client: SocketClient,
91
+ initial: RemoteControlSnapshot,
92
+ ): Promise<string | undefined> {
93
+ const deadline = Date.now() + PAGE_WAIT_MS;
94
+ let snapshot = initial;
95
+ while (Date.now() < deadline) {
96
+ if (snapshot.pageUrl) return snapshot.pageUrl;
97
+ if (snapshot.state === "error") {
98
+ printEnableHint(`远程控制连接失败:${snapshot.message ?? "未知错误"}`);
99
+ process.exit(1);
100
+ }
101
+ await new Promise((resolve) => setTimeout(resolve, PAGE_POLL_MS));
102
+ snapshot = (await client.request(
103
+ "remoteControlStatus",
104
+ {},
105
+ )) as RemoteControlSnapshot;
106
+ }
107
+ return snapshot.pageUrl;
108
+ }
109
+
110
+ /**
111
+ * Turn remote control on for this machine and show the page a phone opens.
112
+ * Idempotent: running it while already online re-reads the same address.
113
+ */
114
+ export async function remoteControlOnCommand(name?: string): Promise<void> {
115
+ let client: SocketClient | undefined;
116
+ try {
117
+ client = await connectDaemonOrExit(DEFAULT_DAEMON_SOCKET);
118
+ const connecting = (await client.request(
119
+ "remoteControlConnect",
120
+ name ? { name } : {},
121
+ )) as RemoteControlSnapshot;
122
+
123
+ if (connecting.state === "error") {
124
+ printEnableHint(`远程控制开启失败:${connecting.message ?? "未知错误"}`);
125
+ process.exit(1);
126
+ }
127
+
128
+ const pageUrl = await waitForPageUrl(client, connecting);
129
+ if (!pageUrl) {
130
+ console.error("远程控制已连上,但没能从 daemon 拿到页面地址。");
131
+ process.exit(1);
132
+ }
133
+ await printPageUrl(pageUrl);
134
+ console.log("");
135
+ console.log("关闭远程控制:`wave remote-control off`");
136
+ } catch (err) {
137
+ printEnableHint(`wave remote-control on 失败:${describe(err)}`);
138
+ process.exit(1);
139
+ } finally {
140
+ client?.dispose();
141
+ }
142
+ process.exit(0);
143
+ }
144
+
145
+ /**
146
+ * Disconnect. Idempotent, and like `wave daemon stop` it never starts a daemon
147
+ * — with nothing running there is nothing to turn off.
148
+ */
149
+ export async function remoteControlOffCommand(): Promise<void> {
150
+ const client = await connectDaemonIfRunning(DEFAULT_DAEMON_SOCKET);
151
+ if (!client) {
152
+ console.log("远程控制未开启(wave daemon 未运行)");
153
+ process.exit(0);
154
+ }
155
+ try {
156
+ await client.request("remoteControlDisconnect", {});
157
+ console.log("远程控制已断开");
158
+ } catch (err) {
159
+ // "Not enabled" lands here too: the daemon has no relay at all, which is
160
+ // the same end state the user asked for, so it is reported as success —
161
+ // with the switch hint on stderr, because turning it on would change that.
162
+ const message = describe(err);
163
+ console.log(`远程控制已关闭(${message})`);
164
+ const hint = hintFor(message);
165
+ if (hint) console.error(hint);
166
+ } finally {
167
+ client.dispose();
168
+ }
169
+ process.exit(0);
170
+ }
171
+
172
+ /** Report the connection state, and the page address when there is one. */
173
+ export async function remoteControlStatusCommand(): Promise<void> {
174
+ const client = await connectDaemonIfRunning(DEFAULT_DAEMON_SOCKET);
175
+ if (!client) {
176
+ console.log("状态:未开启(wave daemon 未运行)");
177
+ process.exit(0);
178
+ }
179
+ let ok = true;
180
+ try {
181
+ const snapshot = (await client.request(
182
+ "remoteControlStatus",
183
+ {},
184
+ )) as RemoteControlSnapshot;
185
+ console.log(`状态:${snapshot.state}`);
186
+ if (snapshot.message) console.log(`说明:${snapshot.message}`);
187
+ if (snapshot.pageUrl) await printPageUrl(snapshot.pageUrl);
188
+ } catch (err) {
189
+ // Unlike `off`, a status we could not read is a real failure: the daemon
190
+ // may be an older build that predates this method.
191
+ ok = false;
192
+ printEnableHint(`读取远程控制状态失败:${describe(err)}`);
193
+ } finally {
194
+ client.dispose();
195
+ }
196
+ process.exit(ok ? 0 : 1);
197
+ }