wave-code 1.4.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 +713 -842
- package/package.json +7 -7
- package/src/cli.tsx +14 -22
- package/src/components/AgentsManager.tsx +3 -3
- package/src/components/App.tsx +5 -0
- package/src/components/BtwDisplay.tsx +71 -15
- package/src/components/CompactDisplay.tsx +1 -6
- package/src/components/ConfirmationDetails.tsx +22 -15
- 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 -13
- package/src/components/LoginCommand.tsx +16 -35
- package/src/components/Markdown.tsx +7 -3
- package/src/components/MarketplaceAddForm.tsx +15 -19
- 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 +117 -3
- package/src/contexts/useChat.tsx +87 -165
- package/src/daemon/commands.ts +18 -18
- package/src/hooks/useInputManager.ts +24 -42
- package/src/hooks/useLineScroll.ts +51 -5
- package/src/hooks/useRemoteControl.ts +23 -1
- package/src/managers/inputHandlers.ts +4 -1
- package/src/managers/inputReducer.ts +39 -1
- package/src/remote/messageWindow.ts +18 -69
- package/src/remote/notificationCoalescer.ts +21 -3
- package/src/remote/relayClient.ts +48 -26
- package/src/remote/remoteControlCliHost.ts +72 -43
- package/src/remote/remoteControlConfig.ts +25 -6
- package/src/remote/remoteControlService.ts +93 -70
- package/src/remote/remoteControlSink.ts +4 -3
- package/src/remote/remoteNotifications.ts +8 -2
- package/src/stdio/agentBridge.ts +325 -91
- package/src/stdio/hostSlashCommands.ts +209 -0
- package/src/stdio/uiPayload.ts +65 -0
- package/src/types.ts +7 -0
- package/src/utils/bracketedPaste.ts +0 -170
|
@@ -33,10 +33,19 @@ import type {
|
|
|
33
33
|
Task,
|
|
34
34
|
ToolPermissionContext,
|
|
35
35
|
} from "wave-agent-sdk";
|
|
36
|
-
import {
|
|
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";
|
|
37
46
|
import {
|
|
38
47
|
InvalidMessageWindowError,
|
|
39
|
-
|
|
48
|
+
normalizeMessageWindow,
|
|
40
49
|
type MessageWindow,
|
|
41
50
|
type MessageWindowResult,
|
|
42
51
|
} from "./messageWindow.js";
|
|
@@ -53,8 +62,20 @@ import {
|
|
|
53
62
|
export interface RemoteControlCliSession {
|
|
54
63
|
/** Empty until the first turn mints one — there is nothing to serve yet. */
|
|
55
64
|
sessionId(): string;
|
|
65
|
+
/** Where the shell is now (drifts on `cd`) — used for live directory reads. */
|
|
56
66
|
workdir(): string;
|
|
57
|
-
|
|
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
|
+
};
|
|
58
79
|
contextUsagePercent(): number;
|
|
59
80
|
latestTotalTokens(): number;
|
|
60
81
|
permissionMode(): PermissionMode;
|
|
@@ -123,18 +144,6 @@ function hostUnsupported(method: string): Error {
|
|
|
123
144
|
return err;
|
|
124
145
|
}
|
|
125
146
|
|
|
126
|
-
function firstUserText(messages: Message[]): string | undefined {
|
|
127
|
-
const user = messages.find((message) => message.role === "user");
|
|
128
|
-
if (!user) return undefined;
|
|
129
|
-
for (const block of user.blocks) {
|
|
130
|
-
if (block.type === "text" && typeof block.content === "string") {
|
|
131
|
-
const text = block.content.trim();
|
|
132
|
-
if (text) return text;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
return undefined;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
147
|
export class RemoteControlCliHost implements RemoteControlHost {
|
|
139
148
|
constructor(private readonly session: RemoteControlCliSession) {}
|
|
140
149
|
|
|
@@ -149,6 +158,14 @@ export class RemoteControlCliHost implements RemoteControlHost {
|
|
|
149
158
|
return this.getSessionInfo();
|
|
150
159
|
case "getMessages":
|
|
151
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;
|
|
152
169
|
case "listPendingPermissions":
|
|
153
170
|
return this.listPendingPermissions();
|
|
154
171
|
case "sendMessage":
|
|
@@ -184,15 +201,14 @@ export class RemoteControlCliHost implements RemoteControlHost {
|
|
|
184
201
|
// Narrowed to what the page renders, like the desktop host does: the
|
|
185
202
|
// SDK's SlashCommand carries a `handler` function, which has no
|
|
186
203
|
// business travelling over a relay (and would be dropped silently,
|
|
187
|
-
// leaving the frame's shape to chance).
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
...(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
})),
|
|
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
|
+
],
|
|
196
212
|
};
|
|
197
213
|
default:
|
|
198
214
|
// The white list already rejected everything outside it, so anything
|
|
@@ -255,7 +271,7 @@ export class RemoteControlCliHost implements RemoteControlHost {
|
|
|
255
271
|
|
|
256
272
|
private synthesizedMetadata(sessionId: string): SessionMetadata {
|
|
257
273
|
const now = new Date();
|
|
258
|
-
const firstMessage =
|
|
274
|
+
const firstMessage = this.session.threadSummary().firstUserText;
|
|
259
275
|
const customTitle = this.session.customTitle();
|
|
260
276
|
return {
|
|
261
277
|
id: sessionId,
|
|
@@ -282,7 +298,7 @@ export class RemoteControlCliHost implements RemoteControlHost {
|
|
|
282
298
|
private listDaemonSessions(): {
|
|
283
299
|
sessions: Array<{
|
|
284
300
|
sessionId: string;
|
|
285
|
-
|
|
301
|
+
sessionCwd: string;
|
|
286
302
|
isLoading: boolean;
|
|
287
303
|
hasRunningBackgroundWork: boolean;
|
|
288
304
|
hasPendingMessages: boolean;
|
|
@@ -297,19 +313,19 @@ export class RemoteControlCliHost implements RemoteControlHost {
|
|
|
297
313
|
} {
|
|
298
314
|
const sessionId = this.session.sessionId();
|
|
299
315
|
if (!sessionId) return { sessions: [] };
|
|
300
|
-
const messages = this.session.displayMessages();
|
|
301
316
|
const customTitle = this.session.customTitle();
|
|
302
|
-
const
|
|
303
|
-
const
|
|
317
|
+
const summary = this.session.threadSummary();
|
|
318
|
+
const firstMessage = summary.firstUserText;
|
|
319
|
+
const lastActiveAt = summary.lastActiveAt;
|
|
304
320
|
return {
|
|
305
321
|
sessions: [
|
|
306
322
|
{
|
|
307
323
|
sessionId,
|
|
308
|
-
|
|
324
|
+
sessionCwd: this.session.sessionCwd(),
|
|
309
325
|
isLoading: this.session.isLoading(),
|
|
310
326
|
hasRunningBackgroundWork: this.session.hasRunningBackgroundWork(),
|
|
311
327
|
hasPendingMessages: this.session.hasPendingMessages(),
|
|
312
|
-
messageCount:
|
|
328
|
+
messageCount: summary.messageCount,
|
|
313
329
|
...(customTitle ? { customTitle } : {}),
|
|
314
330
|
...(firstMessage ? { firstMessage } : {}),
|
|
315
331
|
...(lastActiveAt ? { lastActiveAt } : {}),
|
|
@@ -320,7 +336,7 @@ export class RemoteControlCliHost implements RemoteControlHost {
|
|
|
320
336
|
|
|
321
337
|
private getSessionInfo(): {
|
|
322
338
|
sessionId: string;
|
|
323
|
-
|
|
339
|
+
sessionCwd: string;
|
|
324
340
|
latestTotalTokens: number;
|
|
325
341
|
permissionMode: PermissionMode;
|
|
326
342
|
availableTools: string[];
|
|
@@ -332,7 +348,7 @@ export class RemoteControlCliHost implements RemoteControlHost {
|
|
|
332
348
|
} {
|
|
333
349
|
return {
|
|
334
350
|
sessionId: this.session.sessionId(),
|
|
335
|
-
|
|
351
|
+
sessionCwd: this.session.sessionCwd(),
|
|
336
352
|
latestTotalTokens: this.session.latestTotalTokens(),
|
|
337
353
|
permissionMode: this.session.permissionMode(),
|
|
338
354
|
availableTools: this.session.availableTools(),
|
|
@@ -346,30 +362,43 @@ export class RemoteControlCliHost implements RemoteControlHost {
|
|
|
346
362
|
};
|
|
347
363
|
}
|
|
348
364
|
|
|
349
|
-
private getMessages(p: Record<string, unknown>): {
|
|
365
|
+
private async getMessages(p: Record<string, unknown>): Promise<{
|
|
350
366
|
messages: Message[];
|
|
351
367
|
contextUsagePercent: number;
|
|
352
|
-
hasMore?: boolean;
|
|
353
368
|
reset?: boolean;
|
|
354
|
-
|
|
355
|
-
} {
|
|
356
|
-
const all = this.session.displayMessages();
|
|
369
|
+
}> {
|
|
357
370
|
const contextUsagePercent = this.session.contextUsagePercent();
|
|
358
371
|
const window: MessageWindow = {};
|
|
359
|
-
if (typeof p.before === "string") window.before = p.before;
|
|
360
372
|
if (typeof p.since === "string") window.since = p.since;
|
|
361
373
|
if (p.limit !== undefined) window.limit = Number(p.limit);
|
|
362
374
|
|
|
363
|
-
let
|
|
375
|
+
let normalized: MessageWindow | undefined;
|
|
364
376
|
try {
|
|
365
|
-
|
|
377
|
+
normalized = normalizeMessageWindow(window);
|
|
366
378
|
} catch (err) {
|
|
367
379
|
if (err instanceof InvalidMessageWindowError)
|
|
368
380
|
throw invalidParams(err.message);
|
|
369
381
|
throw err;
|
|
370
382
|
}
|
|
371
|
-
|
|
372
|
-
|
|
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
|
+
};
|
|
373
402
|
}
|
|
374
403
|
|
|
375
404
|
private listPendingPermissions(): {
|
|
@@ -225,18 +225,33 @@ export async function registerRelayHost(options: {
|
|
|
225
225
|
* fresh registration. Never registers twice for the same machine — and, with the
|
|
226
226
|
* derived id, no longer registers a second row for it either when the state file
|
|
227
227
|
* went missing.
|
|
228
|
+
*
|
|
229
|
+
* `refresh` is the one exception, and it has one caller: the relay answered
|
|
230
|
+
* `4003` with a stale-identity reason, so the stored pair is known to be dead
|
|
231
|
+
* (revoked row / expired token / a file whose id and token never belonged
|
|
232
|
+
* together). Dropping it *before* registering is deliberate — if the
|
|
233
|
+
* registration then fails (an expired sign-in is the common case), the file must
|
|
234
|
+
* not keep pointing at an identity the relay has already refused, or the next
|
|
235
|
+
* attempt would hand the same dead token over and the user would be back to
|
|
236
|
+
* moving that file by hand.
|
|
228
237
|
*/
|
|
229
238
|
export async function ensureRelayHostRegistration(
|
|
230
239
|
name?: string,
|
|
240
|
+
options: { refresh?: boolean } = {},
|
|
231
241
|
): Promise<RelayHostRegistration> {
|
|
232
242
|
const state = readState();
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
243
|
+
const storedName = asString(state.name);
|
|
244
|
+
if (!options.refresh) {
|
|
245
|
+
const storedId = asString(state.hostId);
|
|
246
|
+
const storedToken = asString(state.hostToken);
|
|
247
|
+
if (storedId && storedToken) {
|
|
248
|
+
return { hostId: storedId, hostToken: storedToken };
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
writeState(storedName ? { name: storedName } : {});
|
|
237
252
|
}
|
|
238
253
|
|
|
239
|
-
const label = name
|
|
254
|
+
const label = name?.trim() || storedName || os.hostname();
|
|
240
255
|
const registration = await registerRelayHost({
|
|
241
256
|
name: label,
|
|
242
257
|
id: deriveHostId(),
|
|
@@ -294,6 +309,8 @@ export function buildPageUrl(hostId: string): string | undefined {
|
|
|
294
309
|
export async function resolveRemoteControlEndpoint(
|
|
295
310
|
options: {
|
|
296
311
|
name?: string;
|
|
312
|
+
/** Register again instead of trusting the stored pair (see above). */
|
|
313
|
+
refreshIdentity?: boolean;
|
|
297
314
|
} = {},
|
|
298
315
|
): Promise<{
|
|
299
316
|
endpoint?: RemoteControlEndpoint;
|
|
@@ -302,7 +319,9 @@ export async function resolveRemoteControlEndpoint(
|
|
|
302
319
|
}> {
|
|
303
320
|
let registration: RelayHostRegistration;
|
|
304
321
|
try {
|
|
305
|
-
registration = await ensureRelayHostRegistration(options.name
|
|
322
|
+
registration = await ensureRelayHostRegistration(options.name, {
|
|
323
|
+
refresh: options.refreshIdentity,
|
|
324
|
+
});
|
|
306
325
|
} catch (err) {
|
|
307
326
|
return { error: err instanceof Error ? err.message : String(err) };
|
|
308
327
|
}
|
|
@@ -95,38 +95,9 @@ import type { RemoteControlEndpoint } from "./remoteControlConfig.js";
|
|
|
95
95
|
* the write fails), so switching on the phone switches it for every session on
|
|
96
96
|
* this machine. The page says so in its confirmation.
|
|
97
97
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
|
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
|
|
130
101
|
* (`listAllSessions` / `restoreSession`), the process-lifecycle calls
|
|
131
102
|
* (`shutdown` / `bang`), and the **read-only panes the page has no
|
|
132
103
|
* surface for** — `getWorkspaceDiff` / `getWorktreeChanges` / `getPlanFile` /
|
|
@@ -192,16 +163,11 @@ export const REMOTE_ALLOWED_METHODS: ReadonlySet<string> = new Set([
|
|
|
192
163
|
"searchFiles",
|
|
193
164
|
"getPromptHistory",
|
|
194
165
|
"searchPromptHistory",
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
"
|
|
200
|
-
"updateUserSettings",
|
|
201
|
-
"getAgentsContent",
|
|
202
|
-
"setAgentsContent",
|
|
203
|
-
"getProjectSettings",
|
|
204
|
-
"setBuiltinPluginEnabled",
|
|
166
|
+
// `/btw` 旁路提问(spec「Web 页也能用 /btw 旁路提问」):单轮 fork、不执行工具、
|
|
167
|
+
// 不进对话历史 —— 它作用于当前会话、既不需要编辑器也不需要文件系统,与第二批
|
|
168
|
+
// 那条「页面能不能承载」同一条判据。**只放行请求**:流式增量是宿主推的
|
|
169
|
+
// `btwContent` 通知,走既有的通知扇出,白名单管不到也不需要管。
|
|
170
|
+
"askBtw",
|
|
205
171
|
// ── 第四批:会话删除(spec「手机端的会话列表与删除」)──
|
|
206
172
|
// 写:`destroy` 带上可选 `removeWorktree`(默认 false)。工作树由 daemon 从该
|
|
207
173
|
// 会话的 createdWorkdir 定位、并在 agent.destroy() 之后删除,页面给不出路径。
|
|
@@ -211,6 +177,13 @@ export const REMOTE_ALLOWED_METHODS: ReadonlySet<string> = new Set([
|
|
|
211
177
|
// 「能指名任意路径」不是同一个能力。
|
|
212
178
|
"destroy",
|
|
213
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",
|
|
214
187
|
]);
|
|
215
188
|
|
|
216
189
|
/**
|
|
@@ -313,7 +286,10 @@ export interface RemoteControlServiceOptions {
|
|
|
313
286
|
* this is also where the one registration call happens, which is why it is
|
|
314
287
|
* async.
|
|
315
288
|
*/
|
|
316
|
-
resolveEndpoint: (options?: {
|
|
289
|
+
resolveEndpoint: (options?: {
|
|
290
|
+
name?: string;
|
|
291
|
+
refreshIdentity?: boolean;
|
|
292
|
+
}) => Promise<{
|
|
317
293
|
endpoint?: RemoteControlEndpoint;
|
|
318
294
|
pageUrl?: string;
|
|
319
295
|
error?: string;
|
|
@@ -341,6 +317,17 @@ export class RemoteControlService implements RemoteControlController {
|
|
|
341
317
|
* connection the user has already turned off.
|
|
342
318
|
*/
|
|
343
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;
|
|
344
331
|
|
|
345
332
|
constructor(
|
|
346
333
|
private readonly bridge: RemoteControlHost,
|
|
@@ -359,17 +346,30 @@ export class RemoteControlService implements RemoteControlController {
|
|
|
359
346
|
* Resolve the identity (registering this machine on first use), then dial.
|
|
360
347
|
* Every await is followed by a generation check, so a `stop` that arrives
|
|
361
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.
|
|
362
352
|
*/
|
|
363
|
-
private async setup(
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
+
});
|
|
367
361
|
if (generation !== this.generation) return;
|
|
368
362
|
|
|
369
363
|
if (!resolved.endpoint) {
|
|
364
|
+
const reason = resolved.error ?? "Remote control is unavailable.";
|
|
370
365
|
this.setStatus({
|
|
371
366
|
state: "error",
|
|
372
|
-
|
|
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
373
|
});
|
|
374
374
|
return;
|
|
375
375
|
}
|
|
@@ -411,14 +411,10 @@ export class RemoteControlService implements RemoteControlController {
|
|
|
411
411
|
},
|
|
412
412
|
onAuthError: (info) => {
|
|
413
413
|
// The close that follows carries the mechanics (4003 = do not retry);
|
|
414
|
-
// the frame is the only place the *reason* lives
|
|
415
|
-
// floor the only place the required version lives.
|
|
414
|
+
// the frame is the only place the *reason* lives.
|
|
416
415
|
this.setStatus({
|
|
417
416
|
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}`,
|
|
417
|
+
message: `the relay refused this machine: ${info.error}`,
|
|
422
418
|
});
|
|
423
419
|
},
|
|
424
420
|
onPeerDisconnected: () => {
|
|
@@ -436,6 +432,10 @@ export class RemoteControlService implements RemoteControlController {
|
|
|
436
432
|
this.emitStatus(true);
|
|
437
433
|
},
|
|
438
434
|
onFrame: (frame) => this.handleFrame(frame),
|
|
435
|
+
onStaleIdentity: (info) => {
|
|
436
|
+
if (generation !== this.generation) return;
|
|
437
|
+
this.refreshIdentity(info.error);
|
|
438
|
+
},
|
|
439
439
|
onFatal: (message) => {
|
|
440
440
|
if (generation !== this.generation) return;
|
|
441
441
|
this.setStatus({ state: "error", message });
|
|
@@ -444,6 +444,34 @@ export class RemoteControlService implements RemoteControlController {
|
|
|
444
444
|
this.client.start();
|
|
445
445
|
}
|
|
446
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
|
+
|
|
447
475
|
stop(): void {
|
|
448
476
|
// Ordered before the teardown so a registration still in flight cannot
|
|
449
477
|
// finish and dial out afterwards.
|
|
@@ -583,11 +611,10 @@ export class RemoteControlService implements RemoteControlController {
|
|
|
583
611
|
* Answer a request, hammering the answer into the frame budget.
|
|
584
612
|
*
|
|
585
613
|
* A read answer is the one frame whose size follows the session (a page of
|
|
586
|
-
*
|
|
587
|
-
* dropped. `getMessages`
|
|
588
|
-
* tail, so an over-budget
|
|
589
|
-
*
|
|
590
|
-
* walking further back is a smaller `limit` away. Every other over-budget
|
|
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
|
|
591
618
|
* answer is an explicit error: truncating a `getSessionInfo` or a
|
|
592
619
|
* `listSessions` would hand the phone a well-formed lie about which sessions
|
|
593
620
|
* exist, and there is no "newer part" of such an answer to prefer.
|
|
@@ -615,9 +642,8 @@ export class RemoteControlService implements RemoteControlController {
|
|
|
615
642
|
(count) =>
|
|
616
643
|
frameBytes(pageFrame(replyId, page, count)) <= REMOTE_MAX_FRAME_BYTES,
|
|
617
644
|
);
|
|
618
|
-
// Not even one message fits:
|
|
619
|
-
//
|
|
620
|
-
// only make it ask again forever.
|
|
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.
|
|
621
647
|
if (kept > 0) {
|
|
622
648
|
this.send(pageFrame(replyId, page, kept));
|
|
623
649
|
return;
|
|
@@ -756,18 +782,18 @@ function describeRpcError(err: unknown): { code: number; message: string } {
|
|
|
756
782
|
return { code: Number.isFinite(code) ? code : -32603, message };
|
|
757
783
|
}
|
|
758
784
|
|
|
759
|
-
/** A `getMessages` answer: the one read shape that can be cut
|
|
785
|
+
/** A `getMessages` answer: the one read shape that can be cut. */
|
|
760
786
|
interface MessagePage {
|
|
761
787
|
messages: unknown[];
|
|
762
|
-
/** Whatever else the host put in the answer (`
|
|
788
|
+
/** Whatever else the host put in the answer (`reset` / `contextUsagePercent`). */
|
|
763
789
|
rest: Record<string, unknown>;
|
|
764
790
|
}
|
|
765
791
|
|
|
766
792
|
/**
|
|
767
|
-
* Recognise a
|
|
793
|
+
* Recognise a cuttable answer by its shape rather than by the method that
|
|
768
794
|
* produced it: `getMessages` is the only method that returns `messages` today,
|
|
769
|
-
* and matching on the shape means a future
|
|
770
|
-
*
|
|
795
|
+
* and matching on the shape means a future read is covered without a second
|
|
796
|
+
* list of method names to keep in sync.
|
|
771
797
|
*/
|
|
772
798
|
function asMessagePage(result: unknown): MessagePage | undefined {
|
|
773
799
|
if (typeof result !== "object" || result === null) return undefined;
|
|
@@ -790,9 +816,6 @@ function pageFrame(
|
|
|
790
816
|
result: {
|
|
791
817
|
...page.rest,
|
|
792
818
|
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
819
|
},
|
|
797
820
|
},
|
|
798
821
|
};
|
|
@@ -64,9 +64,10 @@ export interface RemoteControlSink
|
|
|
64
64
|
export function createRemoteControlSinkFactory(
|
|
65
65
|
workdir: string,
|
|
66
66
|
): ((host: RemoteControlHost) => RemoteControlSink) | undefined {
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
67
|
+
// Two-step by design: this switch (on unless a settings file or a remote
|
|
68
|
+
// push turns it off) says the machine *may* serve its sessions to a paired
|
|
69
|
+
// phone, and `wave remote-control on` / `/remote-control` is what actually
|
|
70
|
+
// dials out. The sink is therefore built but not connected.
|
|
70
71
|
if (!isRemoteControlEnabled(workdir)) {
|
|
71
72
|
return undefined;
|
|
72
73
|
}
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
ToolBlockUpdateCallbackParams,
|
|
28
28
|
ToolPermissionContext,
|
|
29
29
|
} from "wave-agent-sdk";
|
|
30
|
+
import { toolBlockUpdateForUi } from "../stdio/uiPayload.js";
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* A content delta exactly as the SDK callback hands it over — derived rather
|
|
@@ -73,10 +74,15 @@ export const remoteWire = {
|
|
|
73
74
|
* `running` (one-time snapshots) and `end` (the authoritative full value) keep
|
|
74
75
|
* `parameters`. The coalescer's merge rule is keyed on exactly this shape, so
|
|
75
76
|
* this pick is what makes the 150 ms window behave the same in both hosts.
|
|
77
|
+
*
|
|
78
|
+
* `images` is cut on every stage: tool pictures are base64 that no UI renders
|
|
79
|
+
* (specs/ui/stdio-transport.md「工具图片的字节不下发 UI」), and one of them can
|
|
80
|
+
* be larger than a remote frame may be.
|
|
76
81
|
*/
|
|
77
82
|
toolBlockUpdated: (params: ToolBlockUpdateCallbackParams) => {
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
const trimmed = toolBlockUpdateForUi(params);
|
|
84
|
+
if (trimmed.stage !== "streaming") return trimmed;
|
|
85
|
+
const { parameters, ...rest } = trimmed;
|
|
80
86
|
void parameters;
|
|
81
87
|
return rest;
|
|
82
88
|
},
|