wave-code 1.1.4 → 1.2.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.
@@ -31,7 +31,10 @@ 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,
36
39
  searchFiles,
37
40
  generateRandomName,
@@ -43,6 +46,9 @@ import {
43
46
  validateWorktreeRemovalPath,
44
47
  type SlashCommand,
45
48
  loadUserConfigEnv,
49
+ loadWaveConfigFromFile,
50
+ getUserConfigPaths,
51
+ getProjectConfigPaths,
46
52
  type SubagentConfiguration,
47
53
  type SkillMetadata,
48
54
  } from "wave-agent-sdk";
@@ -53,10 +59,15 @@ import {
53
59
  METHOD_NOT_FOUND as PROTOCOL_METHOD_NOT_FOUND,
54
60
  } from "./protocol.js";
55
61
  import { execFileSync } from "node:child_process";
56
- import { mkdirSync, existsSync, writeFileSync } from "node:fs";
57
- import { tmpdir } from "node:os";
58
- import { basename, extname, join } from "node:path";
59
- import { createWorktree, removeWorktree } from "../utils/worktree.js";
62
+ import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
63
+ import { mkdir, open, readFile, writeFile } from "node:fs/promises";
64
+ import { homedir, tmpdir } from "node:os";
65
+ import { basename, dirname, extname, join } from "node:path";
66
+ import {
67
+ createWorktree,
68
+ getWorktreeChanges,
69
+ removeWorktree,
70
+ } from "../utils/worktree.js";
60
71
  import { logger } from "../utils/logger.js";
61
72
  import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
62
73
 
@@ -68,6 +79,11 @@ export type NotificationEmitter = (
68
79
 
69
80
  export interface AgentBridgeOptions {
70
81
  emit: NotificationEmitter;
82
+ /** Daemon transport only (`wave daemon stop`/`restart`): invoked by the
83
+ * `shutdown` RPC after every session has been destroyed — the server tears
84
+ * down its socket and exits. stdio mode leaves it unset, so `shutdown` is
85
+ * "Method not found" there. */
86
+ onShutdownRequest?: () => void;
71
87
  }
72
88
 
73
89
  interface InitializeParams {
@@ -88,6 +104,9 @@ interface InitializeParams {
88
104
  mcpServers?: Record<string, McpServerConfig>;
89
105
  worktreeName?: string;
90
106
  isNewWorktree?: boolean;
107
+ /** Settings-page auto-memory toggle/frequency (session-level override). */
108
+ autoMemoryEnabled?: boolean;
109
+ autoMemoryFrequency?: number;
91
110
  }
92
111
 
93
112
  interface UpdateConfigParams {
@@ -98,6 +117,9 @@ interface UpdateConfigParams {
98
117
  model?: string;
99
118
  fastModel?: string;
100
119
  language?: string;
120
+ /** Settings-page auto-memory toggle/frequency (session-level override). */
121
+ autoMemoryEnabled?: boolean;
122
+ autoMemoryFrequency?: number;
101
123
  }
102
124
 
103
125
  interface SearchFilesParams {
@@ -109,6 +131,16 @@ interface SearchFilesParams {
109
131
  interface SessionEntry {
110
132
  agent: Agent;
111
133
  storedConfig: Partial<InitializeParams>;
134
+ /**
135
+ * The workdir the session was CREATED in — an immutable identity, unlike the
136
+ * live `agent.workingDirectory`, which drifts as the session `cd`s (bash tool
137
+ * / worktree switches). Hosted-session consumers (destroy --remove-worktree's
138
+ * worktree resolution, list) read this instead of the live value so a session
139
+ * that moved out of its worktree into the main repo is still cleaned up at
140
+ * the right place. Recovered from the transcript's creation-time metadata
141
+ * header when the session is re-attached from disk after a daemon restart.
142
+ */
143
+ createdWorkdir: string;
112
144
  }
113
145
 
114
146
  /**
@@ -138,11 +170,13 @@ export class AgentBridge {
138
170
  >();
139
171
  private permissionCounter = 0;
140
172
  private emit: NotificationEmitter;
173
+ private onShutdownRequest: (() => void) | undefined;
141
174
  private pluginCore: PluginCore | undefined;
142
175
  private pluginCoreWorkdir: string | undefined;
143
176
 
144
177
  constructor(options: AgentBridgeOptions) {
145
178
  this.emit = options.emit;
179
+ this.onShutdownRequest = options.onShutdownRequest;
146
180
  // Mirror the user-level settings env WAVE_SERVER_URL into process.env
147
181
  // before any agent initializes. getAuthStatus (webviewReady →
148
182
  // pushInitialState) can run before the first agent, and AuthService falls
@@ -178,6 +212,20 @@ export class AgentBridge {
178
212
  return this.listPendingPermissions();
179
213
  case "listDaemonSessions":
180
214
  return this.listDaemonSessions();
215
+ case "shutdown":
216
+ // Daemon-level graceful exit (`wave daemon stop` / `restart`): destroy
217
+ // every session first (each agent saves its transcript and drains
218
+ // auto-memory), then hand the process exit to the transport owner.
219
+ // Only the daemon registers the hook — stdio has no such concept.
220
+ if (!this.onShutdownRequest) {
221
+ throw new RpcError(
222
+ PROTOCOL_METHOD_NOT_FOUND,
223
+ "Method not found: shutdown",
224
+ );
225
+ }
226
+ await this.destroyAll();
227
+ this.onShutdownRequest();
228
+ return null;
181
229
  case "updateConfig":
182
230
  return this.updateConfig(p as unknown as UpdateConfigParams, sessionId);
183
231
  case "getConfiguredModels":
@@ -228,6 +276,8 @@ export class AgentBridge {
228
276
  return this.setPermissionMode(p.mode as PermissionMode, sessionId);
229
277
  case "getPermissionMode":
230
278
  return this.getPermissionMode(sessionId);
279
+ case "getPlanFile":
280
+ return this.getPlanFile(sessionId);
231
281
 
232
282
  // ── MCP ──
233
283
  case "getMcpServers":
@@ -236,6 +286,14 @@ export class AgentBridge {
236
286
  return this.connectMcpServer(p.serverName as string, sessionId);
237
287
  case "disconnectMcpServer":
238
288
  return this.disconnectMcpServer(p.serverName as string, sessionId);
289
+ case "removeMcpServer":
290
+ return this.removeMcpServer(
291
+ p.scope as "user" | "project",
292
+ p.serverName as string,
293
+ sessionId,
294
+ );
295
+ case "getMcpConfigPaths":
296
+ return this.getMcpConfigPaths(sessionId);
239
297
 
240
298
  // ── Commands ──
241
299
  case "getSlashCommands":
@@ -244,6 +302,21 @@ export class AgentBridge {
244
302
  return this.getSubagentConfigurations(sessionId);
245
303
  case "getSkillMetadata":
246
304
  return this.getSkillMetadata(sessionId);
305
+ case "deleteSkill":
306
+ return this.deleteSkill(p.name as string, sessionId);
307
+ case "deleteSubagent":
308
+ return this.deleteSubagent(p.name as string, sessionId);
309
+ case "getHooksByScope":
310
+ return this.getHooksByScope(
311
+ p.scope as "user" | "project" | "plugin",
312
+ sessionId,
313
+ );
314
+ case "deleteHook":
315
+ return this.deleteHook(
316
+ p.scope as "user" | "project",
317
+ p.hookName as string,
318
+ sessionId,
319
+ );
247
320
 
248
321
  // ── File / History (global — no session required) ──
249
322
  case "searchFiles":
@@ -267,11 +340,29 @@ export class AgentBridge {
267
340
  // ── Auth (global — no session required) ──
268
341
  case "getAuthStatus":
269
342
  return this.getAuthStatus();
343
+ case "getAccountInfo":
344
+ return this.getAccountInfo();
270
345
  case "login":
271
346
  return this.login(p.serverUrl as string | undefined);
272
347
  case "logout":
273
348
  return this.logout();
274
349
 
350
+ // ── Memory files (settings UI — user-level ~/.wave/AGENTS.md and
351
+ // project-level <workdir>/AGENTS.md) ──
352
+ case "getAgentsContent":
353
+ return this.getAgentsContent(
354
+ p.scope as "user" | "project",
355
+ p.workdir as string | undefined,
356
+ sessionId,
357
+ );
358
+ case "setAgentsContent":
359
+ return this.setAgentsContent(
360
+ p.scope as "user" | "project",
361
+ p.content as string,
362
+ p.workdir as string | undefined,
363
+ sessionId,
364
+ );
365
+
275
366
  // ── Plugins (global — no session required) ──
276
367
  case "listPlugins":
277
368
  return this.listPlugins(p.workdir as string | undefined, sessionId);
@@ -307,6 +398,18 @@ export class AgentBridge {
307
398
  p.workdir as string | undefined,
308
399
  sessionId,
309
400
  );
401
+ case "getHooksConfig":
402
+ return this.getHooksConfig(
403
+ p.scope as "user" | "project" | undefined,
404
+ p.workdir as string | undefined,
405
+ sessionId,
406
+ );
407
+ case "getMcpConfig":
408
+ return this.getMcpConfig(
409
+ p.scope as "user" | "project" | undefined,
410
+ p.workdir as string | undefined,
411
+ sessionId,
412
+ );
310
413
  case "setBuiltinPluginEnabled":
311
414
  return this.setBuiltinPluginEnabled(
312
415
  p.pluginId as string,
@@ -376,6 +479,10 @@ export class AgentBridge {
376
479
  name?: string;
377
480
  },
378
481
  );
482
+ case "getWorktreeChanges":
483
+ return this.getWorktreeChanges(
484
+ p as unknown as { path?: string; baseBranch?: string },
485
+ );
379
486
  case "removeWorktree":
380
487
  return this.removeWorktreeSession(
381
488
  p as unknown as {
@@ -446,6 +553,8 @@ export class AgentBridge {
446
553
  model: params.model,
447
554
  fastModel: params.fastModel,
448
555
  language: params.language,
556
+ autoMemoryEnabled: params.autoMemoryEnabled,
557
+ autoMemoryFrequency: params.autoMemoryFrequency,
449
558
  permissionMode: params.permissionMode,
450
559
  tools: params.tools,
451
560
  allowedTools: params.allowedTools,
@@ -462,9 +571,29 @@ export class AgentBridge {
462
571
  ctx.agent = agent;
463
572
  ctx.registeredSessionId = agent.sessionId;
464
573
 
574
+ // Record where the session was created (see SessionEntry.createdWorkdir).
575
+ // A fresh session's initialize workdir IS the creation dir. A from-disk
576
+ // re-attach (the daemon was restarted — killed / CLI 升级重启 / reboot —
577
+ // then status/send re-hosts the session) instead passes the re-attaching
578
+ // client's cwd — the transcript header (`{"type":"metadata","workdir":...}`, append-only since session
579
+ // creation) restores the original value when the agent's transcript is
580
+ // readable; otherwise the client cwd is the best available anchor.
581
+ let createdWorkdir: string;
582
+ if (params.restoreSessionId) {
583
+ createdWorkdir =
584
+ (await readTranscriptWorkdir(agent.sessionFilePath)) ??
585
+ params.workdir ??
586
+ agent.workingDirectory;
587
+ } else {
588
+ createdWorkdir = params.workdir ?? agent.workingDirectory;
589
+ }
590
+
465
591
  this.sessions.set(agent.sessionId, {
466
592
  agent,
467
- storedConfig: { ...params },
593
+ // Keep storedConfig.workdir in sync: updateConfig rebuilds the agent from
594
+ // storedConfig, so the rebuild must stay anchored on the creation dir too.
595
+ storedConfig: { ...params, workdir: createdWorkdir },
596
+ createdWorkdir,
468
597
  });
469
598
 
470
599
  return {
@@ -486,27 +615,6 @@ export class AgentBridge {
486
615
  return null;
487
616
  }
488
617
 
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
618
  /**
511
619
  * Destroy every hosted session agent. Each Agent.destroy() saves its
512
620
  * transcript, drains in-flight auto-memory extraction, and cleans up
@@ -533,7 +641,7 @@ export class AgentBridge {
533
641
  if (entry.agent.sessionId === restoreId) {
534
642
  this.emit(
535
643
  "messagesChange",
536
- { messages: entry.agent.messages },
644
+ { messages: entry.agent.displayMessages },
537
645
  entry.agent.sessionId,
538
646
  );
539
647
  // The re-attached client also missed the loading state that settled
@@ -548,12 +656,57 @@ export class AgentBridge {
548
656
  },
549
657
  entry.agent.sessionId,
550
658
  );
659
+ // Same story for the context-usage indicator: the SDK's restore-time
660
+ // onLatestTotalTokensChange fired inside initialize — before this
661
+ // client's router registered — so replay the current usage or the
662
+ // webview keeps the previous session's (or no) percentage until the
663
+ // next turn pushes a fresh value (spec desktop-app 上下文用量指示器
664
+ // 场景 6: 恢复即显示该会话上次用量).
665
+ this.emitContextUsage(entry.agent);
551
666
  return null;
552
667
  }
553
668
  await entry.agent.restoreSession(restoreId);
669
+ // A real restore must also re-emit the usage unconditionally: the SDK
670
+ // only fires onLatestTotalTokensChange on a VALUE CHANGE, so restoring a
671
+ // conversation whose persisted total equals the agent's current one
672
+ // suppresses the change-push and the webview — which cleared the previous
673
+ // session's number on the switch — keeps a blank ring until the next turn
674
+ // (the change-push, when it does fire inside restoreSession, already
675
+ // delivered the same value; re-emitting is idempotent).
676
+ this.emitContextUsage(entry.agent);
554
677
  return null;
555
678
  }
556
679
 
680
+ /**
681
+ * Push the session's current context-usage percentage to the client. Shared
682
+ * by every restore path so a conversation the client just switched to always
683
+ * shows its usage right away instead of waiting for the next token change
684
+ * (spec desktop-app 上下文用量指示器 场景 6). A zero total means no real
685
+ * usage to report; the host keeps the empty ring until the first push.
686
+ */
687
+ private emitContextUsage(agent: Agent): void {
688
+ const percent = this.contextUsagePercentOf(agent);
689
+ if (percent !== undefined) {
690
+ this.emit("contextUsage", { percent }, agent.sessionId);
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Current context-usage percentage of a session, or undefined when there is
696
+ * nothing to report yet (no tokens) or the model exposes no limit. Shared by
697
+ * the change-driven push and the getMessages response, so a host whose
698
+ * webview was re-created long after the last token change can still restore
699
+ * the ring without a per-host cache.
700
+ */
701
+ private contextUsagePercentOf(agent: Agent): number | undefined {
702
+ const tokens = agent.latestTotalTokens;
703
+ const max = agent.getMaxInputTokens();
704
+ if (tokens > 0 && max > 0) {
705
+ return Math.min(100, Math.round((tokens / max) * 100));
706
+ }
707
+ return undefined;
708
+ }
709
+
557
710
  private async listSessions(
558
711
  workdir?: string,
559
712
  sessionId?: string,
@@ -647,6 +800,21 @@ export class AgentBridge {
647
800
  }
648
801
  }
649
802
 
803
+ /**
804
+ * What a worktree deletion would throw away (uncommitted files and commits
805
+ * not on the base branch), so the caller can warn before deleting. Returns
806
+ * null when the worktree cannot be inspected.
807
+ */
808
+ private async getWorktreeChanges(params: {
809
+ path?: string;
810
+ baseBranch?: string;
811
+ }): Promise<{ files: number; commits: number } | null> {
812
+ if (!params.path) {
813
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, "path is required");
814
+ }
815
+ return getWorktreeChanges(params.path, params.baseBranch);
816
+ }
817
+
650
818
  private async removeWorktreeSession(params: {
651
819
  path: string;
652
820
  branch: string;
@@ -692,7 +860,10 @@ export class AgentBridge {
692
860
  const entry = this.requireSession(sessionId);
693
861
  return {
694
862
  sessionId: entry.agent.sessionId,
695
- workingDirectory: entry.agent.workingDirectory,
863
+ // The recorded creation workdir, not the live agent.workingDirectory:
864
+ // destroy --remove-worktree resolves the session's worktree from this
865
+ // value, and it must not drift when the session cd'd out of the worktree.
866
+ workingDirectory: entry.createdWorkdir,
696
867
  latestTotalTokens: entry.agent.latestTotalTokens,
697
868
  permissionMode: entry.agent.getPermissionMode(),
698
869
  availableTools: entry.agent.getAvailableToolNames(),
@@ -723,6 +894,8 @@ export class AgentBridge {
723
894
  model: entry.storedConfig.model,
724
895
  fastModel: entry.storedConfig.fastModel,
725
896
  language: entry.storedConfig.language,
897
+ autoMemoryEnabled: entry.storedConfig.autoMemoryEnabled,
898
+ autoMemoryFrequency: entry.storedConfig.autoMemoryFrequency,
726
899
  permissionMode: entry.storedConfig.permissionMode,
727
900
  tools: entry.storedConfig.tools,
728
901
  allowedTools: entry.storedConfig.allowedTools,
@@ -764,6 +937,7 @@ export class AgentBridge {
764
937
  this.sessions.set(agent.sessionId, {
765
938
  agent,
766
939
  storedConfig: { ...entry.storedConfig },
940
+ createdWorkdir: entry.createdWorkdir,
767
941
  });
768
942
 
769
943
  return { sessionId: agent.sessionId };
@@ -973,9 +1147,15 @@ export class AgentBridge {
973
1147
  return null;
974
1148
  }
975
1149
 
976
- private getMessages(sessionId?: string): { messages: Message[] } {
1150
+ private getMessages(sessionId?: string): {
1151
+ messages: Message[];
1152
+ contextUsagePercent?: number;
1153
+ } {
977
1154
  const entry = this.requireSession(sessionId);
978
- return { messages: entry.agent.messages };
1155
+ return {
1156
+ messages: entry.agent.displayMessages,
1157
+ contextUsagePercent: this.contextUsagePercentOf(entry.agent),
1158
+ };
979
1159
  }
980
1160
 
981
1161
  private async getFullMessageThread(sessionId?: string): Promise<{
@@ -1059,6 +1239,32 @@ export class AgentBridge {
1059
1239
  return { mode: entry.agent.getPermissionMode() };
1060
1240
  }
1061
1241
 
1242
+ /**
1243
+ * Returns the current plan file path and its contents for the session.
1244
+ * When the path generation is still in flight (e.g. the host just switched
1245
+ * to plan mode via setPermissionMode), awaits it so the caller can read the
1246
+ * plan before triggering a query (the plan mode reminder needs the path).
1247
+ */
1248
+ private async getPlanFile(sessionId?: string): Promise<{
1249
+ path: string | null;
1250
+ content: string | null;
1251
+ }> {
1252
+ const entry = this.requireSession(sessionId);
1253
+ const agent = entry.agent;
1254
+ const planPath =
1255
+ agent.getPlanFilePath() ?? (await agent.awaitPlanFilePath());
1256
+ if (!planPath) {
1257
+ return { path: null, content: null };
1258
+ }
1259
+ try {
1260
+ const content = await readFile(planPath, "utf8");
1261
+ return { path: planPath, content };
1262
+ } catch (error) {
1263
+ logger?.warn("Failed to read plan file", error);
1264
+ return { path: planPath, content: null };
1265
+ }
1266
+ }
1267
+
1062
1268
  // ── MCP ───────────────────────────────────────────────────────
1063
1269
 
1064
1270
  private getMcpServers(sessionId?: string): { servers: McpServerStatus[] } {
@@ -1084,6 +1290,27 @@ export class AgentBridge {
1084
1290
  return { success };
1085
1291
  }
1086
1292
 
1293
+ private async removeMcpServer(
1294
+ scope: "user" | "project",
1295
+ serverName: string,
1296
+ sessionId?: string,
1297
+ ): Promise<{ success: boolean }> {
1298
+ const entry = this.requireSession(sessionId);
1299
+ const success = await entry.agent.removeMcpServer(scope, serverName);
1300
+ return { success };
1301
+ }
1302
+
1303
+ private getMcpConfigPaths(sessionId?: string): {
1304
+ userPath: string | null;
1305
+ projectPath: string | null;
1306
+ } {
1307
+ const entry = this.requireSession(sessionId);
1308
+ return {
1309
+ userPath: entry.agent.getUserMcpConfigPath(),
1310
+ projectPath: entry.agent.getProjectMcpConfigPath(),
1311
+ };
1312
+ }
1313
+
1087
1314
  // ── Commands ──────────────────────────────────────────────────
1088
1315
 
1089
1316
  private getSlashCommands(sessionId?: string): { commands: SlashCommand[] } {
@@ -1103,6 +1330,50 @@ export class AgentBridge {
1103
1330
  return { skills: entry.agent.getSkillMetadata() };
1104
1331
  }
1105
1332
 
1333
+ private async deleteSkill(
1334
+ name: string,
1335
+ sessionId?: string,
1336
+ ): Promise<{ success: boolean }> {
1337
+ const entry = this.requireSession(sessionId);
1338
+ const success = await entry.agent.deleteSkill(name);
1339
+ return { success };
1340
+ }
1341
+
1342
+ private async deleteSubagent(
1343
+ name: string,
1344
+ sessionId?: string,
1345
+ ): Promise<{ success: boolean }> {
1346
+ const entry = this.requireSession(sessionId);
1347
+ const success = await entry.agent.deleteSubagent(name);
1348
+ return { success };
1349
+ }
1350
+
1351
+ private async getHooksByScope(
1352
+ scope: "user" | "project" | "plugin",
1353
+ sessionId?: string,
1354
+ ): Promise<{
1355
+ hooks: Partial<Record<string, unknown[]>>;
1356
+ configPath: string | null;
1357
+ }> {
1358
+ const entry = this.requireSession(sessionId);
1359
+ const hooks = await entry.agent.getHooksByScope(scope);
1360
+ // 回带该 scope 钩子所在 settings.json 的绝对路径(宿主 GUI 打开文件用;
1361
+ // plugin 钩子来自代码而非配置文件 → null)。
1362
+ const configPath =
1363
+ scope === "plugin" ? null : entry.agent.getHookConfigPath(scope);
1364
+ return { hooks, configPath };
1365
+ }
1366
+
1367
+ private async deleteHook(
1368
+ scope: "user" | "project",
1369
+ hookName: string,
1370
+ sessionId?: string,
1371
+ ): Promise<{ success: boolean }> {
1372
+ const entry = this.requireSession(sessionId);
1373
+ await entry.agent.deleteHook(scope, hookName);
1374
+ return { success: true };
1375
+ }
1376
+
1106
1377
  // ── File / History (global) ───────────────────────────────────
1107
1378
 
1108
1379
  private async searchFiles(
@@ -1219,7 +1490,9 @@ export class AgentBridge {
1219
1490
  }
1220
1491
 
1221
1492
  /** Daemon list: expose the in-memory session registry (live sessions only,
1222
- * not disk-scanning). Registration order is preserved. */
1493
+ * not disk-scanning). Registration order is preserved. The working directory
1494
+ * shown is the session's recorded creation workdir (stable identity), not the
1495
+ * live value that drifts with in-session `cd`. */
1223
1496
  private listDaemonSessions(): {
1224
1497
  sessions: Array<{
1225
1498
  sessionId: string;
@@ -1231,9 +1504,9 @@ export class AgentBridge {
1231
1504
  return {
1232
1505
  sessions: [...this.sessions.entries()].map(([sessionId, entry]) => ({
1233
1506
  sessionId,
1234
- workingDirectory: entry.agent.workingDirectory,
1507
+ workingDirectory: entry.createdWorkdir,
1235
1508
  isLoading: entry.agent.isLoading,
1236
- messageCount: entry.agent.messages.length,
1509
+ messageCount: entry.agent.displayMessages.length,
1237
1510
  })),
1238
1511
  };
1239
1512
  }
@@ -1254,6 +1527,31 @@ export class AgentBridge {
1254
1527
  };
1255
1528
  }
1256
1529
 
1530
+ private async getAccountInfo(): Promise<{
1531
+ plan?: { monthlyQuota: number; months: number; used: number } | null;
1532
+ apiQuota?: { limit: number | null; used: number } | null;
1533
+ }> {
1534
+ const authService = AuthService.getInstance();
1535
+ await authService.checkAndRefreshTokenIfNeeded();
1536
+ if (!authService.isSSOAuthenticated()) return {};
1537
+ const token = authService.getSSOToken();
1538
+ if (!token) return {};
1539
+ const response = await fetch(
1540
+ `${authService.getServerUrl()}/api/v1/account`,
1541
+ {
1542
+ headers: { Authorization: `Bearer ${token}` },
1543
+ },
1544
+ );
1545
+ if (!response.ok) {
1546
+ // 401 / 5xx — 让调用方(desktop host)保留上次成功数据(spec 场景 9)。
1547
+ throw new Error(`Account query failed (${response.status})`);
1548
+ }
1549
+ return (await response.json()) as {
1550
+ plan?: { monthlyQuota: number; months: number; used: number } | null;
1551
+ apiQuota?: { limit: number | null; used: number } | null;
1552
+ };
1553
+ }
1554
+
1257
1555
  private async login(
1258
1556
  serverUrl?: string,
1259
1557
  ): Promise<{ user: { id: string; email?: string } | undefined }> {
@@ -1273,6 +1571,88 @@ export class AgentBridge {
1273
1571
  return null;
1274
1572
  }
1275
1573
 
1574
+ // ── Memory files (settings UI) ───────────────────────────────
1575
+
1576
+ /**
1577
+ * Read an AGENTS.md file for the settings UI: user-level reads
1578
+ * ~/.wave/AGENTS.md, project-level reads <workdir>/AGENTS.md. Prefers the
1579
+ * live agent (clears its caches on write) and falls back to direct file
1580
+ * access when no session is bound (e.g. desktop before any session exists).
1581
+ */
1582
+ private async getAgentsContent(
1583
+ scope: "user" | "project",
1584
+ workdir?: string,
1585
+ sessionId?: string,
1586
+ ): Promise<{ content: string; path?: string }> {
1587
+ const agent = this.sessions.get(sessionId ?? "")?.agent;
1588
+ if (scope === "user") {
1589
+ if (agent) {
1590
+ return { content: await agent.readUserMemoryContent() };
1591
+ }
1592
+ const path = this.getUserMemoryFilePath();
1593
+ return { content: await readTextFileSafe(path), path };
1594
+ }
1595
+ const resolvedWorkdir =
1596
+ workdir || (agent?.workingDirectory as string | undefined);
1597
+ if (!resolvedWorkdir) {
1598
+ throw new RpcError(
1599
+ PROTOCOL_INVALID_PARAMS,
1600
+ "Project-scope AGENTS.md requires a workdir",
1601
+ );
1602
+ }
1603
+ if (agent) {
1604
+ return {
1605
+ content: await agent.readProjectMemoryContent(resolvedWorkdir),
1606
+ };
1607
+ }
1608
+ const path = join(resolvedWorkdir, "AGENTS.md");
1609
+ return { content: await readTextFileSafe(path), path };
1610
+ }
1611
+
1612
+ /**
1613
+ * Write an AGENTS.md file from the settings UI: user-level writes
1614
+ * ~/.wave/AGENTS.md, project-level writes <workdir>/AGENTS.md. Goes through
1615
+ * the live agent when available so its memory caches are invalidated.
1616
+ */
1617
+ private async setAgentsContent(
1618
+ scope: "user" | "project",
1619
+ content: string,
1620
+ workdir?: string,
1621
+ sessionId?: string,
1622
+ ): Promise<{ ok: boolean; path?: string }> {
1623
+ const agent = this.sessions.get(sessionId ?? "")?.agent;
1624
+ if (scope === "user") {
1625
+ if (agent) {
1626
+ await agent.writeUserMemoryContent(content);
1627
+ } else {
1628
+ const path = this.getUserMemoryFilePath();
1629
+ await mkdir(dirname(path), { recursive: true });
1630
+ await writeFile(path, content, "utf-8");
1631
+ }
1632
+ return { ok: true };
1633
+ }
1634
+ const resolvedWorkdir =
1635
+ workdir || (agent?.workingDirectory as string | undefined);
1636
+ if (!resolvedWorkdir) {
1637
+ throw new RpcError(
1638
+ PROTOCOL_INVALID_PARAMS,
1639
+ "Project-scope AGENTS.md requires a workdir",
1640
+ );
1641
+ }
1642
+ if (agent) {
1643
+ await agent.writeProjectMemoryContent(resolvedWorkdir, content);
1644
+ } else {
1645
+ const path = join(resolvedWorkdir, "AGENTS.md");
1646
+ await mkdir(dirname(path), { recursive: true });
1647
+ await writeFile(path, content, "utf-8");
1648
+ }
1649
+ return { ok: true };
1650
+ }
1651
+
1652
+ private getUserMemoryFilePath(): string {
1653
+ return join(homedir(), ".wave", "AGENTS.md");
1654
+ }
1655
+
1276
1656
  // ── Plugins (global) ─────────────────────────────────────────
1277
1657
 
1278
1658
  private getPluginCore(workdir?: string, sessionId?: string): PluginCore {
@@ -1359,6 +1739,81 @@ export class AgentBridge {
1359
1739
  };
1360
1740
  }
1361
1741
 
1742
+ /**
1743
+ * Read the hooks fragment from a single config scope (user or project), for
1744
+ * the settings page read-only hooks view. Hooks come from settings.json
1745
+ * (user: ~/.wave/settings.json; project: .wave/settings.json +
1746
+ * settings.local.json merged, append per event).
1747
+ */
1748
+ private async getHooksConfig(
1749
+ scope: "user" | "project" | undefined,
1750
+ workdir?: string,
1751
+ sessionId?: string,
1752
+ ): Promise<{ hooks: PartialHookConfiguration | undefined }> {
1753
+ const resolvedScope = scope ?? "user";
1754
+
1755
+ if (resolvedScope === "user") {
1756
+ const config = loadWaveConfigFromFile(getUserConfigPaths()[0]);
1757
+ return { hooks: config?.hooks };
1758
+ }
1759
+
1760
+ const resolvedWorkdir =
1761
+ workdir || this.getSessionWorkdir(sessionId) || process.cwd();
1762
+ const [localPath, jsonPath] = getProjectConfigPaths(resolvedWorkdir);
1763
+ const hooks: PartialHookConfiguration = {};
1764
+ for (const config of [
1765
+ loadWaveConfigFromFile(jsonPath),
1766
+ loadWaveConfigFromFile(localPath),
1767
+ ]) {
1768
+ if (!config?.hooks) continue;
1769
+ for (const [event, eventConfigs] of Object.entries(config.hooks)) {
1770
+ if (!isValidHookEvent(event)) continue;
1771
+ hooks[event] = [...(hooks[event] ?? []), ...(eventConfigs ?? [])];
1772
+ }
1773
+ }
1774
+ return { hooks: Object.keys(hooks).length > 0 ? hooks : undefined };
1775
+ }
1776
+
1777
+ /**
1778
+ * Read the mcpServers fragment from a single config scope (user or project),
1779
+ * for the settings page read-only MCP view. MCP servers come from mcp.json
1780
+ * (user: ~/.wave/mcp.json; project: <workdir>/.mcp.json). Runtime status
1781
+ * (connect/disconnect/tools) is served by the existing getMcpServers RPC.
1782
+ */
1783
+ private async getMcpConfig(
1784
+ scope: "user" | "project" | undefined,
1785
+ workdir?: string,
1786
+ sessionId?: string,
1787
+ ): Promise<{ mcpServers: Record<string, McpServerConfig> }> {
1788
+ const resolvedScope = scope ?? "user";
1789
+
1790
+ if (resolvedScope === "user") {
1791
+ return {
1792
+ mcpServers: this.readMcpServersFile(
1793
+ join(homedir(), ".wave", "mcp.json"),
1794
+ ),
1795
+ };
1796
+ }
1797
+
1798
+ const resolvedWorkdir =
1799
+ workdir || this.getSessionWorkdir(sessionId) || process.cwd();
1800
+ return {
1801
+ mcpServers: this.readMcpServersFile(join(resolvedWorkdir, ".mcp.json")),
1802
+ };
1803
+ }
1804
+
1805
+ private readMcpServersFile(
1806
+ filePath: string,
1807
+ ): Record<string, McpServerConfig> {
1808
+ try {
1809
+ if (!existsSync(filePath)) return {};
1810
+ const raw = JSON.parse(readFileSync(filePath, "utf-8")) as McpConfig;
1811
+ return raw.mcpServers ?? {};
1812
+ } catch {
1813
+ return {};
1814
+ }
1815
+ }
1816
+
1362
1817
  private async setBuiltinPluginEnabled(
1363
1818
  pluginId: string,
1364
1819
  enabled: boolean,
@@ -1483,6 +1938,18 @@ export class AgentBridge {
1483
1938
  ctx.registeredSessionId,
1484
1939
  );
1485
1940
  },
1941
+ onLatestTotalTokensChange: (tokens: number) => {
1942
+ // Batch 2 压缩上下文 button: push the context usage percentage on
1943
+ // every token-count change. The SDK fires this on each finished
1944
+ // request (fresh total) and on session restore (the persisted total),
1945
+ // so the webview button shows real usage without waiting for a turn —
1946
+ // aligned with the CLI, which reads the same callback (spec 场景 5/6).
1947
+ const max = ctx.agent?.getMaxInputTokens() ?? 0;
1948
+ if (max > 0) {
1949
+ const percent = Math.min(100, Math.round((tokens / max) * 100));
1950
+ this.emit("contextUsage", { percent }, ctx.registeredSessionId);
1951
+ }
1952
+ },
1486
1953
  onCommandRunningChange: (running: boolean) => {
1487
1954
  this.emit("commandRunningChange", { running }, ctx.registeredSessionId);
1488
1955
  },
@@ -1542,29 +2009,8 @@ export class AgentBridge {
1542
2009
  onMcpServersChange: (servers: McpServerStatus[]) => {
1543
2010
  this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
1544
2011
  },
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
2012
  onNotificationMessageAdded: (params) => {
1567
- const msg = ctx.agent?.messages.find(
2013
+ const msg = ctx.agent?.displayMessages.find(
1568
2014
  (m) =>
1569
2015
  m.role === "user" &&
1570
2016
  m.blocks.some(
@@ -1656,3 +2102,54 @@ function isSessionRecoveryError(error: unknown): boolean {
1656
2102
  message.startsWith("Session not found:")
1657
2103
  );
1658
2104
  }
2105
+
2106
+ /** Read a text file, returning "" when it does not exist. */
2107
+ async function readTextFileSafe(filePath: string): Promise<string> {
2108
+ try {
2109
+ return await readFile(filePath, "utf-8");
2110
+ } catch (error) {
2111
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
2112
+ return "";
2113
+ }
2114
+ throw error;
2115
+ }
2116
+ }
2117
+
2118
+ /**
2119
+ * Read the creation-time workdir from a session transcript's metadata header —
2120
+ * the first line `{"type":"metadata","workdir":...,"createdAt":...,"gitBranch":...}`
2121
+ * the SDK writes once at session creation. The header is append-only, so it
2122
+ * records where the session was created even after the live working directory
2123
+ * drifted (in-session `cd`) or after a daemon restart re-anchored the agent at
2124
+ * a client's cwd. Returns undefined when the file is missing, legacy (no
2125
+ * header), or unreadable. Only the header line is read (transcripts can grow
2126
+ * large); a 4KiB read covers any realistic header.
2127
+ */
2128
+ async function readTranscriptWorkdir(
2129
+ transcriptPath?: string,
2130
+ ): Promise<string | undefined> {
2131
+ if (!transcriptPath) return undefined;
2132
+ try {
2133
+ const handle = await open(transcriptPath, "r");
2134
+ try {
2135
+ const buffer = Buffer.alloc(4096);
2136
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
2137
+ if (bytesRead === 0) return undefined;
2138
+ const newline = buffer.indexOf(0x0a);
2139
+ const firstLine = buffer
2140
+ .subarray(0, newline === -1 ? bytesRead : newline)
2141
+ .toString("utf8");
2142
+ const header = JSON.parse(firstLine) as {
2143
+ type?: string;
2144
+ workdir?: unknown;
2145
+ };
2146
+ return header.type === "metadata" && typeof header.workdir === "string"
2147
+ ? header.workdir
2148
+ : undefined;
2149
+ } finally {
2150
+ await handle.close();
2151
+ }
2152
+ } catch {
2153
+ return undefined;
2154
+ }
2155
+ }