wave-code 1.1.5 → 1.3.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 (46) hide show
  1. package/dist/bundle/wave.mjs +1075 -613
  2. package/package.json +4 -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/components/ChatInterface.tsx +3 -0
  7. package/src/components/ConfirmationDetails.tsx +36 -15
  8. package/src/components/ConfirmationSelector.tsx +39 -0
  9. package/src/components/HooksManager.tsx +286 -0
  10. package/src/components/InputBox.tsx +125 -51
  11. package/src/components/Markdown.tsx +21 -8
  12. package/src/components/MarketplaceDetail.tsx +10 -27
  13. package/src/components/MarketplaceView.tsx +17 -3
  14. package/src/components/MessageBlockItem.tsx +0 -5
  15. package/src/components/MessageList.tsx +0 -1
  16. package/src/components/Notifications.tsx +7 -0
  17. package/src/components/PlanView.tsx +146 -0
  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/constants/commands.ts +30 -0
  25. package/src/contexts/useChat.tsx +252 -98
  26. package/src/daemon/commands.ts +1046 -67
  27. package/src/daemon-cli.ts +6 -0
  28. package/src/hooks/useInputManager.ts +30 -0
  29. package/src/hooks/useLineScroll.ts +58 -0
  30. package/src/hooks/usePluginManager.ts +104 -78
  31. package/src/index.ts +156 -16
  32. package/src/managers/inputHandlers.ts +16 -9
  33. package/src/managers/inputReducer.ts +39 -9
  34. package/src/reducers/confirmationReducer.ts +20 -1
  35. package/src/reducers/hooksManagerReducer.ts +92 -0
  36. package/src/reducers/selectorReducer.ts +3 -0
  37. package/src/session-selector-cli.tsx +9 -255
  38. package/src/stdio/agentBridge.ts +932 -133
  39. package/src/stdio/daemonServer.ts +26 -82
  40. package/src/stdio/protocol.ts +23 -4
  41. package/src/stdio-cli.ts +60 -0
  42. package/src/utils/rewindCheckpoints.ts +2 -2
  43. package/src/utils/sessionOwnership.ts +62 -0
  44. package/src/utils/worktree.ts +175 -50
  45. package/src/components/BangDisplay.tsx +0 -41
  46. package/src/components/PlanDisplay.tsx +0 -18
@@ -31,8 +31,13 @@ import {
31
31
  type QueuedMessage,
32
32
  type SessionMetadata,
33
33
  type McpServerConfig,
34
+ type McpConfig,
34
35
  type Scope,
36
+ type PartialHookConfiguration,
37
+ isValidHookEvent,
35
38
  listSessions,
39
+ listAllSessions,
40
+ setSessionCustomTitle,
36
41
  searchFiles,
37
42
  generateRandomName,
38
43
  getDefaultRemoteBranch,
@@ -43,8 +48,16 @@ import {
43
48
  validateWorktreeRemovalPath,
44
49
  type SlashCommand,
45
50
  loadUserConfigEnv,
51
+ loadWaveConfigFromFile,
52
+ getUserConfigPaths,
53
+ getProjectConfigPaths,
54
+ readUserPreferenceView,
55
+ updateUserPreferenceSettings,
56
+ type UserPreferenceSettings,
57
+ type UserPreferenceSettingsView,
46
58
  type SubagentConfiguration,
47
59
  type SkillMetadata,
60
+ type PluginReloadResult,
48
61
  } from "wave-agent-sdk";
49
62
  import {
50
63
  type JsonRpcError,
@@ -53,10 +66,15 @@ import {
53
66
  METHOD_NOT_FOUND as PROTOCOL_METHOD_NOT_FOUND,
54
67
  } from "./protocol.js";
55
68
  import { execFileSync } from "node:child_process";
56
- import { mkdirSync, existsSync, writeFileSync } from "node:fs";
57
- import { tmpdir } from "node:os";
58
- import { basename, extname, join } from "node:path";
59
- import { createWorktree, removeWorktree } from "../utils/worktree.js";
69
+ import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
70
+ import { mkdir, open, readFile, writeFile } from "node:fs/promises";
71
+ import { homedir, tmpdir } from "node:os";
72
+ import { basename, dirname, extname, join } from "node:path";
73
+ import {
74
+ createWorktree,
75
+ getWorktreeChanges,
76
+ removeWorktree,
77
+ } from "../utils/worktree.js";
60
78
  import { logger } from "../utils/logger.js";
61
79
  import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
62
80
 
@@ -68,6 +86,11 @@ export type NotificationEmitter = (
68
86
 
69
87
  export interface AgentBridgeOptions {
70
88
  emit: NotificationEmitter;
89
+ /** Daemon transport only (`wave daemon stop`/`restart`): invoked by the
90
+ * `shutdown` RPC after every session has been destroyed — the server tears
91
+ * down its socket and exits. stdio mode leaves it unset, so `shutdown` is
92
+ * "Method not found" there. */
93
+ onShutdownRequest?: () => void;
71
94
  }
72
95
 
73
96
  interface InitializeParams {
@@ -79,7 +102,6 @@ interface InitializeParams {
79
102
  defaultHeaders?: Record<string, string>;
80
103
  model?: string;
81
104
  fastModel?: string;
82
- language?: string;
83
105
  permissionMode?: PermissionMode;
84
106
  tools?: string[];
85
107
  allowedTools?: string[];
@@ -90,6 +112,9 @@ interface InitializeParams {
90
112
  isNewWorktree?: boolean;
91
113
  }
92
114
 
115
+ // 会话级覆盖项:用户偏好(语言 / 上下文长度 / 自动记忆开关与频率)不在此层——
116
+ // 它们落用户级 ~/.wave/settings.json 经实时重载生效(getUserSettings /
117
+ // updateUserSettings),走覆盖层会永久遮蔽 settings.json 的实时值。
93
118
  interface UpdateConfigParams {
94
119
  apiKey?: string;
95
120
  baseURL?: string;
@@ -97,7 +122,6 @@ interface UpdateConfigParams {
97
122
  defaultHeaders?: Record<string, string>;
98
123
  model?: string;
99
124
  fastModel?: string;
100
- language?: string;
101
125
  }
102
126
 
103
127
  interface SearchFilesParams {
@@ -109,6 +133,34 @@ interface SearchFilesParams {
109
133
  interface SessionEntry {
110
134
  agent: Agent;
111
135
  storedConfig: Partial<InitializeParams>;
136
+ /**
137
+ * The workdir the session was CREATED in — an immutable identity, unlike the
138
+ * live `agent.workingDirectory`, which drifts as the session `cd`s (bash tool
139
+ * / worktree switches). Hosted-session consumers (destroy --remove-worktree's
140
+ * worktree resolution, list) read this instead of the live value so a session
141
+ * that moved out of its worktree into the main repo is still cleaned up at
142
+ * the right place. Recovered from the transcript's creation-time metadata
143
+ * header when the session is re-attached from disk after a daemon restart.
144
+ */
145
+ createdWorkdir: string;
146
+ /**
147
+ * True while `updateConfig` is tearing the session's agent down and rebuilding
148
+ * it from the updated config — the entry stays in the registry (so the session
149
+ * does not look deleted to a `wait` that is blocked on it) but has no usable
150
+ * agent. Two consequences, both deliberate:
151
+ *
152
+ * - `listDaemonSessions` reports it as `isLoading: true`. The teardown aborts
153
+ * the in-flight turn, so the outgoing agent reads `isLoading: false` while the
154
+ * rebuild is still running; reporting that would let a `wait` print an idle
155
+ * snapshot for a session whose agent is mid-replacement. A waiter that keeps
156
+ * waiting is never wrong here — the rebuild is bounded, and if it fails the
157
+ * entry is dropped, so the waiter then gets a clear "no longer exists".
158
+ * - State-changing RPCs refuse it (`requireSession`); a message sent to the
159
+ * outgoing agent would be silently lost. Read-only RPCs still work
160
+ * (`requireSessionForRead`) and see the outgoing agent's data, which is this
161
+ * same session's transcript.
162
+ */
163
+ transitioning?: boolean;
112
164
  }
113
165
 
114
166
  /**
@@ -138,11 +190,13 @@ export class AgentBridge {
138
190
  >();
139
191
  private permissionCounter = 0;
140
192
  private emit: NotificationEmitter;
193
+ private onShutdownRequest: (() => void) | undefined;
141
194
  private pluginCore: PluginCore | undefined;
142
195
  private pluginCoreWorkdir: string | undefined;
143
196
 
144
197
  constructor(options: AgentBridgeOptions) {
145
198
  this.emit = options.emit;
199
+ this.onShutdownRequest = options.onShutdownRequest;
146
200
  // Mirror the user-level settings env WAVE_SERVER_URL into process.env
147
201
  // before any agent initializes. getAuthStatus (webviewReady →
148
202
  // pushInitialState) can run before the first agent, and AuthService falls
@@ -172,12 +226,41 @@ export class AgentBridge {
172
226
  return this.restoreSession(p.sessionId as string, sessionId);
173
227
  case "listSessions":
174
228
  return this.listSessions(p.workdir as string | undefined, sessionId);
229
+ case "listAllSessions":
230
+ // Every project on this host (no session required) — the desktop's
231
+ // /resume picker lists sessions the app never created, so it cannot use
232
+ // the per-workdir listSessions.
233
+ return this.listAllSessions();
234
+ case "renameSession":
235
+ // Host-level like listAllSessions: the target session may have no live
236
+ // agent (the desktop renames sessions straight from its sidebar, and a
237
+ // restored one is not running), so the id/workdir come from the caller
238
+ // instead of the bound session.
239
+ return this.renameSession(
240
+ p.sessionId as string,
241
+ p.workdir as string,
242
+ p.title as string,
243
+ );
175
244
  case "getSessionInfo":
176
245
  return this.getSessionInfo(sessionId);
177
246
  case "listPendingPermissions":
178
247
  return this.listPendingPermissions();
179
248
  case "listDaemonSessions":
180
249
  return this.listDaemonSessions();
250
+ case "shutdown":
251
+ // Daemon-level graceful exit (`wave daemon stop` / `restart`): destroy
252
+ // every session first (each agent saves its transcript and drains
253
+ // auto-memory), then hand the process exit to the transport owner.
254
+ // Only the daemon registers the hook — stdio has no such concept.
255
+ if (!this.onShutdownRequest) {
256
+ throw new RpcError(
257
+ PROTOCOL_METHOD_NOT_FOUND,
258
+ "Method not found: shutdown",
259
+ );
260
+ }
261
+ await this.destroyAll();
262
+ this.onShutdownRequest();
263
+ return null;
181
264
  case "updateConfig":
182
265
  return this.updateConfig(p as unknown as UpdateConfigParams, sessionId);
183
266
  case "getConfiguredModels":
@@ -185,6 +268,15 @@ export class AgentBridge {
185
268
  case "setModel":
186
269
  return this.setModel(p.model as string, sessionId);
187
270
 
271
+ // ── User preferences (global — user-level ~/.wave/settings.json) ──
272
+ // 设置页保存路径:用户偏好写文件后由 SDK 实时重载在各会话下一轮生效,
273
+ // 不经 updateConfig 当 AgentOptions 覆盖层下发(见 protocol.ts 注释)。
274
+ // 读回的是**生效值**(可能来自 Remote 组织下发/环境变量)+ 每键来源层。
275
+ case "getUserSettings":
276
+ return this.userSettingsView();
277
+ case "updateUserSettings":
278
+ return this.updateUserSettings(p as UserPreferenceSettings);
279
+
188
280
  // ── Messages ──
189
281
  case "sendMessage":
190
282
  return this.sendMessage(
@@ -228,6 +320,8 @@ export class AgentBridge {
228
320
  return this.setPermissionMode(p.mode as PermissionMode, sessionId);
229
321
  case "getPermissionMode":
230
322
  return this.getPermissionMode(sessionId);
323
+ case "getPlanFile":
324
+ return this.getPlanFile(sessionId);
231
325
 
232
326
  // ── MCP ──
233
327
  case "getMcpServers":
@@ -236,6 +330,14 @@ export class AgentBridge {
236
330
  return this.connectMcpServer(p.serverName as string, sessionId);
237
331
  case "disconnectMcpServer":
238
332
  return this.disconnectMcpServer(p.serverName as string, sessionId);
333
+ case "removeMcpServer":
334
+ return this.removeMcpServer(
335
+ p.scope as "user" | "project",
336
+ p.serverName as string,
337
+ sessionId,
338
+ );
339
+ case "getMcpConfigPaths":
340
+ return this.getMcpConfigPaths(sessionId);
239
341
 
240
342
  // ── Commands ──
241
343
  case "getSlashCommands":
@@ -244,6 +346,21 @@ export class AgentBridge {
244
346
  return this.getSubagentConfigurations(sessionId);
245
347
  case "getSkillMetadata":
246
348
  return this.getSkillMetadata(sessionId);
349
+ case "deleteSkill":
350
+ return this.deleteSkill(p.name as string, sessionId);
351
+ case "deleteSubagent":
352
+ return this.deleteSubagent(p.name as string, sessionId);
353
+ case "getHooksByScope":
354
+ return this.getHooksByScope(
355
+ p.scope as "user" | "project" | "plugin",
356
+ sessionId,
357
+ );
358
+ case "deleteHook":
359
+ return this.deleteHook(
360
+ p.scope as "user" | "project",
361
+ p.hookName as string,
362
+ sessionId,
363
+ );
247
364
 
248
365
  // ── File / History (global — no session required) ──
249
366
  case "searchFiles":
@@ -267,11 +384,29 @@ export class AgentBridge {
267
384
  // ── Auth (global — no session required) ──
268
385
  case "getAuthStatus":
269
386
  return this.getAuthStatus();
387
+ case "getAccountInfo":
388
+ return this.getAccountInfo();
270
389
  case "login":
271
390
  return this.login(p.serverUrl as string | undefined);
272
391
  case "logout":
273
392
  return this.logout();
274
393
 
394
+ // ── Memory files (settings UI — user-level ~/.wave/AGENTS.md and
395
+ // project-level <workdir>/AGENTS.md) ──
396
+ case "getAgentsContent":
397
+ return this.getAgentsContent(
398
+ p.scope as "user" | "project",
399
+ p.workdir as string | undefined,
400
+ sessionId,
401
+ );
402
+ case "setAgentsContent":
403
+ return this.setAgentsContent(
404
+ p.scope as "user" | "project",
405
+ p.content as string,
406
+ p.workdir as string | undefined,
407
+ sessionId,
408
+ );
409
+
275
410
  // ── Plugins (global — no session required) ──
276
411
  case "listPlugins":
277
412
  return this.listPlugins(p.workdir as string | undefined, sessionId);
@@ -285,6 +420,7 @@ export class AgentBridge {
285
420
  case "uninstallPlugin":
286
421
  return this.uninstallPlugin(
287
422
  p.pluginId as string,
423
+ p.scope as Scope | undefined,
288
424
  p.workdir as string | undefined,
289
425
  sessionId,
290
426
  );
@@ -307,6 +443,18 @@ export class AgentBridge {
307
443
  p.workdir as string | undefined,
308
444
  sessionId,
309
445
  );
446
+ case "getHooksConfig":
447
+ return this.getHooksConfig(
448
+ p.scope as "user" | "project" | undefined,
449
+ p.workdir as string | undefined,
450
+ sessionId,
451
+ );
452
+ case "getMcpConfig":
453
+ return this.getMcpConfig(
454
+ p.scope as "user" | "project" | undefined,
455
+ p.workdir as string | undefined,
456
+ sessionId,
457
+ );
310
458
  case "setBuiltinPluginEnabled":
311
459
  return this.setBuiltinPluginEnabled(
312
460
  p.pluginId as string,
@@ -321,11 +469,25 @@ export class AgentBridge {
321
469
  p.workdir as string | undefined,
322
470
  sessionId,
323
471
  );
472
+ case "setPluginScope":
473
+ return this.setPluginScope(
474
+ p.pluginId as string,
475
+ p.scope as Scope,
476
+ p.workdir as string | undefined,
477
+ sessionId,
478
+ );
479
+ case "reloadPlugins":
480
+ return this.reloadPlugins();
324
481
  case "listMarketplaces":
325
482
  return this.listMarketplaces(
326
483
  p.workdir as string | undefined,
327
484
  sessionId,
328
485
  );
486
+ case "refreshMarketplaces":
487
+ return this.refreshMarketplaces(
488
+ p.workdir as string | undefined,
489
+ sessionId,
490
+ );
329
491
  case "addMarketplace":
330
492
  return this.addMarketplace(
331
493
  p.input as string,
@@ -376,6 +538,10 @@ export class AgentBridge {
376
538
  name?: string;
377
539
  },
378
540
  );
541
+ case "getWorktreeChanges":
542
+ return this.getWorktreeChanges(
543
+ p as unknown as { path?: string; baseBranch?: string },
544
+ );
379
545
  case "removeWorktree":
380
546
  return this.removeWorktreeSession(
381
547
  p as unknown as {
@@ -445,7 +611,6 @@ export class AgentBridge {
445
611
  defaultHeaders: params.defaultHeaders,
446
612
  model: params.model,
447
613
  fastModel: params.fastModel,
448
- language: params.language,
449
614
  permissionMode: params.permissionMode,
450
615
  tools: params.tools,
451
616
  allowedTools: params.allowedTools,
@@ -462,9 +627,29 @@ export class AgentBridge {
462
627
  ctx.agent = agent;
463
628
  ctx.registeredSessionId = agent.sessionId;
464
629
 
630
+ // Record where the session was created (see SessionEntry.createdWorkdir).
631
+ // A fresh session's initialize workdir IS the creation dir. A from-disk
632
+ // re-attach (the daemon was restarted — killed / CLI 升级重启 / reboot —
633
+ // then status/send re-hosts the session) instead passes the re-attaching
634
+ // client's cwd — the transcript header (`{"type":"metadata","workdir":...}`, append-only since session
635
+ // creation) restores the original value when the agent's transcript is
636
+ // readable; otherwise the client cwd is the best available anchor.
637
+ let createdWorkdir: string;
638
+ if (params.restoreSessionId) {
639
+ createdWorkdir =
640
+ (await readTranscriptWorkdir(agent.sessionFilePath)) ??
641
+ params.workdir ??
642
+ agent.workingDirectory;
643
+ } else {
644
+ createdWorkdir = params.workdir ?? agent.workingDirectory;
645
+ }
646
+
465
647
  this.sessions.set(agent.sessionId, {
466
648
  agent,
467
- storedConfig: { ...params },
649
+ // Keep storedConfig.workdir in sync: updateConfig rebuilds the agent from
650
+ // storedConfig, so the rebuild must stay anchored on the creation dir too.
651
+ storedConfig: { ...params, workdir: createdWorkdir },
652
+ createdWorkdir,
468
653
  });
469
654
 
470
655
  return {
@@ -479,34 +664,35 @@ export class AgentBridge {
479
664
  if (sessionId) {
480
665
  const entry = this.sessions.get(sessionId);
481
666
  if (entry) {
482
- await entry.agent.destroy();
667
+ if (entry.transitioning) {
668
+ // A rebuild owns this entry (updateConfig): destroying it here would
669
+ // race the rebuild, which would then re-register a replacement for a
670
+ // session the caller just destroyed. Refuse loudly instead — the
671
+ // caller can retry once the reload settles.
672
+ throw new RpcError(
673
+ PROTOCOL_INTERNAL_ERROR,
674
+ `Session ${sessionId} is being reconfigured (config reload in progress), retry shortly`,
675
+ );
676
+ }
677
+ // Drop the registry entry BEFORE awaiting the teardown. Agent.destroy()
678
+ // aborts the in-flight turn, which pushes `loadingChange:false` — the
679
+ // exact notification a finished turn pushes — and the teardown itself is
680
+ // not instant (transcript save, subagent/mcp cleanup, auto-memory drain).
681
+ // A client blocked in `wait` that is woken by that push re-reads the
682
+ // registry for the verdict: with the entry still listed (isLoading now
683
+ // false) it settles as exit 0 on an intermediate snapshot — a false
684
+ // "finished" for a session that is being removed. With the entry gone the
685
+ // existence check wins and it reports "no longer exists" (exit 1).
686
+ // Removed first also means a throwing teardown cannot leave a
687
+ // half-destroyed agent listed (destroyAll() below clears the map first
688
+ // for the same reason).
483
689
  this.sessions.delete(sessionId);
690
+ await entry.agent.destroy();
484
691
  }
485
692
  }
486
693
  return null;
487
694
  }
488
695
 
489
- /**
490
- * True when every hosted session has settled: not generating, nothing queued,
491
- * and no background work (background bash / subagents / workflows) — the same
492
- * condition `wave -p` waits on before exiting (print-cli.ts). Pending
493
- * permission approvals keep the owning agent's isLoading true, so they are
494
- * covered without an explicit check.
495
- */
496
- public isIdle(): boolean {
497
- for (const entry of this.sessions.values()) {
498
- const agent = entry.agent;
499
- if (
500
- agent.isLoading ||
501
- agent.hasPendingMessages ||
502
- agent.hasRunningBackgroundWork
503
- ) {
504
- return false;
505
- }
506
- }
507
- return true;
508
- }
509
-
510
696
  /**
511
697
  * Destroy every hosted session agent. Each Agent.destroy() saves its
512
698
  * transcript, drains in-flight auto-memory extraction, and cleans up
@@ -525,7 +711,7 @@ export class AgentBridge {
525
711
  restoreId: string,
526
712
  sessionId?: string,
527
713
  ): Promise<null> {
528
- const entry = this.requireSession(sessionId);
714
+ const entry = this.requireSessionForRead(sessionId);
529
715
  // Re-attach to a live session: the SDK restore would no-op (target is
530
716
  // already current). Emit the current messages so the freshly attached
531
717
  // client — whose router registered only after initialize returned and so
@@ -533,7 +719,7 @@ export class AgentBridge {
533
719
  if (entry.agent.sessionId === restoreId) {
534
720
  this.emit(
535
721
  "messagesChange",
536
- { messages: entry.agent.messages },
722
+ { messages: entry.agent.displayMessages },
537
723
  entry.agent.sessionId,
538
724
  );
539
725
  // The re-attached client also missed the loading state that settled
@@ -548,12 +734,69 @@ export class AgentBridge {
548
734
  },
549
735
  entry.agent.sessionId,
550
736
  );
737
+ // Same story for the context-usage indicator: the SDK's restore-time
738
+ // onLatestTotalTokensChange fired inside initialize — before this
739
+ // client's router registered — so replay the current usage or the
740
+ // webview keeps the previous session's (or no) percentage until the
741
+ // next turn pushes a fresh value (spec desktop-app 上下文用量指示器
742
+ // 场景 6: 恢复即显示该会话上次用量).
743
+ this.emitContextUsage(entry.agent);
551
744
  return null;
552
745
  }
746
+ // The replay branch above is safe during an `updateConfig` transition (it
747
+ // only re-pushes the outgoing agent's own state, which is this session's
748
+ // transcript). A REAL restore is not: it would load another conversation
749
+ // into the agent that is about to be destroyed and replaced, so the call
750
+ // would be silently thrown away together with the outgoing agent. Refuse it
751
+ // with the same retryable error the mutating RPCs use.
752
+ if (entry.transitioning) {
753
+ throw new RpcError(
754
+ PROTOCOL_INTERNAL_ERROR,
755
+ `Session ${sessionId} is being reconfigured (config reload in progress), retry shortly`,
756
+ );
757
+ }
553
758
  await entry.agent.restoreSession(restoreId);
759
+ // A real restore must also re-emit the usage unconditionally: the SDK
760
+ // only fires onLatestTotalTokensChange on a VALUE CHANGE, so restoring a
761
+ // conversation whose persisted total equals the agent's current one
762
+ // suppresses the change-push and the webview — which cleared the previous
763
+ // session's number on the switch — keeps a blank ring until the next turn
764
+ // (the change-push, when it does fire inside restoreSession, already
765
+ // delivered the same value; re-emitting is idempotent).
766
+ this.emitContextUsage(entry.agent);
554
767
  return null;
555
768
  }
556
769
 
770
+ /**
771
+ * Push the session's current context-usage percentage to the client. Shared
772
+ * by every restore path so a conversation the client just switched to always
773
+ * shows its usage right away instead of waiting for the next token change
774
+ * (spec desktop-app 上下文用量指示器 场景 6). A zero total means no real
775
+ * usage to report; the host keeps the empty ring until the first push.
776
+ */
777
+ private emitContextUsage(agent: Agent): void {
778
+ const percent = this.contextUsagePercentOf(agent);
779
+ if (percent !== undefined) {
780
+ this.emit("contextUsage", { percent }, agent.sessionId);
781
+ }
782
+ }
783
+
784
+ /**
785
+ * Current context-usage percentage of a session, or undefined when there is
786
+ * nothing to report yet (no tokens) or the model exposes no limit. Shared by
787
+ * the change-driven push and the getMessages response, so a host whose
788
+ * webview was re-created long after the last token change can still restore
789
+ * the ring without a per-host cache.
790
+ */
791
+ private contextUsagePercentOf(agent: Agent): number | undefined {
792
+ const tokens = agent.latestTotalTokens;
793
+ const max = agent.getMaxInputTokens();
794
+ if (tokens > 0 && max > 0) {
795
+ return Math.min(100, Math.round((tokens / max) * 100));
796
+ }
797
+ return undefined;
798
+ }
799
+
557
800
  private async listSessions(
558
801
  workdir?: string,
559
802
  sessionId?: string,
@@ -564,6 +807,38 @@ export class AgentBridge {
564
807
  return { sessions };
565
808
  }
566
809
 
810
+ /**
811
+ * All main sessions across every project directory of this host, newest
812
+ * first (deduplicated by session id). Each entry carries the session's real
813
+ * `workdir`, which is what a caller needs to resume it.
814
+ */
815
+ private async listAllSessions(): Promise<{ sessions: SessionMetadata[] }> {
816
+ const sessions = await listAllSessions();
817
+ return { sessions };
818
+ }
819
+
820
+ /**
821
+ * Set a session's user-visible title. Works for any session on this host —
822
+ * the target does not need to be the bound one, or even live.
823
+ */
824
+ private async renameSession(
825
+ sessionId: string,
826
+ workdir: string,
827
+ title: string,
828
+ ): Promise<void> {
829
+ // A session this host is running keeps the title in memory and writes it
830
+ // back at its next flush point (exit, compaction, resume, rewind), so a
831
+ // file-only write here would be clobbered by the stale in-memory value.
832
+ // Route through the agent; fall back to the file for sessions that are not
833
+ // bound to this host's process.
834
+ const bound = this.sessions.get(sessionId)?.agent;
835
+ if (bound) {
836
+ await bound.renameSession(title);
837
+ return;
838
+ }
839
+ await setSessionCustomTitle(sessionId, workdir, title);
840
+ }
841
+
567
842
  // ── Git / worktree ────────────────────────────────────────────
568
843
 
569
844
  private listGitBranches(workdir?: string): {
@@ -647,6 +922,21 @@ export class AgentBridge {
647
922
  }
648
923
  }
649
924
 
925
+ /**
926
+ * What a worktree deletion would throw away (uncommitted files and commits
927
+ * not on the base branch), so the caller can warn before deleting. Returns
928
+ * null when the worktree cannot be inspected.
929
+ */
930
+ private async getWorktreeChanges(params: {
931
+ path?: string;
932
+ baseBranch?: string;
933
+ }): Promise<{ files: number; commits: number } | null> {
934
+ if (!params.path) {
935
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, "path is required");
936
+ }
937
+ return getWorktreeChanges(params.path, params.baseBranch);
938
+ }
939
+
650
940
  private async removeWorktreeSession(params: {
651
941
  path: string;
652
942
  branch: string;
@@ -689,10 +979,13 @@ export class AgentBridge {
689
979
  permissionMode: PermissionMode;
690
980
  availableTools: string[];
691
981
  } {
692
- const entry = this.requireSession(sessionId);
982
+ const entry = this.requireSessionForRead(sessionId);
693
983
  return {
694
984
  sessionId: entry.agent.sessionId,
695
- workingDirectory: entry.agent.workingDirectory,
985
+ // The recorded creation workdir, not the live agent.workingDirectory:
986
+ // destroy --remove-worktree resolves the session's worktree from this
987
+ // value, and it must not drift when the session cd'd out of the worktree.
988
+ workingDirectory: entry.createdWorkdir,
696
989
  latestTotalTokens: entry.agent.latestTotalTokens,
697
990
  permissionMode: entry.agent.getPermissionMode(),
698
991
  availableTools: entry.agent.getAvailableToolNames(),
@@ -707,73 +1000,105 @@ export class AgentBridge {
707
1000
  const currentSessionId = entry.agent.sessionId;
708
1001
  // Merge new config into stored config
709
1002
  entry.storedConfig = { ...entry.storedConfig, ...params };
710
- // Destroy and recreate within the same session slot
711
- await entry.agent.destroy();
712
- this.sessions.delete(currentSessionId);
713
-
1003
+ // The entry is NOT removed while the agent is replaced: dropping it would
1004
+ // make a `wait` blocked on this session report "no longer exists" (exit 1)
1005
+ // for a session that is alive and merely reloading — a false error, the same
1006
+ // family as the false idle this whole change is about. Instead the entry is
1007
+ // flagged `transitioning` for the whole destroy + recreate, which
1008
+ // `listDaemonSessions` reports as `isLoading: true` (so no reader can see the
1009
+ // "listed + not busy" combination a waiter would settle on) while
1010
+ // state-changing RPCs refuse it (`requireSession`) instead of talking to the
1011
+ // outgoing, dying agent. Read-only RPCs keep working off that agent.
1012
+ entry.transitioning = true;
714
1013
  const ctx: SessionContext = {};
715
- const callbacks = this.createCallbacks(ctx);
716
- const options: AgentOptions = {
717
- callbacks,
718
- workdir: entry.storedConfig.workdir,
719
- restoreSessionId: currentSessionId,
720
- apiKey: entry.storedConfig.apiKey,
721
- baseURL: entry.storedConfig.baseURL,
722
- defaultHeaders: entry.storedConfig.defaultHeaders,
723
- model: entry.storedConfig.model,
724
- fastModel: entry.storedConfig.fastModel,
725
- language: entry.storedConfig.language,
726
- permissionMode: entry.storedConfig.permissionMode,
727
- tools: entry.storedConfig.tools,
728
- allowedTools: entry.storedConfig.allowedTools,
729
- disallowedTools: entry.storedConfig.disallowedTools,
730
- plugins: entry.storedConfig.pluginDirs?.map((p) => ({
731
- type: "local",
732
- path: p,
733
- })),
734
- mcpServers: entry.storedConfig.mcpServers,
735
- // Keep worktree context (permission safety) across recreation, but never
736
- // re-fire WorktreeCreate — the hook ran at initial creation.
737
- worktreeName: entry.storedConfig.worktreeName,
738
- canUseTool: (context: ToolPermissionContext) =>
739
- this.canUseTool(context, ctx),
740
- };
741
-
742
- let agent: Agent;
743
1014
  try {
744
- agent = await Agent.create(options);
745
- } catch (createError) {
746
- // The old entry was already destroyed and removed above. If the session
747
- // file is missing or unrecoverable (fresh session, cleared chat, or a
748
- // never-persisted empty session), fail soft: recreate WITHOUT
749
- // restoreSessionId so this session slot keeps working. Without this the
750
- // client's sessionId still points at the destroyed entry and every later
751
- // request fails with "Session not found" until the window is reloaded.
752
- // Non-session errors (bad baseURL/apiKey/config) must surface unchanged.
753
- if (!options.restoreSessionId || !isSessionRecoveryError(createError)) {
754
- throw createError;
1015
+ // Destroy and recreate within the same session slot. The destroy comes
1016
+ // first so the outgoing agent stops writing the transcript before the
1017
+ // replacement loads it.
1018
+ await entry.agent.destroy();
1019
+
1020
+ const callbacks = this.createCallbacks(ctx);
1021
+ const options: AgentOptions = {
1022
+ callbacks,
1023
+ workdir: entry.storedConfig.workdir,
1024
+ restoreSessionId: currentSessionId,
1025
+ apiKey: entry.storedConfig.apiKey,
1026
+ baseURL: entry.storedConfig.baseURL,
1027
+ defaultHeaders: entry.storedConfig.defaultHeaders,
1028
+ model: entry.storedConfig.model,
1029
+ fastModel: entry.storedConfig.fastModel,
1030
+ permissionMode: entry.storedConfig.permissionMode,
1031
+ tools: entry.storedConfig.tools,
1032
+ allowedTools: entry.storedConfig.allowedTools,
1033
+ disallowedTools: entry.storedConfig.disallowedTools,
1034
+ plugins: entry.storedConfig.pluginDirs?.map((p) => ({
1035
+ type: "local",
1036
+ path: p,
1037
+ })),
1038
+ mcpServers: entry.storedConfig.mcpServers,
1039
+ // Keep worktree context (permission safety) across recreation, but never
1040
+ // re-fire WorktreeCreate — the hook ran at initial creation.
1041
+ worktreeName: entry.storedConfig.worktreeName,
1042
+ canUseTool: (context: ToolPermissionContext) =>
1043
+ this.canUseTool(context, ctx),
1044
+ };
1045
+
1046
+ let agent: Agent;
1047
+ try {
1048
+ agent = await Agent.create(options);
1049
+ } catch (createError) {
1050
+ // The old agent is already destroyed. If the session file is missing or
1051
+ // unrecoverable (fresh session, cleared chat, or a never-persisted empty
1052
+ // session), fail soft: recreate WITHOUT restoreSessionId so this session
1053
+ // slot keeps working. Without this the client's sessionId still points at
1054
+ // the destroyed entry and every later request fails with "Session not
1055
+ // found" until the window is reloaded. Non-session errors (bad
1056
+ // baseURL/apiKey/config) must surface unchanged.
1057
+ if (!options.restoreSessionId || !isSessionRecoveryError(createError)) {
1058
+ throw createError;
1059
+ }
1060
+ logger?.warn(
1061
+ `updateConfig: failed to restore session ${currentSessionId}, recreating as a fresh session:`,
1062
+ createError,
1063
+ );
1064
+ agent = await Agent.create({ ...options, restoreSessionId: undefined });
755
1065
  }
756
- logger?.warn(
757
- `updateConfig: failed to restore session ${currentSessionId}, recreating as a fresh session:`,
758
- createError,
759
- );
760
- agent = await Agent.create({ ...options, restoreSessionId: undefined });
1066
+ ctx.agent = agent;
1067
+ ctx.registeredSessionId = agent.sessionId;
1068
+ entry.agent = agent;
1069
+ // A recovery-recreated session gets a NEW id: move the registry key with it.
1070
+ // (The entry object is reused, so the flag and the recorded creation workdir
1071
+ // survive either way.) Announced with the OLD id as the envelope — same
1072
+ // contract as `onSessionIdChange` — so a client blocked on this session can
1073
+ // rebind to the new id instead of concluding it disappeared.
1074
+ if (agent.sessionId !== currentSessionId) {
1075
+ this.emit(
1076
+ "sessionIdChange",
1077
+ { sessionId: agent.sessionId },
1078
+ currentSessionId,
1079
+ );
1080
+ this.sessions.delete(currentSessionId);
1081
+ this.sessions.set(agent.sessionId, entry);
1082
+ }
1083
+ entry.transitioning = false;
1084
+ return { sessionId: agent.sessionId };
1085
+ } catch (teardownError) {
1086
+ // Nothing usable is left: the outgoing agent is torn down (fully or
1087
+ // partially) and no replacement was built. Leaving the entry listed would
1088
+ // be a "half-dead" session — reachable through the registry but with no
1089
+ // working agent — so it is dropped, exactly like a failing `destroy`: a
1090
+ // waiter then reports "no longer exists" (exit 1) instead of hanging or
1091
+ // settling. The error surfaces to the caller unchanged.
1092
+ this.sessions.delete(currentSessionId);
1093
+ throw teardownError;
761
1094
  }
762
- ctx.agent = agent;
763
- ctx.registeredSessionId = agent.sessionId;
764
- this.sessions.set(agent.sessionId, {
765
- agent,
766
- storedConfig: { ...entry.storedConfig },
767
- });
768
-
769
- return { sessionId: agent.sessionId };
770
1095
  }
771
1096
 
772
1097
  private getConfiguredModels(sessionId?: string): {
773
1098
  models: string[];
774
1099
  currentModel: string | undefined;
775
1100
  } {
776
- const entry = this.requireSession(sessionId);
1101
+ const entry = this.requireSessionForRead(sessionId);
777
1102
  return {
778
1103
  models: entry.agent.getConfiguredModels(),
779
1104
  currentModel: entry.agent.getModelConfig().model,
@@ -782,7 +1107,7 @@ export class AgentBridge {
782
1107
 
783
1108
  private async setModel(model: string, sessionId?: string): Promise<null> {
784
1109
  const entry = this.requireSession(sessionId);
785
- entry.agent.setModel(model);
1110
+ await entry.agent.setModel(model);
786
1111
  // Keep storedConfig in sync: updateConfig recreates the agent from
787
1112
  // storedConfig, so without this a later config save would revert the
788
1113
  // model chosen here.
@@ -790,6 +1115,47 @@ export class AgentBridge {
790
1115
  return null;
791
1116
  }
792
1117
 
1118
+ // ── User preferences ──────────────────────────────────────────
1119
+
1120
+ /**
1121
+ * 设置页保存路径:写用户级 `~/.wave/settings.json` 后,**显式重载本进程内
1122
+ * 全部会话**的实时配置。
1123
+ *
1124
+ * 不能只依赖文件监视:会话启动时该文件(乃至 `~/.wave` 目录)通常还不存在,
1125
+ * 监视要靠在父目录链存在的前提下补发事件;而这个文件正是被本次保存**创建**
1126
+ * 出来的最常见场景(全新安装的第一次保存,spec core/agent-config.md
1127
+ * 「设置实时重载」场景 7)。写完即重载使「保存 ⇒ 下一轮生效」与监视是否就绪
1128
+ * 无关;监视若也命中,重载是幂等的(并发重载按 `reloadInProgress` 去重)。
1129
+ *
1130
+ * 空载荷(无差异保存、只回读)不落盘,也不重载。
1131
+ *
1132
+ * 回包是**生效视图**(同 `getUserSettings`):设置页在保存后仍须看到「被组织
1133
+ * 配置覆盖」的键的生效值与来源,而不是回退成用户文件里的值。
1134
+ */
1135
+ private async updateUserSettings(
1136
+ patch: UserPreferenceSettings,
1137
+ ): Promise<UserPreferenceSettingsView> {
1138
+ await updateUserPreferenceSettings(patch);
1139
+ if (!Object.values(patch).every((value) => value === undefined)) {
1140
+ await Promise.all(
1141
+ [...this.sessions.values()].map((entry) =>
1142
+ entry.agent.reloadConfiguration(),
1143
+ ),
1144
+ );
1145
+ }
1146
+ return this.userSettingsView();
1147
+ }
1148
+
1149
+ /**
1150
+ * 用户偏好的**生效视图**:生效值 + 每键来源层(用户级文件 / Remote 组织下发 /
1151
+ * 机器环境变量 / 默认)。设置页据此把「由组织配置管理」的键显示为生效值 +
1152
+ * 置灰(spec core/agent-config.md 边界说明「用户偏好的层与来源」)。
1153
+ */
1154
+ private userSettingsView(): UserPreferenceSettingsView {
1155
+ const { values, sources } = readUserPreferenceView();
1156
+ return { ...values, preferenceSources: sources };
1157
+ }
1158
+
793
1159
  // ── Messages ──────────────────────────────────────────────────
794
1160
 
795
1161
  private async sendMessage(
@@ -930,7 +1296,7 @@ export class AgentBridge {
930
1296
  private async listRewindCheckpoints(sessionId?: string): Promise<{
931
1297
  checkpoints: Array<{ id: string; content: string }>;
932
1298
  }> {
933
- const entry = this.requireSession(sessionId);
1299
+ const entry = this.requireSessionForRead(sessionId);
934
1300
  const { messages } = await entry.agent.getFullMessageThread();
935
1301
  // 压缩 append-only 后同 id 消息在磁盘完整线程中重复出现(压缩前历史 +
936
1302
  // 压缩后 append 的重复)。按 id 去重并保留最后一次出现(与折叠后的
@@ -973,16 +1339,22 @@ export class AgentBridge {
973
1339
  return null;
974
1340
  }
975
1341
 
976
- private getMessages(sessionId?: string): { messages: Message[] } {
977
- const entry = this.requireSession(sessionId);
978
- return { messages: entry.agent.messages };
1342
+ private getMessages(sessionId?: string): {
1343
+ messages: Message[];
1344
+ contextUsagePercent?: number;
1345
+ } {
1346
+ const entry = this.requireSessionForRead(sessionId);
1347
+ return {
1348
+ messages: entry.agent.displayMessages,
1349
+ contextUsagePercent: this.contextUsagePercentOf(entry.agent),
1350
+ };
979
1351
  }
980
1352
 
981
1353
  private async getFullMessageThread(sessionId?: string): Promise<{
982
1354
  messages: Message[];
983
1355
  sessionIds: string[];
984
1356
  }> {
985
- const entry = this.requireSession(sessionId);
1357
+ const entry = this.requireSessionForRead(sessionId);
986
1358
  return entry.agent.getFullMessageThread();
987
1359
  }
988
1360
 
@@ -1001,7 +1373,7 @@ export class AgentBridge {
1001
1373
  taskId: string,
1002
1374
  sessionId?: string,
1003
1375
  ): { output: ReturnType<Agent["getBackgroundTaskOutput"]> } {
1004
- const entry = this.requireSession(sessionId);
1376
+ const entry = this.requireSessionForRead(sessionId);
1005
1377
  return { output: entry.agent.getBackgroundTaskOutput(taskId) };
1006
1378
  }
1007
1379
 
@@ -1023,7 +1395,7 @@ export class AgentBridge {
1023
1395
  private async getWorkflowRuns(
1024
1396
  sessionId?: string,
1025
1397
  ): Promise<{ runs: SerializableWorkflowRun[] }> {
1026
- const entry = this.requireSession(sessionId);
1398
+ const entry = this.requireSessionForRead(sessionId);
1027
1399
  const runs = await entry.agent.getWorkflowRuns();
1028
1400
  return {
1029
1401
  runs: runs.map((r) => {
@@ -1055,14 +1427,40 @@ export class AgentBridge {
1055
1427
  }
1056
1428
 
1057
1429
  private getPermissionMode(sessionId?: string): { mode: PermissionMode } {
1058
- const entry = this.requireSession(sessionId);
1430
+ const entry = this.requireSessionForRead(sessionId);
1059
1431
  return { mode: entry.agent.getPermissionMode() };
1060
1432
  }
1061
1433
 
1434
+ /**
1435
+ * Returns the current plan file path and its contents for the session.
1436
+ * When the path generation is still in flight (e.g. the host just switched
1437
+ * to plan mode via setPermissionMode), awaits it so the caller can read the
1438
+ * plan before triggering a query (the plan mode reminder needs the path).
1439
+ */
1440
+ private async getPlanFile(sessionId?: string): Promise<{
1441
+ path: string | null;
1442
+ content: string | null;
1443
+ }> {
1444
+ const entry = this.requireSessionForRead(sessionId);
1445
+ const agent = entry.agent;
1446
+ const planPath =
1447
+ agent.getPlanFilePath() ?? (await agent.awaitPlanFilePath());
1448
+ if (!planPath) {
1449
+ return { path: null, content: null };
1450
+ }
1451
+ try {
1452
+ const content = await readFile(planPath, "utf8");
1453
+ return { path: planPath, content };
1454
+ } catch (error) {
1455
+ logger?.warn("Failed to read plan file", error);
1456
+ return { path: planPath, content: null };
1457
+ }
1458
+ }
1459
+
1062
1460
  // ── MCP ───────────────────────────────────────────────────────
1063
1461
 
1064
1462
  private getMcpServers(sessionId?: string): { servers: McpServerStatus[] } {
1065
- const entry = this.requireSession(sessionId);
1463
+ const entry = this.requireSessionForRead(sessionId);
1066
1464
  return { servers: entry.agent.getMcpServers() };
1067
1465
  }
1068
1466
 
@@ -1084,25 +1482,90 @@ export class AgentBridge {
1084
1482
  return { success };
1085
1483
  }
1086
1484
 
1485
+ private async removeMcpServer(
1486
+ scope: "user" | "project",
1487
+ serverName: string,
1488
+ sessionId?: string,
1489
+ ): Promise<{ success: boolean }> {
1490
+ const entry = this.requireSession(sessionId);
1491
+ const success = await entry.agent.removeMcpServer(scope, serverName);
1492
+ return { success };
1493
+ }
1494
+
1495
+ private getMcpConfigPaths(sessionId?: string): {
1496
+ userPath: string | null;
1497
+ projectPath: string | null;
1498
+ } {
1499
+ const entry = this.requireSessionForRead(sessionId);
1500
+ return {
1501
+ userPath: entry.agent.getUserMcpConfigPath(),
1502
+ projectPath: entry.agent.getProjectMcpConfigPath(),
1503
+ };
1504
+ }
1505
+
1087
1506
  // ── Commands ──────────────────────────────────────────────────
1088
1507
 
1089
1508
  private getSlashCommands(sessionId?: string): { commands: SlashCommand[] } {
1090
- const entry = this.requireSession(sessionId);
1509
+ const entry = this.requireSessionForRead(sessionId);
1091
1510
  return { commands: entry.agent.getSlashCommands() };
1092
1511
  }
1093
1512
 
1094
1513
  private getSubagentConfigurations(sessionId?: string): {
1095
1514
  configurations: SubagentConfiguration[];
1096
1515
  } {
1097
- const entry = this.requireSession(sessionId);
1516
+ const entry = this.requireSessionForRead(sessionId);
1098
1517
  return { configurations: entry.agent.getSubagentConfigurations() };
1099
1518
  }
1100
1519
 
1101
1520
  private getSkillMetadata(sessionId?: string): { skills: SkillMetadata[] } {
1102
- const entry = this.requireSession(sessionId);
1521
+ const entry = this.requireSessionForRead(sessionId);
1103
1522
  return { skills: entry.agent.getSkillMetadata() };
1104
1523
  }
1105
1524
 
1525
+ private async deleteSkill(
1526
+ name: string,
1527
+ sessionId?: string,
1528
+ ): Promise<{ success: boolean }> {
1529
+ const entry = this.requireSession(sessionId);
1530
+ const success = await entry.agent.deleteSkill(name);
1531
+ return { success };
1532
+ }
1533
+
1534
+ private async deleteSubagent(
1535
+ name: string,
1536
+ sessionId?: string,
1537
+ ): Promise<{ success: boolean }> {
1538
+ const entry = this.requireSession(sessionId);
1539
+ const success = await entry.agent.deleteSubagent(name);
1540
+ return { success };
1541
+ }
1542
+
1543
+ private async getHooksByScope(
1544
+ scope: "user" | "project" | "plugin",
1545
+ sessionId?: string,
1546
+ ): Promise<{
1547
+ hooks: Partial<Record<string, unknown[]>>;
1548
+ configPath: string | null;
1549
+ }> {
1550
+ const entry = this.requireSessionForRead(sessionId);
1551
+ const hooks = await entry.agent.getHooksByScope(scope);
1552
+ // 回带该 scope 钩子所在 settings.json 的绝对路径(宿主 GUI 打开文件用;
1553
+ // plugin 钩子来自代码而非配置文件 → null)。
1554
+ const configPath =
1555
+ scope === "plugin" ? null : entry.agent.getHookConfigPath(scope);
1556
+ return { hooks, configPath };
1557
+ }
1558
+
1559
+ private async deleteHook(
1560
+ scope: "user" | "project",
1561
+ hookName: string,
1562
+ sessionId?: string,
1563
+ ): Promise<{ success: boolean }> {
1564
+ const entry = this.requireSession(sessionId);
1565
+ await entry.agent.deleteHook(scope, hookName);
1566
+ return { success: true };
1567
+ }
1568
+
1106
1569
  // ── File / History (global) ───────────────────────────────────
1107
1570
 
1108
1571
  private async searchFiles(
@@ -1219,21 +1682,44 @@ export class AgentBridge {
1219
1682
  }
1220
1683
 
1221
1684
  /** Daemon list: expose the in-memory session registry (live sessions only,
1222
- * not disk-scanning). Registration order is preserved. */
1685
+ * not disk-scanning). Registration order is preserved. The working directory
1686
+ * shown is the session's recorded creation workdir (stable identity), not the
1687
+ * live value that drifts with in-session `cd`.
1688
+ *
1689
+ * A session mid-rebuild (`transitioning`, see `SessionEntry`) reports
1690
+ * `isLoading: true`: its outgoing agent has already been aborted by the
1691
+ * teardown, so its own flag reads false while the replacement is still being
1692
+ * built — reporting that would hand a `wait` the "listed + not busy"
1693
+ * combination it settles on. "Not idle" is the honest reading of "this session
1694
+ * has no agent right now", and it can only make a waiter wait.
1695
+ *
1696
+ * `hasRunningBackgroundWork` / `hasPendingMessages` are the other two halves of
1697
+ * the idle predicate `wait` / `status` share with `wave -p` (print mode): a
1698
+ * session whose foreground turn has ended can still own a running background
1699
+ * bash task or subagent, and the last such task flips to `completed` before its
1700
+ * completion notification is enqueued for the main agent's follow-up turn —
1701
+ * reading only `isLoading` reports "done" in both cases. They are read from the
1702
+ * live agent here rather than tracked separately, and mirror it as-is: the
1703
+ * rebuild window above is already covered by `isLoading`, so there is no need
1704
+ * to also claim a `transitioning` session owns background work it doesn't. */
1223
1705
  private listDaemonSessions(): {
1224
1706
  sessions: Array<{
1225
1707
  sessionId: string;
1226
1708
  workingDirectory: string;
1227
1709
  isLoading: boolean;
1710
+ hasRunningBackgroundWork: boolean;
1711
+ hasPendingMessages: boolean;
1228
1712
  messageCount: number;
1229
1713
  }>;
1230
1714
  } {
1231
1715
  return {
1232
1716
  sessions: [...this.sessions.entries()].map(([sessionId, entry]) => ({
1233
1717
  sessionId,
1234
- workingDirectory: entry.agent.workingDirectory,
1235
- isLoading: entry.agent.isLoading,
1236
- messageCount: entry.agent.messages.length,
1718
+ workingDirectory: entry.createdWorkdir,
1719
+ isLoading: entry.transitioning === true || entry.agent.isLoading,
1720
+ hasRunningBackgroundWork: entry.agent.hasRunningBackgroundWork,
1721
+ hasPendingMessages: entry.agent.hasPendingMessages,
1722
+ messageCount: entry.agent.displayMessages.length,
1237
1723
  })),
1238
1724
  };
1239
1725
  }
@@ -1254,6 +1740,36 @@ export class AgentBridge {
1254
1740
  };
1255
1741
  }
1256
1742
 
1743
+ private async getAccountInfo(): Promise<{
1744
+ /**
1745
+ * 计费结论(套餐两根条 + 到期日 + 降级原因),形状见 codechat `GET /api/v1/account` → `billing`
1746
+ * (判定出自与 proxy 扣费同一份代码)。本层只转发、不解析;具体形状由 wave-webview-fixtures
1747
+ * 的 AccountBillingInfo 在宿主/卡片侧约束。
1748
+ */
1749
+ billing?: unknown;
1750
+ apiQuota?: { limit: number | null; used: number } | null;
1751
+ }> {
1752
+ const authService = AuthService.getInstance();
1753
+ await authService.checkAndRefreshTokenIfNeeded();
1754
+ if (!authService.isSSOAuthenticated()) return {};
1755
+ const token = authService.getSSOToken();
1756
+ if (!token) return {};
1757
+ const response = await fetch(
1758
+ `${authService.getServerUrl()}/api/v1/account`,
1759
+ {
1760
+ headers: { Authorization: `Bearer ${token}` },
1761
+ },
1762
+ );
1763
+ if (!response.ok) {
1764
+ // 401 / 5xx — 让调用方(desktop host)保留上次成功数据(spec 场景 9)。
1765
+ throw new Error(`Account query failed (${response.status})`);
1766
+ }
1767
+ return (await response.json()) as {
1768
+ billing?: unknown;
1769
+ apiQuota?: { limit: number | null; used: number } | null;
1770
+ };
1771
+ }
1772
+
1257
1773
  private async login(
1258
1774
  serverUrl?: string,
1259
1775
  ): Promise<{ user: { id: string; email?: string } | undefined }> {
@@ -1273,6 +1789,88 @@ export class AgentBridge {
1273
1789
  return null;
1274
1790
  }
1275
1791
 
1792
+ // ── Memory files (settings UI) ───────────────────────────────
1793
+
1794
+ /**
1795
+ * Read an AGENTS.md file for the settings UI: user-level reads
1796
+ * ~/.wave/AGENTS.md, project-level reads <workdir>/AGENTS.md. Prefers the
1797
+ * live agent (clears its caches on write) and falls back to direct file
1798
+ * access when no session is bound (e.g. desktop before any session exists).
1799
+ */
1800
+ private async getAgentsContent(
1801
+ scope: "user" | "project",
1802
+ workdir?: string,
1803
+ sessionId?: string,
1804
+ ): Promise<{ content: string; path?: string }> {
1805
+ const agent = this.sessions.get(sessionId ?? "")?.agent;
1806
+ if (scope === "user") {
1807
+ if (agent) {
1808
+ return { content: await agent.readUserMemoryContent() };
1809
+ }
1810
+ const path = this.getUserMemoryFilePath();
1811
+ return { content: await readTextFileSafe(path), path };
1812
+ }
1813
+ const resolvedWorkdir =
1814
+ workdir || (agent?.workingDirectory as string | undefined);
1815
+ if (!resolvedWorkdir) {
1816
+ throw new RpcError(
1817
+ PROTOCOL_INVALID_PARAMS,
1818
+ "Project-scope AGENTS.md requires a workdir",
1819
+ );
1820
+ }
1821
+ if (agent) {
1822
+ return {
1823
+ content: await agent.readProjectMemoryContent(resolvedWorkdir),
1824
+ };
1825
+ }
1826
+ const path = join(resolvedWorkdir, "AGENTS.md");
1827
+ return { content: await readTextFileSafe(path), path };
1828
+ }
1829
+
1830
+ /**
1831
+ * Write an AGENTS.md file from the settings UI: user-level writes
1832
+ * ~/.wave/AGENTS.md, project-level writes <workdir>/AGENTS.md. Goes through
1833
+ * the live agent when available so its memory caches are invalidated.
1834
+ */
1835
+ private async setAgentsContent(
1836
+ scope: "user" | "project",
1837
+ content: string,
1838
+ workdir?: string,
1839
+ sessionId?: string,
1840
+ ): Promise<{ ok: boolean; path?: string }> {
1841
+ const agent = this.sessions.get(sessionId ?? "")?.agent;
1842
+ if (scope === "user") {
1843
+ if (agent) {
1844
+ await agent.writeUserMemoryContent(content);
1845
+ } else {
1846
+ const path = this.getUserMemoryFilePath();
1847
+ await mkdir(dirname(path), { recursive: true });
1848
+ await writeFile(path, content, "utf-8");
1849
+ }
1850
+ return { ok: true };
1851
+ }
1852
+ const resolvedWorkdir =
1853
+ workdir || (agent?.workingDirectory as string | undefined);
1854
+ if (!resolvedWorkdir) {
1855
+ throw new RpcError(
1856
+ PROTOCOL_INVALID_PARAMS,
1857
+ "Project-scope AGENTS.md requires a workdir",
1858
+ );
1859
+ }
1860
+ if (agent) {
1861
+ await agent.writeProjectMemoryContent(resolvedWorkdir, content);
1862
+ } else {
1863
+ const path = join(resolvedWorkdir, "AGENTS.md");
1864
+ await mkdir(dirname(path), { recursive: true });
1865
+ await writeFile(path, content, "utf-8");
1866
+ }
1867
+ return { ok: true };
1868
+ }
1869
+
1870
+ private getUserMemoryFilePath(): string {
1871
+ return join(homedir(), ".wave", "AGENTS.md");
1872
+ }
1873
+
1276
1874
  // ── Plugins (global) ─────────────────────────────────────────
1277
1875
 
1278
1876
  private getPluginCore(workdir?: string, sessionId?: string): PluginCore {
@@ -1298,6 +1896,7 @@ export class AgentBridge {
1298
1896
  marketplace: p.marketplace,
1299
1897
  installed: p.installed,
1300
1898
  version: p.version,
1899
+ latestVersion: p.latestVersion,
1301
1900
  enabled: mergedEnabled[pluginId] !== false,
1302
1901
  scope: p.scope,
1303
1902
  };
@@ -1319,10 +1918,14 @@ export class AgentBridge {
1319
1918
 
1320
1919
  private async uninstallPlugin(
1321
1920
  pluginId: string,
1921
+ scope?: Scope,
1322
1922
  workdir?: string,
1323
1923
  sessionId?: string,
1324
1924
  ) {
1325
- await this.getPluginCore(workdir, sessionId).uninstallPlugin(pluginId);
1925
+ await this.getPluginCore(workdir, sessionId).uninstallPlugin(
1926
+ pluginId,
1927
+ scope,
1928
+ );
1326
1929
  return null;
1327
1930
  }
1328
1931
 
@@ -1347,6 +1950,21 @@ export class AgentBridge {
1347
1950
  );
1348
1951
  }
1349
1952
 
1953
+ /**
1954
+ * `/reload-plugins`:把磁盘上的插件状态就地换装进**本进程内全部活跃会话**
1955
+ * (spec ecosystem/plugin.md「插件变更的就地重载」)。不重建会话、sessionId
1956
+ * 不变;没有活跃会话时也返回成功——新会话创建时会按磁盘状态自然装载。
1957
+ *
1958
+ * 每个会话读的是同一份磁盘状态,因此取任一份结果汇报即可(各会话的差异只体现
1959
+ * 在各自注册表的可见性上)。失败不回滚,原样回传给宿主提示。
1960
+ */
1961
+ private async reloadPlugins(): Promise<PluginReloadResult> {
1962
+ const results = await Promise.all(
1963
+ [...this.sessions.values()].map((entry) => entry.agent.reloadPlugins()),
1964
+ );
1965
+ return results[0] ?? { plugins: [], failures: [] };
1966
+ }
1967
+
1350
1968
  private async getProjectSettings(
1351
1969
  workdir?: string,
1352
1970
  sessionId?: string,
@@ -1359,6 +1977,81 @@ export class AgentBridge {
1359
1977
  };
1360
1978
  }
1361
1979
 
1980
+ /**
1981
+ * Read the hooks fragment from a single config scope (user or project), for
1982
+ * the settings page read-only hooks view. Hooks come from settings.json
1983
+ * (user: ~/.wave/settings.json; project: .wave/settings.json +
1984
+ * settings.local.json merged, append per event).
1985
+ */
1986
+ private async getHooksConfig(
1987
+ scope: "user" | "project" | undefined,
1988
+ workdir?: string,
1989
+ sessionId?: string,
1990
+ ): Promise<{ hooks: PartialHookConfiguration | undefined }> {
1991
+ const resolvedScope = scope ?? "user";
1992
+
1993
+ if (resolvedScope === "user") {
1994
+ const config = loadWaveConfigFromFile(getUserConfigPaths()[0]);
1995
+ return { hooks: config?.hooks };
1996
+ }
1997
+
1998
+ const resolvedWorkdir =
1999
+ workdir || this.getSessionWorkdir(sessionId) || process.cwd();
2000
+ const [localPath, jsonPath] = getProjectConfigPaths(resolvedWorkdir);
2001
+ const hooks: PartialHookConfiguration = {};
2002
+ for (const config of [
2003
+ loadWaveConfigFromFile(jsonPath),
2004
+ loadWaveConfigFromFile(localPath),
2005
+ ]) {
2006
+ if (!config?.hooks) continue;
2007
+ for (const [event, eventConfigs] of Object.entries(config.hooks)) {
2008
+ if (!isValidHookEvent(event)) continue;
2009
+ hooks[event] = [...(hooks[event] ?? []), ...(eventConfigs ?? [])];
2010
+ }
2011
+ }
2012
+ return { hooks: Object.keys(hooks).length > 0 ? hooks : undefined };
2013
+ }
2014
+
2015
+ /**
2016
+ * Read the mcpServers fragment from a single config scope (user or project),
2017
+ * for the settings page read-only MCP view. MCP servers come from mcp.json
2018
+ * (user: ~/.wave/mcp.json; project: <workdir>/.mcp.json). Runtime status
2019
+ * (connect/disconnect/tools) is served by the existing getMcpServers RPC.
2020
+ */
2021
+ private async getMcpConfig(
2022
+ scope: "user" | "project" | undefined,
2023
+ workdir?: string,
2024
+ sessionId?: string,
2025
+ ): Promise<{ mcpServers: Record<string, McpServerConfig> }> {
2026
+ const resolvedScope = scope ?? "user";
2027
+
2028
+ if (resolvedScope === "user") {
2029
+ return {
2030
+ mcpServers: this.readMcpServersFile(
2031
+ join(homedir(), ".wave", "mcp.json"),
2032
+ ),
2033
+ };
2034
+ }
2035
+
2036
+ const resolvedWorkdir =
2037
+ workdir || this.getSessionWorkdir(sessionId) || process.cwd();
2038
+ return {
2039
+ mcpServers: this.readMcpServersFile(join(resolvedWorkdir, ".mcp.json")),
2040
+ };
2041
+ }
2042
+
2043
+ private readMcpServersFile(
2044
+ filePath: string,
2045
+ ): Record<string, McpServerConfig> {
2046
+ try {
2047
+ if (!existsSync(filePath)) return {};
2048
+ const raw = JSON.parse(readFileSync(filePath, "utf-8")) as McpConfig;
2049
+ return raw.mcpServers ?? {};
2050
+ } catch {
2051
+ return {};
2052
+ }
2053
+ }
2054
+
1362
2055
  private async setBuiltinPluginEnabled(
1363
2056
  pluginId: string,
1364
2057
  enabled: boolean,
@@ -1384,10 +2077,36 @@ export class AgentBridge {
1384
2077
  return this.getPluginCore(workdir, sessionId).updatePlugin(pluginId);
1385
2078
  }
1386
2079
 
2080
+ /**
2081
+ * 更换已安装插件的安装作用域(设置页插件市场):清掉各作用域的启用记录后
2082
+ * 在目标作用域启用,因此旧作用域不再保留该插件。
2083
+ */
2084
+ private async setPluginScope(
2085
+ pluginId: string,
2086
+ scope: Scope,
2087
+ workdir?: string,
2088
+ sessionId?: string,
2089
+ ) {
2090
+ return this.getPluginCore(workdir, sessionId).setPluginScope(
2091
+ pluginId,
2092
+ scope,
2093
+ );
2094
+ }
2095
+
1387
2096
  private async listMarketplaces(workdir?: string, sessionId?: string) {
1388
2097
  return this.getPluginCore(workdir, sessionId).listMarketplaces();
1389
2098
  }
1390
2099
 
2100
+ /**
2101
+ * 刷新各已注册市场的检出(不装插件),供「打开插件市场界面」时后台调用
2102
+ * (spec 插件市场 A-013 场景 5)。市场级错误在 SDK 内静默记录,宿主侧失败
2103
+ * 也不打断界面(场景 9)。
2104
+ */
2105
+ private async refreshMarketplaces(workdir?: string, sessionId?: string) {
2106
+ await this.getPluginCore(workdir, sessionId).refreshMarketplaces();
2107
+ return null;
2108
+ }
2109
+
1391
2110
  private async addMarketplace(
1392
2111
  input: string,
1393
2112
  scope?: Scope,
@@ -1412,8 +2131,16 @@ export class AgentBridge {
1412
2131
  workdir?: string,
1413
2132
  sessionId?: string,
1414
2133
  ) {
1415
- await this.getPluginCore(workdir, sessionId).updateMarketplace(name);
1416
- return null;
2134
+ // 这个通道只服务 GUI 的「批量更新插件」按钮:按当前清单升级、不拉检出
2135
+ // (清单刷新由打开视图时的 refreshMarketplaces 承担,spec 插件市场 A-013/
2136
+ // 场景 14)。
2137
+ const updated = await this.getPluginCore(
2138
+ workdir,
2139
+ sessionId,
2140
+ ).updateMarketplacePlugins(name);
2141
+ // 实际升级的插件数回给宿主:0 = 该市场已是最新(宿主据此提示,
2142
+ // spec 插件市场场景 13)。
2143
+ return { updated };
1417
2144
  }
1418
2145
 
1419
2146
  // ── Callbacks → Notifications ─────────────────────────────────
@@ -1483,6 +2210,18 @@ export class AgentBridge {
1483
2210
  ctx.registeredSessionId,
1484
2211
  );
1485
2212
  },
2213
+ onLatestTotalTokensChange: (tokens: number) => {
2214
+ // Batch 2 压缩上下文 button: push the context usage percentage on
2215
+ // every token-count change. The SDK fires this on each finished
2216
+ // request (fresh total) and on session restore (the persisted total),
2217
+ // so the webview button shows real usage without waiting for a turn —
2218
+ // aligned with the CLI, which reads the same callback (spec 场景 5/6).
2219
+ const max = ctx.agent?.getMaxInputTokens() ?? 0;
2220
+ if (max > 0) {
2221
+ const percent = Math.min(100, Math.round((tokens / max) * 100));
2222
+ this.emit("contextUsage", { percent }, ctx.registeredSessionId);
2223
+ }
2224
+ },
1486
2225
  onCommandRunningChange: (running: boolean) => {
1487
2226
  this.emit("commandRunningChange", { running }, ctx.registeredSessionId);
1488
2227
  },
@@ -1542,29 +2281,8 @@ export class AgentBridge {
1542
2281
  onMcpServersChange: (servers: McpServerStatus[]) => {
1543
2282
  this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
1544
2283
  },
1545
- onAddBangMessage: (command, messageId) => {
1546
- this.emit(
1547
- "bangMessageAdded",
1548
- { command, messageId },
1549
- ctx.registeredSessionId,
1550
- );
1551
- },
1552
- onUpdateBangMessage: (command, output, messageId) => {
1553
- this.emit(
1554
- "bangMessageUpdated",
1555
- { command, output, messageId },
1556
- ctx.registeredSessionId,
1557
- );
1558
- },
1559
- onCompleteBangMessage: (command, exitCode, messageId, output) => {
1560
- this.emit(
1561
- "bangMessageCompleted",
1562
- { command, exitCode, messageId, output },
1563
- ctx.registeredSessionId,
1564
- );
1565
- },
1566
2284
  onNotificationMessageAdded: (params) => {
1567
- const msg = ctx.agent?.messages.find(
2285
+ const msg = ctx.agent?.displayMessages.find(
1568
2286
  (m) =>
1569
2287
  m.role === "user" &&
1570
2288
  m.blocks.some(
@@ -1596,6 +2314,9 @@ export class AgentBridge {
1596
2314
  ctx.registeredSessionId,
1597
2315
  );
1598
2316
  },
2317
+ onPlanFileUpdated: (content: string) => {
2318
+ this.emit("planFileUpdated", { content }, ctx.registeredSessionId);
2319
+ },
1599
2320
  };
1600
2321
  }
1601
2322
 
@@ -1606,7 +2327,34 @@ export class AgentBridge {
1606
2327
 
1607
2328
  // ── Utils ─────────────────────────────────────────────────────
1608
2329
 
2330
+ /**
2331
+ * The session entry for a request that CHANGES session state. A session whose
2332
+ * agent is mid-rebuild (`updateConfig` → `SessionEntry.transitioning`) has no
2333
+ * usable agent: its outgoing one is being torn down, so a message injected into
2334
+ * it would be lost and a config write would be overwritten by the rebuild's own
2335
+ * stored config. Those requests are refused with a retryable error instead.
2336
+ * Read-only requests use `requireSessionForRead` and stay available.
2337
+ */
1609
2338
  private requireSession(sessionId?: string): SessionEntry {
2339
+ const entry = this.requireSessionForRead(sessionId);
2340
+ if (entry.transitioning) {
2341
+ throw new RpcError(
2342
+ PROTOCOL_INTERNAL_ERROR,
2343
+ `Session ${sessionId} is being reconfigured (config reload in progress), retry shortly`,
2344
+ );
2345
+ }
2346
+ return entry;
2347
+ }
2348
+
2349
+ /**
2350
+ * The session entry for a request that only READS. During a rebuild the entry
2351
+ * holds the outgoing agent: its messages are this same session's transcript and
2352
+ * its config is the pre-reload one, both of which are the best answer available
2353
+ * for the few hundred milliseconds the rebuild takes. Refusing reads instead
2354
+ * would turn a config reload into a burst of errors in whatever host happens to
2355
+ * be polling (`getMcpServers`, `getSlashCommands`, …).
2356
+ */
2357
+ private requireSessionForRead(sessionId?: string): SessionEntry {
1610
2358
  if (!sessionId) {
1611
2359
  throw new RpcError(
1612
2360
  PROTOCOL_INTERNAL_ERROR,
@@ -1656,3 +2404,54 @@ function isSessionRecoveryError(error: unknown): boolean {
1656
2404
  message.startsWith("Session not found:")
1657
2405
  );
1658
2406
  }
2407
+
2408
+ /** Read a text file, returning "" when it does not exist. */
2409
+ async function readTextFileSafe(filePath: string): Promise<string> {
2410
+ try {
2411
+ return await readFile(filePath, "utf-8");
2412
+ } catch (error) {
2413
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
2414
+ return "";
2415
+ }
2416
+ throw error;
2417
+ }
2418
+ }
2419
+
2420
+ /**
2421
+ * Read the creation-time workdir from a session transcript's metadata header —
2422
+ * the first line `{"type":"metadata","workdir":...,"createdAt":...,"gitBranch":...}`
2423
+ * the SDK writes once at session creation. The header is append-only, so it
2424
+ * records where the session was created even after the live working directory
2425
+ * drifted (in-session `cd`) or after a daemon restart re-anchored the agent at
2426
+ * a client's cwd. Returns undefined when the file is missing, legacy (no
2427
+ * header), or unreadable. Only the header line is read (transcripts can grow
2428
+ * large); a 4KiB read covers any realistic header.
2429
+ */
2430
+ async function readTranscriptWorkdir(
2431
+ transcriptPath?: string,
2432
+ ): Promise<string | undefined> {
2433
+ if (!transcriptPath) return undefined;
2434
+ try {
2435
+ const handle = await open(transcriptPath, "r");
2436
+ try {
2437
+ const buffer = Buffer.alloc(4096);
2438
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
2439
+ if (bytesRead === 0) return undefined;
2440
+ const newline = buffer.indexOf(0x0a);
2441
+ const firstLine = buffer
2442
+ .subarray(0, newline === -1 ? bytesRead : newline)
2443
+ .toString("utf8");
2444
+ const header = JSON.parse(firstLine) as {
2445
+ type?: string;
2446
+ workdir?: unknown;
2447
+ };
2448
+ return header.type === "metadata" && typeof header.workdir === "string"
2449
+ ? header.workdir
2450
+ : undefined;
2451
+ } finally {
2452
+ await handle.close();
2453
+ }
2454
+ } catch {
2455
+ return undefined;
2456
+ }
2457
+ }