wave-code 1.3.0 → 1.5.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 (63) hide show
  1. package/dist/bundle/wave.mjs +1382 -1131
  2. package/package.json +9 -7
  3. package/src/cli.tsx +14 -22
  4. package/src/commands/update.ts +62 -22
  5. package/src/components/AgentsManager.tsx +3 -3
  6. package/src/components/App.tsx +5 -0
  7. package/src/components/BackgroundTaskManager.tsx +76 -54
  8. package/src/components/BtwDisplay.tsx +71 -15
  9. package/src/components/CompactDisplay.tsx +1 -6
  10. package/src/components/ConfirmationDetails.tsx +34 -13
  11. package/src/components/ConfirmationSelector.tsx +31 -36
  12. package/src/components/DiffDisplay.tsx +1 -1
  13. package/src/components/HooksManager.tsx +3 -3
  14. package/src/components/InputBox.tsx +37 -11
  15. package/src/components/LoginCommand.tsx +16 -35
  16. package/src/components/Markdown.tsx +7 -3
  17. package/src/components/MarketplaceAddForm.tsx +15 -19
  18. package/src/components/MessageBlockItem.tsx +0 -6
  19. package/src/components/RemoteControlView.tsx +175 -0
  20. package/src/components/ScrolledContent.tsx +15 -13
  21. package/src/components/SkillsManager.tsx +3 -3
  22. package/src/components/TaskList.tsx +3 -3
  23. package/src/components/ToolDisplay.tsx +3 -18
  24. package/src/components/WorkflowManager.tsx +150 -136
  25. package/src/components/WorkflowRunDetail.tsx +193 -0
  26. package/src/constants/commands.ts +6 -0
  27. package/src/contexts/useChat.tsx +308 -154
  28. package/src/daemon/commands.ts +31 -22
  29. package/src/daemon-cli.ts +6 -1
  30. package/src/hooks/useInputManager.ts +25 -39
  31. package/src/hooks/useLineScroll.ts +51 -5
  32. package/src/hooks/useRemoteControl.ts +346 -0
  33. package/src/index.ts +35 -0
  34. package/src/managers/inputHandlers.ts +5 -0
  35. package/src/managers/inputReducer.ts +39 -0
  36. package/src/print-cli.ts +5 -0
  37. package/src/reducers/workflowManagerReducer.ts +21 -2
  38. package/src/remote/frameBudget.ts +45 -0
  39. package/src/remote/hostIdentity.ts +128 -0
  40. package/src/remote/messageWindow.ts +46 -0
  41. package/src/remote/notificationCoalescer.ts +192 -0
  42. package/src/remote/relayClient.ts +690 -0
  43. package/src/remote/remoteControlCliHost.ts +420 -0
  44. package/src/remote/remoteControlCommands.ts +197 -0
  45. package/src/remote/remoteControlConfig.ts +353 -0
  46. package/src/remote/remoteControlService.ts +822 -0
  47. package/src/remote/remoteControlSink.ts +78 -0
  48. package/src/remote/remoteNotifications.ts +200 -0
  49. package/src/stdio/agentBridge.ts +874 -106
  50. package/src/stdio/daemonServer.ts +30 -0
  51. package/src/stdio/hostSlashCommands.ts +209 -0
  52. package/src/stdio/jsonRpcConnection.ts +11 -2
  53. package/src/stdio/protocol.ts +14 -115
  54. package/src/stdio/stdioServer.ts +33 -2
  55. package/src/stdio/uiPayload.ts +65 -0
  56. package/src/stdio-cli.ts +11 -2
  57. package/src/types.ts +7 -0
  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/workspaceDiff.ts +620 -0
  62. package/src/utils/worktree.ts +97 -0
  63. package/src/utils/bracketedPaste.ts +0 -170
@@ -0,0 +1,420 @@
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 {
37
+ listSessions,
38
+ readRecentWorkdirs,
39
+ rememberRecentWorkdir,
40
+ } from "wave-agent-sdk";
41
+ import {
42
+ hostSlashCommandsFor,
43
+ toSlashCommandInfo,
44
+ } from "../stdio/hostSlashCommands.js";
45
+ import { messagesForUi } from "../stdio/uiPayload.js";
46
+ import {
47
+ InvalidMessageWindowError,
48
+ normalizeMessageWindow,
49
+ type MessageWindow,
50
+ type MessageWindowResult,
51
+ } from "./messageWindow.js";
52
+ import {
53
+ REMOTE_HOST_UNSUPPORTED,
54
+ type RemoteControlHost,
55
+ } from "./remoteControlService.js";
56
+
57
+ /**
58
+ * What this host needs from the front end. Supplied by the chat hook, which
59
+ * owns all of it; every getter must return the **current** value (the host is
60
+ * long-lived and the session underneath it is not).
61
+ */
62
+ export interface RemoteControlCliSession {
63
+ /** Empty until the first turn mints one — there is nothing to serve yet. */
64
+ sessionId(): string;
65
+ /** Where the shell is now (drifts on `cd`) — used for live directory reads. */
66
+ workdir(): string;
67
+ /** Where the session was opened; the row the page groups by. */
68
+ sessionCwd(): string;
69
+ /** One page of the list, sliced in memory. */
70
+ messagePage(window: MessageWindow): Promise<MessageWindowResult>;
71
+ /** The whole list as the agent holds it (it starts at the last compaction). */
72
+ currentThread(): Promise<Message[]>;
73
+ /** Thread totals the session list needs (the window cannot answer them). */
74
+ threadSummary(): {
75
+ messageCount: number;
76
+ firstUserText?: string;
77
+ lastActiveAt?: string;
78
+ };
79
+ contextUsagePercent(): number;
80
+ latestTotalTokens(): number;
81
+ permissionMode(): PermissionMode;
82
+ /** User-set title (the one `/rename` sets); undefined when it has none. */
83
+ customTitle(): string | undefined;
84
+ availableTools(): string[];
85
+ isLoading(): boolean;
86
+ hasRunningBackgroundWork(): boolean;
87
+ hasPendingMessages(): boolean;
88
+ /** The todo list (TaskCreate/TaskUpdate) — a *state*, the page re-reads it. */
89
+ tasks(): Task[];
90
+ isCompacting(): boolean;
91
+ isCommandRunning(): boolean;
92
+ queuedMessages(): QueuedMessage[];
93
+ /**
94
+ * Background tasks (shell / subagent / workflow) as the TUI holds them. Same
95
+ * role as `tasks`: the page's `/tasks` dialog reads the current set on its
96
+ * alignment paths, because the change notification only reports changes.
97
+ */
98
+ backgroundTasks(): BackgroundTaskSummary[];
99
+ /** Approvals waiting on the user right now (terminal or phone). */
100
+ pendingPermissions(): Array<{
101
+ requestId: string;
102
+ context: ToolPermissionContext;
103
+ }>;
104
+ sendMessage(text: string, force?: boolean): void | Promise<void>;
105
+ abort(): void;
106
+ setPermissionMode(mode: PermissionMode): void;
107
+ /** Resolve a pending approval, whichever side asked for it. */
108
+ respondPermission(requestId: string, decision: PermissionDecision): void;
109
+ /** Set this session's custom title (the value `wave -r` lists). */
110
+ renameSession(title: string): void | Promise<void>;
111
+ /** Compact this conversation, optionally with custom instructions. */
112
+ compact(customInstructions?: string): void | Promise<void>;
113
+ /**
114
+ * The slash commands this session offers (skills / custom commands). The
115
+ * page renders these as a menu; the SDK's own list is exactly what a phone
116
+ * can run, since host-local commands (`/plan`, `/config`, …) live outside it.
117
+ */
118
+ slashCommands(): SlashCommand[];
119
+ }
120
+
121
+ /** The JSON-RPC code for a rejected parameter (matches the daemon's answer). */
122
+ const INVALID_PARAMS = -32602;
123
+
124
+ function invalidParams(message: string): Error {
125
+ const err = new Error(message) as Error & { code?: number };
126
+ err.code = INVALID_PARAMS;
127
+ return err;
128
+ }
129
+
130
+ /**
131
+ * "The channel carries this method, this host does not." The white list is
132
+ * channel-wide (a daemon, a GUI's `--stdio` child and this foreground session all
133
+ * read the same set), while the three hosts implement different amounts of it:
134
+ * `AgentBridge` serves a registry of conversations, this host serves the one on
135
+ * screen and is wired to a subset. Answering `-32601` here would tell the page the
136
+ * method is unavailable over remote control at all — i.e. "upgrade the machine" —
137
+ * when the truth is "this machine can, this session cannot".
138
+ */
139
+ function hostUnsupported(method: string): Error {
140
+ const err = new Error(
141
+ `Not implemented by a foreground CLI session: ${method}`,
142
+ ) as Error & { code?: number };
143
+ err.code = REMOTE_HOST_UNSUPPORTED;
144
+ return err;
145
+ }
146
+
147
+ export class RemoteControlCliHost implements RemoteControlHost {
148
+ constructor(private readonly session: RemoteControlCliSession) {}
149
+
150
+ async handleRequest(method: string, params: unknown): Promise<unknown> {
151
+ const p = (params ?? {}) as Record<string, unknown>;
152
+ switch (method) {
153
+ case "listSessions":
154
+ return this.listSessions();
155
+ case "listDaemonSessions":
156
+ return this.listDaemonSessions();
157
+ case "getSessionInfo":
158
+ return this.getSessionInfo();
159
+ case "getMessages":
160
+ return this.getMessages(p);
161
+ // 最近打开的工作目录是本机的事实,与「这条前台会话」无关:同一个文件
162
+ // AgentBridge 也在读(spec core/recent-workdirs.md)。不实现的话,页面
163
+ // 会落到「这台电脑上的会话不支持这个操作」——白名单放行 ≠ 宿主实现。
164
+ case "getRecentWorkdirs":
165
+ return { dirs: readRecentWorkdirs() };
166
+ case "rememberRecentWorkdir":
167
+ rememberRecentWorkdir(typeof p.workdir === "string" ? p.workdir : "");
168
+ return null;
169
+ case "listPendingPermissions":
170
+ return this.listPendingPermissions();
171
+ case "sendMessage":
172
+ await this.session.sendMessage(
173
+ typeof p.text === "string" ? p.text : "",
174
+ p.force === true,
175
+ );
176
+ return null;
177
+ case "abortMessage":
178
+ this.session.abort();
179
+ return null;
180
+ case "setPermissionMode":
181
+ this.session.setPermissionMode(p.mode as PermissionMode);
182
+ return null;
183
+ case "renameSession": {
184
+ // The daemon's AgentBridge takes the target session from params (a host
185
+ // serves many). This host serves exactly one, so the title is the only
186
+ // input that means anything here — an empty one is a caller bug.
187
+ const title = typeof p.title === "string" ? p.title.trim() : "";
188
+ if (!title) throw invalidParams("renameSession requires a title");
189
+ await this.session.renameSession(title);
190
+ return null;
191
+ }
192
+ case "compact":
193
+ await this.session.compact(
194
+ typeof p.customInstructions === "string"
195
+ ? p.customInstructions
196
+ : undefined,
197
+ );
198
+ return null;
199
+ case "getSlashCommands":
200
+ return {
201
+ // Narrowed to what the page renders, like the desktop host does: the
202
+ // SDK's SlashCommand carries a `handler` function, which has no
203
+ // business travelling over a relay (and would be dropped silently,
204
+ // leaving the frame's shape to chance). The host-local half comes
205
+ // 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.
208
+ commands: [
209
+ ...this.session.slashCommands().map(toSlashCommandInfo),
210
+ ...hostSlashCommandsFor("remote-page"),
211
+ ],
212
+ };
213
+ default:
214
+ // The white list already rejected everything outside it, so anything
215
+ // arriving here is a method the *channel* carries and this host does not
216
+ // (rewind, `clearMessages`, background tasks, `searchFiles`, … — see
217
+ // `REMOTE_HOST_UNSUPPORTED`). Said as exactly that, not as a bad
218
+ // parameter and not as silence: the page turns it into one visible line.
219
+ throw hostUnsupported(method);
220
+ }
221
+ }
222
+
223
+ handleNotification(method: string, params: unknown): void {
224
+ if (method !== "permissionResponse") return;
225
+ const p = (params ?? {}) as {
226
+ requestId?: unknown;
227
+ decision?: unknown;
228
+ };
229
+ if (typeof p.requestId !== "string" || !p.decision) return;
230
+ this.session.respondPermission(
231
+ p.requestId,
232
+ p.decision as PermissionDecision,
233
+ );
234
+ }
235
+
236
+ /**
237
+ * One row, one word. The relay turns a transition *into* `requires_action`
238
+ * into a Web Push, so this is what makes a phone ring when the terminal is
239
+ * waiting on an approval.
240
+ */
241
+ listSessionStatuses(): Array<{
242
+ sessionId: string;
243
+ status: "running" | "idle" | "requires_action";
244
+ }> {
245
+ const sessionId = this.session.sessionId();
246
+ if (!sessionId) return [];
247
+ if (this.session.pendingPermissions().length > 0) {
248
+ return [{ sessionId, status: "requires_action" }];
249
+ }
250
+ const busy =
251
+ this.session.isLoading() || this.session.hasRunningBackgroundWork();
252
+ return [{ sessionId, status: busy ? "running" : "idle" }];
253
+ }
254
+
255
+ private async listSessions(): Promise<{ sessions: SessionMetadata[] }> {
256
+ const sessionId = this.session.sessionId();
257
+ if (!sessionId) return { sessions: [] };
258
+ // Prefer the real record from disk — it carries the title, timestamps and
259
+ // token count the page renders. A session that has not been flushed yet
260
+ // has no record, and an empty list would read on the phone as "this
261
+ // machine has nothing to control", so synthesize the row instead.
262
+ try {
263
+ const persisted = await listSessions(this.session.workdir());
264
+ const mine = persisted.find((entry) => entry.id === sessionId);
265
+ if (mine) return { sessions: [mine] };
266
+ } catch {
267
+ // Unreadable project directory: the synthesized row is still the truth.
268
+ }
269
+ return { sessions: [this.synthesizedMetadata(sessionId)] };
270
+ }
271
+
272
+ private synthesizedMetadata(sessionId: string): SessionMetadata {
273
+ const now = new Date();
274
+ const firstMessage = this.session.threadSummary().firstUserText;
275
+ const customTitle = this.session.customTitle();
276
+ return {
277
+ id: sessionId,
278
+ sessionType: "main",
279
+ workdir: this.session.workdir(),
280
+ createdAt: now,
281
+ lastActiveAt: now,
282
+ latestTotalTokens: this.session.latestTotalTokens(),
283
+ ...(customTitle ? { customTitle } : {}),
284
+ ...(firstMessage ? { firstMessage } : {}),
285
+ };
286
+ }
287
+
288
+ /**
289
+ * The session list the page actually reads. A daemon has a real registry of
290
+ * them (`AgentBridge.listDaemonSessions`); this host holds exactly one, so its
291
+ * registry is that one — same shape, one row, and no disk read (the page can
292
+ * only open sessions that are live in the serving process, and this is the
293
+ * only one that is; see 规格 remote-control「边界情况」).
294
+ *
295
+ * Not `listSessions`: that reads the transcripts and would answer with rows
296
+ * the page cannot open — including this machine's older, unloaded ones.
297
+ */
298
+ private listDaemonSessions(): {
299
+ sessions: Array<{
300
+ sessionId: string;
301
+ sessionCwd: string;
302
+ isLoading: boolean;
303
+ hasRunningBackgroundWork: boolean;
304
+ hasPendingMessages: boolean;
305
+ messageCount: number;
306
+ /** User-set title; wins over `firstMessage` in the UI. */
307
+ customTitle?: string;
308
+ /** First real user message — the label when no custom title is set. */
309
+ firstMessage?: string;
310
+ /** Last display message's timestamp: the session list's ordering key. */
311
+ lastActiveAt?: string;
312
+ }>;
313
+ } {
314
+ const sessionId = this.session.sessionId();
315
+ if (!sessionId) return { sessions: [] };
316
+ const customTitle = this.session.customTitle();
317
+ const summary = this.session.threadSummary();
318
+ const firstMessage = summary.firstUserText;
319
+ const lastActiveAt = summary.lastActiveAt;
320
+ return {
321
+ sessions: [
322
+ {
323
+ sessionId,
324
+ sessionCwd: this.session.sessionCwd(),
325
+ isLoading: this.session.isLoading(),
326
+ hasRunningBackgroundWork: this.session.hasRunningBackgroundWork(),
327
+ hasPendingMessages: this.session.hasPendingMessages(),
328
+ messageCount: summary.messageCount,
329
+ ...(customTitle ? { customTitle } : {}),
330
+ ...(firstMessage ? { firstMessage } : {}),
331
+ ...(lastActiveAt ? { lastActiveAt } : {}),
332
+ },
333
+ ],
334
+ };
335
+ }
336
+
337
+ private getSessionInfo(): {
338
+ sessionId: string;
339
+ sessionCwd: string;
340
+ latestTotalTokens: number;
341
+ permissionMode: PermissionMode;
342
+ availableTools: string[];
343
+ tasks: Task[];
344
+ isCompacting: boolean;
345
+ isCommandRunning: boolean;
346
+ queuedMessages: QueuedMessage[];
347
+ backgroundTasks: BackgroundTaskSummary[];
348
+ } {
349
+ return {
350
+ sessionId: this.session.sessionId(),
351
+ sessionCwd: this.session.sessionCwd(),
352
+ latestTotalTokens: this.session.latestTotalTokens(),
353
+ permissionMode: this.session.permissionMode(),
354
+ availableTools: this.session.availableTools(),
355
+ // The session-state family the page re-reads (spec remote-control
356
+ // 「Web 页作为第五种宿主」场景 26): current values, not transitions.
357
+ tasks: this.session.tasks(),
358
+ isCompacting: this.session.isCompacting(),
359
+ isCommandRunning: this.session.isCommandRunning(),
360
+ queuedMessages: this.session.queuedMessages(),
361
+ backgroundTasks: this.session.backgroundTasks(),
362
+ };
363
+ }
364
+
365
+ private async getMessages(p: Record<string, unknown>): Promise<{
366
+ messages: Message[];
367
+ contextUsagePercent: number;
368
+ reset?: boolean;
369
+ }> {
370
+ const contextUsagePercent = this.session.contextUsagePercent();
371
+ const window: MessageWindow = {};
372
+ if (typeof p.since === "string") window.since = p.since;
373
+ if (p.limit !== undefined) window.limit = Number(p.limit);
374
+
375
+ let normalized: MessageWindow | undefined;
376
+ try {
377
+ normalized = normalizeMessageWindow(window);
378
+ } catch (err) {
379
+ if (err instanceof InvalidMessageWindowError)
380
+ throw invalidParams(err.message);
381
+ throw err;
382
+ }
383
+ // No parameters = the whole list the agent holds (spec
384
+ // ui/message-rendering-system.md「首屏就是整份列表」). A `limit` is a tail
385
+ // slice of the same list, for the phone's frame budget.
386
+ //
387
+ // Both answers are trimmed: the phone renders no tool pictures, and its
388
+ // frame budget cannot carry one
389
+ // (specs/ui/stdio-transport.md「工具图片的字节不下发 UI」).
390
+ if (!normalized) {
391
+ return {
392
+ messages: messagesForUi(await this.session.currentThread()),
393
+ contextUsagePercent,
394
+ };
395
+ }
396
+ const page = await this.session.messagePage(normalized);
397
+ return {
398
+ ...page,
399
+ messages: messagesForUi(page.messages),
400
+ contextUsagePercent,
401
+ };
402
+ }
403
+
404
+ private listPendingPermissions(): {
405
+ requests: Array<{
406
+ requestId: string;
407
+ sessionId?: string;
408
+ context: ToolPermissionContext;
409
+ }>;
410
+ } {
411
+ const sessionId = this.session.sessionId();
412
+ return {
413
+ requests: this.session.pendingPermissions().map((pending) => ({
414
+ requestId: pending.requestId,
415
+ ...(sessionId ? { sessionId } : {}),
416
+ context: pending.context,
417
+ })),
418
+ };
419
+ }
420
+ }
@@ -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
+ }