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.
- package/dist/bundle/wave.mjs +1382 -1131
- package/package.json +9 -7
- package/src/cli.tsx +14 -22
- package/src/commands/update.ts +62 -22
- package/src/components/AgentsManager.tsx +3 -3
- package/src/components/App.tsx +5 -0
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/BtwDisplay.tsx +71 -15
- package/src/components/CompactDisplay.tsx +1 -6
- package/src/components/ConfirmationDetails.tsx +34 -13
- package/src/components/ConfirmationSelector.tsx +31 -36
- package/src/components/DiffDisplay.tsx +1 -1
- package/src/components/HooksManager.tsx +3 -3
- package/src/components/InputBox.tsx +37 -11
- package/src/components/LoginCommand.tsx +16 -35
- package/src/components/Markdown.tsx +7 -3
- package/src/components/MarketplaceAddForm.tsx +15 -19
- package/src/components/MessageBlockItem.tsx +0 -6
- package/src/components/RemoteControlView.tsx +175 -0
- package/src/components/ScrolledContent.tsx +15 -13
- package/src/components/SkillsManager.tsx +3 -3
- package/src/components/TaskList.tsx +3 -3
- package/src/components/ToolDisplay.tsx +3 -18
- package/src/components/WorkflowManager.tsx +150 -136
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +6 -0
- package/src/contexts/useChat.tsx +308 -154
- package/src/daemon/commands.ts +31 -22
- package/src/daemon-cli.ts +6 -1
- package/src/hooks/useInputManager.ts +25 -39
- package/src/hooks/useLineScroll.ts +51 -5
- package/src/hooks/useRemoteControl.ts +346 -0
- package/src/index.ts +35 -0
- package/src/managers/inputHandlers.ts +5 -0
- package/src/managers/inputReducer.ts +39 -0
- package/src/print-cli.ts +5 -0
- package/src/reducers/workflowManagerReducer.ts +21 -2
- package/src/remote/frameBudget.ts +45 -0
- package/src/remote/hostIdentity.ts +128 -0
- package/src/remote/messageWindow.ts +46 -0
- package/src/remote/notificationCoalescer.ts +192 -0
- package/src/remote/relayClient.ts +690 -0
- package/src/remote/remoteControlCliHost.ts +420 -0
- package/src/remote/remoteControlCommands.ts +197 -0
- package/src/remote/remoteControlConfig.ts +353 -0
- package/src/remote/remoteControlService.ts +822 -0
- package/src/remote/remoteControlSink.ts +78 -0
- package/src/remote/remoteNotifications.ts +200 -0
- package/src/stdio/agentBridge.ts +874 -106
- package/src/stdio/daemonServer.ts +30 -0
- package/src/stdio/hostSlashCommands.ts +209 -0
- package/src/stdio/jsonRpcConnection.ts +11 -2
- package/src/stdio/protocol.ts +14 -115
- package/src/stdio/stdioServer.ts +33 -2
- package/src/stdio/uiPayload.ts +65 -0
- package/src/stdio-cli.ts +11 -2
- package/src/types.ts +7 -0
- package/src/utils/gitCredentialHelper.ts +106 -0
- package/src/utils/logger.ts +5 -2
- package/src/utils/rewindCheckpoints.ts +2 -4
- package/src/utils/workspaceDiff.ts +620 -0
- package/src/utils/worktree.ts +97 -0
- package/src/utils/bracketedPaste.ts +0 -170
|
@@ -0,0 +1,822 @@
|
|
|
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
|
+
* Still out, and deliberately: settings / plugin market / login (`getUserSettings`,
|
|
99
|
+
* `updateUserSettings`, plugins, `login`, `logout`, …), anything that opens an
|
|
100
|
+
* editor (`openFile` is not even a bridge method), the on-disk session list
|
|
101
|
+
* (`listAllSessions` / `restoreSession`), the process-lifecycle calls
|
|
102
|
+
* (`shutdown` / `bang`), and the **read-only panes the page has no
|
|
103
|
+
* surface for** — `getWorkspaceDiff` / `getWorktreeChanges` / `getPlanFile` /
|
|
104
|
+
* `getFullMessageThread` (no diff / worktree / plan pane on the page; the plan
|
|
105
|
+
* body travels inside the `ExitPlanMode` approval card).
|
|
106
|
+
*
|
|
107
|
+
* **Session deletion (2026-10-17, fourth batch)** — the batch where the page
|
|
108
|
+
* gained the entry `removeWorktree` was being kept out for. Two of the three
|
|
109
|
+
* things involved are whitelisted, one is not, and the split is the whole design:
|
|
110
|
+
*
|
|
111
|
+
* - `destroy` (now with an optional `removeWorktree` param) is the write. It is
|
|
112
|
+
* **session-keyed**: the daemon resolves the worktree from that session's own
|
|
113
|
+
* creation directory, so the phone names a session, never a path.
|
|
114
|
+
* - `getSessionWorktreeChanges` is the read the delete confirmation needs — the
|
|
115
|
+
* same counts the desktop dialog shows, resolved the same way.
|
|
116
|
+
* - `removeWorktree` / `getWorktreeChanges` stay **out**: they take `path` /
|
|
117
|
+
* `branch` / `repoRoot` (or `path`) from the caller, i.e. they would let the
|
|
118
|
+
* page name any directory on this machine to delete or to inspect. Being able
|
|
119
|
+
* to delete a session it was handed is not the same capability.
|
|
120
|
+
*/
|
|
121
|
+
export const REMOTE_ALLOWED_METHODS: ReadonlySet<string> = new Set([
|
|
122
|
+
"listSessions",
|
|
123
|
+
"listDaemonSessions",
|
|
124
|
+
"getSessionInfo",
|
|
125
|
+
"getMessages",
|
|
126
|
+
"listPendingPermissions",
|
|
127
|
+
"permissionResponse",
|
|
128
|
+
"sendMessage",
|
|
129
|
+
"abortMessage",
|
|
130
|
+
"setPermissionMode",
|
|
131
|
+
"renameSession",
|
|
132
|
+
"compact",
|
|
133
|
+
"getSlashCommands",
|
|
134
|
+
// 发起新会话(spec ui/remote-control.md「Web 端也能发起新会话」):白名单
|
|
135
|
+
// 从「只碰已存在的会话」显式扩到「可在选定目录建会话」。initialize 在下方
|
|
136
|
+
// 收敛入参——放行的是「建一条会话」这个能力,不是它的整张参数表。
|
|
137
|
+
"initialize",
|
|
138
|
+
"listDirectories",
|
|
139
|
+
"listGitBranches",
|
|
140
|
+
"createWorktree",
|
|
141
|
+
// ── 第二批解禁(spec「页面能执行的那批宿主命令」)──
|
|
142
|
+
// 回滚:与桌面同语义(截断对话 + 按 file_history 快照还原本机工作区文件),
|
|
143
|
+
// 页面执行前有一次确认。
|
|
144
|
+
"rewindToMessage",
|
|
145
|
+
"listRewindCheckpoints",
|
|
146
|
+
// 清空:换绑新 sessionId(与 IDE 端「新建对话」同一条底层),页面确认一次。
|
|
147
|
+
"clearMessages",
|
|
148
|
+
// 模型:getConfiguredModels 只读;setModel 写用户级 ~/.wave/settings.json。
|
|
149
|
+
"getConfiguredModels",
|
|
150
|
+
"setModel",
|
|
151
|
+
// 排队消息三动作里的两条(「立即发送」= sendMessage{force} + 删这条)。
|
|
152
|
+
"updateQueuedMessage",
|
|
153
|
+
"deleteQueuedMessageById",
|
|
154
|
+
// 后台任务与工作流:读输出 / 停止(不毁文件,回执即可)。
|
|
155
|
+
// 注意这里没有 `backgroundCurrentTask`(把当前这轮转为后台):页面上唯一的入口
|
|
156
|
+
// 是 Ctrl/Cmd+B,手机键盘没有这个键 —— 放行一个页面根本调不到的方法,只是把
|
|
157
|
+
// 暴露面放大,与本清单「跟页面能力一致」的判据相反。
|
|
158
|
+
"getBackgroundTaskOutput",
|
|
159
|
+
"stopBackgroundTask",
|
|
160
|
+
"getWorkflowRuns",
|
|
161
|
+
"stopWorkflowRun",
|
|
162
|
+
// `@` 文件建议与历史提示词:都只读,锚在信封 sessionId 的 workdir 上。
|
|
163
|
+
"searchFiles",
|
|
164
|
+
"getPromptHistory",
|
|
165
|
+
"searchPromptHistory",
|
|
166
|
+
// `/btw` 旁路提问(spec「Web 页也能用 /btw 旁路提问」):单轮 fork、不执行工具、
|
|
167
|
+
// 不进对话历史 —— 它作用于当前会话、既不需要编辑器也不需要文件系统,与第二批
|
|
168
|
+
// 那条「页面能不能承载」同一条判据。**只放行请求**:流式增量是宿主推的
|
|
169
|
+
// `btwContent` 通知,走既有的通知扇出,白名单管不到也不需要管。
|
|
170
|
+
"askBtw",
|
|
171
|
+
// ── 第四批:会话删除(spec「手机端的会话列表与删除」)──
|
|
172
|
+
// 写:`destroy` 带上可选 `removeWorktree`(默认 false)。工作树由 daemon 从该
|
|
173
|
+
// 会话的 createdWorkdir 定位、并在 agent.destroy() 之后删除,页面给不出路径。
|
|
174
|
+
// 读:确认卡片要的改动计数走会话作用域的那一条(路径同样由 daemon 推导)。
|
|
175
|
+
// 裸 `removeWorktree` / `getWorktreeChanges` **仍然不放行**:它们收调用方给的
|
|
176
|
+
// path,等于把「这台机器上的任意目录」开给远端——「能删一条被列出来的会话」与
|
|
177
|
+
// 「能指名任意路径」不是同一个能力。
|
|
178
|
+
"destroy",
|
|
179
|
+
"getSessionWorktreeChanges",
|
|
180
|
+
// ── 第五批:最近打开的工作目录(spec core/recent-workdirs.md)──
|
|
181
|
+
// 读:`getRecentWorkdirs` 回这台机器自己的 MRU 列表(`~/.wave/recent-workdirs.json`)。
|
|
182
|
+
// 写:`rememberRecentWorkdir` 只在新建会话成功后由页面调用一次,参数是那次
|
|
183
|
+
// 创建用的目录——它不会删改已有条目,也换不来「在任意目录跑 agent」之外
|
|
184
|
+
// 的任何东西(那个能力本来就在 `initialize` 里)。
|
|
185
|
+
"getRecentWorkdirs",
|
|
186
|
+
"rememberRecentWorkdir",
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The JSON-RPC code for "the channel would carry this, but **this host** has not
|
|
191
|
+
* implemented it".
|
|
192
|
+
*
|
|
193
|
+
* Three failures have to stay distinguishable on the page: `-32601` means the
|
|
194
|
+
* whitelist above refuses the method (the page should ask for a newer build),
|
|
195
|
+
* `-32602` means the parameters are wrong, and this one means the method is
|
|
196
|
+
* allowed but the host behind the channel does not serve it — a foreground CLI
|
|
197
|
+
* session serves its own conversation, not a whole registry. Collapsing them
|
|
198
|
+
* into `-32601` told the user to upgrade a machine that was already current,
|
|
199
|
+
* and returning nothing at all left the page stuck on the action.
|
|
200
|
+
*
|
|
201
|
+
* The page's shim mirrors this number (`packages/webview/remote/shim.js`); it is
|
|
202
|
+
* browser JS shipped verbatim and cannot import from this package, so the
|
|
203
|
+
* literal is duplicated there with a pointer back here.
|
|
204
|
+
*/
|
|
205
|
+
export const REMOTE_HOST_UNSUPPORTED = -32011;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The only `initialize` shape the remote channel may request: create a session
|
|
209
|
+
* in a chosen directory, with an optional permission mode. Everything else in
|
|
210
|
+
* its parameter surface (model / apiKey / tools / plugins / mcpServers /
|
|
211
|
+
* restoreSessionId …) is dropped, not validated — a whitelisted method must
|
|
212
|
+
* not smuggle in knobs the whitelist never approved.
|
|
213
|
+
*
|
|
214
|
+
* `entrypoint` is stamped here rather than picked: it describes the CHANNEL, not
|
|
215
|
+
* the caller's request, and must not be spoofable (the page cannot claim to be
|
|
216
|
+
* the desktop app). A session created this way runs in whichever process serves
|
|
217
|
+
* the relay — a daemon or the interactive CLI — so without this the phone's
|
|
218
|
+
* usage would be indistinguishable from that process's own sessions.
|
|
219
|
+
*/
|
|
220
|
+
function pickInitializeParams(params: unknown): Record<string, unknown> {
|
|
221
|
+
const p = (params ?? {}) as Record<string, unknown>;
|
|
222
|
+
const picked: Record<string, unknown> = { entrypoint: "remote" };
|
|
223
|
+
if (typeof p.workdir === "string" && p.workdir) picked.workdir = p.workdir;
|
|
224
|
+
if (typeof p.permissionMode === "string" && p.permissionMode) {
|
|
225
|
+
picked.permissionMode = p.permissionMode;
|
|
226
|
+
}
|
|
227
|
+
return picked;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* How long to wait before recomputing the session status signal. Any
|
|
232
|
+
* notification may be the one that flipped a session between states, so the
|
|
233
|
+
* signal is recomputed on a debounce rather than by enumerating every possible
|
|
234
|
+
* trigger — a missed trigger would leave a phone believing nobody is waiting
|
|
235
|
+
* on an approval, which is the one thing this feature exists to fix.
|
|
236
|
+
*/
|
|
237
|
+
export const REMOTE_STATUS_DEBOUNCE_MS = 250;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* What the bridge routes `remoteControl*` requests to. The bridge owns nothing
|
|
241
|
+
* here — same shape as `onShutdownRequest`, where the daemon owns the process
|
|
242
|
+
* lifecycle and the bridge only forwards the call.
|
|
243
|
+
*
|
|
244
|
+
* The snapshot shapes it returns live in the SDK (`wave-agent-sdk`): the hosts
|
|
245
|
+
* and the webview render them and cannot import this package.
|
|
246
|
+
*/
|
|
247
|
+
export interface RemoteControlController {
|
|
248
|
+
getSnapshot(): RemoteControlSnapshot;
|
|
249
|
+
/** `name` is an optional label for this machine, shown on the phone. */
|
|
250
|
+
connect(name?: string): void;
|
|
251
|
+
disconnect(): void;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The host a service serves. Two shapes satisfy it:
|
|
256
|
+
*
|
|
257
|
+
* - `AgentBridge` — a process that stands up a JSON-RPC server (`--daemon`,
|
|
258
|
+
* `--stdio`) and keeps its sessions in a registry;
|
|
259
|
+
* - the interactive CLI front end (`RemoteControlCliHost`) — one session held in
|
|
260
|
+
* React state, with no RPC server (and so no `AgentBridge`) anywhere.
|
|
261
|
+
*
|
|
262
|
+
* These three methods are the whole of what a service asks of its host: fan
|
|
263
|
+
* inbound frames back in, and read the per-session status the relay cannot
|
|
264
|
+
* derive for itself. Keeping the type this narrow is what lets the CLI serve the
|
|
265
|
+
* session the user is sitting in front of, instead of only the sessions that
|
|
266
|
+
* happen to live behind an RPC server.
|
|
267
|
+
*/
|
|
268
|
+
export interface RemoteControlHost {
|
|
269
|
+
handleRequest(
|
|
270
|
+
method: string,
|
|
271
|
+
params: unknown,
|
|
272
|
+
sessionId?: string,
|
|
273
|
+
): Promise<unknown>;
|
|
274
|
+
handleNotification(method: string, params: unknown): void;
|
|
275
|
+
listSessionStatuses(): Array<{
|
|
276
|
+
sessionId: string;
|
|
277
|
+
status: "running" | "idle" | "requires_action";
|
|
278
|
+
}>;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface RemoteControlServiceOptions {
|
|
282
|
+
/**
|
|
283
|
+
* Resolved when connecting, not when the service is built: a resident daemon
|
|
284
|
+
* outlives the SSO token it booted with, and remote control is usually turned
|
|
285
|
+
* on long after the process started. On a machine that has never registered
|
|
286
|
+
* this is also where the one registration call happens, which is why it is
|
|
287
|
+
* async.
|
|
288
|
+
*/
|
|
289
|
+
resolveEndpoint: (options?: {
|
|
290
|
+
name?: string;
|
|
291
|
+
refreshIdentity?: boolean;
|
|
292
|
+
}) => Promise<{
|
|
293
|
+
endpoint?: RemoteControlEndpoint;
|
|
294
|
+
pageUrl?: string;
|
|
295
|
+
error?: string;
|
|
296
|
+
}>;
|
|
297
|
+
createSocket?: RelaySocketFactory;
|
|
298
|
+
onStatusChange?: (status: RemoteControlStatus) => void;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export class RemoteControlService implements RemoteControlController {
|
|
302
|
+
private client: RelayClient | undefined;
|
|
303
|
+
private readonly coalescer: NotificationCoalescer;
|
|
304
|
+
private status: RemoteControlStatus = { state: "off" };
|
|
305
|
+
private statusTimer: ReturnType<typeof setTimeout> | undefined;
|
|
306
|
+
private lastStatusPayload = "";
|
|
307
|
+
private pageUrl: string | undefined;
|
|
308
|
+
private url: string | undefined;
|
|
309
|
+
private name: string | undefined;
|
|
310
|
+
/** Last state announced per session over the control channel. */
|
|
311
|
+
private readonly lastHostStates = new Map<string, string>();
|
|
312
|
+
/** A one-off note from the relay worth showing beside the state. */
|
|
313
|
+
private notice: string | undefined;
|
|
314
|
+
/**
|
|
315
|
+
* Bumped by every `start`/`stop`: registering a machine is an HTTP round trip,
|
|
316
|
+
* and a `stop` that lands in the middle of one must not end with a live
|
|
317
|
+
* connection the user has already turned off.
|
|
318
|
+
*/
|
|
319
|
+
private generation = 0;
|
|
320
|
+
/**
|
|
321
|
+
* The stale-identity self-heal already ran in this process.
|
|
322
|
+
*
|
|
323
|
+
* Re-registering mints a **new** token, and any other entry on this machine is
|
|
324
|
+
* still holding the old one — so an unbounded heal turns two processes into a
|
|
325
|
+
* loop of re-signing each other's credential away (the same shape as the `4001`
|
|
326
|
+
* takeover ping-pong). One attempt per process is what keeps it convergent:
|
|
327
|
+
* whoever healed last serves, the other stops. A second, different reason is
|
|
328
|
+
* an expiry 90 days out, which is not worth spending the budget on.
|
|
329
|
+
*/
|
|
330
|
+
private identityRefreshUsed = false;
|
|
331
|
+
|
|
332
|
+
constructor(
|
|
333
|
+
private readonly bridge: RemoteControlHost,
|
|
334
|
+
private readonly options: RemoteControlServiceOptions,
|
|
335
|
+
) {
|
|
336
|
+
this.coalescer = new NotificationCoalescer((frames) => this.write(frames));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
start(): void {
|
|
340
|
+
const generation = ++this.generation;
|
|
341
|
+
this.setStatus({ state: "connecting" });
|
|
342
|
+
void this.setup(generation);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Resolve the identity (registering this machine on first use), then dial.
|
|
347
|
+
* Every await is followed by a generation check, so a `stop` that arrives
|
|
348
|
+
* while the relay is being asked for a host id wins.
|
|
349
|
+
*
|
|
350
|
+
* `refreshIdentity` forces that registration to happen again, which is only
|
|
351
|
+
* ever set by the self-heal below.
|
|
352
|
+
*/
|
|
353
|
+
private async setup(
|
|
354
|
+
generation: number,
|
|
355
|
+
options: { refreshIdentity?: boolean } = {},
|
|
356
|
+
): Promise<void> {
|
|
357
|
+
const resolved = await this.options.resolveEndpoint({
|
|
358
|
+
...(this.name !== undefined ? { name: this.name } : {}),
|
|
359
|
+
...(options.refreshIdentity ? { refreshIdentity: true } : {}),
|
|
360
|
+
});
|
|
361
|
+
if (generation !== this.generation) return;
|
|
362
|
+
|
|
363
|
+
if (!resolved.endpoint) {
|
|
364
|
+
const reason = resolved.error ?? "Remote control is unavailable.";
|
|
365
|
+
this.setStatus({
|
|
366
|
+
state: "error",
|
|
367
|
+
// Two layers, because the user has to act on both halves: the identity
|
|
368
|
+
// this machine stored is gone, *and* replacing it did not work. Saying
|
|
369
|
+
// only the latter would read like a flaky network.
|
|
370
|
+
message: options.refreshIdentity
|
|
371
|
+
? `the relay no longer knows this machine, and registering it again failed: ${reason}`
|
|
372
|
+
: reason,
|
|
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.
|
|
415
|
+
this.setStatus({
|
|
416
|
+
state: "error",
|
|
417
|
+
message: `the relay refused this machine: ${info.error}`,
|
|
418
|
+
});
|
|
419
|
+
},
|
|
420
|
+
onPeerDisconnected: () => {
|
|
421
|
+
// A host should never be told this; a subscriber is. Recorded rather
|
|
422
|
+
// than acted on: the 4001 that follows is what drives the retry.
|
|
423
|
+
this.notice = "the relay reports the other end of this channel is gone";
|
|
424
|
+
},
|
|
425
|
+
onResyncRequired: () => {
|
|
426
|
+
// Our frames to a slow peer were dropped, so its view is no longer
|
|
427
|
+
// complete — and it cannot tell which frames it lost. Re-announcing
|
|
428
|
+
// every state (not just the changed ones) is what lets it rebuild.
|
|
429
|
+
this.notice =
|
|
430
|
+
"a subscriber fell behind: the relay dropped frames and asked for a resync";
|
|
431
|
+
this.lastHostStates.clear();
|
|
432
|
+
this.emitStatus(true);
|
|
433
|
+
},
|
|
434
|
+
onFrame: (frame) => this.handleFrame(frame),
|
|
435
|
+
onStaleIdentity: (info) => {
|
|
436
|
+
if (generation !== this.generation) return;
|
|
437
|
+
this.refreshIdentity(info.error);
|
|
438
|
+
},
|
|
439
|
+
onFatal: (message) => {
|
|
440
|
+
if (generation !== this.generation) return;
|
|
441
|
+
this.setStatus({ state: "error", message });
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
this.client.start();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The relay refused the credential with a stale-identity reason — the stored
|
|
449
|
+
* row is gone (revoked, expired, or a file whose id and token never belonged
|
|
450
|
+
* together). Recover by registering again: the id is derived from the machine,
|
|
451
|
+
* so the relay reuses the same row and the page URL survives, and the fresh
|
|
452
|
+
* token then dials out. Without this the only way back was for the user to
|
|
453
|
+
* delete `~/.wave/remote-control.json` by hand, which nobody knows exists
|
|
454
|
+
* (2026-10-16: a revoked row left a Mac unable to reconnect at all).
|
|
455
|
+
*
|
|
456
|
+
* Attempted **once per process** — see `identityRefreshUsed`.
|
|
457
|
+
*/
|
|
458
|
+
private refreshIdentity(error: string): void {
|
|
459
|
+
if (this.identityRefreshUsed) {
|
|
460
|
+
this.setStatus({
|
|
461
|
+
state: "error",
|
|
462
|
+
message: `the relay rejected this machine even after registering it again (4003): ${error}`,
|
|
463
|
+
});
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
this.identityRefreshUsed = true;
|
|
467
|
+
const generation = ++this.generation;
|
|
468
|
+
this.setStatus({
|
|
469
|
+
state: "connecting",
|
|
470
|
+
message: "the relay no longer knows this machine: registering it again",
|
|
471
|
+
});
|
|
472
|
+
void this.setup(generation, { refreshIdentity: true });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
stop(): void {
|
|
476
|
+
// Ordered before the teardown so a registration still in flight cannot
|
|
477
|
+
// finish and dial out afterwards.
|
|
478
|
+
this.generation += 1;
|
|
479
|
+
if (this.statusTimer !== undefined) {
|
|
480
|
+
clearTimeout(this.statusTimer);
|
|
481
|
+
this.statusTimer = undefined;
|
|
482
|
+
}
|
|
483
|
+
this.coalescer.discard();
|
|
484
|
+
const client = this.client;
|
|
485
|
+
this.client = undefined;
|
|
486
|
+
client?.stop();
|
|
487
|
+
this.pageUrl = undefined;
|
|
488
|
+
this.lastHostStates.clear();
|
|
489
|
+
this.notice = undefined;
|
|
490
|
+
this.lastStatusPayload = "";
|
|
491
|
+
this.setStatus({ state: "off" });
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
getStatus(): RemoteControlStatus {
|
|
495
|
+
return this.status;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
getSnapshot(): RemoteControlSnapshot {
|
|
499
|
+
// `message` is the error reason when there is one, and otherwise the last
|
|
500
|
+
// thing the relay told us (a peer went away, a resync was requested).
|
|
501
|
+
const message = this.status.message ?? this.notice;
|
|
502
|
+
return {
|
|
503
|
+
state: this.status.state,
|
|
504
|
+
...(this.url ? { url: this.url } : {}),
|
|
505
|
+
...(message ? { message } : {}),
|
|
506
|
+
...(this.pageUrl ? { pageUrl: this.pageUrl } : {}),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** The controller surface the bridge routes to. */
|
|
511
|
+
connect(name?: string): void {
|
|
512
|
+
if (name !== undefined) this.name = name;
|
|
513
|
+
this.start();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
disconnect(): void {
|
|
517
|
+
this.stop();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ── Outbound: the daemon's notification fan-out ───────────────
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Same signature as `JsonRpcConnection.sendNotification`, so the daemon can
|
|
524
|
+
* treat local clients and the relay identically.
|
|
525
|
+
*/
|
|
526
|
+
sendNotification(method: string, params: unknown, sessionId?: string): void {
|
|
527
|
+
// Nothing to serve while the link is down. Dropping here (rather than
|
|
528
|
+
// buffering) is deliberate: a phone that was disconnected re-reads the tail
|
|
529
|
+
// through `getMessages({ since })` anyway, so a backlog would only be a
|
|
530
|
+
// burst of frames it is about to discard.
|
|
531
|
+
if (!this.client) return;
|
|
532
|
+
|
|
533
|
+
if (method === "messagesChange") {
|
|
534
|
+
// Signal only — and it stays that way for the phone no matter what the
|
|
535
|
+
// producing side did. Every producer in this build sends `{}` already (the
|
|
536
|
+
// bridge's re-attach push and the foreground CLI's structural notice), but
|
|
537
|
+
// an older machine may still attach the full list: dropping `params` here
|
|
538
|
+
// is what keeps the phone on one protocol instead of two
|
|
539
|
+
// (spec「messagesChange 只有一种形态」). Sending immediately rather than
|
|
540
|
+
// through the coalescer is the other half of this branch: a structural
|
|
541
|
+
// notice must not wait for the next delta window.
|
|
542
|
+
this.send({ method: "messagesChange", sessionId });
|
|
543
|
+
this.scheduleStatus();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
this.coalescer.push(method, params, sessionId);
|
|
547
|
+
this.scheduleStatus();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ── Inbound: what the phone asks for ──────────────────────────
|
|
551
|
+
|
|
552
|
+
private handleFrame(frame: RelayEnvelope): void {
|
|
553
|
+
if (frame.id === undefined) {
|
|
554
|
+
// Notification (no reply expected) — `permissionResponse` is the one the
|
|
555
|
+
// protocol defines.
|
|
556
|
+
try {
|
|
557
|
+
this.bridge.handleNotification(frame.method, frame.params);
|
|
558
|
+
} catch {
|
|
559
|
+
// Notifications have no reply channel; a bad one is not fatal.
|
|
560
|
+
}
|
|
561
|
+
this.scheduleStatus();
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Captured once: the narrowing above does not survive into the callbacks
|
|
566
|
+
// below, and a reply without an id is not a reply.
|
|
567
|
+
const replyId = frame.id;
|
|
568
|
+
|
|
569
|
+
if (!REMOTE_ALLOWED_METHODS.has(frame.method)) {
|
|
570
|
+
this.send({
|
|
571
|
+
id: replyId,
|
|
572
|
+
method: "error",
|
|
573
|
+
params: {
|
|
574
|
+
code: -32601,
|
|
575
|
+
message: `Method not available over remote control: ${frame.method}`,
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// `initialize` is whitelisted for exactly one shape: create a session in a
|
|
582
|
+
// chosen directory (and optionally set its permission mode). Its full
|
|
583
|
+
// parameter surface — apiKey / baseURL / model / tools / plugins /
|
|
584
|
+
// mcpServers … — would let the page reshape the machine-side agent, so
|
|
585
|
+
// everything outside the two allowed keys is dropped here rather than
|
|
586
|
+
// trusted to the caller (spec 「Web 端也能发起新会话」场景 5). Re-attach
|
|
587
|
+
// (`restoreSessionId`) is NOT part of the whitelisted shape: the page
|
|
588
|
+
// switches sessions via `getMessages`, not by re-binding the host agent.
|
|
589
|
+
const requestParams =
|
|
590
|
+
frame.method === "initialize"
|
|
591
|
+
? pickInitializeParams(frame.params)
|
|
592
|
+
: frame.params;
|
|
593
|
+
|
|
594
|
+
void this.bridge
|
|
595
|
+
.handleRequest(frame.method, requestParams, frame.sessionId)
|
|
596
|
+
.then((result) => {
|
|
597
|
+
this.sendResult(replyId, frame.method, result);
|
|
598
|
+
this.scheduleStatus();
|
|
599
|
+
})
|
|
600
|
+
.catch((err: unknown) => {
|
|
601
|
+
const { code, message } = describeRpcError(err);
|
|
602
|
+
this.send({
|
|
603
|
+
id: replyId,
|
|
604
|
+
method: "error",
|
|
605
|
+
params: { code, message },
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Answer a request, hammering the answer into the frame budget.
|
|
612
|
+
*
|
|
613
|
+
* A read answer is the one frame whose size follows the session (a page of
|
|
614
|
+
* the message list), so it is the one frame that gets trimmed instead of
|
|
615
|
+
* dropped. A `getMessages` answer can be cut and the phone asked for the
|
|
616
|
+
* tail, so an over-budget one is cut to the **newest** messages that fit — the
|
|
617
|
+
* phone keeps a usable list instead of nothing. Every other over-budget
|
|
618
|
+
* answer is an explicit error: truncating a `getSessionInfo` or a
|
|
619
|
+
* `listSessions` would hand the phone a well-formed lie about which sessions
|
|
620
|
+
* exist, and there is no "newer part" of such an answer to prefer.
|
|
621
|
+
*/
|
|
622
|
+
private sendResult(
|
|
623
|
+
replyId: number | string,
|
|
624
|
+
method: string,
|
|
625
|
+
result: unknown,
|
|
626
|
+
): void {
|
|
627
|
+
const envelope: RelayEnvelope = {
|
|
628
|
+
id: replyId,
|
|
629
|
+
method: "result",
|
|
630
|
+
params: { result },
|
|
631
|
+
};
|
|
632
|
+
const bytes = frameBytes(envelope);
|
|
633
|
+
if (bytes <= REMOTE_MAX_FRAME_BYTES) {
|
|
634
|
+
this.send(envelope);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const page = asMessagePage(result);
|
|
639
|
+
if (page) {
|
|
640
|
+
const kept = trailingCountThatFits(
|
|
641
|
+
page.messages.length,
|
|
642
|
+
(count) =>
|
|
643
|
+
frameBytes(pageFrame(replyId, page, count)) <= REMOTE_MAX_FRAME_BYTES,
|
|
644
|
+
);
|
|
645
|
+
// Not even one message fits: there is nothing to send that the phone
|
|
646
|
+
// could render, and an empty list would only make it re-ask forever.
|
|
647
|
+
if (kept > 0) {
|
|
648
|
+
this.send(pageFrame(replyId, page, kept));
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
this.send({
|
|
654
|
+
id: replyId,
|
|
655
|
+
method: "error",
|
|
656
|
+
params: {
|
|
657
|
+
code: -32603,
|
|
658
|
+
message:
|
|
659
|
+
`${method}: the answer is larger than the remote channel's frame budget` +
|
|
660
|
+
` (${String(bytes)} bytes over the ${String(REMOTE_MAX_FRAME_BYTES)}-byte limit)` +
|
|
661
|
+
` — ask for a smaller page`,
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ── Plumbing ──────────────────────────────────────────────────
|
|
667
|
+
|
|
668
|
+
/** Frames are simply dropped when the link is down; the phone re-syncs. */
|
|
669
|
+
private send(frame: RelayEnvelope): void {
|
|
670
|
+
this.client?.send(frame);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Hand the coalesced notifications over, one frame each — unless a frame is
|
|
675
|
+
* over budget, which for a notification means "the phone must re-read".
|
|
676
|
+
*
|
|
677
|
+
* The relay would drop such a frame anyway and ask for a resync, so saying it
|
|
678
|
+
* ourselves is the same outcome without the round trip — and it is the only
|
|
679
|
+
* way the phone hears about it *before* the drop. Which matters, because the
|
|
680
|
+
* notification most likely to be oversized is a **permission request** (its
|
|
681
|
+
* payload carries the command and the diff): dropping it silently would leave
|
|
682
|
+
* an approval that nobody can answer. So the stand-in is the `full` variant of
|
|
683
|
+
* the list-changed signal, and the page answers it by re-pulling the snapshot
|
|
684
|
+
* — messages *and* pending approvals — rather than only the messages it is
|
|
685
|
+
* missing.
|
|
686
|
+
*/
|
|
687
|
+
private write(frames: RelayFrame[]): void {
|
|
688
|
+
for (const frame of frames) {
|
|
689
|
+
const envelope: RelayEnvelope = {
|
|
690
|
+
method: frame.method,
|
|
691
|
+
params: frame.params,
|
|
692
|
+
sessionId: frame.sessionId,
|
|
693
|
+
};
|
|
694
|
+
if (frameBytes(envelope) > REMOTE_MAX_FRAME_BYTES) {
|
|
695
|
+
this.send({
|
|
696
|
+
method: "messagesChanged",
|
|
697
|
+
params: { full: true },
|
|
698
|
+
sessionId: frame.sessionId,
|
|
699
|
+
});
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
this.send(envelope);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
private scheduleStatus(): void {
|
|
707
|
+
if (this.statusTimer !== undefined) return;
|
|
708
|
+
this.statusTimer = setTimeout(() => {
|
|
709
|
+
this.statusTimer = undefined;
|
|
710
|
+
this.emitStatus(false);
|
|
711
|
+
}, REMOTE_STATUS_DEBOUNCE_MS);
|
|
712
|
+
this.statusTimer.unref?.();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Recompute and push the per-session status signal. Sent only when it
|
|
717
|
+
* actually changed: at 250 ms debounce this would otherwise be a steady
|
|
718
|
+
* trickle of identical frames for the life of a session.
|
|
719
|
+
*
|
|
720
|
+
* Two carriers, because they serve two readers: the JSON-RPC notification is
|
|
721
|
+
* what the phone page renders, and the `host_state` control frame is the only
|
|
722
|
+
* thing the **relay** reads — it has to, because "somebody is waiting on an
|
|
723
|
+
* approval" is the one state worth a Web Push, and deriving that from a
|
|
724
|
+
* business envelope would mean parsing the payloads it promises not to parse.
|
|
725
|
+
*/
|
|
726
|
+
private emitStatus(force: boolean): void {
|
|
727
|
+
let sessions: Array<{ sessionId: string; status: string }>;
|
|
728
|
+
try {
|
|
729
|
+
sessions = this.bridge.listSessionStatuses();
|
|
730
|
+
} catch {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const payload = JSON.stringify(sessions);
|
|
734
|
+
if (!force && payload === this.lastStatusPayload) return;
|
|
735
|
+
this.lastStatusPayload = payload;
|
|
736
|
+
this.send({ method: "sessionStatus", params: { sessions } });
|
|
737
|
+
this.reportHostStates(sessions);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Announce every state the relay has not been told yet, one frame per session
|
|
742
|
+
* (`{type:'host_state', sessionId, state}`). Only changes, because the relay
|
|
743
|
+
* turns a transition *into* `requires_action` into a push — re-announcing an
|
|
744
|
+
* unchanged state would be a push per frame.
|
|
745
|
+
*
|
|
746
|
+
* Sessions that went away are simply forgotten: a state the relay holds for a
|
|
747
|
+
* session nobody can ask about does nothing, while a stale `requires_action`
|
|
748
|
+
* would keep a phone ringing about a session that no longer exists.
|
|
749
|
+
*/
|
|
750
|
+
private reportHostStates(
|
|
751
|
+
sessions: Array<{ sessionId: string; status: string }>,
|
|
752
|
+
): void {
|
|
753
|
+
const seen = new Set<string>();
|
|
754
|
+
for (const session of sessions) {
|
|
755
|
+
seen.add(session.sessionId);
|
|
756
|
+
if (this.lastHostStates.get(session.sessionId) === session.status)
|
|
757
|
+
continue;
|
|
758
|
+
this.lastHostStates.set(session.sessionId, session.status);
|
|
759
|
+
this.client?.sendControl({
|
|
760
|
+
type: "host_state",
|
|
761
|
+
sessionId: session.sessionId,
|
|
762
|
+
state: session.status,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
for (const sessionId of [...this.lastHostStates.keys()]) {
|
|
766
|
+
if (!seen.has(sessionId)) this.lastHostStates.delete(sessionId);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private setStatus(status: RemoteControlStatus): void {
|
|
771
|
+
this.status = status;
|
|
772
|
+
this.options.onStatusChange?.(status);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function describeRpcError(err: unknown): { code: number; message: string } {
|
|
777
|
+
const code =
|
|
778
|
+
err && typeof err === "object" && "code" in err
|
|
779
|
+
? Number((err as { code: unknown }).code)
|
|
780
|
+
: -32603;
|
|
781
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
782
|
+
return { code: Number.isFinite(code) ? code : -32603, message };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** A `getMessages` answer: the one read shape that can be cut. */
|
|
786
|
+
interface MessagePage {
|
|
787
|
+
messages: unknown[];
|
|
788
|
+
/** Whatever else the host put in the answer (`reset` / `contextUsagePercent`). */
|
|
789
|
+
rest: Record<string, unknown>;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Recognise a cuttable answer by its shape rather than by the method that
|
|
794
|
+
* produced it: `getMessages` is the only method that returns `messages` today,
|
|
795
|
+
* and matching on the shape means a future read is covered without a second
|
|
796
|
+
* list of method names to keep in sync.
|
|
797
|
+
*/
|
|
798
|
+
function asMessagePage(result: unknown): MessagePage | undefined {
|
|
799
|
+
if (typeof result !== "object" || result === null) return undefined;
|
|
800
|
+
const { messages, ...rest } = result as { messages?: unknown };
|
|
801
|
+
return Array.isArray(messages)
|
|
802
|
+
? { messages, rest: rest as Record<string, unknown> }
|
|
803
|
+
: undefined;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** The answer, cut to its newest `count` messages. */
|
|
807
|
+
function pageFrame(
|
|
808
|
+
replyId: number | string,
|
|
809
|
+
page: MessagePage,
|
|
810
|
+
count: number,
|
|
811
|
+
): RelayEnvelope {
|
|
812
|
+
return {
|
|
813
|
+
id: replyId,
|
|
814
|
+
method: "result",
|
|
815
|
+
params: {
|
|
816
|
+
result: {
|
|
817
|
+
...page.rest,
|
|
818
|
+
messages: page.messages.slice(-count),
|
|
819
|
+
},
|
|
820
|
+
},
|
|
821
|
+
};
|
|
822
|
+
}
|