wave-agent-sdk 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.
Files changed (89) hide show
  1. package/dist/agent.d.ts +72 -24
  2. package/dist/agent.js +123 -31
  3. package/dist/builtin/plugins.js +17 -2
  4. package/dist/builtin/skills/settings.js +6 -8
  5. package/dist/core/plugin.d.ts +4 -0
  6. package/dist/core/plugin.js +7 -1
  7. package/dist/index.d.ts +2 -1
  8. package/dist/index.js +2 -1
  9. package/dist/managers/aiManager.d.ts +0 -24
  10. package/dist/managers/aiManager.js +23 -181
  11. package/dist/managers/bashModeManager.d.ts +33 -0
  12. package/dist/managers/bashModeManager.js +110 -0
  13. package/dist/managers/hookManager.d.ts +5 -0
  14. package/dist/managers/hookManager.js +7 -0
  15. package/dist/managers/liveConfigManager.js +3 -3
  16. package/dist/managers/mcpManager.d.ts +23 -0
  17. package/dist/managers/mcpManager.js +162 -14
  18. package/dist/managers/messageManager.d.ts +12 -13
  19. package/dist/managers/messageManager.js +63 -60
  20. package/dist/managers/permissionManager.d.ts +29 -0
  21. package/dist/managers/permissionManager.js +158 -70
  22. package/dist/managers/planManager.d.ts +9 -0
  23. package/dist/managers/planManager.js +19 -1
  24. package/dist/managers/skillManager.d.ts +31 -0
  25. package/dist/managers/skillManager.js +122 -12
  26. package/dist/managers/slashCommandManager.js +9 -28
  27. package/dist/managers/subagentManager.d.ts +7 -0
  28. package/dist/managers/subagentManager.js +61 -9
  29. package/dist/managers/workflowManager.js +6 -0
  30. package/dist/prompts/index.d.ts +0 -1
  31. package/dist/prompts/index.js +0 -4
  32. package/dist/services/MarketplaceService.js +36 -14
  33. package/dist/services/configurationService.d.ts +34 -2
  34. package/dist/services/configurationService.js +123 -16
  35. package/dist/services/initializationService.js +2 -2
  36. package/dist/services/jsonlHandler.d.ts +14 -0
  37. package/dist/services/jsonlHandler.js +44 -1
  38. package/dist/services/memory.d.ts +14 -0
  39. package/dist/services/memory.js +33 -0
  40. package/dist/services/officialMarketplaceMirror.d.ts +85 -0
  41. package/dist/services/officialMarketplaceMirror.js +289 -0
  42. package/dist/services/remoteSettingsService.js +4 -4
  43. package/dist/services/session.js +30 -13
  44. package/dist/services/worktreeHooks.js +6 -1
  45. package/dist/stdio/index.d.ts +10 -0
  46. package/dist/stdio/index.js +10 -0
  47. package/dist/stdio/notificationRouter.d.ts +38 -0
  48. package/dist/stdio/notificationRouter.js +96 -0
  49. package/dist/stdio/rpcClient.d.ts +18 -0
  50. package/dist/stdio/rpcClient.js +10 -0
  51. package/dist/stdio/stdioAgent.d.ts +222 -0
  52. package/dist/stdio/stdioAgent.js +341 -0
  53. package/dist/tools/bashTool.js +2 -0
  54. package/dist/tools/exitPlanMode.js +10 -2
  55. package/dist/types/agent.d.ts +8 -0
  56. package/dist/types/commands.d.ts +7 -0
  57. package/dist/types/configuration.d.ts +6 -1
  58. package/dist/types/hooks.d.ts +1 -0
  59. package/dist/types/hooks.js +19 -0
  60. package/dist/types/mcp.d.ts +3 -0
  61. package/dist/types/messaging.d.ts +1 -8
  62. package/dist/types/skills.d.ts +11 -0
  63. package/dist/utils/bashParser.d.ts +17 -0
  64. package/dist/utils/bashParser.js +72 -0
  65. package/dist/utils/bashStructure/bashLexer.d.ts +96 -0
  66. package/dist/utils/bashStructure/bashLexer.js +676 -0
  67. package/dist/utils/bashStructure/bashParser.d.ts +144 -0
  68. package/dist/utils/bashStructure/bashParser.js +606 -0
  69. package/dist/utils/bashStructure/bashSemantics.d.ts +70 -0
  70. package/dist/utils/bashStructure/bashSemantics.js +477 -0
  71. package/dist/utils/bashStructure/index.d.ts +26 -0
  72. package/dist/utils/bashStructure/index.js +27 -0
  73. package/dist/utils/bashStructure/types.d.ts +62 -0
  74. package/dist/utils/bashStructure/types.js +47 -0
  75. package/dist/utils/container.d.ts +6 -0
  76. package/dist/utils/container.js +9 -0
  77. package/dist/utils/containerSetup.d.ts +11 -1
  78. package/dist/utils/containerSetup.js +26 -11
  79. package/dist/utils/fileUtils.d.ts +11 -0
  80. package/dist/utils/fileUtils.js +37 -0
  81. package/dist/utils/messageOperations.d.ts +0 -18
  82. package/dist/utils/messageOperations.js +0 -62
  83. package/dist/utils/subagentParser.js +9 -2
  84. package/dist/utils/tokenCalculation.js +0 -8
  85. package/dist/utils/worktreeUtils.d.ts +2 -1
  86. package/dist/utils/worktreeUtils.js +64 -34
  87. package/package.json +6 -1
  88. package/dist/managers/bangManager.d.ts +0 -26
  89. package/dist/managers/bangManager.js +0 -78
@@ -1,5 +1,6 @@
1
1
  import { promises as fs } from "fs";
2
2
  import { join } from "path";
3
+ import { homedir } from "os";
3
4
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
5
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
5
6
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
@@ -73,9 +74,12 @@ export class McpManager {
73
74
  this.servers = new Map();
74
75
  this.connections = new Map();
75
76
  this.configPath = "";
77
+ this.userConfigPath = "";
76
78
  this.workdir = "";
77
79
  this.reconnectTimers = new Map();
78
80
  this.reconnectAttempts = new Map();
81
+ /** Explicit disconnectServer teardowns currently in flight */
82
+ this.disconnecting = new Set();
79
83
  this.callbacks = options.callbacks || {};
80
84
  this.mcpServers = options.mcpServers;
81
85
  }
@@ -94,6 +98,7 @@ export class McpManager {
94
98
  */
95
99
  async initialize(workdir, autoConnect = false) {
96
100
  this.configPath = join(workdir, ".mcp.json");
101
+ this.userConfigPath = join(homedir(), ".wave", "mcp.json");
97
102
  this.workdir = workdir;
98
103
  // Register constructor-provided servers before loading .mcp.json
99
104
  if (this.mcpServers) {
@@ -141,22 +146,40 @@ export class McpManager {
141
146
  return null;
142
147
  }
143
148
  try {
144
- const configContent = await fs.readFile(this.configPath, "utf-8");
145
- const rawConfig = JSON.parse(configContent);
146
- const workspaceConfig = resolveMcpConfig(rawConfig, this.envSnapshot);
149
+ // Load user-level (~/.wave/mcp.json) and project-level (<workdir>/.mcp.json)
150
+ // configs, then merge with project taking precedence over user-level.
151
+ const userConfig = await this.readConfigFile(this.userConfigPath);
152
+ const projectConfig = await this.readConfigFile(this.configPath);
153
+ // Neither file exists: preserve the original null contract (constructor-
154
+ // provided servers may still be present in this.config).
155
+ if (!userConfig && !projectConfig) {
156
+ return this.config;
157
+ }
158
+ const userResolved = userConfig
159
+ ? resolveMcpConfig(userConfig, this.envSnapshot)
160
+ : null;
161
+ const projectResolved = projectConfig
162
+ ? resolveMcpConfig(projectConfig, this.envSnapshot)
163
+ : null;
147
164
  // Extract original (pre-resolution) URLs for safe display
148
165
  const originalUrls = {};
149
- for (const [name, serverConfig] of Object.entries(rawConfig.mcpServers)) {
166
+ for (const [name, serverConfig] of Object.entries({
167
+ ...(userConfig?.mcpServers || {}),
168
+ ...(projectConfig?.mcpServers || {}),
169
+ })) {
150
170
  originalUrls[name] = serverConfig.url;
151
171
  }
152
- // Merge workspace config with any existing config (e.g., from plugins or constructor)
153
- // Constructor-provided servers take precedence, then workspace config, then existing config
172
+ // Merge: existing config (e.g. plugin servers) -> user -> project -> constructor
154
173
  const merged = { mcpServers: {} };
155
174
  if (this.config) {
156
175
  Object.assign(merged.mcpServers, this.config.mcpServers);
157
176
  }
158
- Object.assign(merged.mcpServers, workspaceConfig.mcpServers);
159
- // Constructor-provided servers override both for same names
177
+ if (userResolved) {
178
+ Object.assign(merged.mcpServers, userResolved.mcpServers);
179
+ }
180
+ if (projectResolved) {
181
+ Object.assign(merged.mcpServers, projectResolved.mcpServers);
182
+ }
160
183
  if (this.mcpServers) {
161
184
  Object.assign(merged.mcpServers, this.mcpServers);
162
185
  }
@@ -165,12 +188,27 @@ export class McpManager {
165
188
  if (this.config) {
166
189
  for (const [name, config] of Object.entries(this.config.mcpServers)) {
167
190
  const existingServer = this.servers.get(name);
191
+ // Determine scope: plugin servers registered with pluginRoot keep
192
+ // their plugin scope; user-level names come from ~/.wave/mcp.json.
193
+ let scope = existingServer?.scope;
194
+ if (!scope) {
195
+ if (config.pluginRoot) {
196
+ scope = "plugin";
197
+ }
198
+ else if (userConfig?.mcpServers?.[name]) {
199
+ scope = "user";
200
+ }
201
+ else {
202
+ scope = "project";
203
+ }
204
+ }
168
205
  if (existingServer) {
169
206
  // Update config but preserve status and other runtime info
170
207
  this.servers.set(name, {
171
208
  ...existingServer,
172
209
  config, // Update config in case it changed
173
210
  originalUrl: originalUrls[name] ?? existingServer.originalUrl,
211
+ scope,
174
212
  });
175
213
  }
176
214
  else {
@@ -179,6 +217,7 @@ export class McpManager {
179
217
  name,
180
218
  config,
181
219
  originalUrl: originalUrls[name],
220
+ scope,
182
221
  status: "disconnected",
183
222
  });
184
223
  }
@@ -189,11 +228,41 @@ export class McpManager {
189
228
  catch (error) {
190
229
  // Only log error if it's not a "file not found" error
191
230
  if (error.code !== "ENOENT") {
192
- logger?.error("Failed to load .mcp.json:", error);
231
+ logger?.error("Failed to load MCP config:", error);
193
232
  }
194
233
  return null;
195
234
  }
196
235
  }
236
+ /**
237
+ * Read a raw MCP config file, tolerating ENOENT and corrupt JSON.
238
+ */
239
+ async readConfigFile(configPath) {
240
+ if (!configPath)
241
+ return null;
242
+ try {
243
+ const content = await fs.readFile(configPath, "utf-8");
244
+ return JSON.parse(content);
245
+ }
246
+ catch (error) {
247
+ const code = error.code;
248
+ if (code === "ENOENT")
249
+ return null;
250
+ logger?.error("Failed to load .mcp.json:", error);
251
+ return null;
252
+ }
253
+ }
254
+ /**
255
+ * The user-level MCP config path (~/.wave/mcp.json).
256
+ */
257
+ getUserConfigPath() {
258
+ return this.userConfigPath;
259
+ }
260
+ /**
261
+ * The project-level MCP config path (<workdir>/.mcp.json).
262
+ */
263
+ getProjectConfigPath() {
264
+ return this.configPath;
265
+ }
197
266
  async saveConfig(config) {
198
267
  try {
199
268
  await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
@@ -258,6 +327,7 @@ export class McpManager {
258
327
  name,
259
328
  config: resolvedConfig,
260
329
  originalUrl,
330
+ scope: resolvedConfig.pluginRoot ? "plugin" : undefined,
261
331
  status: "disconnected",
262
332
  };
263
333
  this.servers.set(name, newServer);
@@ -283,6 +353,34 @@ export class McpManager {
283
353
  }
284
354
  return removed;
285
355
  }
356
+ /**
357
+ * Remove a server from a persisted config file (user or project scope),
358
+ * then disconnect and drop it from the in-memory registry.
359
+ * @param scope - "user" removes from ~/.wave/mcp.json; "project" removes from <workdir>/.mcp.json
360
+ * @param name - The server name
361
+ * @returns true if the server was found and removed
362
+ */
363
+ async removeServerFromConfig(scope, name) {
364
+ const configPath = scope === "user" ? this.userConfigPath : this.configPath;
365
+ if (!configPath)
366
+ return false;
367
+ const rawConfig = await this.readConfigFile(configPath);
368
+ if (!rawConfig ||
369
+ !rawConfig.mcpServers ||
370
+ !(name in rawConfig.mcpServers)) {
371
+ return false;
372
+ }
373
+ delete rawConfig.mcpServers[name];
374
+ if (Object.keys(rawConfig.mcpServers).length === 0) {
375
+ await fs.rm(configPath, { force: true });
376
+ }
377
+ else {
378
+ await fs.writeFile(configPath, JSON.stringify(rawConfig, null, 2));
379
+ }
380
+ // Drop from memory (disconnects if connected)
381
+ this.removeServer(name);
382
+ return true;
383
+ }
286
384
  // Real MCP connection implementation
287
385
  async connectServer(name) {
288
386
  const server = this.servers.get(name);
@@ -291,6 +389,11 @@ export class McpManager {
291
389
  // Already connected
292
390
  if (this.connections.has(name))
293
391
  return true;
392
+ // An explicit disconnectServer teardown is in flight for this server —
393
+ // don't race it (e.g. an auto-reconnect timer that just fired) by spawning
394
+ // a fresh process the user asked to stop.
395
+ if (this.disconnecting.has(name))
396
+ return false;
294
397
  this.updateServerStatus(name, { status: "connecting" });
295
398
  try {
296
399
  // Create transport - it will manage the process
@@ -454,14 +557,22 @@ export class McpManager {
454
557
  };
455
558
  transport.onclose = () => {
456
559
  logger?.debug(`MCP Server ${name} transport closed`);
560
+ // Only the current generation of this server's transport may mutate
561
+ // state: a stale process 'close' arriving after an auto-reconnect must
562
+ // not tear down (or schedule a reconnect for) the live connection.
563
+ const current = this.connections.get(name);
564
+ if (!current || current.transport !== transport)
565
+ return;
457
566
  this.connections.delete(name);
458
567
  this.updateServerStatus(name, {
459
568
  status: "disconnected",
460
569
  tools: [],
461
570
  toolCount: 0,
462
571
  });
463
- // Auto-reconnect with exponential backoff (not triggered by explicit disconnect)
464
- if (!this.reconnectTimers.has(name)) {
572
+ // Auto-reconnect with exponential backoff. Skipped while an explicit
573
+ // disconnectServer teardown is in flight — that close is expected, and
574
+ // reconnecting would silently undo a user-initiated disconnect.
575
+ if (!this.disconnecting.has(name) && !this.reconnectTimers.has(name)) {
465
576
  this.scheduleReconnect(name);
466
577
  }
467
578
  };
@@ -570,27 +681,64 @@ export class McpManager {
570
681
  async disconnectServer(name) {
571
682
  // Cancel any pending reconnect attempts
572
683
  this.cancelReconnect(name);
684
+ const server = this.servers.get(name);
573
685
  const connection = this.connections.get(name);
574
- if (!connection)
686
+ // Nothing live to tear down. Still reconcile a stale "connected"-style
687
+ // status (e.g. after an unobserved crash left no connection entry) so the
688
+ // UI converges instead of staying wedged on a disconnect action.
689
+ if (!connection) {
690
+ if (server && server.status !== "disconnected") {
691
+ this.updateServerStatus(name, {
692
+ status: "disconnected",
693
+ tools: [],
694
+ toolCount: 0,
695
+ error: undefined,
696
+ });
697
+ }
575
698
  return false;
699
+ }
700
+ // Idempotent: a teardown is already running for this server.
701
+ if (this.disconnecting.has(name))
702
+ return false;
703
+ // Mark before closing so transport.onclose knows this is an expected,
704
+ // user-initiated teardown and must not schedule an auto-reconnect.
705
+ this.disconnecting.add(name);
576
706
  try {
577
707
  // Close client connection and transport
578
708
  await connection.client.close();
579
709
  await connection.transport.close();
580
- // Remove connection
581
- this.connections.delete(name);
710
+ // Remove connection (transport.onclose may already have done so)
711
+ if (this.connections.get(name) === connection) {
712
+ this.connections.delete(name);
713
+ }
582
714
  // updateServerStatus will trigger the callback
583
715
  this.updateServerStatus(name, {
584
716
  status: "disconnected",
585
717
  tools: [],
586
718
  toolCount: 0,
719
+ error: undefined,
587
720
  });
588
721
  return true;
589
722
  }
590
723
  catch (error) {
591
724
  logger?.error(`Error disconnecting from MCP server ${name}:`, error);
725
+ // Never leave the server reporting connected after a failed teardown:
726
+ // drop the connection and push a terminal state so the caller/UI gets a
727
+ // definitive outcome instead of an endless "disconnecting" spinner.
728
+ if (this.connections.get(name) === connection) {
729
+ this.connections.delete(name);
730
+ }
731
+ this.updateServerStatus(name, {
732
+ status: "disconnected",
733
+ tools: [],
734
+ toolCount: 0,
735
+ error: error instanceof Error ? error.message : String(error),
736
+ });
592
737
  return false;
593
738
  }
739
+ finally {
740
+ this.disconnecting.delete(name);
741
+ }
594
742
  }
595
743
  // Get all tools from connected servers.
596
744
  // "reconnecting" servers retain their last-known tool snapshot so that
@@ -24,9 +24,6 @@ export interface MessageManagerCallbacks {
24
24
  onErrorBlockAdded?: (error: string) => void;
25
25
  onCompactBlockAdded?: (content: string) => void;
26
26
  onCompactionStateChange?: (isCompacting: boolean) => void;
27
- onAddBangMessage?: (command: string, messageId: string) => void;
28
- onUpdateBangMessage?: (command: string, output: string, messageId: string) => void;
29
- onCompleteBangMessage?: (command: string, exitCode: number, messageId: string, output?: string) => void;
30
27
  onInfoBlockAdded?: (content: string) => void;
31
28
  onShowRewind?: () => void;
32
29
  onFileHistoryBlockAdded?: (snapshots: import("../types/reversion.js").FileSnapshot[]) => void;
@@ -47,6 +44,13 @@ export declare class MessageManager {
47
44
  private container;
48
45
  private sessionId;
49
46
  private messages;
47
+ /** Full display message stream for the UI. Mirrors `messages` (context) on
48
+ * every normal change; the two diverge only around compaction, where
49
+ * `messages` is replaced by [compact, ...last API rounds] for the API
50
+ * context while `displayMessages` keeps the full history and appends the
51
+ * compact block (aligned with Claude Code desktop/VS Code: compaction
52
+ * folds the API context but leaves the UI transcript intact). */
53
+ private displayMessages;
50
54
  private latestTotalTokens;
51
55
  private workdir;
52
56
  private encodedWorkdir;
@@ -66,6 +70,8 @@ export declare class MessageManager {
66
70
  getSessionId(): string;
67
71
  getRootSessionId(): string;
68
72
  getMessages(): Message[];
73
+ /** Full UI display message stream (includes pre-compaction history). */
74
+ getDisplayMessages(): Message[];
69
75
  getUsages(): Usage[];
70
76
  getLatestTotalTokens(): number;
71
77
  getWorkdir(): string;
@@ -110,7 +116,9 @@ export declare class MessageManager {
110
116
  * Uses recentFileReads Map populated from Read tool execution.
111
117
  */
112
118
  hasFileBeenRead(filePath: string): boolean;
113
- setMessages(messages: Message[]): void;
119
+ setMessages(messages: Message[], opts?: {
120
+ keepDisplay?: boolean;
121
+ }): void;
114
122
  /**
115
123
  * Save current session
116
124
  */
@@ -145,15 +153,6 @@ export declare class MessageManager {
145
153
  */
146
154
  compactMessagesAndUpdateSession(compactedContent: string, usage?: Usage): Promise<void>;
147
155
  addFileHistoryBlock(snapshots: import("../types/reversion.js").FileSnapshot[]): void;
148
- addBangMessage(command: string): void;
149
- updateBangMessage(command: string, output: string): void;
150
- completeBangMessage(command: string, exitCode: number, output?: string): void;
151
- /**
152
- * Find the message ID of the most recent message containing a bang block
153
- * for the given command. Bang callbacks do not carry a block ID, so the
154
- * message is located by matching the command against bang blocks.
155
- */
156
- private findBangMessageId;
157
156
  addNotificationMessage(params: Omit<AddNotificationMessageParams, "messages">): void;
158
157
  /**
159
158
  * Rebuild usage array from messages containing usage metadata
@@ -1,4 +1,4 @@
1
- import { addAssistantMessageToMessages, updateToolBlockInMessage, addErrorBlockToMessage, addUserMessageToMessages, updateUserMessageInMessages, addBangMessage, updateBangInMessage, completeBangInMessage, removeLastUserMessage, addToolBlockToMessageInMessages, addNotificationMessageToMessages, generateMessageId, sliceFromLastCompact, } from "../utils/messageOperations.js";
1
+ import { addAssistantMessageToMessages, updateToolBlockInMessage, addErrorBlockToMessage, addUserMessageToMessages, updateUserMessageInMessages, removeLastUserMessage, addToolBlockToMessageInMessages, addNotificationMessageToMessages, generateMessageId, sliceFromLastCompact, } from "../utils/messageOperations.js";
2
2
  import { getLastApiRounds } from "../utils/groupMessagesByApiRound.js";
3
3
  import { join, isAbsolute, relative } from "path";
4
4
  import { appendMessages, createSession, generateSessionId, SESSION_DIR, } from "../services/session.js";
@@ -16,6 +16,13 @@ import { logger } from "../utils/globalLogger.js";
16
16
  export class MessageManager {
17
17
  constructor(container, options) {
18
18
  this.container = container;
19
+ /** Full display message stream for the UI. Mirrors `messages` (context) on
20
+ * every normal change; the two diverge only around compaction, where
21
+ * `messages` is replaced by [compact, ...last API rounds] for the API
22
+ * context while `displayMessages` keeps the full history and appends the
23
+ * compact block (aligned with Claude Code desktop/VS Code: compaction
24
+ * folds the API context but leaves the UI transcript intact). */
25
+ this.displayMessages = [];
19
26
  this.pendingFileReadTriggers = new Set(); // File paths read via Read tool, awaiting rule matching
20
27
  this.loadedRuleIds = new Set(); // IDs of conditional rules already injected as meta messages
21
28
  this.recentFileReads = new Map(); // Track file read contents
@@ -55,6 +62,10 @@ export class MessageManager {
55
62
  getMessages() {
56
63
  return [...this.messages];
57
64
  }
65
+ /** Full UI display message stream (includes pre-compaction history). */
66
+ getDisplayMessages() {
67
+ return [...this.displayMessages];
68
+ }
58
69
  getUsages() {
59
70
  return [...this._usages];
60
71
  }
@@ -174,9 +185,35 @@ export class MessageManager {
174
185
  return (this.recentFileReads.has(normalizedPath) ||
175
186
  this.recentFileReads.has(filePath));
176
187
  }
177
- setMessages(messages) {
188
+ setMessages(messages, opts) {
178
189
  const oldLength = this.messages.length;
179
190
  this.messages = [...messages];
191
+ // The UI display stream follows the context by default. Compaction /
192
+ // session restore / rewind pass keepDisplay and manage displayMessages
193
+ // explicitly (the display stream must keep the full pre-compaction
194
+ // history while the context folds at the last compact boundary).
195
+ if (!opts?.keepDisplay) {
196
+ if (messages.length === 0) {
197
+ // Clear path — nothing to merge.
198
+ this.displayMessages = [...messages];
199
+ }
200
+ else {
201
+ // Routine changes (new messages, tool block updates) still touch the
202
+ // display stream, but after compaction the display holds the full
203
+ // pre-compaction history while the context is folded. Merge by id so
204
+ // new messages append to the display stream and updated messages
205
+ // replace their display copy without dropping that history.
206
+ const byId = new Map(messages.map((m) => [m.id, m]));
207
+ const merged = this.displayMessages.map((m) => byId.get(m.id) ?? m);
208
+ const known = new Set(this.displayMessages.map((m) => m.id));
209
+ for (const m of messages) {
210
+ if (!known.has(m.id)) {
211
+ merged.push(m);
212
+ }
213
+ }
214
+ this.displayMessages = merged;
215
+ }
216
+ }
180
217
  // Incrementally extract metadata from new messages
181
218
  const newMessages = messages.slice(oldLength);
182
219
  for (const message of newMessages) {
@@ -250,13 +287,19 @@ export class MessageManager {
250
287
  // Initialize state from session data
251
288
  initializeFromSession(sessionData) {
252
289
  this.setSessionId(sessionData.id);
253
- this.setMessages([...sessionData.messages]);
290
+ // The UI displays the full transcript (pre-compaction history included);
291
+ // the API context folds at the last compact boundary so the model only
292
+ // sees messages from the latest summary forward.
293
+ this.setMessages(sliceFromLastCompact(sessionData.messages), {
294
+ keepDisplay: true,
295
+ });
296
+ this.displayMessages = [...sessionData.messages];
254
297
  this.setlatestTotalTokens(sessionData.metadata.latestTotalTokens);
255
298
  // Rebuild loadedRuleIds from persisted meta messages (session restore)
256
299
  this.rebuildLoadedRuleIds();
257
300
  // Set saved message count to the number of loaded messages since they're already saved
258
301
  // This must be done after setSessionId which resets it to 0
259
- this.savedMessageCount = sessionData.messages.length;
302
+ this.savedMessageCount = this.messages.length;
260
303
  }
261
304
  // Encapsulated message operation functions
262
305
  addUserMessage(params) {
@@ -398,11 +441,18 @@ export class MessageManager {
398
441
  };
399
442
  // Build new message array: keep the compacted message and last messages
400
443
  const newMessages = [compactMessage, ...lastThreeMessages];
401
- // APPEND to the same session file (append-only, like Claude Code)
444
+ // APPEND only the compact block to the session file (append-only, like
445
+ // Claude Code). The preserved last API rounds were already written before
446
+ // compaction (saveSession ran at the start), so re-appending them would
447
+ // duplicate those lines in the transcript — the loader relies on each
448
+ // message id appearing exactly once (dedup-skipped, aligned with CC).
402
449
  const { appendMessages } = await import("../services/session.js");
403
- await appendMessages(this.sessionId, newMessages, this.workdir, this.sessionType);
404
- // Update in-memory state
405
- this.setMessages(newMessages);
450
+ await appendMessages(this.sessionId, [compactMessage], this.workdir, this.sessionType);
451
+ // Update in-memory state: the API context folds to [compact, ...last
452
+ // rounds], while the UI display stream keeps the full history and appends
453
+ // the compact block at the end.
454
+ this.setMessages(newMessages, { keepDisplay: true });
455
+ this.displayMessages = [...this.displayMessages, compactMessage];
406
456
  this.savedMessageCount = newMessages.length;
407
457
  // Trim recent file read cache so contents compacted away are released
408
458
  this.trimRecentFileReads();
@@ -425,54 +475,6 @@ export class MessageManager {
425
475
  this.callbacks.onFileHistoryBlockAdded?.(snapshots);
426
476
  }
427
477
  }
428
- // Bang related message operations
429
- addBangMessage(command) {
430
- const updatedMessages = addBangMessage({
431
- messages: this.messages,
432
- command,
433
- });
434
- this.setMessages(updatedMessages);
435
- // The bang message is appended as the last message
436
- const messageId = this.messages[this.messages.length - 1]?.id ?? "";
437
- this.callbacks.onAddBangMessage?.(command, messageId);
438
- }
439
- updateBangMessage(command, output) {
440
- const updatedMessages = updateBangInMessage({
441
- messages: this.messages,
442
- command,
443
- output,
444
- });
445
- this.setMessages(updatedMessages);
446
- const messageId = this.findBangMessageId(command) ?? "";
447
- this.callbacks.onUpdateBangMessage?.(command, output, messageId);
448
- }
449
- completeBangMessage(command, exitCode, output) {
450
- const updatedMessages = completeBangInMessage({
451
- messages: this.messages,
452
- command,
453
- exitCode,
454
- output,
455
- });
456
- this.setMessages(updatedMessages);
457
- const messageId = this.findBangMessageId(command) ?? "";
458
- this.callbacks.onCompleteBangMessage?.(command, exitCode, messageId, output?.trim());
459
- }
460
- /**
461
- * Find the message ID of the most recent message containing a bang block
462
- * for the given command. Bang callbacks do not carry a block ID, so the
463
- * message is located by matching the command against bang blocks.
464
- */
465
- findBangMessageId(command) {
466
- for (let i = this.messages.length - 1; i >= 0; i--) {
467
- const message = this.messages[i];
468
- if (message.role !== "user")
469
- continue;
470
- const hasBangBlock = message.blocks?.some((block) => block.type === "bang" && block.command === command);
471
- if (hasBangBlock)
472
- return message.id;
473
- }
474
- return undefined;
475
- }
476
478
  addNotificationMessage(params) {
477
479
  const newMessages = addNotificationMessageToMessages({
478
480
  messages: this.messages,
@@ -737,10 +739,11 @@ export class MessageManager {
737
739
  }
738
740
  // Rewrite file with truncated messages (full history kept on disk)
739
741
  await this.rewriteSessionFile(newMessages);
740
- // Fold the in-memory messages at the last compact boundary so the agent
741
- // only sees messages from the latest compact summary forward — matching
742
- // the compact and resume behaviors (which also fold memory).
743
- this.setMessages(sliceFromLastCompact(newMessages));
742
+ // The UI keeps the full truncated thread; the agent context folds at the
743
+ // last compact boundary so the agent only sees messages from the latest
744
+ // compact summary forward — matching the compact and resume behaviors.
745
+ this.setMessages(sliceFromLastCompact(newMessages), { keepDisplay: true });
746
+ this.displayMessages = newMessages;
744
747
  // savedMessageCount tracks in-memory progress, so it must be the folded
745
748
  // length. Using the full disk count here would make saveSession's
746
749
  // slice(savedMessageCount) empty after a rewind past a compact boundary
@@ -8,6 +8,13 @@
8
8
  import type { PermissionDecision, ToolPermissionContext, PermissionCallback, PermissionMode } from "../types/permissions.js";
9
9
  import type { Logger } from "../types/index.js";
10
10
  import { Container } from "../utils/container.js";
11
+ /**
12
+ * Resolve an additional-directory config entry to an absolute path.
13
+ * Supports `~` / `~/` prefixes (expanded against the user home directory,
14
+ * aligned with Claude Code's expandPath) alongside absolute paths and paths
15
+ * relative to the working directory.
16
+ */
17
+ export declare function resolveAdditionalDirectory(dir: string, workdir: string | undefined): string;
11
18
  export interface PermissionManagerOptions {
12
19
  /** Configured permission mode from settings */
13
20
  configuredPermissionMode?: PermissionMode;
@@ -192,10 +199,32 @@ export declare class PermissionManager {
192
199
  * command substitution, process substitution, or sed -i are auto-allowed.
193
200
  */
194
201
  private isAutoAllowedPart;
202
+ /**
203
+ * Check whether a bash command part takes path arguments (cd or a
204
+ * path-taking read-only command) that escape the Safe Zone. Used to
205
+ * disqualify auto-allow via DEFAULT_ALLOWED_RULES and to hide the
206
+ * "don't ask again" persistence option for out-of-bounds access.
207
+ */
208
+ private isOutOfSafeZonePart;
195
209
  /**
196
210
  * Check if a tool call is allowed by persistent or temporary rules
197
211
  */
198
212
  private isAllowedByRule;
213
+ /**
214
+ * Classify one expanded leaf command. Every leaf of a compound command or
215
+ * command substitution is judged with the exact rules used for a plain
216
+ * command, with two structure-aware additions:
217
+ *
218
+ * - a leaf the parser flagged `unsafe` (a bare reference to a variable whose
219
+ * value is not statically known, or a command substitution inside the
220
+ * leaf's own argument words) is never auto-allowed and never matches the
221
+ * built-in default rules — only an explicit allow rule may cover it;
222
+ * - `read` (e.g. the condition of `while read …`) only reads stdin, so it
223
+ * is treated as safe when it carries no redirection of its own.
224
+ */
225
+ private isBashLeafAllowed;
226
+ /** A context whose Bash command is a single part/leaf text. */
227
+ private ruleContext;
199
228
  /**
200
229
  * Expand a bash command into individual permission rules, filtering out safe commands.
201
230
  * Used when saving permissions to the allow list.