wave-code 1.2.0 → 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 (37) hide show
  1. package/dist/bundle/wave.mjs +1049 -584
  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 +1 -0
  7. package/src/components/ConfirmationDetails.tsx +36 -15
  8. package/src/components/ConfirmationSelector.tsx +39 -0
  9. package/src/components/InputBox.tsx +39 -2
  10. package/src/components/Markdown.tsx +21 -8
  11. package/src/components/MarketplaceDetail.tsx +10 -27
  12. package/src/components/MarketplaceView.tsx +17 -3
  13. package/src/components/Notifications.tsx +7 -0
  14. package/src/components/PlanView.tsx +22 -17
  15. package/src/components/PluginManagerShell.tsx +3 -3
  16. package/src/components/PluginManagerTypes.ts +2 -1
  17. package/src/components/ResumeCommand.tsx +84 -0
  18. package/src/components/ScrolledContent.tsx +71 -0
  19. package/src/components/SessionSelector.tsx +184 -6
  20. package/src/components/SessionSelectorFlow.tsx +246 -0
  21. package/src/constants/commands.ts +18 -0
  22. package/src/contexts/useChat.tsx +158 -28
  23. package/src/daemon/commands.ts +569 -46
  24. package/src/daemon-cli.ts +6 -0
  25. package/src/hooks/useInputManager.ts +16 -0
  26. package/src/hooks/usePluginManager.ts +104 -78
  27. package/src/index.ts +62 -8
  28. package/src/managers/inputHandlers.ts +14 -9
  29. package/src/managers/inputReducer.ts +29 -9
  30. package/src/reducers/confirmationReducer.ts +20 -1
  31. package/src/reducers/selectorReducer.ts +3 -0
  32. package/src/session-selector-cli.tsx +9 -255
  33. package/src/stdio/agentBridge.ts +397 -95
  34. package/src/stdio/protocol.ts +9 -0
  35. package/src/stdio-cli.ts +6 -0
  36. package/src/utils/sessionOwnership.ts +62 -0
  37. 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,8 +51,13 @@ 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,
54
61
  } from "wave-agent-sdk";
55
62
  import {
56
63
  type JsonRpcError,
@@ -95,7 +102,6 @@ interface InitializeParams {
95
102
  defaultHeaders?: Record<string, string>;
96
103
  model?: string;
97
104
  fastModel?: string;
98
- language?: string;
99
105
  permissionMode?: PermissionMode;
100
106
  tools?: string[];
101
107
  allowedTools?: string[];
@@ -104,11 +110,11 @@ interface InitializeParams {
104
110
  mcpServers?: Record<string, McpServerConfig>;
105
111
  worktreeName?: string;
106
112
  isNewWorktree?: boolean;
107
- /** Settings-page auto-memory toggle/frequency (session-level override). */
108
- autoMemoryEnabled?: boolean;
109
- autoMemoryFrequency?: number;
110
113
  }
111
114
 
115
+ // 会话级覆盖项:用户偏好(语言 / 上下文长度 / 自动记忆开关与频率)不在此层——
116
+ // 它们落用户级 ~/.wave/settings.json 经实时重载生效(getUserSettings /
117
+ // updateUserSettings),走覆盖层会永久遮蔽 settings.json 的实时值。
112
118
  interface UpdateConfigParams {
113
119
  apiKey?: string;
114
120
  baseURL?: string;
@@ -116,10 +122,6 @@ interface UpdateConfigParams {
116
122
  defaultHeaders?: Record<string, string>;
117
123
  model?: string;
118
124
  fastModel?: string;
119
- language?: string;
120
- /** Settings-page auto-memory toggle/frequency (session-level override). */
121
- autoMemoryEnabled?: boolean;
122
- autoMemoryFrequency?: number;
123
125
  }
124
126
 
125
127
  interface SearchFilesParams {
@@ -141,6 +143,24 @@ interface SessionEntry {
141
143
  * header when the session is re-attached from disk after a daemon restart.
142
144
  */
143
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;
144
164
  }
145
165
 
146
166
  /**
@@ -206,6 +226,21 @@ export class AgentBridge {
206
226
  return this.restoreSession(p.sessionId as string, sessionId);
207
227
  case "listSessions":
208
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
+ );
209
244
  case "getSessionInfo":
210
245
  return this.getSessionInfo(sessionId);
211
246
  case "listPendingPermissions":
@@ -233,6 +268,15 @@ export class AgentBridge {
233
268
  case "setModel":
234
269
  return this.setModel(p.model as string, sessionId);
235
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
+
236
280
  // ── Messages ──
237
281
  case "sendMessage":
238
282
  return this.sendMessage(
@@ -376,6 +420,7 @@ export class AgentBridge {
376
420
  case "uninstallPlugin":
377
421
  return this.uninstallPlugin(
378
422
  p.pluginId as string,
423
+ p.scope as Scope | undefined,
379
424
  p.workdir as string | undefined,
380
425
  sessionId,
381
426
  );
@@ -424,11 +469,25 @@ export class AgentBridge {
424
469
  p.workdir as string | undefined,
425
470
  sessionId,
426
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();
427
481
  case "listMarketplaces":
428
482
  return this.listMarketplaces(
429
483
  p.workdir as string | undefined,
430
484
  sessionId,
431
485
  );
486
+ case "refreshMarketplaces":
487
+ return this.refreshMarketplaces(
488
+ p.workdir as string | undefined,
489
+ sessionId,
490
+ );
432
491
  case "addMarketplace":
433
492
  return this.addMarketplace(
434
493
  p.input as string,
@@ -552,9 +611,6 @@ export class AgentBridge {
552
611
  defaultHeaders: params.defaultHeaders,
553
612
  model: params.model,
554
613
  fastModel: params.fastModel,
555
- language: params.language,
556
- autoMemoryEnabled: params.autoMemoryEnabled,
557
- autoMemoryFrequency: params.autoMemoryFrequency,
558
614
  permissionMode: params.permissionMode,
559
615
  tools: params.tools,
560
616
  allowedTools: params.allowedTools,
@@ -608,8 +664,30 @@ export class AgentBridge {
608
664
  if (sessionId) {
609
665
  const entry = this.sessions.get(sessionId);
610
666
  if (entry) {
611
- 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).
612
689
  this.sessions.delete(sessionId);
690
+ await entry.agent.destroy();
613
691
  }
614
692
  }
615
693
  return null;
@@ -633,7 +711,7 @@ export class AgentBridge {
633
711
  restoreId: string,
634
712
  sessionId?: string,
635
713
  ): Promise<null> {
636
- const entry = this.requireSession(sessionId);
714
+ const entry = this.requireSessionForRead(sessionId);
637
715
  // Re-attach to a live session: the SDK restore would no-op (target is
638
716
  // already current). Emit the current messages so the freshly attached
639
717
  // client — whose router registered only after initialize returned and so
@@ -665,6 +743,18 @@ export class AgentBridge {
665
743
  this.emitContextUsage(entry.agent);
666
744
  return null;
667
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
+ }
668
758
  await entry.agent.restoreSession(restoreId);
669
759
  // A real restore must also re-emit the usage unconditionally: the SDK
670
760
  // only fires onLatestTotalTokensChange on a VALUE CHANGE, so restoring a
@@ -717,6 +807,38 @@ export class AgentBridge {
717
807
  return { sessions };
718
808
  }
719
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
+
720
842
  // ── Git / worktree ────────────────────────────────────────────
721
843
 
722
844
  private listGitBranches(workdir?: string): {
@@ -857,7 +979,7 @@ export class AgentBridge {
857
979
  permissionMode: PermissionMode;
858
980
  availableTools: string[];
859
981
  } {
860
- const entry = this.requireSession(sessionId);
982
+ const entry = this.requireSessionForRead(sessionId);
861
983
  return {
862
984
  sessionId: entry.agent.sessionId,
863
985
  // The recorded creation workdir, not the live agent.workingDirectory:
@@ -878,76 +1000,105 @@ export class AgentBridge {
878
1000
  const currentSessionId = entry.agent.sessionId;
879
1001
  // Merge new config into stored config
880
1002
  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
-
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;
885
1013
  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
1014
  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;
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 });
928
1065
  }
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 });
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;
934
1094
  }
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
1095
  }
945
1096
 
946
1097
  private getConfiguredModels(sessionId?: string): {
947
1098
  models: string[];
948
1099
  currentModel: string | undefined;
949
1100
  } {
950
- const entry = this.requireSession(sessionId);
1101
+ const entry = this.requireSessionForRead(sessionId);
951
1102
  return {
952
1103
  models: entry.agent.getConfiguredModels(),
953
1104
  currentModel: entry.agent.getModelConfig().model,
@@ -956,7 +1107,7 @@ export class AgentBridge {
956
1107
 
957
1108
  private async setModel(model: string, sessionId?: string): Promise<null> {
958
1109
  const entry = this.requireSession(sessionId);
959
- entry.agent.setModel(model);
1110
+ await entry.agent.setModel(model);
960
1111
  // Keep storedConfig in sync: updateConfig recreates the agent from
961
1112
  // storedConfig, so without this a later config save would revert the
962
1113
  // model chosen here.
@@ -964,6 +1115,47 @@ export class AgentBridge {
964
1115
  return null;
965
1116
  }
966
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
+
967
1159
  // ── Messages ──────────────────────────────────────────────────
968
1160
 
969
1161
  private async sendMessage(
@@ -1104,7 +1296,7 @@ export class AgentBridge {
1104
1296
  private async listRewindCheckpoints(sessionId?: string): Promise<{
1105
1297
  checkpoints: Array<{ id: string; content: string }>;
1106
1298
  }> {
1107
- const entry = this.requireSession(sessionId);
1299
+ const entry = this.requireSessionForRead(sessionId);
1108
1300
  const { messages } = await entry.agent.getFullMessageThread();
1109
1301
  // 压缩 append-only 后同 id 消息在磁盘完整线程中重复出现(压缩前历史 +
1110
1302
  // 压缩后 append 的重复)。按 id 去重并保留最后一次出现(与折叠后的
@@ -1151,7 +1343,7 @@ export class AgentBridge {
1151
1343
  messages: Message[];
1152
1344
  contextUsagePercent?: number;
1153
1345
  } {
1154
- const entry = this.requireSession(sessionId);
1346
+ const entry = this.requireSessionForRead(sessionId);
1155
1347
  return {
1156
1348
  messages: entry.agent.displayMessages,
1157
1349
  contextUsagePercent: this.contextUsagePercentOf(entry.agent),
@@ -1162,7 +1354,7 @@ export class AgentBridge {
1162
1354
  messages: Message[];
1163
1355
  sessionIds: string[];
1164
1356
  }> {
1165
- const entry = this.requireSession(sessionId);
1357
+ const entry = this.requireSessionForRead(sessionId);
1166
1358
  return entry.agent.getFullMessageThread();
1167
1359
  }
1168
1360
 
@@ -1181,7 +1373,7 @@ export class AgentBridge {
1181
1373
  taskId: string,
1182
1374
  sessionId?: string,
1183
1375
  ): { output: ReturnType<Agent["getBackgroundTaskOutput"]> } {
1184
- const entry = this.requireSession(sessionId);
1376
+ const entry = this.requireSessionForRead(sessionId);
1185
1377
  return { output: entry.agent.getBackgroundTaskOutput(taskId) };
1186
1378
  }
1187
1379
 
@@ -1203,7 +1395,7 @@ export class AgentBridge {
1203
1395
  private async getWorkflowRuns(
1204
1396
  sessionId?: string,
1205
1397
  ): Promise<{ runs: SerializableWorkflowRun[] }> {
1206
- const entry = this.requireSession(sessionId);
1398
+ const entry = this.requireSessionForRead(sessionId);
1207
1399
  const runs = await entry.agent.getWorkflowRuns();
1208
1400
  return {
1209
1401
  runs: runs.map((r) => {
@@ -1235,7 +1427,7 @@ export class AgentBridge {
1235
1427
  }
1236
1428
 
1237
1429
  private getPermissionMode(sessionId?: string): { mode: PermissionMode } {
1238
- const entry = this.requireSession(sessionId);
1430
+ const entry = this.requireSessionForRead(sessionId);
1239
1431
  return { mode: entry.agent.getPermissionMode() };
1240
1432
  }
1241
1433
 
@@ -1249,7 +1441,7 @@ export class AgentBridge {
1249
1441
  path: string | null;
1250
1442
  content: string | null;
1251
1443
  }> {
1252
- const entry = this.requireSession(sessionId);
1444
+ const entry = this.requireSessionForRead(sessionId);
1253
1445
  const agent = entry.agent;
1254
1446
  const planPath =
1255
1447
  agent.getPlanFilePath() ?? (await agent.awaitPlanFilePath());
@@ -1268,7 +1460,7 @@ export class AgentBridge {
1268
1460
  // ── MCP ───────────────────────────────────────────────────────
1269
1461
 
1270
1462
  private getMcpServers(sessionId?: string): { servers: McpServerStatus[] } {
1271
- const entry = this.requireSession(sessionId);
1463
+ const entry = this.requireSessionForRead(sessionId);
1272
1464
  return { servers: entry.agent.getMcpServers() };
1273
1465
  }
1274
1466
 
@@ -1304,7 +1496,7 @@ export class AgentBridge {
1304
1496
  userPath: string | null;
1305
1497
  projectPath: string | null;
1306
1498
  } {
1307
- const entry = this.requireSession(sessionId);
1499
+ const entry = this.requireSessionForRead(sessionId);
1308
1500
  return {
1309
1501
  userPath: entry.agent.getUserMcpConfigPath(),
1310
1502
  projectPath: entry.agent.getProjectMcpConfigPath(),
@@ -1314,19 +1506,19 @@ export class AgentBridge {
1314
1506
  // ── Commands ──────────────────────────────────────────────────
1315
1507
 
1316
1508
  private getSlashCommands(sessionId?: string): { commands: SlashCommand[] } {
1317
- const entry = this.requireSession(sessionId);
1509
+ const entry = this.requireSessionForRead(sessionId);
1318
1510
  return { commands: entry.agent.getSlashCommands() };
1319
1511
  }
1320
1512
 
1321
1513
  private getSubagentConfigurations(sessionId?: string): {
1322
1514
  configurations: SubagentConfiguration[];
1323
1515
  } {
1324
- const entry = this.requireSession(sessionId);
1516
+ const entry = this.requireSessionForRead(sessionId);
1325
1517
  return { configurations: entry.agent.getSubagentConfigurations() };
1326
1518
  }
1327
1519
 
1328
1520
  private getSkillMetadata(sessionId?: string): { skills: SkillMetadata[] } {
1329
- const entry = this.requireSession(sessionId);
1521
+ const entry = this.requireSessionForRead(sessionId);
1330
1522
  return { skills: entry.agent.getSkillMetadata() };
1331
1523
  }
1332
1524
 
@@ -1355,7 +1547,7 @@ export class AgentBridge {
1355
1547
  hooks: Partial<Record<string, unknown[]>>;
1356
1548
  configPath: string | null;
1357
1549
  }> {
1358
- const entry = this.requireSession(sessionId);
1550
+ const entry = this.requireSessionForRead(sessionId);
1359
1551
  const hooks = await entry.agent.getHooksByScope(scope);
1360
1552
  // 回带该 scope 钩子所在 settings.json 的绝对路径(宿主 GUI 打开文件用;
1361
1553
  // plugin 钩子来自代码而非配置文件 → null)。
@@ -1492,12 +1684,31 @@ export class AgentBridge {
1492
1684
  /** Daemon list: expose the in-memory session registry (live sessions only,
1493
1685
  * not disk-scanning). Registration order is preserved. The working directory
1494
1686
  * shown is the session's recorded creation workdir (stable identity), not the
1495
- * live value that drifts with in-session `cd`. */
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. */
1496
1705
  private listDaemonSessions(): {
1497
1706
  sessions: Array<{
1498
1707
  sessionId: string;
1499
1708
  workingDirectory: string;
1500
1709
  isLoading: boolean;
1710
+ hasRunningBackgroundWork: boolean;
1711
+ hasPendingMessages: boolean;
1501
1712
  messageCount: number;
1502
1713
  }>;
1503
1714
  } {
@@ -1505,7 +1716,9 @@ export class AgentBridge {
1505
1716
  sessions: [...this.sessions.entries()].map(([sessionId, entry]) => ({
1506
1717
  sessionId,
1507
1718
  workingDirectory: entry.createdWorkdir,
1508
- isLoading: entry.agent.isLoading,
1719
+ isLoading: entry.transitioning === true || entry.agent.isLoading,
1720
+ hasRunningBackgroundWork: entry.agent.hasRunningBackgroundWork,
1721
+ hasPendingMessages: entry.agent.hasPendingMessages,
1509
1722
  messageCount: entry.agent.displayMessages.length,
1510
1723
  })),
1511
1724
  };
@@ -1528,7 +1741,12 @@ export class AgentBridge {
1528
1741
  }
1529
1742
 
1530
1743
  private async getAccountInfo(): Promise<{
1531
- plan?: { monthlyQuota: number; months: number; used: number } | null;
1744
+ /**
1745
+ * 计费结论(套餐两根条 + 到期日 + 降级原因),形状见 codechat `GET /api/v1/account` → `billing`
1746
+ * (判定出自与 proxy 扣费同一份代码)。本层只转发、不解析;具体形状由 wave-webview-fixtures
1747
+ * 的 AccountBillingInfo 在宿主/卡片侧约束。
1748
+ */
1749
+ billing?: unknown;
1532
1750
  apiQuota?: { limit: number | null; used: number } | null;
1533
1751
  }> {
1534
1752
  const authService = AuthService.getInstance();
@@ -1547,7 +1765,7 @@ export class AgentBridge {
1547
1765
  throw new Error(`Account query failed (${response.status})`);
1548
1766
  }
1549
1767
  return (await response.json()) as {
1550
- plan?: { monthlyQuota: number; months: number; used: number } | null;
1768
+ billing?: unknown;
1551
1769
  apiQuota?: { limit: number | null; used: number } | null;
1552
1770
  };
1553
1771
  }
@@ -1678,6 +1896,7 @@ export class AgentBridge {
1678
1896
  marketplace: p.marketplace,
1679
1897
  installed: p.installed,
1680
1898
  version: p.version,
1899
+ latestVersion: p.latestVersion,
1681
1900
  enabled: mergedEnabled[pluginId] !== false,
1682
1901
  scope: p.scope,
1683
1902
  };
@@ -1699,10 +1918,14 @@ export class AgentBridge {
1699
1918
 
1700
1919
  private async uninstallPlugin(
1701
1920
  pluginId: string,
1921
+ scope?: Scope,
1702
1922
  workdir?: string,
1703
1923
  sessionId?: string,
1704
1924
  ) {
1705
- await this.getPluginCore(workdir, sessionId).uninstallPlugin(pluginId);
1925
+ await this.getPluginCore(workdir, sessionId).uninstallPlugin(
1926
+ pluginId,
1927
+ scope,
1928
+ );
1706
1929
  return null;
1707
1930
  }
1708
1931
 
@@ -1727,6 +1950,21 @@ export class AgentBridge {
1727
1950
  );
1728
1951
  }
1729
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
+
1730
1968
  private async getProjectSettings(
1731
1969
  workdir?: string,
1732
1970
  sessionId?: string,
@@ -1839,10 +2077,36 @@ export class AgentBridge {
1839
2077
  return this.getPluginCore(workdir, sessionId).updatePlugin(pluginId);
1840
2078
  }
1841
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
+
1842
2096
  private async listMarketplaces(workdir?: string, sessionId?: string) {
1843
2097
  return this.getPluginCore(workdir, sessionId).listMarketplaces();
1844
2098
  }
1845
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
+
1846
2110
  private async addMarketplace(
1847
2111
  input: string,
1848
2112
  scope?: Scope,
@@ -1867,8 +2131,16 @@ export class AgentBridge {
1867
2131
  workdir?: string,
1868
2132
  sessionId?: string,
1869
2133
  ) {
1870
- await this.getPluginCore(workdir, sessionId).updateMarketplace(name);
1871
- 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 };
1872
2144
  }
1873
2145
 
1874
2146
  // ── Callbacks → Notifications ─────────────────────────────────
@@ -2042,6 +2314,9 @@ export class AgentBridge {
2042
2314
  ctx.registeredSessionId,
2043
2315
  );
2044
2316
  },
2317
+ onPlanFileUpdated: (content: string) => {
2318
+ this.emit("planFileUpdated", { content }, ctx.registeredSessionId);
2319
+ },
2045
2320
  };
2046
2321
  }
2047
2322
 
@@ -2052,7 +2327,34 @@ export class AgentBridge {
2052
2327
 
2053
2328
  // ── Utils ─────────────────────────────────────────────────────
2054
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
+ */
2055
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 {
2056
2358
  if (!sessionId) {
2057
2359
  throw new RpcError(
2058
2360
  PROTOCOL_INTERNAL_ERROR,