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.
- package/dist/bundle/wave.mjs +1075 -613
- package/package.json +4 -3
- package/src/cli.tsx +9 -0
- package/src/commands/plugin/install.ts +6 -7
- package/src/commands/plugin/uninstall.ts +11 -5
- package/src/components/ChatInterface.tsx +3 -0
- package/src/components/ConfirmationDetails.tsx +36 -15
- package/src/components/ConfirmationSelector.tsx +39 -0
- package/src/components/HooksManager.tsx +286 -0
- package/src/components/InputBox.tsx +125 -51
- package/src/components/Markdown.tsx +21 -8
- package/src/components/MarketplaceDetail.tsx +10 -27
- package/src/components/MarketplaceView.tsx +17 -3
- package/src/components/MessageBlockItem.tsx +0 -5
- package/src/components/MessageList.tsx +0 -1
- package/src/components/Notifications.tsx +7 -0
- package/src/components/PlanView.tsx +146 -0
- package/src/components/PluginManagerShell.tsx +3 -3
- package/src/components/PluginManagerTypes.ts +2 -1
- package/src/components/ResumeCommand.tsx +84 -0
- package/src/components/ScrolledContent.tsx +71 -0
- package/src/components/SessionSelector.tsx +184 -6
- package/src/components/SessionSelectorFlow.tsx +246 -0
- package/src/constants/commands.ts +30 -0
- package/src/contexts/useChat.tsx +252 -98
- package/src/daemon/commands.ts +1046 -67
- package/src/daemon-cli.ts +6 -0
- package/src/hooks/useInputManager.ts +30 -0
- package/src/hooks/useLineScroll.ts +58 -0
- package/src/hooks/usePluginManager.ts +104 -78
- package/src/index.ts +156 -16
- package/src/managers/inputHandlers.ts +16 -9
- package/src/managers/inputReducer.ts +39 -9
- package/src/reducers/confirmationReducer.ts +20 -1
- package/src/reducers/hooksManagerReducer.ts +92 -0
- package/src/reducers/selectorReducer.ts +3 -0
- package/src/session-selector-cli.tsx +9 -255
- package/src/stdio/agentBridge.ts +932 -133
- package/src/stdio/daemonServer.ts +26 -82
- package/src/stdio/protocol.ts +23 -4
- package/src/stdio-cli.ts +60 -0
- package/src/utils/rewindCheckpoints.ts +2 -2
- package/src/utils/sessionOwnership.ts +62 -0
- package/src/utils/worktree.ts +175 -50
- package/src/components/BangDisplay.tsx +0 -41
- package/src/components/PlanDisplay.tsx +0 -18
package/src/stdio/agentBridge.ts
CHANGED
|
@@ -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 {
|
|
58
|
-
import {
|
|
59
|
-
import {
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
982
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
693
983
|
return {
|
|
694
984
|
sessionId: entry.agent.sessionId,
|
|
695
|
-
|
|
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
|
-
//
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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.
|
|
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.
|
|
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): {
|
|
977
|
-
|
|
978
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
1235
|
-
isLoading: entry.agent.isLoading,
|
|
1236
|
-
|
|
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(
|
|
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
|
-
|
|
1416
|
-
|
|
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?.
|
|
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
|
+
}
|