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,799 @@
1
+ /**
2
+ * RemoteControlService — serves a session to a paired phone.
3
+ *
4
+ * It sits beside the local transports: `DaemonServer` fans every notification
5
+ * out to its unix-socket clients, and additionally to this service when remote
6
+ * control is on. The bridge itself is untouched, so a machine with remote
7
+ * control off behaves exactly as before.
8
+ *
9
+ * Three things happen on the remote path that do not happen locally:
10
+ *
11
+ * 1. **Coalescing.** Streamed deltas are merged over a short window
12
+ * (`NotificationCoalescer`); the local path keeps its per-chunk frames.
13
+ * 2. **Structural pushes become signals.** `messagesChange` is content-free on
14
+ * every path — the bridge's re-attach push and the foreground CLI's notice
15
+ * both send `{}`. The phone answers it by pulling with
16
+ * `getMessages({ since })` — the same path a cold start takes — and this
17
+ * service still strips a payload from any build that attaches one, so the
18
+ * wire has exactly one shape.
19
+ * 3. **A status signal.** The relay cannot see inside the frames (that is the
20
+ * whole design), so it cannot know that somebody is waiting on an approval —
21
+ * which is the one state where pushing to a phone matters. The machine
22
+ * therefore reports `running` / `idle` / `requires_action` per session.
23
+ * This carries no message content, by construction: session ids and one word
24
+ * each.
25
+ * 4. **A frame budget.** The relay drops any single frame that does not fit its
26
+ * per-subscriber buffer (1 MiB by default) and asks the phone for a resync —
27
+ * silently, with no error for us to see. So nothing over budget is handed
28
+ * over: pages are trimmed to the newest messages that fit, and a
29
+ * notification too large to send becomes a content-free "re-read" signal.
30
+ * See `frameBudget.ts` for why the number is what it is.
31
+ */
32
+
33
+ import type {
34
+ RemoteControlSnapshot,
35
+ RemoteControlStatus,
36
+ } from "wave-agent-sdk";
37
+ import {
38
+ frameBytes,
39
+ REMOTE_MAX_FRAME_BYTES,
40
+ trailingCountThatFits,
41
+ } from "./frameBudget.js";
42
+ import {
43
+ NotificationCoalescer,
44
+ type RelayFrame,
45
+ } from "./notificationCoalescer.js";
46
+ import {
47
+ RelayClient,
48
+ type RelayEnvelope,
49
+ type RelaySocketFactory,
50
+ } from "./relayClient.js";
51
+ import type { RemoteControlEndpoint } from "./remoteControlConfig.js";
52
+
53
+ /**
54
+ * Methods the remote channel accepts.
55
+ *
56
+ * The local desktop / IDE transports have always been able to call anything —
57
+ * they are the user's own machine talking to itself. A phone page is a web page
58
+ * holding a token, so the remote channel is default-deny and this is the whole
59
+ * of what it may ask for (the chat subset the phone actually renders, taken
60
+ * from the method list in the feature request). Widening it is a deliberate
61
+ * act, not an accident of the bridge's surface.
62
+ *
63
+ * `listDaemonSessions` is the session list's data source rather than
64
+ * `listSessions`: the page can only open sessions that are live in this process
65
+ * (`getMessages` requires the envelope's sessionId to be in the registry), while
66
+ * `listSessions` reads the transcripts on disk and would offer it conversations
67
+ * it cannot read. See 规格 remote-control「边界情况」。
68
+ *
69
+ * `renameSession` / `compact` / `getSlashCommands` (2026-10-14): conversation
70
+ * upkeep the phone can do without an editor, a file system or a plugin manager —
71
+ * all three are session-scoped and already implemented by both hosts
72
+ * (`AgentBridge` for daemon sessions, `RemoteControlCliHost` for a foreground
73
+ * CLI session). The page's slash-command menu reads `getSlashCommands`; the same
74
+ * method is what makes 重命名 / 压缩 usable there instead of a request that comes
75
+ * back unreachable.
76
+ *
77
+ * **Second batch (2026-10-17)** — rewind / clear / model / background tasks and
78
+ * workflows / queue surgery / file search / prompt history. The first batch's
79
+ * rule was "anything the page cannot *carry* stays out", and it lumped these in
80
+ * with settings and file paths under a single reason ("the host holds the data").
81
+ * Checked one by one, that reason only holds for the **interface** surfaces —
82
+ * a settings page, a plugin market, an editor, the on-disk session list. Every
83
+ * method below is already implemented on `AgentBridge`, so what was missing was
84
+ * one line here, one case in the shim, and one remote gate in the UI; keeping
85
+ * them out left the phone able to *read* but not to *act*. Two of them are worth
86
+ * spelling out because the page must warn before running them:
87
+ *
88
+ * - `rewindToMessage` is **the same operation the desktop runs**, not a
89
+ * conversation-only truncation: `truncateHistory` → `reversionManager.revertTo`
90
+ * restores this machine's workspace files from each message's `file_history`
91
+ * snapshot. A mis-tap on a phone is therefore a file operation, which is why
92
+ * the page confirms once before sending it.
93
+ * - `setModel` is **not** session-scoped: it writes the `model` key of the
94
+ * user-level `~/.wave/settings.json` (falling back to this session only when
95
+ * the write fails), so switching on the phone switches it for every session on
96
+ * this machine. The page says so in its confirmation.
97
+ *
98
+ * **Settings (2026-10-17, third batch)** — read the configuration, edit the
99
+ * personalisation, and toggle the project's built-in plugin. This used to sit in
100
+ * the paragraph below as one undifferentiated "the host holds the data" lump;
101
+ * checked key by key, exactly three of the settings page's views are things a
102
+ * phone can carry — 全局设置 / 个性化 / 项目设置. What the page cannot carry is
103
+ * the **interface** around them: the plugin market, the MCP / skills / subagents
104
+ * / hooks managers (all four need a folder tree, a file path or an install
105
+ * flow), login, and the desktop-only 主题 / Beta 通道 blocks. Those stay
106
+ * unrendered on the page and their methods stay out of this list. So:
107
+ *
108
+ * - `getUserSettings` / `updateUserSettings` — the user preference file, **effective
109
+ * value + per-key source layer**. The layer is why the page can keep the same
110
+ * "由组织配置管理" grey-out the desktop has: a key inherited from the org's
111
+ * remote config arrives already marked, no second read.
112
+ * - `getAgentsContent` / `setAgentsContent` — the two AGENTS.md editors
113
+ * (user-level / project-level). Same RPC the desktop and IDE settings pages use.
114
+ * - `getProjectSettings` / `setBuiltinPluginEnabled` — the 项目设置 view's one
115
+ * switch (SDD). **Project scope only** is reachable from the page's UI; the
116
+ * method's `scope` parameter is not narrowed here because the desktop uses the
117
+ * same call for the same single toggle.
118
+ *
119
+ * Deliberately **not** added with them: `updateConfig` (the session-level
120
+ * `AgentOptions` overlay — the settings page stopped using it on 2026-09-10;
121
+ * every key it saves is a user preference, so `updateUserSettings` is the whole
122
+ * write path and adding `updateConfig` would expose a knob with no caller) and
123
+ * `getConfiguredModels` (already allowed since the second batch — the page's
124
+ * `/model` needs it; the settings page's completion-model dropdown does **not**
125
+ * exist here, see below).
126
+ *
127
+ * Still out, and deliberately: the plugin market / skills / subagents / hooks /
128
+ * MCP managers, login (`login`, `logout`, …), anything that opens an editor
129
+ * (`openFile` is not even a bridge method), the on-disk session list
130
+ * (`listAllSessions` / `restoreSession`), the process-lifecycle calls
131
+ * (`shutdown` / `bang`), and the **read-only panes the page has no
132
+ * surface for** — `getWorkspaceDiff` / `getWorktreeChanges` / `getPlanFile` /
133
+ * `getFullMessageThread` (no diff / worktree / plan pane on the page; the plan
134
+ * body travels inside the `ExitPlanMode` approval card).
135
+ *
136
+ * **Session deletion (2026-10-17, fourth batch)** — the batch where the page
137
+ * gained the entry `removeWorktree` was being kept out for. Two of the three
138
+ * things involved are whitelisted, one is not, and the split is the whole design:
139
+ *
140
+ * - `destroy` (now with an optional `removeWorktree` param) is the write. It is
141
+ * **session-keyed**: the daemon resolves the worktree from that session's own
142
+ * creation directory, so the phone names a session, never a path.
143
+ * - `getSessionWorktreeChanges` is the read the delete confirmation needs — the
144
+ * same counts the desktop dialog shows, resolved the same way.
145
+ * - `removeWorktree` / `getWorktreeChanges` stay **out**: they take `path` /
146
+ * `branch` / `repoRoot` (or `path`) from the caller, i.e. they would let the
147
+ * page name any directory on this machine to delete or to inspect. Being able
148
+ * to delete a session it was handed is not the same capability.
149
+ */
150
+ export const REMOTE_ALLOWED_METHODS: ReadonlySet<string> = new Set([
151
+ "listSessions",
152
+ "listDaemonSessions",
153
+ "getSessionInfo",
154
+ "getMessages",
155
+ "listPendingPermissions",
156
+ "permissionResponse",
157
+ "sendMessage",
158
+ "abortMessage",
159
+ "setPermissionMode",
160
+ "renameSession",
161
+ "compact",
162
+ "getSlashCommands",
163
+ // 发起新会话(spec ui/remote-control.md「Web 端也能发起新会话」):白名单
164
+ // 从「只碰已存在的会话」显式扩到「可在选定目录建会话」。initialize 在下方
165
+ // 收敛入参——放行的是「建一条会话」这个能力,不是它的整张参数表。
166
+ "initialize",
167
+ "listDirectories",
168
+ "listGitBranches",
169
+ "createWorktree",
170
+ // ── 第二批解禁(spec「页面能执行的那批宿主命令」)──
171
+ // 回滚:与桌面同语义(截断对话 + 按 file_history 快照还原本机工作区文件),
172
+ // 页面执行前有一次确认。
173
+ "rewindToMessage",
174
+ "listRewindCheckpoints",
175
+ // 清空:换绑新 sessionId(与 IDE 端「新建对话」同一条底层),页面确认一次。
176
+ "clearMessages",
177
+ // 模型:getConfiguredModels 只读;setModel 写用户级 ~/.wave/settings.json。
178
+ "getConfiguredModels",
179
+ "setModel",
180
+ // 排队消息三动作里的两条(「立即发送」= sendMessage{force} + 删这条)。
181
+ "updateQueuedMessage",
182
+ "deleteQueuedMessageById",
183
+ // 后台任务与工作流:读输出 / 停止(不毁文件,回执即可)。
184
+ // 注意这里没有 `backgroundCurrentTask`(把当前这轮转为后台):页面上唯一的入口
185
+ // 是 Ctrl/Cmd+B,手机键盘没有这个键 —— 放行一个页面根本调不到的方法,只是把
186
+ // 暴露面放大,与本清单「跟页面能力一致」的判据相反。
187
+ "getBackgroundTaskOutput",
188
+ "stopBackgroundTask",
189
+ "getWorkflowRuns",
190
+ "stopWorkflowRun",
191
+ // `@` 文件建议与历史提示词:都只读,锚在信封 sessionId 的 workdir 上。
192
+ "searchFiles",
193
+ "getPromptHistory",
194
+ "searchPromptHistory",
195
+ // ── 第三批:设置页(spec ui/remote-control.md「Web 端也能改设置」)──
196
+ // 只有「读配置 + 个性化 + 项目设置」这三块页面:用户偏好(生效值 + 每键来源层)、
197
+ // 两份 AGENTS.md、项目内置插件开关。插件市场 / MCP / 技能 / 子代理 / 钩子 / 登录
198
+ // 那批要的是宿主的**界面**能力,页面不渲染、方法也不放行(见上面注释)。
199
+ "getUserSettings",
200
+ "updateUserSettings",
201
+ "getAgentsContent",
202
+ "setAgentsContent",
203
+ "getProjectSettings",
204
+ "setBuiltinPluginEnabled",
205
+ // ── 第四批:会话删除(spec「手机端的会话列表与删除」)──
206
+ // 写:`destroy` 带上可选 `removeWorktree`(默认 false)。工作树由 daemon 从该
207
+ // 会话的 createdWorkdir 定位、并在 agent.destroy() 之后删除,页面给不出路径。
208
+ // 读:确认卡片要的改动计数走会话作用域的那一条(路径同样由 daemon 推导)。
209
+ // 裸 `removeWorktree` / `getWorktreeChanges` **仍然不放行**:它们收调用方给的
210
+ // path,等于把「这台机器上的任意目录」开给远端——「能删一条被列出来的会话」与
211
+ // 「能指名任意路径」不是同一个能力。
212
+ "destroy",
213
+ "getSessionWorktreeChanges",
214
+ ]);
215
+
216
+ /**
217
+ * The JSON-RPC code for "the channel would carry this, but **this host** has not
218
+ * implemented it".
219
+ *
220
+ * Three failures have to stay distinguishable on the page: `-32601` means the
221
+ * whitelist above refuses the method (the page should ask for a newer build),
222
+ * `-32602` means the parameters are wrong, and this one means the method is
223
+ * allowed but the host behind the channel does not serve it — a foreground CLI
224
+ * session serves its own conversation, not a whole registry. Collapsing them
225
+ * into `-32601` told the user to upgrade a machine that was already current,
226
+ * and returning nothing at all left the page stuck on the action.
227
+ *
228
+ * The page's shim mirrors this number (`packages/webview/remote/shim.js`); it is
229
+ * browser JS shipped verbatim and cannot import from this package, so the
230
+ * literal is duplicated there with a pointer back here.
231
+ */
232
+ export const REMOTE_HOST_UNSUPPORTED = -32011;
233
+
234
+ /**
235
+ * The only `initialize` shape the remote channel may request: create a session
236
+ * in a chosen directory, with an optional permission mode. Everything else in
237
+ * its parameter surface (model / apiKey / tools / plugins / mcpServers /
238
+ * restoreSessionId …) is dropped, not validated — a whitelisted method must
239
+ * not smuggle in knobs the whitelist never approved.
240
+ *
241
+ * `entrypoint` is stamped here rather than picked: it describes the CHANNEL, not
242
+ * the caller's request, and must not be spoofable (the page cannot claim to be
243
+ * the desktop app). A session created this way runs in whichever process serves
244
+ * the relay — a daemon or the interactive CLI — so without this the phone's
245
+ * usage would be indistinguishable from that process's own sessions.
246
+ */
247
+ function pickInitializeParams(params: unknown): Record<string, unknown> {
248
+ const p = (params ?? {}) as Record<string, unknown>;
249
+ const picked: Record<string, unknown> = { entrypoint: "remote" };
250
+ if (typeof p.workdir === "string" && p.workdir) picked.workdir = p.workdir;
251
+ if (typeof p.permissionMode === "string" && p.permissionMode) {
252
+ picked.permissionMode = p.permissionMode;
253
+ }
254
+ return picked;
255
+ }
256
+
257
+ /**
258
+ * How long to wait before recomputing the session status signal. Any
259
+ * notification may be the one that flipped a session between states, so the
260
+ * signal is recomputed on a debounce rather than by enumerating every possible
261
+ * trigger — a missed trigger would leave a phone believing nobody is waiting
262
+ * on an approval, which is the one thing this feature exists to fix.
263
+ */
264
+ export const REMOTE_STATUS_DEBOUNCE_MS = 250;
265
+
266
+ /**
267
+ * What the bridge routes `remoteControl*` requests to. The bridge owns nothing
268
+ * here — same shape as `onShutdownRequest`, where the daemon owns the process
269
+ * lifecycle and the bridge only forwards the call.
270
+ *
271
+ * The snapshot shapes it returns live in the SDK (`wave-agent-sdk`): the hosts
272
+ * and the webview render them and cannot import this package.
273
+ */
274
+ export interface RemoteControlController {
275
+ getSnapshot(): RemoteControlSnapshot;
276
+ /** `name` is an optional label for this machine, shown on the phone. */
277
+ connect(name?: string): void;
278
+ disconnect(): void;
279
+ }
280
+
281
+ /**
282
+ * The host a service serves. Two shapes satisfy it:
283
+ *
284
+ * - `AgentBridge` — a process that stands up a JSON-RPC server (`--daemon`,
285
+ * `--stdio`) and keeps its sessions in a registry;
286
+ * - the interactive CLI front end (`RemoteControlCliHost`) — one session held in
287
+ * React state, with no RPC server (and so no `AgentBridge`) anywhere.
288
+ *
289
+ * These three methods are the whole of what a service asks of its host: fan
290
+ * inbound frames back in, and read the per-session status the relay cannot
291
+ * derive for itself. Keeping the type this narrow is what lets the CLI serve the
292
+ * session the user is sitting in front of, instead of only the sessions that
293
+ * happen to live behind an RPC server.
294
+ */
295
+ export interface RemoteControlHost {
296
+ handleRequest(
297
+ method: string,
298
+ params: unknown,
299
+ sessionId?: string,
300
+ ): Promise<unknown>;
301
+ handleNotification(method: string, params: unknown): void;
302
+ listSessionStatuses(): Array<{
303
+ sessionId: string;
304
+ status: "running" | "idle" | "requires_action";
305
+ }>;
306
+ }
307
+
308
+ export interface RemoteControlServiceOptions {
309
+ /**
310
+ * Resolved when connecting, not when the service is built: a resident daemon
311
+ * outlives the SSO token it booted with, and remote control is usually turned
312
+ * on long after the process started. On a machine that has never registered
313
+ * this is also where the one registration call happens, which is why it is
314
+ * async.
315
+ */
316
+ resolveEndpoint: (options?: { name?: string }) => Promise<{
317
+ endpoint?: RemoteControlEndpoint;
318
+ pageUrl?: string;
319
+ error?: string;
320
+ }>;
321
+ createSocket?: RelaySocketFactory;
322
+ onStatusChange?: (status: RemoteControlStatus) => void;
323
+ }
324
+
325
+ export class RemoteControlService implements RemoteControlController {
326
+ private client: RelayClient | undefined;
327
+ private readonly coalescer: NotificationCoalescer;
328
+ private status: RemoteControlStatus = { state: "off" };
329
+ private statusTimer: ReturnType<typeof setTimeout> | undefined;
330
+ private lastStatusPayload = "";
331
+ private pageUrl: string | undefined;
332
+ private url: string | undefined;
333
+ private name: string | undefined;
334
+ /** Last state announced per session over the control channel. */
335
+ private readonly lastHostStates = new Map<string, string>();
336
+ /** A one-off note from the relay worth showing beside the state. */
337
+ private notice: string | undefined;
338
+ /**
339
+ * Bumped by every `start`/`stop`: registering a machine is an HTTP round trip,
340
+ * and a `stop` that lands in the middle of one must not end with a live
341
+ * connection the user has already turned off.
342
+ */
343
+ private generation = 0;
344
+
345
+ constructor(
346
+ private readonly bridge: RemoteControlHost,
347
+ private readonly options: RemoteControlServiceOptions,
348
+ ) {
349
+ this.coalescer = new NotificationCoalescer((frames) => this.write(frames));
350
+ }
351
+
352
+ start(): void {
353
+ const generation = ++this.generation;
354
+ this.setStatus({ state: "connecting" });
355
+ void this.setup(generation);
356
+ }
357
+
358
+ /**
359
+ * Resolve the identity (registering this machine on first use), then dial.
360
+ * Every await is followed by a generation check, so a `stop` that arrives
361
+ * while the relay is being asked for a host id wins.
362
+ */
363
+ private async setup(generation: number): Promise<void> {
364
+ const resolved = await this.options.resolveEndpoint(
365
+ this.name !== undefined ? { name: this.name } : {},
366
+ );
367
+ if (generation !== this.generation) return;
368
+
369
+ if (!resolved.endpoint) {
370
+ this.setStatus({
371
+ state: "error",
372
+ message: resolved.error ?? "Remote control is unavailable.",
373
+ });
374
+ return;
375
+ }
376
+ const { url, hostToken, version } = resolved.endpoint;
377
+ this.url = url;
378
+ if (resolved.pageUrl) this.pageUrl = resolved.pageUrl;
379
+ // A reconnect has to re-announce every session: the relay keeps the last
380
+ // state it was told, and that state may be minutes old.
381
+ this.lastHostStates.clear();
382
+ this.notice = undefined;
383
+
384
+ this.client = new RelayClient({
385
+ url,
386
+ // The machine is not a phone with a login token: it is the thing being
387
+ // controlled, identified by the long-lived token the relay minted when it
388
+ // registered this machine. With `sso` the relay would take this side for a
389
+ // subscriber — the handshake would succeed and the phone would then be
390
+ // told nobody is home (4001).
391
+ credential: { type: "host", token: hostToken },
392
+ version,
393
+ createSocket: this.options.createSocket,
394
+ onAuthenticated: () => {
395
+ this.setStatus({ state: "online" });
396
+ // Tell the phone which CLI build is behind this session: the page is
397
+ // always the newest one, so a mismatch is resolved by asking the user
398
+ // to upgrade the machine, never by keeping old pages around. The label
399
+ // rides along so a user with two computers can tell them apart. Sent
400
+ // after `auth_ok` because frames sent before the handshake are not
401
+ // forwarded anywhere.
402
+ this.client?.send({
403
+ method: "hostHello",
404
+ params: {
405
+ version,
406
+ ...(this.name ? { name: this.name } : {}),
407
+ },
408
+ });
409
+ this.lastHostStates.clear();
410
+ this.emitStatus(true);
411
+ },
412
+ onAuthError: (info) => {
413
+ // The close that follows carries the mechanics (4003 = do not retry);
414
+ // the frame is the only place the *reason* lives, and for a version
415
+ // floor the only place the required version lives.
416
+ this.setStatus({
417
+ state: "error",
418
+ message:
419
+ info.error === "host_version_unsupported"
420
+ ? `the relay requires a newer wave on this machine${info.minVersion ? ` (minimum ${info.minVersion})` : ""}`
421
+ : `the relay refused this machine: ${info.error}`,
422
+ });
423
+ },
424
+ onPeerDisconnected: () => {
425
+ // A host should never be told this; a subscriber is. Recorded rather
426
+ // than acted on: the 4001 that follows is what drives the retry.
427
+ this.notice = "the relay reports the other end of this channel is gone";
428
+ },
429
+ onResyncRequired: () => {
430
+ // Our frames to a slow peer were dropped, so its view is no longer
431
+ // complete — and it cannot tell which frames it lost. Re-announcing
432
+ // every state (not just the changed ones) is what lets it rebuild.
433
+ this.notice =
434
+ "a subscriber fell behind: the relay dropped frames and asked for a resync";
435
+ this.lastHostStates.clear();
436
+ this.emitStatus(true);
437
+ },
438
+ onFrame: (frame) => this.handleFrame(frame),
439
+ onFatal: (message) => {
440
+ if (generation !== this.generation) return;
441
+ this.setStatus({ state: "error", message });
442
+ },
443
+ });
444
+ this.client.start();
445
+ }
446
+
447
+ stop(): void {
448
+ // Ordered before the teardown so a registration still in flight cannot
449
+ // finish and dial out afterwards.
450
+ this.generation += 1;
451
+ if (this.statusTimer !== undefined) {
452
+ clearTimeout(this.statusTimer);
453
+ this.statusTimer = undefined;
454
+ }
455
+ this.coalescer.discard();
456
+ const client = this.client;
457
+ this.client = undefined;
458
+ client?.stop();
459
+ this.pageUrl = undefined;
460
+ this.lastHostStates.clear();
461
+ this.notice = undefined;
462
+ this.lastStatusPayload = "";
463
+ this.setStatus({ state: "off" });
464
+ }
465
+
466
+ getStatus(): RemoteControlStatus {
467
+ return this.status;
468
+ }
469
+
470
+ getSnapshot(): RemoteControlSnapshot {
471
+ // `message` is the error reason when there is one, and otherwise the last
472
+ // thing the relay told us (a peer went away, a resync was requested).
473
+ const message = this.status.message ?? this.notice;
474
+ return {
475
+ state: this.status.state,
476
+ ...(this.url ? { url: this.url } : {}),
477
+ ...(message ? { message } : {}),
478
+ ...(this.pageUrl ? { pageUrl: this.pageUrl } : {}),
479
+ };
480
+ }
481
+
482
+ /** The controller surface the bridge routes to. */
483
+ connect(name?: string): void {
484
+ if (name !== undefined) this.name = name;
485
+ this.start();
486
+ }
487
+
488
+ disconnect(): void {
489
+ this.stop();
490
+ }
491
+
492
+ // ── Outbound: the daemon's notification fan-out ───────────────
493
+
494
+ /**
495
+ * Same signature as `JsonRpcConnection.sendNotification`, so the daemon can
496
+ * treat local clients and the relay identically.
497
+ */
498
+ sendNotification(method: string, params: unknown, sessionId?: string): void {
499
+ // Nothing to serve while the link is down. Dropping here (rather than
500
+ // buffering) is deliberate: a phone that was disconnected re-reads the tail
501
+ // through `getMessages({ since })` anyway, so a backlog would only be a
502
+ // burst of frames it is about to discard.
503
+ if (!this.client) return;
504
+
505
+ if (method === "messagesChange") {
506
+ // Signal only — and it stays that way for the phone no matter what the
507
+ // producing side did. Every producer in this build sends `{}` already (the
508
+ // bridge's re-attach push and the foreground CLI's structural notice), but
509
+ // an older machine may still attach the full list: dropping `params` here
510
+ // is what keeps the phone on one protocol instead of two
511
+ // (spec「messagesChange 只有一种形态」). Sending immediately rather than
512
+ // through the coalescer is the other half of this branch: a structural
513
+ // notice must not wait for the next delta window.
514
+ this.send({ method: "messagesChange", sessionId });
515
+ this.scheduleStatus();
516
+ return;
517
+ }
518
+ this.coalescer.push(method, params, sessionId);
519
+ this.scheduleStatus();
520
+ }
521
+
522
+ // ── Inbound: what the phone asks for ──────────────────────────
523
+
524
+ private handleFrame(frame: RelayEnvelope): void {
525
+ if (frame.id === undefined) {
526
+ // Notification (no reply expected) — `permissionResponse` is the one the
527
+ // protocol defines.
528
+ try {
529
+ this.bridge.handleNotification(frame.method, frame.params);
530
+ } catch {
531
+ // Notifications have no reply channel; a bad one is not fatal.
532
+ }
533
+ this.scheduleStatus();
534
+ return;
535
+ }
536
+
537
+ // Captured once: the narrowing above does not survive into the callbacks
538
+ // below, and a reply without an id is not a reply.
539
+ const replyId = frame.id;
540
+
541
+ if (!REMOTE_ALLOWED_METHODS.has(frame.method)) {
542
+ this.send({
543
+ id: replyId,
544
+ method: "error",
545
+ params: {
546
+ code: -32601,
547
+ message: `Method not available over remote control: ${frame.method}`,
548
+ },
549
+ });
550
+ return;
551
+ }
552
+
553
+ // `initialize` is whitelisted for exactly one shape: create a session in a
554
+ // chosen directory (and optionally set its permission mode). Its full
555
+ // parameter surface — apiKey / baseURL / model / tools / plugins /
556
+ // mcpServers … — would let the page reshape the machine-side agent, so
557
+ // everything outside the two allowed keys is dropped here rather than
558
+ // trusted to the caller (spec 「Web 端也能发起新会话」场景 5). Re-attach
559
+ // (`restoreSessionId`) is NOT part of the whitelisted shape: the page
560
+ // switches sessions via `getMessages`, not by re-binding the host agent.
561
+ const requestParams =
562
+ frame.method === "initialize"
563
+ ? pickInitializeParams(frame.params)
564
+ : frame.params;
565
+
566
+ void this.bridge
567
+ .handleRequest(frame.method, requestParams, frame.sessionId)
568
+ .then((result) => {
569
+ this.sendResult(replyId, frame.method, result);
570
+ this.scheduleStatus();
571
+ })
572
+ .catch((err: unknown) => {
573
+ const { code, message } = describeRpcError(err);
574
+ this.send({
575
+ id: replyId,
576
+ method: "error",
577
+ params: { code, message },
578
+ });
579
+ });
580
+ }
581
+
582
+ /**
583
+ * Answer a request, hammering the answer into the frame budget.
584
+ *
585
+ * A read answer is the one frame whose size follows the session (a page of
586
+ * `displayMessages`), so it is the one frame that gets trimmed instead of
587
+ * dropped. `getMessages` answers are pageable and the phone asked for the
588
+ * tail, so an over-budget page is cut to the **newest** messages that fit and
589
+ * flagged `hasMore` — the phone keeps a usable page instead of nothing, and
590
+ * walking further back is a smaller `limit` away. Every other over-budget
591
+ * answer is an explicit error: truncating a `getSessionInfo` or a
592
+ * `listSessions` would hand the phone a well-formed lie about which sessions
593
+ * exist, and there is no "newer part" of such an answer to prefer.
594
+ */
595
+ private sendResult(
596
+ replyId: number | string,
597
+ method: string,
598
+ result: unknown,
599
+ ): void {
600
+ const envelope: RelayEnvelope = {
601
+ id: replyId,
602
+ method: "result",
603
+ params: { result },
604
+ };
605
+ const bytes = frameBytes(envelope);
606
+ if (bytes <= REMOTE_MAX_FRAME_BYTES) {
607
+ this.send(envelope);
608
+ return;
609
+ }
610
+
611
+ const page = asMessagePage(result);
612
+ if (page) {
613
+ const kept = trailingCountThatFits(
614
+ page.messages.length,
615
+ (count) =>
616
+ frameBytes(pageFrame(replyId, page, count)) <= REMOTE_MAX_FRAME_BYTES,
617
+ );
618
+ // Not even one message fits: pageable or not, there is nothing to send
619
+ // that the phone could render, and an empty page claiming `hasMore` would
620
+ // only make it ask again forever.
621
+ if (kept > 0) {
622
+ this.send(pageFrame(replyId, page, kept));
623
+ return;
624
+ }
625
+ }
626
+
627
+ this.send({
628
+ id: replyId,
629
+ method: "error",
630
+ params: {
631
+ code: -32603,
632
+ message:
633
+ `${method}: the answer is larger than the remote channel's frame budget` +
634
+ ` (${String(bytes)} bytes over the ${String(REMOTE_MAX_FRAME_BYTES)}-byte limit)` +
635
+ ` — ask for a smaller page`,
636
+ },
637
+ });
638
+ }
639
+
640
+ // ── Plumbing ──────────────────────────────────────────────────
641
+
642
+ /** Frames are simply dropped when the link is down; the phone re-syncs. */
643
+ private send(frame: RelayEnvelope): void {
644
+ this.client?.send(frame);
645
+ }
646
+
647
+ /**
648
+ * Hand the coalesced notifications over, one frame each — unless a frame is
649
+ * over budget, which for a notification means "the phone must re-read".
650
+ *
651
+ * The relay would drop such a frame anyway and ask for a resync, so saying it
652
+ * ourselves is the same outcome without the round trip — and it is the only
653
+ * way the phone hears about it *before* the drop. Which matters, because the
654
+ * notification most likely to be oversized is a **permission request** (its
655
+ * payload carries the command and the diff): dropping it silently would leave
656
+ * an approval that nobody can answer. So the stand-in is the `full` variant of
657
+ * the list-changed signal, and the page answers it by re-pulling the snapshot
658
+ * — messages *and* pending approvals — rather than only the messages it is
659
+ * missing.
660
+ */
661
+ private write(frames: RelayFrame[]): void {
662
+ for (const frame of frames) {
663
+ const envelope: RelayEnvelope = {
664
+ method: frame.method,
665
+ params: frame.params,
666
+ sessionId: frame.sessionId,
667
+ };
668
+ if (frameBytes(envelope) > REMOTE_MAX_FRAME_BYTES) {
669
+ this.send({
670
+ method: "messagesChanged",
671
+ params: { full: true },
672
+ sessionId: frame.sessionId,
673
+ });
674
+ continue;
675
+ }
676
+ this.send(envelope);
677
+ }
678
+ }
679
+
680
+ private scheduleStatus(): void {
681
+ if (this.statusTimer !== undefined) return;
682
+ this.statusTimer = setTimeout(() => {
683
+ this.statusTimer = undefined;
684
+ this.emitStatus(false);
685
+ }, REMOTE_STATUS_DEBOUNCE_MS);
686
+ this.statusTimer.unref?.();
687
+ }
688
+
689
+ /**
690
+ * Recompute and push the per-session status signal. Sent only when it
691
+ * actually changed: at 250 ms debounce this would otherwise be a steady
692
+ * trickle of identical frames for the life of a session.
693
+ *
694
+ * Two carriers, because they serve two readers: the JSON-RPC notification is
695
+ * what the phone page renders, and the `host_state` control frame is the only
696
+ * thing the **relay** reads — it has to, because "somebody is waiting on an
697
+ * approval" is the one state worth a Web Push, and deriving that from a
698
+ * business envelope would mean parsing the payloads it promises not to parse.
699
+ */
700
+ private emitStatus(force: boolean): void {
701
+ let sessions: Array<{ sessionId: string; status: string }>;
702
+ try {
703
+ sessions = this.bridge.listSessionStatuses();
704
+ } catch {
705
+ return;
706
+ }
707
+ const payload = JSON.stringify(sessions);
708
+ if (!force && payload === this.lastStatusPayload) return;
709
+ this.lastStatusPayload = payload;
710
+ this.send({ method: "sessionStatus", params: { sessions } });
711
+ this.reportHostStates(sessions);
712
+ }
713
+
714
+ /**
715
+ * Announce every state the relay has not been told yet, one frame per session
716
+ * (`{type:'host_state', sessionId, state}`). Only changes, because the relay
717
+ * turns a transition *into* `requires_action` into a push — re-announcing an
718
+ * unchanged state would be a push per frame.
719
+ *
720
+ * Sessions that went away are simply forgotten: a state the relay holds for a
721
+ * session nobody can ask about does nothing, while a stale `requires_action`
722
+ * would keep a phone ringing about a session that no longer exists.
723
+ */
724
+ private reportHostStates(
725
+ sessions: Array<{ sessionId: string; status: string }>,
726
+ ): void {
727
+ const seen = new Set<string>();
728
+ for (const session of sessions) {
729
+ seen.add(session.sessionId);
730
+ if (this.lastHostStates.get(session.sessionId) === session.status)
731
+ continue;
732
+ this.lastHostStates.set(session.sessionId, session.status);
733
+ this.client?.sendControl({
734
+ type: "host_state",
735
+ sessionId: session.sessionId,
736
+ state: session.status,
737
+ });
738
+ }
739
+ for (const sessionId of [...this.lastHostStates.keys()]) {
740
+ if (!seen.has(sessionId)) this.lastHostStates.delete(sessionId);
741
+ }
742
+ }
743
+
744
+ private setStatus(status: RemoteControlStatus): void {
745
+ this.status = status;
746
+ this.options.onStatusChange?.(status);
747
+ }
748
+ }
749
+
750
+ function describeRpcError(err: unknown): { code: number; message: string } {
751
+ const code =
752
+ err && typeof err === "object" && "code" in err
753
+ ? Number((err as { code: unknown }).code)
754
+ : -32603;
755
+ const message = err instanceof Error ? err.message : String(err);
756
+ return { code: Number.isFinite(code) ? code : -32603, message };
757
+ }
758
+
759
+ /** A `getMessages` answer: the one read shape that can be cut into a page. */
760
+ interface MessagePage {
761
+ messages: unknown[];
762
+ /** Whatever else the host put in the answer (`hasMore` / `reset` / `total`). */
763
+ rest: Record<string, unknown>;
764
+ }
765
+
766
+ /**
767
+ * Recognise a pageable answer by its shape rather than by the method that
768
+ * produced it: `getMessages` is the only method that returns `messages` today,
769
+ * and matching on the shape means a future paged read is covered without a
770
+ * second list of method names to keep in sync.
771
+ */
772
+ function asMessagePage(result: unknown): MessagePage | undefined {
773
+ if (typeof result !== "object" || result === null) return undefined;
774
+ const { messages, ...rest } = result as { messages?: unknown };
775
+ return Array.isArray(messages)
776
+ ? { messages, rest: rest as Record<string, unknown> }
777
+ : undefined;
778
+ }
779
+
780
+ /** The answer, cut to its newest `count` messages. */
781
+ function pageFrame(
782
+ replyId: number | string,
783
+ page: MessagePage,
784
+ count: number,
785
+ ): RelayEnvelope {
786
+ return {
787
+ id: replyId,
788
+ method: "result",
789
+ params: {
790
+ result: {
791
+ ...page.rest,
792
+ messages: page.messages.slice(-count),
793
+ // The page is by definition no longer the whole answer, and the phone
794
+ // reads this flag to decide whether asking again is worth anything.
795
+ hasMore: true,
796
+ },
797
+ },
798
+ };
799
+ }