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.
Files changed (41) hide show
  1. package/dist/bundle/wave.mjs +713 -842
  2. package/package.json +7 -7
  3. package/src/cli.tsx +14 -22
  4. package/src/components/AgentsManager.tsx +3 -3
  5. package/src/components/App.tsx +5 -0
  6. package/src/components/BtwDisplay.tsx +71 -15
  7. package/src/components/CompactDisplay.tsx +1 -6
  8. package/src/components/ConfirmationDetails.tsx +22 -15
  9. package/src/components/ConfirmationSelector.tsx +31 -36
  10. package/src/components/DiffDisplay.tsx +1 -1
  11. package/src/components/HooksManager.tsx +3 -3
  12. package/src/components/InputBox.tsx +37 -13
  13. package/src/components/LoginCommand.tsx +16 -35
  14. package/src/components/Markdown.tsx +7 -3
  15. package/src/components/MarketplaceAddForm.tsx +15 -19
  16. package/src/components/RemoteControlView.tsx +175 -0
  17. package/src/components/ScrolledContent.tsx +15 -13
  18. package/src/components/SkillsManager.tsx +3 -3
  19. package/src/components/TaskList.tsx +3 -3
  20. package/src/components/ToolDisplay.tsx +3 -18
  21. package/src/components/WorkflowManager.tsx +117 -3
  22. package/src/contexts/useChat.tsx +87 -165
  23. package/src/daemon/commands.ts +18 -18
  24. package/src/hooks/useInputManager.ts +24 -42
  25. package/src/hooks/useLineScroll.ts +51 -5
  26. package/src/hooks/useRemoteControl.ts +23 -1
  27. package/src/managers/inputHandlers.ts +4 -1
  28. package/src/managers/inputReducer.ts +39 -1
  29. package/src/remote/messageWindow.ts +18 -69
  30. package/src/remote/notificationCoalescer.ts +21 -3
  31. package/src/remote/relayClient.ts +48 -26
  32. package/src/remote/remoteControlCliHost.ts +72 -43
  33. package/src/remote/remoteControlConfig.ts +25 -6
  34. package/src/remote/remoteControlService.ts +93 -70
  35. package/src/remote/remoteControlSink.ts +4 -3
  36. package/src/remote/remoteNotifications.ts +8 -2
  37. package/src/stdio/agentBridge.ts +325 -91
  38. package/src/stdio/hostSlashCommands.ts +209 -0
  39. package/src/stdio/uiPayload.ts +65 -0
  40. package/src/types.ts +7 -0
  41. 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 { listSessions } from "wave-agent-sdk";
36
+ import {
37
+ listSessions,
38
+ readRecentWorkdirs,
39
+ rememberRecentWorkdir,
40
+ } from "wave-agent-sdk";
41
+ import {
42
+ hostSlashCommandsFor,
43
+ toSlashCommandInfo,
44
+ } from "../stdio/hostSlashCommands.js";
45
+ import { messagesForUi } from "../stdio/uiPayload.js";
37
46
  import {
38
47
  InvalidMessageWindowError,
39
- sliceMessageWindow,
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
- displayMessages(): Message[];
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
- commands: this.session.slashCommands().map((command) => ({
189
- id: command.id,
190
- name: command.name,
191
- description: command.description,
192
- ...(command.skillSource
193
- ? { skillSource: command.skillSource }
194
- : {}),
195
- })),
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 = firstUserText(this.session.displayMessages());
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
- workingDirectory: string;
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 firstMessage = firstUserText(messages);
303
- const lastActiveAt = messages[messages.length - 1]?.timestamp;
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
- workingDirectory: this.session.workdir(),
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: messages.length,
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
- workingDirectory: string;
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
- workingDirectory: this.session.workdir(),
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
- total?: number;
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 sliced: MessageWindowResult | undefined;
375
+ let normalized: MessageWindow | undefined;
364
376
  try {
365
- sliced = sliceMessageWindow(all, window);
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
- if (!sliced) return { messages: all, contextUsagePercent };
372
- return { ...sliced, contextUsagePercent };
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 storedId = asString(state.hostId);
234
- const storedToken = asString(state.hostToken);
235
- if (storedId && storedToken) {
236
- return { hostId: storedId, hostToken: storedToken };
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 && name.trim().length > 0 ? name.trim() : os.hostname();
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
- * **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
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
- // ── 第三批:设置页(spec ui/remote-control.md「Web 端也能改设置」)──
196
- // 只有「读配置 + 个性化 + 项目设置」这三块页面:用户偏好(生效值 + 每键来源层)、
197
- // 两份 AGENTS.md、项目内置插件开关。插件市场 / MCP / 技能 / 子代理 / 钩子 / 登录
198
- // 那批要的是宿主的**界面**能力,页面不渲染、方法也不放行(见上面注释)。
199
- "getUserSettings",
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?: { name?: string }) => Promise<{
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(generation: number): Promise<void> {
364
- const resolved = await this.options.resolveEndpoint(
365
- this.name !== undefined ? { name: this.name } : {},
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
- message: resolved.error ?? "Remote control is unavailable.",
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, and for a version
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
- * `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
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: 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.
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 into a page. */
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 (`hasMore` / `reset` / `total`). */
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 pageable answer by its shape rather than by the method that
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 paged read is covered without a
770
- * second list of method names to keep in sync.
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
- // Opt-in twice over: this switch says the machine *may* serve its sessions to
68
- // a paired phone, and `wave remote-control on` / `/remote-control` is what
69
- // actually dials out. The sink is therefore built but not connected.
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
- if (params.stage !== "streaming") return params;
79
- const { parameters, ...rest } = params;
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
  },