wave-code 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/bundle/wave.mjs +1738 -893
  2. package/package.json +6 -3
  3. package/src/cli.tsx +9 -0
  4. package/src/commands/plugin/install.ts +6 -7
  5. package/src/commands/plugin/uninstall.ts +11 -5
  6. package/src/commands/update.ts +62 -22
  7. package/src/components/BackgroundTaskManager.tsx +76 -54
  8. package/src/components/ChatInterface.tsx +1 -0
  9. package/src/components/ConfirmationDetails.tsx +51 -16
  10. package/src/components/ConfirmationSelector.tsx +39 -0
  11. package/src/components/InputBox.tsx +41 -2
  12. package/src/components/Markdown.tsx +21 -8
  13. package/src/components/MarketplaceDetail.tsx +10 -27
  14. package/src/components/MarketplaceView.tsx +17 -3
  15. package/src/components/MessageBlockItem.tsx +0 -6
  16. package/src/components/Notifications.tsx +7 -0
  17. package/src/components/PlanView.tsx +22 -17
  18. package/src/components/PluginManagerShell.tsx +3 -3
  19. package/src/components/PluginManagerTypes.ts +2 -1
  20. package/src/components/ResumeCommand.tsx +84 -0
  21. package/src/components/ScrolledContent.tsx +71 -0
  22. package/src/components/SessionSelector.tsx +184 -6
  23. package/src/components/SessionSelectorFlow.tsx +246 -0
  24. package/src/components/WorkflowManager.tsx +38 -138
  25. package/src/components/WorkflowRunDetail.tsx +193 -0
  26. package/src/constants/commands.ts +24 -0
  27. package/src/contexts/useChat.tsx +517 -155
  28. package/src/daemon/commands.ts +582 -50
  29. package/src/daemon-cli.ts +12 -1
  30. package/src/hooks/useInputManager.ts +20 -0
  31. package/src/hooks/usePluginManager.ts +104 -78
  32. package/src/hooks/useRemoteControl.ts +324 -0
  33. package/src/index.ts +97 -8
  34. package/src/managers/inputHandlers.ts +16 -9
  35. package/src/managers/inputReducer.ts +30 -9
  36. package/src/print-cli.ts +5 -0
  37. package/src/reducers/confirmationReducer.ts +20 -1
  38. package/src/reducers/selectorReducer.ts +3 -0
  39. package/src/reducers/workflowManagerReducer.ts +21 -2
  40. package/src/remote/frameBudget.ts +45 -0
  41. package/src/remote/hostIdentity.ts +128 -0
  42. package/src/remote/messageWindow.ts +97 -0
  43. package/src/remote/notificationCoalescer.ts +174 -0
  44. package/src/remote/relayClient.ts +668 -0
  45. package/src/remote/remoteControlCliHost.ts +391 -0
  46. package/src/remote/remoteControlCommands.ts +197 -0
  47. package/src/remote/remoteControlConfig.ts +334 -0
  48. package/src/remote/remoteControlService.ts +799 -0
  49. package/src/remote/remoteControlSink.ts +77 -0
  50. package/src/remote/remoteNotifications.ts +194 -0
  51. package/src/session-selector-cli.tsx +9 -255
  52. package/src/stdio/agentBridge.ts +1001 -165
  53. package/src/stdio/daemonServer.ts +30 -0
  54. package/src/stdio/jsonRpcConnection.ts +11 -2
  55. package/src/stdio/protocol.ts +14 -106
  56. package/src/stdio/stdioServer.ts +33 -2
  57. package/src/stdio-cli.ts +16 -1
  58. package/src/utils/gitCredentialHelper.ts +106 -0
  59. package/src/utils/logger.ts +5 -2
  60. package/src/utils/rewindCheckpoints.ts +2 -4
  61. package/src/utils/sessionOwnership.ts +62 -0
  62. package/src/utils/workspaceDiff.ts +620 -0
  63. package/src/utils/worktree.ts +97 -0
  64. package/src/components/PlanDisplay.tsx +0 -18
@@ -36,6 +36,8 @@ import {
36
36
  type PartialHookConfiguration,
37
37
  isValidHookEvent,
38
38
  listSessions,
39
+ listAllSessions,
40
+ setSessionCustomTitle,
39
41
  searchFiles,
40
42
  generateRandomName,
41
43
  getDefaultRemoteBranch,
@@ -49,30 +51,61 @@ import {
49
51
  loadWaveConfigFromFile,
50
52
  getUserConfigPaths,
51
53
  getProjectConfigPaths,
54
+ readUserPreferenceView,
55
+ updateUserPreferenceSettings,
56
+ type UserPreferenceSettings,
57
+ type UserPreferenceSettingsView,
52
58
  type SubagentConfiguration,
53
59
  type SkillMetadata,
60
+ type PluginReloadResult,
61
+ type RemoteControlSnapshot,
62
+ normalizeEntrypoint,
54
63
  } from "wave-agent-sdk";
55
64
  import {
65
+ type ClientNotificationMethod,
56
66
  type JsonRpcError,
67
+ type RequestMethod,
68
+ type ServerNotificationMethod,
57
69
  INVALID_PARAMS as PROTOCOL_INVALID_PARAMS,
58
70
  INTERNAL_ERROR as PROTOCOL_INTERNAL_ERROR,
59
71
  METHOD_NOT_FOUND as PROTOCOL_METHOD_NOT_FOUND,
60
72
  } from "./protocol.js";
61
73
  import { execFileSync } from "node:child_process";
62
- import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
74
+ import {
75
+ mkdirSync,
76
+ existsSync,
77
+ readFileSync,
78
+ writeFileSync,
79
+ readdirSync,
80
+ realpathSync,
81
+ } from "node:fs";
63
82
  import { mkdir, open, readFile, writeFile } from "node:fs/promises";
64
83
  import { homedir, tmpdir } from "node:os";
65
84
  import { basename, dirname, extname, join } from "node:path";
66
85
  import {
67
86
  createWorktree,
87
+ deriveWorktreeInfo,
68
88
  getWorktreeChanges,
69
89
  removeWorktree,
70
90
  } from "../utils/worktree.js";
71
91
  import { logger } from "../utils/logger.js";
92
+ import {
93
+ InvalidMessageWindowError,
94
+ sliceMessageWindow,
95
+ type MessageWindowResult,
96
+ } from "../remote/messageWindow.js";
97
+ import {
98
+ percentOf,
99
+ remoteWire,
100
+ toBackgroundTaskSummaries,
101
+ } from "../remote/remoteNotifications.js";
72
102
  import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
103
+ import { getWorkspaceDiff } from "../utils/workspaceDiff.js";
104
+ import { installGitCredentialHelper } from "../utils/gitCredentialHelper.js";
105
+ import type { RemoteControlController } from "../remote/remoteControlService.js";
73
106
 
74
107
  export type NotificationEmitter = (
75
- method: string,
108
+ method: ServerNotificationMethod,
76
109
  params: unknown,
77
110
  sessionId?: string,
78
111
  ) => void;
@@ -84,6 +117,17 @@ export interface AgentBridgeOptions {
84
117
  * down its socket and exits. stdio mode leaves it unset, so `shutdown` is
85
118
  * "Method not found" there. */
86
119
  onShutdownRequest?: () => void;
120
+ /** Daemon transport only. Remote control is a daemon-level concern (it owns
121
+ * an outbound socket and a relay session); the bridge owns nothing here and
122
+ * only routes the three `remoteControl*` RPCs to it, exactly as it routes
123
+ * `shutdown` to `onShutdownRequest`. Unset ⇒ those RPCs report that remote
124
+ * control is not enabled, which is the state on every stdio-mode process.
125
+ *
126
+ * Resolved on demand rather than passed as an object: the controller needs
127
+ * this bridge to route the phone's requests back in, so the two are built in
128
+ * a circle. Reading it when an RPC arrives — long after both exist — keeps
129
+ * that circle from having to be broken with a placeholder. */
130
+ remoteControl?: () => RemoteControlController | undefined;
87
131
  }
88
132
 
89
133
  interface InitializeParams {
@@ -95,7 +139,6 @@ interface InitializeParams {
95
139
  defaultHeaders?: Record<string, string>;
96
140
  model?: string;
97
141
  fastModel?: string;
98
- language?: string;
99
142
  permissionMode?: PermissionMode;
100
143
  tools?: string[];
101
144
  allowedTools?: string[];
@@ -104,11 +147,18 @@ interface InitializeParams {
104
147
  mcpServers?: Record<string, McpServerConfig>;
105
148
  worktreeName?: string;
106
149
  isNewWorktree?: boolean;
107
- /** Settings-page auto-memory toggle/frequency (session-level override). */
108
- autoMemoryEnabled?: boolean;
109
- autoMemoryFrequency?: number;
150
+ /**
151
+ * Which host the session belongs to → the `app.entrypoint` attribute. A raw
152
+ * string: it arrives over the wire, from hosts this process does not compile
153
+ * against (the JetBrains plugin builds its own frames). Narrowed to the known
154
+ * table below before it reaches the SDK.
155
+ */
156
+ entrypoint?: string;
110
157
  }
111
158
 
159
+ // 会话级覆盖项:用户偏好(语言 / 上下文长度 / 自动记忆开关与频率)不在此层——
160
+ // 它们落用户级 ~/.wave/settings.json 经实时重载生效(getUserSettings /
161
+ // updateUserSettings),走覆盖层会永久遮蔽 settings.json 的实时值。
112
162
  interface UpdateConfigParams {
113
163
  apiKey?: string;
114
164
  baseURL?: string;
@@ -116,10 +166,6 @@ interface UpdateConfigParams {
116
166
  defaultHeaders?: Record<string, string>;
117
167
  model?: string;
118
168
  fastModel?: string;
119
- language?: string;
120
- /** Settings-page auto-memory toggle/frequency (session-level override). */
121
- autoMemoryEnabled?: boolean;
122
- autoMemoryFrequency?: number;
123
169
  }
124
170
 
125
171
  interface SearchFilesParams {
@@ -141,6 +187,24 @@ interface SessionEntry {
141
187
  * header when the session is re-attached from disk after a daemon restart.
142
188
  */
143
189
  createdWorkdir: string;
190
+ /**
191
+ * True while `updateConfig` is tearing the session's agent down and rebuilding
192
+ * it from the updated config — the entry stays in the registry (so the session
193
+ * does not look deleted to a `wait` that is blocked on it) but has no usable
194
+ * agent. Two consequences, both deliberate:
195
+ *
196
+ * - `listDaemonSessions` reports it as `isLoading: true`. The teardown aborts
197
+ * the in-flight turn, so the outgoing agent reads `isLoading: false` while the
198
+ * rebuild is still running; reporting that would let a `wait` print an idle
199
+ * snapshot for a session whose agent is mid-replacement. A waiter that keeps
200
+ * waiting is never wrong here — the rebuild is bounded, and if it fails the
201
+ * entry is dropped, so the waiter then gets a clear "no longer exists".
202
+ * - State-changing RPCs refuse it (`requireSession`); a message sent to the
203
+ * outgoing agent would be silently lost. Read-only RPCs still work
204
+ * (`requireSessionForRead`) and see the outgoing agent's data, which is this
205
+ * same session's transcript.
206
+ */
207
+ transitioning?: boolean;
144
208
  }
145
209
 
146
210
  /**
@@ -169,14 +233,26 @@ export class AgentBridge {
169
233
  }
170
234
  >();
171
235
  private permissionCounter = 0;
236
+ /**
237
+ * In-flight completion calls, keyed by envelope sessionId. Completion answers
238
+ * a keystroke pause, so a newer request makes the previous one worthless —
239
+ * superseding it here is what keeps "one in-flight request per editor" true
240
+ * even when the host cannot cancel (spec
241
+ * docs/specs/ui/code-completion.md: 同一时刻只允许一个补全请求在途).
242
+ */
243
+ private inFlightCompletions = new Map<string, AbortController>();
172
244
  private emit: NotificationEmitter;
173
245
  private onShutdownRequest: (() => void) | undefined;
246
+ private remoteControl:
247
+ | (() => RemoteControlController | undefined)
248
+ | undefined;
174
249
  private pluginCore: PluginCore | undefined;
175
250
  private pluginCoreWorkdir: string | undefined;
176
251
 
177
252
  constructor(options: AgentBridgeOptions) {
178
253
  this.emit = options.emit;
179
254
  this.onShutdownRequest = options.onShutdownRequest;
255
+ this.remoteControl = options.remoteControl;
180
256
  // Mirror the user-level settings env WAVE_SERVER_URL into process.env
181
257
  // before any agent initializes. getAuthStatus (webviewReady →
182
258
  // pushInitialState) can run before the first agent, and AuthService falls
@@ -191,7 +267,7 @@ export class AgentBridge {
191
267
  // ── Public API ────────────────────────────────────────────────
192
268
 
193
269
  async handleRequest(
194
- method: string,
270
+ method: RequestMethod,
195
271
  params: unknown,
196
272
  sessionId?: string,
197
273
  ): Promise<unknown> {
@@ -201,11 +277,28 @@ export class AgentBridge {
201
277
  case "initialize":
202
278
  return this.initialize(p as unknown as InitializeParams);
203
279
  case "destroy":
204
- return this.destroy(sessionId);
280
+ return this.destroy(sessionId, {
281
+ removeWorktree: p.removeWorktree === true,
282
+ });
205
283
  case "restoreSession":
206
284
  return this.restoreSession(p.sessionId as string, sessionId);
207
285
  case "listSessions":
208
286
  return this.listSessions(p.workdir as string | undefined, sessionId);
287
+ case "listAllSessions":
288
+ // Every project on this host (no session required) — the desktop's
289
+ // /resume picker lists sessions the app never created, so it cannot use
290
+ // the per-workdir listSessions.
291
+ return this.listAllSessions();
292
+ case "renameSession":
293
+ // Host-level like listAllSessions: the target session may have no live
294
+ // agent (the desktop renames sessions straight from its sidebar, and a
295
+ // restored one is not running), so the id/workdir come from the caller
296
+ // instead of the bound session.
297
+ return this.renameSession(
298
+ p.sessionId as string,
299
+ p.workdir as string,
300
+ p.title as string,
301
+ );
209
302
  case "getSessionInfo":
210
303
  return this.getSessionInfo(sessionId);
211
304
  case "listPendingPermissions":
@@ -233,6 +326,15 @@ export class AgentBridge {
233
326
  case "setModel":
234
327
  return this.setModel(p.model as string, sessionId);
235
328
 
329
+ // ── User preferences (global — user-level ~/.wave/settings.json) ──
330
+ // 设置页保存路径:用户偏好写文件后由 SDK 实时重载在各会话下一轮生效,
331
+ // 不经 updateConfig 当 AgentOptions 覆盖层下发(见 protocol.ts 注释)。
332
+ // 读回的是**生效值**(可能来自 Remote 组织下发/环境变量)+ 每键来源层。
333
+ case "getUserSettings":
334
+ return this.userSettingsView();
335
+ case "updateUserSettings":
336
+ return this.updateUserSettings(p as UserPreferenceSettings);
337
+
236
338
  // ── Messages ──
237
339
  case "sendMessage":
238
340
  return this.sendMessage(
@@ -267,10 +369,21 @@ export class AgentBridge {
267
369
  case "deleteQueuedMessageById":
268
370
  return this.deleteQueuedMessageById(p.id as string, sessionId);
269
371
  case "getMessages":
270
- return this.getMessages(sessionId);
372
+ return this.getMessages(sessionId, {
373
+ before: p.before as string | undefined,
374
+ since: p.since as string | undefined,
375
+ limit: p.limit as number | undefined,
376
+ });
271
377
  case "getFullMessageThread":
272
378
  return this.getFullMessageThread(sessionId);
273
379
 
380
+ // ── Remote control (daemon-level) ──
381
+ // The daemon owns the outbound relay socket; the bridge only routes.
382
+ case "remoteControlStatus":
383
+ case "remoteControlConnect":
384
+ case "remoteControlDisconnect":
385
+ return this.remoteControlRequest(method, p);
386
+
274
387
  // ── Permissions ──
275
388
  case "setPermissionMode":
276
389
  return this.setPermissionMode(p.mode as PermissionMode, sessionId);
@@ -346,6 +459,8 @@ export class AgentBridge {
346
459
  return this.login(p.serverUrl as string | undefined);
347
460
  case "logout":
348
461
  return this.logout();
462
+ case "installGitCredentialHelper":
463
+ return installGitCredentialHelper();
349
464
 
350
465
  // ── Memory files (settings UI — user-level ~/.wave/AGENTS.md and
351
466
  // project-level <workdir>/AGENTS.md) ──
@@ -376,6 +491,7 @@ export class AgentBridge {
376
491
  case "uninstallPlugin":
377
492
  return this.uninstallPlugin(
378
493
  p.pluginId as string,
494
+ p.scope as Scope | undefined,
379
495
  p.workdir as string | undefined,
380
496
  sessionId,
381
497
  );
@@ -424,11 +540,25 @@ export class AgentBridge {
424
540
  p.workdir as string | undefined,
425
541
  sessionId,
426
542
  );
543
+ case "setPluginScope":
544
+ return this.setPluginScope(
545
+ p.pluginId as string,
546
+ p.scope as Scope,
547
+ p.workdir as string | undefined,
548
+ sessionId,
549
+ );
550
+ case "reloadPlugins":
551
+ return this.reloadPlugins();
427
552
  case "listMarketplaces":
428
553
  return this.listMarketplaces(
429
554
  p.workdir as string | undefined,
430
555
  sessionId,
431
556
  );
557
+ case "refreshMarketplaces":
558
+ return this.refreshMarketplaces(
559
+ p.workdir as string | undefined,
560
+ sessionId,
561
+ );
432
562
  case "addMarketplace":
433
563
  return this.addMarketplace(
434
564
  p.input as string,
@@ -468,7 +598,9 @@ export class AgentBridge {
468
598
  case "stopWorkflowRun":
469
599
  return this.stopWorkflowRun(p.runId as string, sessionId);
470
600
 
471
- // ── Git / worktree (global — no session required) ──
601
+ // ── Filesystem / Git / worktree (global — no session required) ──
602
+ case "listDirectories":
603
+ return this.listDirectories(p.dir as string | undefined);
472
604
  case "listGitBranches":
473
605
  return this.listGitBranches(p.workdir as string | undefined);
474
606
  case "createWorktree":
@@ -483,6 +615,8 @@ export class AgentBridge {
483
615
  return this.getWorktreeChanges(
484
616
  p as unknown as { path?: string; baseBranch?: string },
485
617
  );
618
+ case "getSessionWorktreeChanges":
619
+ return this.getSessionWorktreeChanges(sessionId);
486
620
  case "removeWorktree":
487
621
  return this.removeWorktreeSession(
488
622
  p as unknown as {
@@ -492,16 +626,42 @@ export class AgentBridge {
492
626
  hookBased?: boolean;
493
627
  },
494
628
  );
629
+ case "getWorkspaceDiff":
630
+ return this.getWorkspaceDiff(
631
+ p.workdir as string | undefined,
632
+ p.commit as string | undefined,
633
+ p.scope as string | undefined,
634
+ );
635
+ case "getCompletion":
636
+ return this.getCompletion(
637
+ p as unknown as {
638
+ prefix?: string;
639
+ suffix?: string;
640
+ language?: string;
641
+ filePath?: string;
642
+ },
643
+ sessionId,
644
+ );
495
645
 
496
- default:
646
+ default: {
647
+ // Type-level exhaustiveness, both directions: a union member without a
648
+ // `case` lands here as a non-`never` and fails to compile, and a `case`
649
+ // naming something outside the union fails to compile at the `case`
650
+ // itself. Runtime behaviour is unchanged — `method` can still be an
651
+ // arbitrary string once the transport boundary cast it.
652
+ const _exhaustive: never = method;
653
+ // Kept referenced so the witness isn't an unused-variable lint error;
654
+ // it is never read at runtime.
655
+ void _exhaustive;
497
656
  throw new RpcError(
498
657
  PROTOCOL_METHOD_NOT_FOUND,
499
658
  `Method not found: ${method}`,
500
659
  );
660
+ }
501
661
  }
502
662
  }
503
663
 
504
- handleNotification(method: string, params: unknown): void {
664
+ handleNotification(method: ClientNotificationMethod, params: unknown): void {
505
665
  if (method === "permissionResponse") {
506
666
  const p = params as {
507
667
  requestId: string;
@@ -542,6 +702,16 @@ export class AgentBridge {
542
702
  const ctx: SessionContext = {};
543
703
  const callbacks = this.createCallbacks(ctx);
544
704
 
705
+ const entrypoint = normalizeEntrypoint(params.entrypoint);
706
+ if (params.entrypoint !== undefined && entrypoint === undefined) {
707
+ // A host that ships a value this version does not know (an older client,
708
+ // a hand-written frame) is treated as if it had declared nothing: an
709
+ // unrecognised value must not mint a series of its own.
710
+ logger?.warn(
711
+ `Unknown entrypoint: ${String(params.entrypoint)}, ignoring`,
712
+ );
713
+ }
714
+
545
715
  const options: AgentOptions = {
546
716
  callbacks,
547
717
  logger,
@@ -552,9 +722,6 @@ export class AgentBridge {
552
722
  defaultHeaders: params.defaultHeaders,
553
723
  model: params.model,
554
724
  fastModel: params.fastModel,
555
- language: params.language,
556
- autoMemoryEnabled: params.autoMemoryEnabled,
557
- autoMemoryFrequency: params.autoMemoryFrequency,
558
725
  permissionMode: params.permissionMode,
559
726
  tools: params.tools,
560
727
  allowedTools: params.allowedTools,
@@ -563,6 +730,7 @@ export class AgentBridge {
563
730
  mcpServers: params.mcpServers,
564
731
  worktreeName: params.worktreeName,
565
732
  isNewWorktree: params.isNewWorktree,
733
+ entrypoint,
566
734
  canUseTool: (context: ToolPermissionContext) =>
567
735
  this.canUseTool(context, ctx),
568
736
  };
@@ -604,15 +772,133 @@ export class AgentBridge {
604
772
  };
605
773
  }
606
774
 
607
- private async destroy(sessionId?: string): Promise<null> {
775
+ /**
776
+ * Destroy one hosted session (idempotent: an unknown id is a no-op).
777
+ *
778
+ * `params.removeWorktree` (optional, default false) additionally removes the
779
+ * git worktree the session was **created** in — the intent the remote page
780
+ * sends when the user confirms 「删除会话与工作树」. Two rules make it safe to
781
+ * offer over the remote channel, where the caller must never be able to name a
782
+ * path:
783
+ *
784
+ * 1. The worktree is resolved here, from `entry.createdWorkdir` — the same
785
+ * anchor `wave daemon destroy --remove-worktree` resolves from (and for the
786
+ * same reason: an in-session `cd` must not move the target). Nothing in the
787
+ * request is trusted as a path, which is why the raw `removeWorktree`
788
+ * method stays off the remote whitelist.
789
+ * 2. It runs **after** `agent.destroy()` resolves. The agent's cwd is the
790
+ * worktree path and destroy flushes the session file (resolving that path
791
+ * via realpath); deleting the directory first makes the save fail ENOENT
792
+ * and drops the trailing messages — the desktop host carries the identical
793
+ * ordering note in `handleDeleteSession`.
794
+ *
795
+ * "Cannot resolve a worktree" is never a reason to fail the delete: the
796
+ * session is still destroyed and the reply says `worktreeRemoved: false`. That
797
+ * covers a plain (non-worktree) session, a worktree that is already gone, and
798
+ * a removal that threw — the caller only ever needs to know whether the
799
+ * directory is gone.
800
+ *
801
+ * The return shape grew from `null` to `{worktreeRemoved}`; callers that pass
802
+ * no params (the CLI's own `wave daemon destroy`) read nothing and are
803
+ * unaffected. The CLI's `--remove-worktree` keeps its own client-side path
804
+ * (getSessionInfo + removeWorktree + destroy) — deliberately unchanged.
805
+ */
806
+ private async destroy(
807
+ sessionId?: string,
808
+ params?: { removeWorktree?: boolean },
809
+ ): Promise<{ worktreeRemoved: boolean }> {
810
+ let createdWorkdir: string | undefined;
608
811
  if (sessionId) {
609
812
  const entry = this.sessions.get(sessionId);
610
813
  if (entry) {
611
- await entry.agent.destroy();
814
+ if (entry.transitioning) {
815
+ // A rebuild owns this entry (updateConfig): destroying it here would
816
+ // race the rebuild, which would then re-register a replacement for a
817
+ // session the caller just destroyed. Refuse loudly instead — the
818
+ // caller can retry once the reload settles.
819
+ throw new RpcError(
820
+ PROTOCOL_INTERNAL_ERROR,
821
+ `Session ${sessionId} is being reconfigured (config reload in progress), retry shortly`,
822
+ );
823
+ }
824
+ // Drop the registry entry BEFORE awaiting the teardown. Agent.destroy()
825
+ // aborts the in-flight turn, which pushes `loadingChange:false` — the
826
+ // exact notification a finished turn pushes — and the teardown itself is
827
+ // not instant (transcript save, subagent/mcp cleanup, auto-memory drain).
828
+ // A client blocked in `wait` that is woken by that push re-reads the
829
+ // registry for the verdict: with the entry still listed (isLoading now
830
+ // false) it settles as exit 0 on an intermediate snapshot — a false
831
+ // "finished" for a session that is being removed. With the entry gone the
832
+ // existence check wins and it reports "no longer exists" (exit 1).
833
+ // Removed first also means a throwing teardown cannot leave a
834
+ // half-destroyed agent listed (destroyAll() below clears the map first
835
+ // for the same reason).
612
836
  this.sessions.delete(sessionId);
837
+ createdWorkdir = entry.createdWorkdir;
838
+ await entry.agent.destroy();
613
839
  }
614
840
  }
615
- return null;
841
+
842
+ let worktreeRemoved = false;
843
+ if (params?.removeWorktree === true && createdWorkdir) {
844
+ worktreeRemoved = await this.removeSessionWorktree(createdWorkdir);
845
+ }
846
+ return { worktreeRemoved };
847
+ }
848
+
849
+ /**
850
+ * Remove the linked worktree a session was created in. Returns whether the
851
+ * directory is actually gone (not whether we asked): `removeWorktree` is
852
+ * best-effort and leaves the directory behind when git and the fs fallback
853
+ * both fail, and for a hook-based worktree with no WorktreeRemove hook it
854
+ * deliberately leaves it in place. Never throws — the caller has already
855
+ * deleted the session.
856
+ */
857
+ private async removeSessionWorktree(sessionDir: string): Promise<boolean> {
858
+ const worktree = deriveWorktreeInfo(sessionDir);
859
+ if (!worktree) return false;
860
+ try {
861
+ await removeWorktree({
862
+ name: "",
863
+ path: worktree.path,
864
+ branch: worktree.branch,
865
+ repoRoot: worktree.repoRoot,
866
+ hasUncommittedChanges: false,
867
+ hasNewCommits: false,
868
+ isNew: false,
869
+ hookBased: worktree.hookBased,
870
+ });
871
+ } catch (e) {
872
+ logger.warn(
873
+ `removeSessionWorktree: failed to remove ${worktree.path}: ${(e as Error).message}`,
874
+ );
875
+ return false;
876
+ }
877
+ return !existsSync(worktree.path);
878
+ }
879
+
880
+ /**
881
+ * What deleting a session's worktree would throw away (uncommitted files and
882
+ * commits not on the base branch) — the counts the delete confirmation names
883
+ * on the remote page.
884
+ *
885
+ * Session-scoped on purpose: the path comes from the session's own
886
+ * `createdWorkdir`, never from the caller. The remote channel carries this
887
+ * instead of the path-taking `getWorktreeChanges`, for the same reason it
888
+ * carries `destroy {removeWorktree}` instead of `removeWorktree` — a phone may
889
+ * only name a session it was given, not an arbitrary directory on the machine.
890
+ *
891
+ * `null` means "could not inspect" (not a worktree, already deleted, git
892
+ * failed), which the page renders as the generic warning rather than as
893
+ * "clean".
894
+ */
895
+ private async getSessionWorktreeChanges(
896
+ sessionId?: string,
897
+ ): Promise<{ files: number; commits: number } | null> {
898
+ const entry = this.requireSessionForRead(sessionId);
899
+ const worktree = deriveWorktreeInfo(entry.createdWorkdir);
900
+ if (!worktree) return null;
901
+ return getWorktreeChanges(worktree.path);
616
902
  }
617
903
 
618
904
  /**
@@ -633,15 +919,25 @@ export class AgentBridge {
633
919
  restoreId: string,
634
920
  sessionId?: string,
635
921
  ): Promise<null> {
636
- const entry = this.requireSession(sessionId);
922
+ const entry = this.requireSessionForRead(sessionId);
637
923
  // Re-attach to a live session: the SDK restore would no-op (target is
638
- // already current). Emit the current messages so the freshly attached
639
- // client — whose router registered only after initialize returned and so
640
- // missed any earlier notifications — gets a snapshot without replay.
924
+ // already current). Re-send the state the freshly attached client missed —
925
+ // its router registered only after initialize returned.
926
+ //
927
+ // `messagesChange` carries **no payload here, only the fact that the list
928
+ // changed**. Nobody ever read a full snapshot from it: the remote relay
929
+ // needs the emission to produce the phone's signal and then the phone pulls
930
+ // `getMessages({ since })` itself, while every IDE host ignores the method
931
+ // and calls `getMessages` after restore. So building one — a multi-MB list
932
+ // on a long session, serialized on a hot re-attach path — was pure cost, and
933
+ // this is why the bridge sends the signal shape instead.
934
+ //
935
+ // This is also the ONLY place the bridge emits `messagesChange`, and only on
936
+ // this re-attach branch — never as the message stream progresses.
641
937
  if (entry.agent.sessionId === restoreId) {
642
938
  this.emit(
643
939
  "messagesChange",
644
- { messages: entry.agent.displayMessages },
940
+ remoteWire.messagesChangeSignal(),
645
941
  entry.agent.sessionId,
646
942
  );
647
943
  // The re-attached client also missed the loading state that settled
@@ -665,6 +961,18 @@ export class AgentBridge {
665
961
  this.emitContextUsage(entry.agent);
666
962
  return null;
667
963
  }
964
+ // The replay branch above is safe during an `updateConfig` transition (it
965
+ // only re-pushes the outgoing agent's own state, which is this session's
966
+ // transcript). A REAL restore is not: it would load another conversation
967
+ // into the agent that is about to be destroyed and replaced, so the call
968
+ // would be silently thrown away together with the outgoing agent. Refuse it
969
+ // with the same retryable error the mutating RPCs use.
970
+ if (entry.transitioning) {
971
+ throw new RpcError(
972
+ PROTOCOL_INTERNAL_ERROR,
973
+ `Session ${sessionId} is being reconfigured (config reload in progress), retry shortly`,
974
+ );
975
+ }
668
976
  await entry.agent.restoreSession(restoreId);
669
977
  // A real restore must also re-emit the usage unconditionally: the SDK
670
978
  // only fires onLatestTotalTokensChange on a VALUE CHANGE, so restoring a
@@ -685,9 +993,14 @@ export class AgentBridge {
685
993
  * usage to report; the host keeps the empty ring until the first push.
686
994
  */
687
995
  private emitContextUsage(agent: Agent): void {
688
- const percent = this.contextUsagePercentOf(agent);
689
- if (percent !== undefined) {
690
- this.emit("contextUsage", { percent }, agent.sessionId);
996
+ const tokens = agent.latestTotalTokens;
997
+ const max = agent.getMaxInputTokens();
998
+ if (tokens > 0 && max > 0) {
999
+ this.emit(
1000
+ "contextUsage",
1001
+ remoteWire.contextUsage(tokens, max),
1002
+ agent.sessionId,
1003
+ );
691
1004
  }
692
1005
  }
693
1006
 
@@ -702,7 +1015,7 @@ export class AgentBridge {
702
1015
  const tokens = agent.latestTotalTokens;
703
1016
  const max = agent.getMaxInputTokens();
704
1017
  if (tokens > 0 && max > 0) {
705
- return Math.min(100, Math.round((tokens / max) * 100));
1018
+ return percentOf(tokens, max);
706
1019
  }
707
1020
  return undefined;
708
1021
  }
@@ -717,8 +1030,84 @@ export class AgentBridge {
717
1030
  return { sessions };
718
1031
  }
719
1032
 
1033
+ /**
1034
+ * All main sessions across every project directory of this host, newest
1035
+ * first (deduplicated by session id). Each entry carries the session's real
1036
+ * `workdir`, which is what a caller needs to resume it.
1037
+ */
1038
+ private async listAllSessions(): Promise<{ sessions: SessionMetadata[] }> {
1039
+ const sessions = await listAllSessions();
1040
+ return { sessions };
1041
+ }
1042
+
1043
+ /**
1044
+ * Set a session's user-visible title. Works for any session on this host —
1045
+ * the target does not need to be the bound one, or even live.
1046
+ */
1047
+ private async renameSession(
1048
+ sessionId: string,
1049
+ workdir: string,
1050
+ title: string,
1051
+ ): Promise<void> {
1052
+ // A session this host is running keeps the title in memory and writes it
1053
+ // back at its next flush point (exit, compaction, resume, rewind), so a
1054
+ // file-only write here would be clobbered by the stale in-memory value.
1055
+ // Route through the agent; fall back to the file for sessions that are not
1056
+ // bound to this host's process.
1057
+ const bound = this.sessions.get(sessionId)?.agent;
1058
+ if (bound) {
1059
+ await bound.renameSession(title);
1060
+ return;
1061
+ }
1062
+ await setSessionCustomTitle(sessionId, workdir, title);
1063
+ }
1064
+
720
1065
  // ── Git / worktree ────────────────────────────────────────────
721
1066
 
1067
+ /**
1068
+ * List the subdirectories of a directory on the machine this CLI runs on —
1069
+ * host-agnostic like `listGitBranches`: whichever wave process receives the
1070
+ * request lists its own filesystem. Serves the new-session directory pickers
1071
+ * (the desktop's remote host browser and the Web page now share this one
1072
+ * implementation; the desktop's ssh shell one-liner retires into it).
1073
+ *
1074
+ * `~` / `~/…` expand against the service process user's home. `resolvedPath`
1075
+ * is the realpath so the picker shows where the user actually is (symlinked
1076
+ * parents normalized). Symlinked subdirectories are NOT listed — the shell
1077
+ * `find -type d` this replaces didn't list them either, and the two paths
1078
+ * must stay interchangeable. A missing or unreadable directory is an explicit
1079
+ * error, never an empty list (an empty list would read as "no subdirs" and
1080
+ * strand the user on a path they can't leave).
1081
+ */
1082
+ private listDirectories(dir?: string): {
1083
+ resolvedPath: string;
1084
+ dirs: string[];
1085
+ } {
1086
+ if (!dir) {
1087
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, "dir is required");
1088
+ }
1089
+ const expanded =
1090
+ dir === "~" || dir.startsWith("~/") ? join(homedir(), dir.slice(1)) : dir;
1091
+ let resolvedPath: string;
1092
+ try {
1093
+ resolvedPath = realpathSync(expanded);
1094
+ } catch {
1095
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, `目录不存在或不可读:${dir}`);
1096
+ }
1097
+ let dirs: string[];
1098
+ try {
1099
+ dirs = readdirSync(resolvedPath, { withFileTypes: true })
1100
+ .filter((e) => e.isDirectory())
1101
+ .map((e) => e.name)
1102
+ .sort((a, b) => a.localeCompare(b));
1103
+ } catch {
1104
+ // realpath succeeded (the path exists) but reading it didn't — same
1105
+ // verdict as missing: the picker cannot descend into either.
1106
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, `目录不存在或不可读:${dir}`);
1107
+ }
1108
+ return { resolvedPath, dirs };
1109
+ }
1110
+
722
1111
  private listGitBranches(workdir?: string): {
723
1112
  branches: string[];
724
1113
  current: string | null;
@@ -765,6 +1154,66 @@ export class AgentBridge {
765
1154
  return { branches, current };
766
1155
  }
767
1156
 
1157
+ /**
1158
+ * Read-only workspace diff for the desktop diff panel. Runs wherever this
1159
+ * CLI runs, so a remote session answers it inside the remote daemon instead
1160
+ * of the desktop shelling out over ssh per git command.
1161
+ *
1162
+ * `scope` selects the viewing range: omitted / "all" = the fork-point base,
1163
+ * "uncommitted" = `HEAD` (the panel's "未提交改动"). A `commit` outranks it.
1164
+ */
1165
+ private getWorkspaceDiff(workdir?: string, commit?: string, scope?: string) {
1166
+ if (!workdir) {
1167
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, "workdir is required");
1168
+ }
1169
+ return getWorkspaceDiff(workdir, {
1170
+ ...(commit ? { commit } : {}),
1171
+ ...(scope === "uncommitted" ? { uncommitted: true } : {}),
1172
+ });
1173
+ }
1174
+
1175
+ /**
1176
+ * One inline (ghost-text) completion for the editor plugins.
1177
+ *
1178
+ * Session-scoped on purpose: the completion model and the gateway credentials
1179
+ * are resolved inside that session (the CLI owns the model configuration —
1180
+ * the plugin never holds a second source of truth). Read-only, so it keeps
1181
+ * working while `updateConfig` is rebuilding the session.
1182
+ */
1183
+ private async getCompletion(
1184
+ params: {
1185
+ prefix?: string;
1186
+ suffix?: string;
1187
+ language?: string;
1188
+ filePath?: string;
1189
+ },
1190
+ sessionId?: string,
1191
+ ): Promise<{ text: string; model: string }> {
1192
+ const entry = this.requireSessionForRead(sessionId);
1193
+ const key = sessionId as string;
1194
+
1195
+ // Supersede the session's previous request: its answer would land at a
1196
+ // cursor position that no longer exists.
1197
+ this.inFlightCompletions.get(key)?.abort();
1198
+ const controller = new AbortController();
1199
+ this.inFlightCompletions.set(key, controller);
1200
+
1201
+ try {
1202
+ return await entry.agent.getCompletion({
1203
+ prefix: params.prefix ?? "",
1204
+ suffix: params.suffix ?? "",
1205
+ ...(params.language ? { language: params.language } : {}),
1206
+ ...(params.filePath ? { filePath: params.filePath } : {}),
1207
+ abortSignal: controller.signal,
1208
+ });
1209
+ } finally {
1210
+ // Only clear our own slot — a newer request may already own it.
1211
+ if (this.inFlightCompletions.get(key) === controller) {
1212
+ this.inFlightCompletions.delete(key);
1213
+ }
1214
+ }
1215
+ }
1216
+
768
1217
  private async createWorktreeSession(params: {
769
1218
  workdir: string;
770
1219
  baseBranch?: string;
@@ -850,14 +1299,19 @@ export class AgentBridge {
850
1299
  return { ok: true };
851
1300
  }
852
1301
 
853
- private getSessionInfo(sessionId?: string): {
1302
+ private async getSessionInfo(sessionId?: string): Promise<{
854
1303
  sessionId: string;
855
1304
  workingDirectory: string;
856
1305
  latestTotalTokens: number;
857
1306
  permissionMode: PermissionMode;
858
1307
  availableTools: string[];
859
- } {
860
- const entry = this.requireSession(sessionId);
1308
+ tasks: Task[];
1309
+ isCompacting: boolean;
1310
+ isCommandRunning: boolean;
1311
+ queuedMessages: QueuedMessage[];
1312
+ backgroundTasks: BackgroundTaskSummary[];
1313
+ }> {
1314
+ const entry = this.requireSessionForRead(sessionId);
861
1315
  return {
862
1316
  sessionId: entry.agent.sessionId,
863
1317
  // The recorded creation workdir, not the live agent.workingDirectory:
@@ -867,6 +1321,23 @@ export class AgentBridge {
867
1321
  latestTotalTokens: entry.agent.latestTotalTokens,
868
1322
  permissionMode: entry.agent.getPermissionMode(),
869
1323
  availableTools: entry.agent.getAvailableToolNames(),
1324
+ // The session-state family the remote page re-reads (spec
1325
+ // remote-control「Web 页作为第五种宿主」场景 26): the todo list —
1326
+ // file-backed in the SDK, hence the await — plus compaction, command
1327
+ // running and the outbound queue, all read off the agent itself so the
1328
+ // reply is the current value, never a transition the page may have
1329
+ // missed.
1330
+ tasks: await entry.agent.getTasks(),
1331
+ isCompacting: entry.agent.isCompacting,
1332
+ isCommandRunning: entry.agent.isCommandRunning,
1333
+ queuedMessages: entry.agent.queuedMessages,
1334
+ // Background tasks join the family for the same reason the todo list is
1335
+ // here: the phone's `/tasks` dialog opens on whatever the machine holds, and
1336
+ // `backgroundTasksChange` only reports *changes* — a task that started
1337
+ // before the page attached would otherwise be invisible until it finished.
1338
+ backgroundTasks: toBackgroundTaskSummaries(
1339
+ entry.agent.getBackgroundTasks(),
1340
+ ),
870
1341
  };
871
1342
  }
872
1343
 
@@ -878,76 +1349,108 @@ export class AgentBridge {
878
1349
  const currentSessionId = entry.agent.sessionId;
879
1350
  // Merge new config into stored config
880
1351
  entry.storedConfig = { ...entry.storedConfig, ...params };
881
- // Destroy and recreate within the same session slot
882
- await entry.agent.destroy();
883
- this.sessions.delete(currentSessionId);
884
-
1352
+ // The entry is NOT removed while the agent is replaced: dropping it would
1353
+ // make a `wait` blocked on this session report "no longer exists" (exit 1)
1354
+ // for a session that is alive and merely reloading — a false error, the same
1355
+ // family as the false idle this whole change is about. Instead the entry is
1356
+ // flagged `transitioning` for the whole destroy + recreate, which
1357
+ // `listDaemonSessions` reports as `isLoading: true` (so no reader can see the
1358
+ // "listed + not busy" combination a waiter would settle on) while
1359
+ // state-changing RPCs refuse it (`requireSession`) instead of talking to the
1360
+ // outgoing, dying agent. Read-only RPCs keep working off that agent.
1361
+ entry.transitioning = true;
885
1362
  const ctx: SessionContext = {};
886
- const callbacks = this.createCallbacks(ctx);
887
- const options: AgentOptions = {
888
- callbacks,
889
- workdir: entry.storedConfig.workdir,
890
- restoreSessionId: currentSessionId,
891
- apiKey: entry.storedConfig.apiKey,
892
- baseURL: entry.storedConfig.baseURL,
893
- defaultHeaders: entry.storedConfig.defaultHeaders,
894
- model: entry.storedConfig.model,
895
- fastModel: entry.storedConfig.fastModel,
896
- language: entry.storedConfig.language,
897
- autoMemoryEnabled: entry.storedConfig.autoMemoryEnabled,
898
- autoMemoryFrequency: entry.storedConfig.autoMemoryFrequency,
899
- permissionMode: entry.storedConfig.permissionMode,
900
- tools: entry.storedConfig.tools,
901
- allowedTools: entry.storedConfig.allowedTools,
902
- disallowedTools: entry.storedConfig.disallowedTools,
903
- plugins: entry.storedConfig.pluginDirs?.map((p) => ({
904
- type: "local",
905
- path: p,
906
- })),
907
- mcpServers: entry.storedConfig.mcpServers,
908
- // Keep worktree context (permission safety) across recreation, but never
909
- // re-fire WorktreeCreate — the hook ran at initial creation.
910
- worktreeName: entry.storedConfig.worktreeName,
911
- canUseTool: (context: ToolPermissionContext) =>
912
- this.canUseTool(context, ctx),
913
- };
914
-
915
- let agent: Agent;
916
1363
  try {
917
- agent = await Agent.create(options);
918
- } catch (createError) {
919
- // The old entry was already destroyed and removed above. If the session
920
- // file is missing or unrecoverable (fresh session, cleared chat, or a
921
- // never-persisted empty session), fail soft: recreate WITHOUT
922
- // restoreSessionId so this session slot keeps working. Without this the
923
- // client's sessionId still points at the destroyed entry and every later
924
- // request fails with "Session not found" until the window is reloaded.
925
- // Non-session errors (bad baseURL/apiKey/config) must surface unchanged.
926
- if (!options.restoreSessionId || !isSessionRecoveryError(createError)) {
927
- throw createError;
1364
+ // Destroy and recreate within the same session slot. The destroy comes
1365
+ // first so the outgoing agent stops writing the transcript before the
1366
+ // replacement loads it.
1367
+ await entry.agent.destroy();
1368
+
1369
+ const callbacks = this.createCallbacks(ctx);
1370
+ const options: AgentOptions = {
1371
+ callbacks,
1372
+ workdir: entry.storedConfig.workdir,
1373
+ restoreSessionId: currentSessionId,
1374
+ apiKey: entry.storedConfig.apiKey,
1375
+ baseURL: entry.storedConfig.baseURL,
1376
+ defaultHeaders: entry.storedConfig.defaultHeaders,
1377
+ model: entry.storedConfig.model,
1378
+ fastModel: entry.storedConfig.fastModel,
1379
+ permissionMode: entry.storedConfig.permissionMode,
1380
+ tools: entry.storedConfig.tools,
1381
+ allowedTools: entry.storedConfig.allowedTools,
1382
+ disallowedTools: entry.storedConfig.disallowedTools,
1383
+ plugins: entry.storedConfig.pluginDirs?.map((p) => ({
1384
+ type: "local",
1385
+ path: p,
1386
+ })),
1387
+ mcpServers: entry.storedConfig.mcpServers,
1388
+ // Keep worktree context (permission safety) across recreation, but never
1389
+ // re-fire WorktreeCreate — the hook ran at initial creation.
1390
+ worktreeName: entry.storedConfig.worktreeName,
1391
+ // The declaration outlives the rebuild: an `updateConfig` replaces the
1392
+ // agent, not the host that asked for the session.
1393
+ entrypoint: normalizeEntrypoint(entry.storedConfig.entrypoint),
1394
+ canUseTool: (context: ToolPermissionContext) =>
1395
+ this.canUseTool(context, ctx),
1396
+ };
1397
+
1398
+ let agent: Agent;
1399
+ try {
1400
+ agent = await Agent.create(options);
1401
+ } catch (createError) {
1402
+ // The old agent is already destroyed. If the session file is missing or
1403
+ // unrecoverable (fresh session, cleared chat, or a never-persisted empty
1404
+ // session), fail soft: recreate WITHOUT restoreSessionId so this session
1405
+ // slot keeps working. Without this the client's sessionId still points at
1406
+ // the destroyed entry and every later request fails with "Session not
1407
+ // found" until the window is reloaded. Non-session errors (bad
1408
+ // baseURL/apiKey/config) must surface unchanged.
1409
+ if (!options.restoreSessionId || !isSessionRecoveryError(createError)) {
1410
+ throw createError;
1411
+ }
1412
+ logger?.warn(
1413
+ `updateConfig: failed to restore session ${currentSessionId}, recreating as a fresh session:`,
1414
+ createError,
1415
+ );
1416
+ agent = await Agent.create({ ...options, restoreSessionId: undefined });
928
1417
  }
929
- logger?.warn(
930
- `updateConfig: failed to restore session ${currentSessionId}, recreating as a fresh session:`,
931
- createError,
932
- );
933
- agent = await Agent.create({ ...options, restoreSessionId: undefined });
1418
+ ctx.agent = agent;
1419
+ ctx.registeredSessionId = agent.sessionId;
1420
+ entry.agent = agent;
1421
+ // A recovery-recreated session gets a NEW id: move the registry key with it.
1422
+ // (The entry object is reused, so the flag and the recorded creation workdir
1423
+ // survive either way.) Announced with the OLD id as the envelope — same
1424
+ // contract as `onSessionIdChange` — so a client blocked on this session can
1425
+ // rebind to the new id instead of concluding it disappeared.
1426
+ if (agent.sessionId !== currentSessionId) {
1427
+ this.emit(
1428
+ "sessionIdChange",
1429
+ { sessionId: agent.sessionId },
1430
+ currentSessionId,
1431
+ );
1432
+ this.sessions.delete(currentSessionId);
1433
+ this.sessions.set(agent.sessionId, entry);
1434
+ }
1435
+ entry.transitioning = false;
1436
+ return { sessionId: agent.sessionId };
1437
+ } catch (teardownError) {
1438
+ // Nothing usable is left: the outgoing agent is torn down (fully or
1439
+ // partially) and no replacement was built. Leaving the entry listed would
1440
+ // be a "half-dead" session — reachable through the registry but with no
1441
+ // working agent — so it is dropped, exactly like a failing `destroy`: a
1442
+ // waiter then reports "no longer exists" (exit 1) instead of hanging or
1443
+ // settling. The error surfaces to the caller unchanged.
1444
+ this.sessions.delete(currentSessionId);
1445
+ throw teardownError;
934
1446
  }
935
- ctx.agent = agent;
936
- ctx.registeredSessionId = agent.sessionId;
937
- this.sessions.set(agent.sessionId, {
938
- agent,
939
- storedConfig: { ...entry.storedConfig },
940
- createdWorkdir: entry.createdWorkdir,
941
- });
942
-
943
- return { sessionId: agent.sessionId };
944
1447
  }
945
1448
 
946
1449
  private getConfiguredModels(sessionId?: string): {
947
1450
  models: string[];
948
1451
  currentModel: string | undefined;
949
1452
  } {
950
- const entry = this.requireSession(sessionId);
1453
+ const entry = this.requireSessionForRead(sessionId);
951
1454
  return {
952
1455
  models: entry.agent.getConfiguredModels(),
953
1456
  currentModel: entry.agent.getModelConfig().model,
@@ -956,7 +1459,7 @@ export class AgentBridge {
956
1459
 
957
1460
  private async setModel(model: string, sessionId?: string): Promise<null> {
958
1461
  const entry = this.requireSession(sessionId);
959
- entry.agent.setModel(model);
1462
+ await entry.agent.setModel(model);
960
1463
  // Keep storedConfig in sync: updateConfig recreates the agent from
961
1464
  // storedConfig, so without this a later config save would revert the
962
1465
  // model chosen here.
@@ -964,6 +1467,47 @@ export class AgentBridge {
964
1467
  return null;
965
1468
  }
966
1469
 
1470
+ // ── User preferences ──────────────────────────────────────────
1471
+
1472
+ /**
1473
+ * 设置页保存路径:写用户级 `~/.wave/settings.json` 后,**显式重载本进程内
1474
+ * 全部会话**的实时配置。
1475
+ *
1476
+ * 不能只依赖文件监视:会话启动时该文件(乃至 `~/.wave` 目录)通常还不存在,
1477
+ * 监视要靠在父目录链存在的前提下补发事件;而这个文件正是被本次保存**创建**
1478
+ * 出来的最常见场景(全新安装的第一次保存,spec core/agent-config.md
1479
+ * 「设置实时重载」场景 7)。写完即重载使「保存 ⇒ 下一轮生效」与监视是否就绪
1480
+ * 无关;监视若也命中,重载是幂等的(并发重载按 `reloadInProgress` 去重)。
1481
+ *
1482
+ * 空载荷(无差异保存、只回读)不落盘,也不重载。
1483
+ *
1484
+ * 回包是**生效视图**(同 `getUserSettings`):设置页在保存后仍须看到「被组织
1485
+ * 配置覆盖」的键的生效值与来源,而不是回退成用户文件里的值。
1486
+ */
1487
+ private async updateUserSettings(
1488
+ patch: UserPreferenceSettings,
1489
+ ): Promise<UserPreferenceSettingsView> {
1490
+ await updateUserPreferenceSettings(patch);
1491
+ if (!Object.values(patch).every((value) => value === undefined)) {
1492
+ await Promise.all(
1493
+ [...this.sessions.values()].map((entry) =>
1494
+ entry.agent.reloadConfiguration(),
1495
+ ),
1496
+ );
1497
+ }
1498
+ return this.userSettingsView();
1499
+ }
1500
+
1501
+ /**
1502
+ * 用户偏好的**生效视图**:生效值 + 每键来源层(用户级文件 / Remote 组织下发 /
1503
+ * 机器环境变量 / 默认)。设置页据此把「由组织配置管理」的键显示为生效值 +
1504
+ * 置灰(spec core/agent-config.md 边界说明「用户偏好的层与来源」)。
1505
+ */
1506
+ private userSettingsView(): UserPreferenceSettingsView {
1507
+ const { values, sources } = readUserPreferenceView();
1508
+ return { ...values, preferenceSources: sources };
1509
+ }
1510
+
967
1511
  // ── Messages ──────────────────────────────────────────────────
968
1512
 
969
1513
  private async sendMessage(
@@ -1104,7 +1648,7 @@ export class AgentBridge {
1104
1648
  private async listRewindCheckpoints(sessionId?: string): Promise<{
1105
1649
  checkpoints: Array<{ id: string; content: string }>;
1106
1650
  }> {
1107
- const entry = this.requireSession(sessionId);
1651
+ const entry = this.requireSessionForRead(sessionId);
1108
1652
  const { messages } = await entry.agent.getFullMessageThread();
1109
1653
  // 压缩 append-only 后同 id 消息在磁盘完整线程中重复出现(压缩前历史 +
1110
1654
  // 压缩后 append 的重复)。按 id 去重并保留最后一次出现(与折叠后的
@@ -1147,22 +1691,71 @@ export class AgentBridge {
1147
1691
  return null;
1148
1692
  }
1149
1693
 
1150
- private getMessages(sessionId?: string): {
1694
+ /**
1695
+ * Read a session's display messages.
1696
+ *
1697
+ * With no window this returns the whole list — that is the contract every
1698
+ * existing host relies on (VS Code / JetBrains / desktop call `getMessages`
1699
+ * with no parameters and must keep seeing the full thread, byte for byte).
1700
+ * The window exists for remote clients on a phone, which only ever want the
1701
+ * tail of a long conversation and must not be handed 13 MB on every cold
1702
+ * start:
1703
+ *
1704
+ * - `limit` — page size.
1705
+ * - `before` — the page that ends immediately before the given message id
1706
+ * (paging *backwards* into history).
1707
+ * - `since` — everything appended after the given message id (catching up
1708
+ * after a reconnect).
1709
+ *
1710
+ * `since` is a position, not a version number: message ids are stable, so a
1711
+ * caller can name its last known message without the list carrying a
1712
+ * sequence number. The one failure that matters is the anchor having been
1713
+ * truncated away (rewind / clear) — there is no seq/version in the store that
1714
+ * could detect it otherwise — and that comes back as `reset: true` so the
1715
+ * caller re-pulls the tail instead of guessing.
1716
+ *
1717
+ * Ids can repeat in the display list (a compacted message is re-added), so
1718
+ * anchors are resolved to their **last** occurrence: "after `since`" means
1719
+ * after the newest copy the caller could have seen.
1720
+ */
1721
+ private getMessages(
1722
+ sessionId?: string,
1723
+ window?: { before?: string; since?: string; limit?: number },
1724
+ ): {
1151
1725
  messages: Message[];
1152
1726
  contextUsagePercent?: number;
1727
+ /** Older messages exist before the returned page. */
1728
+ hasMore?: boolean;
1729
+ /** The `since` anchor is gone — rebuild from the tail. */
1730
+ reset?: boolean;
1731
+ /** Total display messages held for the session. */
1732
+ total?: number;
1153
1733
  } {
1154
- const entry = this.requireSession(sessionId);
1155
- return {
1156
- messages: entry.agent.displayMessages,
1157
- contextUsagePercent: this.contextUsagePercentOf(entry.agent),
1158
- };
1734
+ const entry = this.requireSessionForRead(sessionId);
1735
+ const all = entry.agent.displayMessages;
1736
+ const contextUsagePercent = this.contextUsagePercentOf(entry.agent);
1737
+
1738
+ // The slicing is shared with the CLI front end (`RemoteControlCliHost`): the
1739
+ // phone's reconciliation rules belong to the wire contract, not to this
1740
+ // registry, and the two hosts must answer identically.
1741
+ let sliced: MessageWindowResult | undefined;
1742
+ try {
1743
+ sliced = sliceMessageWindow(all, window);
1744
+ } catch (err) {
1745
+ if (err instanceof InvalidMessageWindowError) {
1746
+ throw new RpcError(PROTOCOL_INVALID_PARAMS, err.message);
1747
+ }
1748
+ throw err;
1749
+ }
1750
+ if (!sliced) return { messages: all, contextUsagePercent };
1751
+ return { ...sliced, contextUsagePercent };
1159
1752
  }
1160
1753
 
1161
1754
  private async getFullMessageThread(sessionId?: string): Promise<{
1162
1755
  messages: Message[];
1163
1756
  sessionIds: string[];
1164
1757
  }> {
1165
- const entry = this.requireSession(sessionId);
1758
+ const entry = this.requireSessionForRead(sessionId);
1166
1759
  return entry.agent.getFullMessageThread();
1167
1760
  }
1168
1761
 
@@ -1181,7 +1774,7 @@ export class AgentBridge {
1181
1774
  taskId: string,
1182
1775
  sessionId?: string,
1183
1776
  ): { output: ReturnType<Agent["getBackgroundTaskOutput"]> } {
1184
- const entry = this.requireSession(sessionId);
1777
+ const entry = this.requireSessionForRead(sessionId);
1185
1778
  return { output: entry.agent.getBackgroundTaskOutput(taskId) };
1186
1779
  }
1187
1780
 
@@ -1203,7 +1796,7 @@ export class AgentBridge {
1203
1796
  private async getWorkflowRuns(
1204
1797
  sessionId?: string,
1205
1798
  ): Promise<{ runs: SerializableWorkflowRun[] }> {
1206
- const entry = this.requireSession(sessionId);
1799
+ const entry = this.requireSessionForRead(sessionId);
1207
1800
  const runs = await entry.agent.getWorkflowRuns();
1208
1801
  return {
1209
1802
  runs: runs.map((r) => {
@@ -1235,7 +1828,7 @@ export class AgentBridge {
1235
1828
  }
1236
1829
 
1237
1830
  private getPermissionMode(sessionId?: string): { mode: PermissionMode } {
1238
- const entry = this.requireSession(sessionId);
1831
+ const entry = this.requireSessionForRead(sessionId);
1239
1832
  return { mode: entry.agent.getPermissionMode() };
1240
1833
  }
1241
1834
 
@@ -1249,7 +1842,7 @@ export class AgentBridge {
1249
1842
  path: string | null;
1250
1843
  content: string | null;
1251
1844
  }> {
1252
- const entry = this.requireSession(sessionId);
1845
+ const entry = this.requireSessionForRead(sessionId);
1253
1846
  const agent = entry.agent;
1254
1847
  const planPath =
1255
1848
  agent.getPlanFilePath() ?? (await agent.awaitPlanFilePath());
@@ -1268,7 +1861,7 @@ export class AgentBridge {
1268
1861
  // ── MCP ───────────────────────────────────────────────────────
1269
1862
 
1270
1863
  private getMcpServers(sessionId?: string): { servers: McpServerStatus[] } {
1271
- const entry = this.requireSession(sessionId);
1864
+ const entry = this.requireSessionForRead(sessionId);
1272
1865
  return { servers: entry.agent.getMcpServers() };
1273
1866
  }
1274
1867
 
@@ -1304,7 +1897,7 @@ export class AgentBridge {
1304
1897
  userPath: string | null;
1305
1898
  projectPath: string | null;
1306
1899
  } {
1307
- const entry = this.requireSession(sessionId);
1900
+ const entry = this.requireSessionForRead(sessionId);
1308
1901
  return {
1309
1902
  userPath: entry.agent.getUserMcpConfigPath(),
1310
1903
  projectPath: entry.agent.getProjectMcpConfigPath(),
@@ -1314,19 +1907,19 @@ export class AgentBridge {
1314
1907
  // ── Commands ──────────────────────────────────────────────────
1315
1908
 
1316
1909
  private getSlashCommands(sessionId?: string): { commands: SlashCommand[] } {
1317
- const entry = this.requireSession(sessionId);
1910
+ const entry = this.requireSessionForRead(sessionId);
1318
1911
  return { commands: entry.agent.getSlashCommands() };
1319
1912
  }
1320
1913
 
1321
1914
  private getSubagentConfigurations(sessionId?: string): {
1322
1915
  configurations: SubagentConfiguration[];
1323
1916
  } {
1324
- const entry = this.requireSession(sessionId);
1917
+ const entry = this.requireSessionForRead(sessionId);
1325
1918
  return { configurations: entry.agent.getSubagentConfigurations() };
1326
1919
  }
1327
1920
 
1328
1921
  private getSkillMetadata(sessionId?: string): { skills: SkillMetadata[] } {
1329
- const entry = this.requireSession(sessionId);
1922
+ const entry = this.requireSessionForRead(sessionId);
1330
1923
  return { skills: entry.agent.getSkillMetadata() };
1331
1924
  }
1332
1925
 
@@ -1355,7 +1948,7 @@ export class AgentBridge {
1355
1948
  hooks: Partial<Record<string, unknown[]>>;
1356
1949
  configPath: string | null;
1357
1950
  }> {
1358
- const entry = this.requireSession(sessionId);
1951
+ const entry = this.requireSessionForRead(sessionId);
1359
1952
  const hooks = await entry.agent.getHooksByScope(scope);
1360
1953
  // 回带该 scope 钩子所在 settings.json 的绝对路径(宿主 GUI 打开文件用;
1361
1954
  // plugin 钩子来自代码而非配置文件 → null)。
@@ -1462,7 +2055,7 @@ export class AgentBridge {
1462
2055
  });
1463
2056
  this.emit(
1464
2057
  "permissionRequest",
1465
- { requestId, context },
2058
+ remoteWire.permissionRequest(requestId, context),
1466
2059
  ctx.registeredSessionId,
1467
2060
  );
1468
2061
  });
@@ -1492,27 +2085,156 @@ export class AgentBridge {
1492
2085
  /** Daemon list: expose the in-memory session registry (live sessions only,
1493
2086
  * not disk-scanning). Registration order is preserved. The working directory
1494
2087
  * shown is the session's recorded creation workdir (stable identity), not the
1495
- * live value that drifts with in-session `cd`. */
2088
+ * live value that drifts with in-session `cd`.
2089
+ *
2090
+ * A session mid-rebuild (`transitioning`, see `SessionEntry`) reports
2091
+ * `isLoading: true`: its outgoing agent has already been aborted by the
2092
+ * teardown, so its own flag reads false while the replacement is still being
2093
+ * built — reporting that would hand a `wait` the "listed + not busy"
2094
+ * combination it settles on. "Not idle" is the honest reading of "this session
2095
+ * has no agent right now", and it can only make a waiter wait.
2096
+ *
2097
+ * `hasRunningBackgroundWork` / `hasPendingMessages` are the other two halves of
2098
+ * the idle predicate `wait` / `status` share with `wave -p` (print mode): a
2099
+ * session whose foreground turn has ended can still own a running background
2100
+ * bash task or subagent, and the last such task flips to `completed` before its
2101
+ * completion notification is enqueued for the main agent's follow-up turn —
2102
+ * reading only `isLoading` reports "done" in both cases. They are read from the
2103
+ * live agent here rather than tracked separately, and mirror it as-is: the
2104
+ * rebuild window above is already covered by `isLoading`, so there is no need
2105
+ * to also claim a `transitioning` session owns background work it doesn't.
2106
+ *
2107
+ * The three label fields are additive (an older CLI simply ignores them) and
2108
+ * exist for the remote-control page: it lists **this process's** sessions —
2109
+ * the only ones it can actually open — and cannot read the machine's disk, so
2110
+ * the title material and the ordering key have to come off the live agent
2111
+ * rather than from `listSessions`, which reads jsonl and would offer it
2112
+ * sessions that are not loaded here (spec remote-control「Web 页作为第五种宿主」
2113
+ * 场景 2)。 */
1496
2114
  private listDaemonSessions(): {
1497
2115
  sessions: Array<{
1498
2116
  sessionId: string;
1499
2117
  workingDirectory: string;
1500
2118
  isLoading: boolean;
2119
+ hasRunningBackgroundWork: boolean;
2120
+ hasPendingMessages: boolean;
1501
2121
  messageCount: number;
2122
+ /** User-set title; wins over `firstMessage` in the UI. */
2123
+ customTitle?: string;
2124
+ /** First real user message — the label when no custom title is set. */
2125
+ firstMessage?: string;
2126
+ /** Last display message's timestamp: the session list's ordering key. */
2127
+ lastActiveAt?: string;
2128
+ /**
2129
+ * The linked git worktree this session was created in, when it was.
2130
+ * Present only for worktree sessions — absent means "not a worktree, or
2131
+ * cannot tell", and the remote page reads it as "deleting this session
2132
+ * will not touch a worktree". Change counts are deliberately NOT here:
2133
+ * they cost git calls per row, and the list is rebuilt on every reconnect
2134
+ * / snapshot / page open. The page asks for them on demand instead, via
2135
+ * `getSessionWorktreeChanges`.
2136
+ */
2137
+ worktree?: {
2138
+ path: string;
2139
+ branch: string;
2140
+ repoRoot: string;
2141
+ hookBased: boolean;
2142
+ };
1502
2143
  }>;
1503
2144
  } {
1504
2145
  return {
1505
- sessions: [...this.sessions.entries()].map(([sessionId, entry]) => ({
1506
- sessionId,
1507
- workingDirectory: entry.createdWorkdir,
1508
- isLoading: entry.agent.isLoading,
1509
- messageCount: entry.agent.displayMessages.length,
1510
- })),
2146
+ sessions: [...this.sessions.entries()].map(([sessionId, entry]) => {
2147
+ const messages = entry.agent.displayMessages;
2148
+ const customTitle = entry.agent.customTitle;
2149
+ const firstMessage = firstUserText(messages);
2150
+ const lastActiveAt = messages[messages.length - 1]?.timestamp;
2151
+ // Derived from the creation workdir (not the live one) and fs-only —
2152
+ // see `deriveWorktreeInfo` for why this must not shell out to git.
2153
+ const worktree = deriveWorktreeInfo(entry.createdWorkdir);
2154
+ return {
2155
+ sessionId,
2156
+ workingDirectory: entry.createdWorkdir,
2157
+ isLoading: entry.transitioning === true || entry.agent.isLoading,
2158
+ hasRunningBackgroundWork: entry.agent.hasRunningBackgroundWork,
2159
+ hasPendingMessages: entry.agent.hasPendingMessages,
2160
+ messageCount: messages.length,
2161
+ ...(customTitle ? { customTitle } : {}),
2162
+ ...(firstMessage ? { firstMessage } : {}),
2163
+ ...(lastActiveAt ? { lastActiveAt } : {}),
2164
+ ...(worktree ? { worktree } : {}),
2165
+ };
2166
+ }),
1511
2167
  };
1512
2168
  }
1513
2169
 
1514
- // ── Auth (global) ────────────────────────────────────────────
2170
+ /**
2171
+ * Route the `remoteControl*` RPCs to the daemon's controller.
2172
+ *
2173
+ * `connect` / `disconnect` change the relay session; all three return the
2174
+ * same snapshot so a caller can render the result without a second round
2175
+ * trip (and a caller that only wanted to read gets `remoteControlStatus`).
2176
+ *
2177
+ * `connect` carries the optional label the user gave this machine
2178
+ * (`wave remote-control on <name>`), which the relay hands to the phone so
2179
+ * its computer list can tell two of the user's computers apart.
2180
+ */
2181
+ private remoteControlRequest(
2182
+ method: string,
2183
+ params: Record<string, unknown>,
2184
+ ): RemoteControlSnapshot {
2185
+ const controller = this.remoteControl?.();
2186
+ if (!controller) {
2187
+ throw new RpcError(
2188
+ PROTOCOL_INVALID_PARAMS,
2189
+ "Remote control is not enabled on this process (set enableRemoteControl: true).",
2190
+ );
2191
+ }
2192
+ if (method === "remoteControlConnect") {
2193
+ const name = typeof params.name === "string" ? params.name : undefined;
2194
+ controller.connect(name);
2195
+ } else if (method === "remoteControlDisconnect") {
2196
+ controller.disconnect();
2197
+ }
2198
+ return controller.getSnapshot();
2199
+ }
2200
+
2201
+ /**
2202
+ * Per-session status for the remote-control signal.
2203
+ *
2204
+ * The relay forwards bytes without looking inside them, so it can never
2205
+ * discover that somebody is waiting on an approval — and "push to the phone
2206
+ * when a session is stuck on the user" is the whole point of the feature.
2207
+ * The machine therefore reports one word per session, carrying no content:
2208
+ * ids and a status, nothing else.
2209
+ *
2210
+ * `running` includes background work: read from a phone there is no terminal
2211
+ * to glance at, so "something is still in flight" is the useful meaning of
2212
+ * busy rather than the narrower "a model turn is streaming".
2213
+ */
2214
+ listSessionStatuses(): Array<{
2215
+ sessionId: string;
2216
+ status: "running" | "idle" | "requires_action";
2217
+ }> {
2218
+ const awaiting = new Set<string>();
2219
+ for (const entry of this.pendingPermissions.values()) {
2220
+ if (entry.sessionId) awaiting.add(entry.sessionId);
2221
+ }
2222
+ return [...this.sessions.entries()].map(([sessionId, entry]) => {
2223
+ let status: "running" | "idle" | "requires_action" = "idle";
2224
+ if (awaiting.has(sessionId)) {
2225
+ status = "requires_action";
2226
+ } else if (
2227
+ entry.transitioning === true ||
2228
+ entry.agent.isLoading ||
2229
+ entry.agent.hasRunningBackgroundWork
2230
+ ) {
2231
+ status = "running";
2232
+ }
2233
+ return { sessionId, status };
2234
+ });
2235
+ }
1515
2236
 
2237
+ // ── Auth (global) ────────────────────────────────────────────
1516
2238
  private async getAuthStatus(): Promise<{
1517
2239
  isAuthenticated: boolean;
1518
2240
  user: { id: string; email?: string } | undefined;
@@ -1528,7 +2250,12 @@ export class AgentBridge {
1528
2250
  }
1529
2251
 
1530
2252
  private async getAccountInfo(): Promise<{
1531
- plan?: { monthlyQuota: number; months: number; used: number } | null;
2253
+ /**
2254
+ * 计费结论(套餐两根条 + 到期日 + 降级原因),形状见 codechat `GET /api/v1/account` → `billing`
2255
+ * (判定出自与 proxy 扣费同一份代码)。本层只转发、不解析;具体形状由 wave-webview-fixtures
2256
+ * 的 AccountBillingInfo 在宿主/卡片侧约束。
2257
+ */
2258
+ billing?: unknown;
1532
2259
  apiQuota?: { limit: number | null; used: number } | null;
1533
2260
  }> {
1534
2261
  const authService = AuthService.getInstance();
@@ -1547,7 +2274,7 @@ export class AgentBridge {
1547
2274
  throw new Error(`Account query failed (${response.status})`);
1548
2275
  }
1549
2276
  return (await response.json()) as {
1550
- plan?: { monthlyQuota: number; months: number; used: number } | null;
2277
+ billing?: unknown;
1551
2278
  apiQuota?: { limit: number | null; used: number } | null;
1552
2279
  };
1553
2280
  }
@@ -1678,6 +2405,7 @@ export class AgentBridge {
1678
2405
  marketplace: p.marketplace,
1679
2406
  installed: p.installed,
1680
2407
  version: p.version,
2408
+ latestVersion: p.latestVersion,
1681
2409
  enabled: mergedEnabled[pluginId] !== false,
1682
2410
  scope: p.scope,
1683
2411
  };
@@ -1699,10 +2427,14 @@ export class AgentBridge {
1699
2427
 
1700
2428
  private async uninstallPlugin(
1701
2429
  pluginId: string,
2430
+ scope?: Scope,
1702
2431
  workdir?: string,
1703
2432
  sessionId?: string,
1704
2433
  ) {
1705
- await this.getPluginCore(workdir, sessionId).uninstallPlugin(pluginId);
2434
+ await this.getPluginCore(workdir, sessionId).uninstallPlugin(
2435
+ pluginId,
2436
+ scope,
2437
+ );
1706
2438
  return null;
1707
2439
  }
1708
2440
 
@@ -1727,6 +2459,21 @@ export class AgentBridge {
1727
2459
  );
1728
2460
  }
1729
2461
 
2462
+ /**
2463
+ * `/reload-plugins`:把磁盘上的插件状态就地换装进**本进程内全部活跃会话**
2464
+ * (spec ecosystem/plugin.md「插件变更的就地重载」)。不重建会话、sessionId
2465
+ * 不变;没有活跃会话时也返回成功——新会话创建时会按磁盘状态自然装载。
2466
+ *
2467
+ * 每个会话读的是同一份磁盘状态,因此取任一份结果汇报即可(各会话的差异只体现
2468
+ * 在各自注册表的可见性上)。失败不回滚,原样回传给宿主提示。
2469
+ */
2470
+ private async reloadPlugins(): Promise<PluginReloadResult> {
2471
+ const results = await Promise.all(
2472
+ [...this.sessions.values()].map((entry) => entry.agent.reloadPlugins()),
2473
+ );
2474
+ return results[0] ?? { plugins: [], failures: [] };
2475
+ }
2476
+
1730
2477
  private async getProjectSettings(
1731
2478
  workdir?: string,
1732
2479
  sessionId?: string,
@@ -1839,10 +2586,36 @@ export class AgentBridge {
1839
2586
  return this.getPluginCore(workdir, sessionId).updatePlugin(pluginId);
1840
2587
  }
1841
2588
 
2589
+ /**
2590
+ * 更换已安装插件的安装作用域(设置页插件市场):清掉各作用域的启用记录后
2591
+ * 在目标作用域启用,因此旧作用域不再保留该插件。
2592
+ */
2593
+ private async setPluginScope(
2594
+ pluginId: string,
2595
+ scope: Scope,
2596
+ workdir?: string,
2597
+ sessionId?: string,
2598
+ ) {
2599
+ return this.getPluginCore(workdir, sessionId).setPluginScope(
2600
+ pluginId,
2601
+ scope,
2602
+ );
2603
+ }
2604
+
1842
2605
  private async listMarketplaces(workdir?: string, sessionId?: string) {
1843
2606
  return this.getPluginCore(workdir, sessionId).listMarketplaces();
1844
2607
  }
1845
2608
 
2609
+ /**
2610
+ * 刷新各已注册市场的检出(不装插件),供「打开插件市场界面」时后台调用
2611
+ * (spec 插件市场 A-013 场景 5)。市场级错误在 SDK 内静默记录,宿主侧失败
2612
+ * 也不打断界面(场景 9)。
2613
+ */
2614
+ private async refreshMarketplaces(workdir?: string, sessionId?: string) {
2615
+ await this.getPluginCore(workdir, sessionId).refreshMarketplaces();
2616
+ return null;
2617
+ }
2618
+
1846
2619
  private async addMarketplace(
1847
2620
  input: string,
1848
2621
  scope?: Scope,
@@ -1867,8 +2640,16 @@ export class AgentBridge {
1867
2640
  workdir?: string,
1868
2641
  sessionId?: string,
1869
2642
  ) {
1870
- await this.getPluginCore(workdir, sessionId).updateMarketplace(name);
1871
- return null;
2643
+ // 这个通道只服务 GUI 的「批量更新插件」按钮:按当前清单升级、不拉检出
2644
+ // (清单刷新由打开视图时的 refreshMarketplaces 承担,spec 插件市场 A-013/
2645
+ // 场景 14)。
2646
+ const updated = await this.getPluginCore(
2647
+ workdir,
2648
+ sessionId,
2649
+ ).updateMarketplacePlugins(name);
2650
+ // 实际升级的插件数回给宿主:0 = 该市场已是最新(宿主据此提示,
2651
+ // spec 插件市场场景 13)。
2652
+ return { updated };
1872
2653
  }
1873
2654
 
1874
2655
  // ── Callbacks → Notifications ─────────────────────────────────
@@ -1893,26 +2674,29 @@ export class AgentBridge {
1893
2674
  ctx.registeredSessionId,
1894
2675
  );
1895
2676
  },
2677
+ onAssistantMessageDiscarded: (messageId: string) => {
2678
+ // A streamed attempt died mid-body and was re-issued without streaming,
2679
+ // so its half message is void: tell the host to drop it instead of
2680
+ // leaving a partial answer above the real one (spec: 流式响应正文中途
2681
+ // 断连时降级为非流式重发).
2682
+ this.emit(
2683
+ "assistantMessageDiscarded",
2684
+ { messageId },
2685
+ ctx.registeredSessionId,
2686
+ );
2687
+ },
1896
2688
  onAssistantContentUpdated: (params) => {
1897
2689
  // Wire carries only the delta; consumers accumulate (spec: 流式通知纯增量负载).
1898
2690
  this.emit(
1899
2691
  "assistantContentUpdated",
1900
- {
1901
- messageId: params.messageId,
1902
- chunk: params.chunk,
1903
- stage: params.stage,
1904
- },
2692
+ remoteWire.assistantContentUpdated(params),
1905
2693
  ctx.registeredSessionId,
1906
2694
  );
1907
2695
  },
1908
2696
  onAssistantReasoningUpdated: (params) => {
1909
2697
  this.emit(
1910
2698
  "assistantReasoningUpdated",
1911
- {
1912
- messageId: params.messageId,
1913
- chunk: params.chunk,
1914
- stage: params.stage,
1915
- },
2699
+ remoteWire.assistantReasoningUpdated(params),
1916
2700
  ctx.registeredSessionId,
1917
2701
  );
1918
2702
  },
@@ -1920,21 +2704,23 @@ export class AgentBridge {
1920
2704
  // Streaming stages carry only the parametersChunk delta; start/running
1921
2705
  // (one-time snapshots) and end (authoritative full value) keep
1922
2706
  // `parameters` (spec: 流式通知纯增量负载).
1923
- const { parameters, ...rest } = params;
1924
- void parameters;
1925
- const wireParams = params.stage === "streaming" ? rest : params;
1926
- this.emit("toolBlockUpdated", wireParams, ctx.registeredSessionId);
2707
+ this.emit(
2708
+ "toolBlockUpdated",
2709
+ remoteWire.toolBlockUpdated(params),
2710
+ ctx.registeredSessionId,
2711
+ );
1927
2712
  },
1928
2713
  onErrorBlockAdded: (error: string) => {
1929
- this.emit("errorBlockAdded", { error }, ctx.registeredSessionId);
2714
+ this.emit(
2715
+ "errorBlockAdded",
2716
+ remoteWire.errorBlockAdded(error),
2717
+ ctx.registeredSessionId,
2718
+ );
1930
2719
  },
1931
2720
  onLoadingChange: (loading: boolean) => {
1932
2721
  this.emit(
1933
2722
  "loadingChange",
1934
- {
1935
- loading,
1936
- latestTotalTokens: ctx.agent?.latestTotalTokens,
1937
- },
2723
+ remoteWire.loadingChange(loading, ctx.agent?.latestTotalTokens),
1938
2724
  ctx.registeredSessionId,
1939
2725
  );
1940
2726
  },
@@ -1946,8 +2732,11 @@ export class AgentBridge {
1946
2732
  // aligned with the CLI, which reads the same callback (spec 场景 5/6).
1947
2733
  const max = ctx.agent?.getMaxInputTokens() ?? 0;
1948
2734
  if (max > 0) {
1949
- const percent = Math.min(100, Math.round((tokens / max) * 100));
1950
- this.emit("contextUsage", { percent }, ctx.registeredSessionId);
2735
+ this.emit(
2736
+ "contextUsage",
2737
+ remoteWire.contextUsage(tokens, max),
2738
+ ctx.registeredSessionId,
2739
+ );
1951
2740
  }
1952
2741
  },
1953
2742
  onCommandRunningChange: (running: boolean) => {
@@ -1964,21 +2753,9 @@ export class AgentBridge {
1964
2753
  this.emit("tasksChange", { tasks }, ctx.registeredSessionId);
1965
2754
  },
1966
2755
  onBackgroundTasksChange: (tasks: BackgroundTask[]) => {
1967
- const summaries: BackgroundTaskSummary[] = tasks.map((t) => ({
1968
- id: t.id,
1969
- type: t.type,
1970
- status: t.status,
1971
- startTime: t.startTime,
1972
- endTime: t.endTime,
1973
- command: t.command,
1974
- description: t.description,
1975
- exitCode: t.exitCode,
1976
- runtime: t.runtime,
1977
- outputPath: t.outputPath,
1978
- }));
1979
2756
  this.emit(
1980
2757
  "backgroundTasksChange",
1981
- { tasks: summaries },
2758
+ { tasks: toBackgroundTaskSummaries(tasks) },
1982
2759
  ctx.registeredSessionId,
1983
2760
  );
1984
2761
  },
@@ -1997,7 +2774,11 @@ export class AgentBridge {
1997
2774
  ctx.registeredSessionId = newSessionId;
1998
2775
  },
1999
2776
  onPermissionModeChange: (mode: PermissionMode) => {
2000
- this.emit("permissionModeChange", { mode }, ctx.registeredSessionId);
2777
+ this.emit(
2778
+ "permissionModeChange",
2779
+ remoteWire.permissionModeChange(mode),
2780
+ ctx.registeredSessionId,
2781
+ );
2001
2782
  },
2002
2783
  onWorkdirChange: (newCwd: string) => {
2003
2784
  this.emit(
@@ -2010,7 +2791,11 @@ export class AgentBridge {
2010
2791
  this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
2011
2792
  },
2012
2793
  onNotificationMessageAdded: (params) => {
2013
- const msg = ctx.agent?.displayMessages.find(
2794
+ // The LAST matching message, not the first: one task can notify more
2795
+ // than once under the same task id (a workflow warns about its size
2796
+ // mid-run, then notifies again when it finishes), and this callback
2797
+ // fires for the message that was just appended — the newest one.
2798
+ const matches = (ctx.agent?.displayMessages ?? []).filter(
2014
2799
  (m) =>
2015
2800
  m.role === "user" &&
2016
2801
  m.blocks.some(
@@ -2019,14 +2804,19 @@ export class AgentBridge {
2019
2804
  (b as { taskId: string }).taskId === params.taskId,
2020
2805
  ),
2021
2806
  );
2807
+ const msg = matches[matches.length - 1];
2022
2808
  this.emit(
2023
2809
  "notificationMessageAdded",
2024
2810
  { ...params, message: msg },
2025
2811
  ctx.registeredSessionId,
2026
2812
  );
2027
2813
  },
2028
- onCompactBlockAdded: (content: string) => {
2029
- this.emit("compactBlockAdded", { content }, ctx.registeredSessionId);
2814
+ onCompactBlockAdded: (content: string, message?: Message) => {
2815
+ this.emit(
2816
+ "compactBlockAdded",
2817
+ { content, message },
2818
+ ctx.registeredSessionId,
2819
+ );
2030
2820
  },
2031
2821
  onCompactionStateChange: (isCompacting: boolean) => {
2032
2822
  this.emit(
@@ -2042,6 +2832,9 @@ export class AgentBridge {
2042
2832
  ctx.registeredSessionId,
2043
2833
  );
2044
2834
  },
2835
+ onPlanFileUpdated: (content: string) => {
2836
+ this.emit("planFileUpdated", { content }, ctx.registeredSessionId);
2837
+ },
2045
2838
  };
2046
2839
  }
2047
2840
 
@@ -2052,7 +2845,34 @@ export class AgentBridge {
2052
2845
 
2053
2846
  // ── Utils ─────────────────────────────────────────────────────
2054
2847
 
2848
+ /**
2849
+ * The session entry for a request that CHANGES session state. A session whose
2850
+ * agent is mid-rebuild (`updateConfig` → `SessionEntry.transitioning`) has no
2851
+ * usable agent: its outgoing one is being torn down, so a message injected into
2852
+ * it would be lost and a config write would be overwritten by the rebuild's own
2853
+ * stored config. Those requests are refused with a retryable error instead.
2854
+ * Read-only requests use `requireSessionForRead` and stay available.
2855
+ */
2055
2856
  private requireSession(sessionId?: string): SessionEntry {
2857
+ const entry = this.requireSessionForRead(sessionId);
2858
+ if (entry.transitioning) {
2859
+ throw new RpcError(
2860
+ PROTOCOL_INTERNAL_ERROR,
2861
+ `Session ${sessionId} is being reconfigured (config reload in progress), retry shortly`,
2862
+ );
2863
+ }
2864
+ return entry;
2865
+ }
2866
+
2867
+ /**
2868
+ * The session entry for a request that only READS. During a rebuild the entry
2869
+ * holds the outgoing agent: its messages are this same session's transcript and
2870
+ * its config is the pre-reload one, both of which are the best answer available
2871
+ * for the few hundred milliseconds the rebuild takes. Refusing reads instead
2872
+ * would turn a config reload into a burst of errors in whatever host happens to
2873
+ * be polling (`getMcpServers`, `getSlashCommands`, …).
2874
+ */
2875
+ private requireSessionForRead(sessionId?: string): SessionEntry {
2056
2876
  if (!sessionId) {
2057
2877
  throw new RpcError(
2058
2878
  PROTOCOL_INTERNAL_ERROR,
@@ -2103,6 +2923,22 @@ function isSessionRecoveryError(error: unknown): boolean {
2103
2923
  );
2104
2924
  }
2105
2925
 
2926
+ /**
2927
+ * The label a session list shows when no custom title is set: the first real
2928
+ * user message. Same rule as the webview's `firstUserMessageText` — meta messages
2929
+ * (task notifications and other injected turns) are skipped, because they are not
2930
+ * what the user typed — so the phone labels a conversation the way the IDE hosts
2931
+ * do.
2932
+ */
2933
+ function firstUserText(messages: Message[]): string | undefined {
2934
+ for (const message of messages) {
2935
+ if (message.role !== "user" || message.isMeta) continue;
2936
+ const text = getMessageContent(message).trim();
2937
+ if (text) return text;
2938
+ }
2939
+ return undefined;
2940
+ }
2941
+
2106
2942
  /** Read a text file, returning "" when it does not exist. */
2107
2943
  async function readTextFileSafe(filePath: string): Promise<string> {
2108
2944
  try {