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.
- package/dist/agent.d.ts +72 -24
- package/dist/agent.js +123 -31
- package/dist/builtin/plugins.js +17 -2
- package/dist/builtin/skills/settings.js +6 -8
- package/dist/core/plugin.d.ts +4 -0
- package/dist/core/plugin.js +7 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/managers/aiManager.d.ts +0 -24
- package/dist/managers/aiManager.js +23 -181
- package/dist/managers/bashModeManager.d.ts +33 -0
- package/dist/managers/bashModeManager.js +110 -0
- package/dist/managers/hookManager.d.ts +5 -0
- package/dist/managers/hookManager.js +7 -0
- package/dist/managers/liveConfigManager.js +3 -3
- package/dist/managers/mcpManager.d.ts +23 -0
- package/dist/managers/mcpManager.js +162 -14
- package/dist/managers/messageManager.d.ts +12 -13
- package/dist/managers/messageManager.js +63 -60
- package/dist/managers/permissionManager.d.ts +29 -0
- package/dist/managers/permissionManager.js +158 -70
- package/dist/managers/planManager.d.ts +9 -0
- package/dist/managers/planManager.js +19 -1
- package/dist/managers/skillManager.d.ts +31 -0
- package/dist/managers/skillManager.js +122 -12
- package/dist/managers/slashCommandManager.js +9 -28
- package/dist/managers/subagentManager.d.ts +7 -0
- package/dist/managers/subagentManager.js +61 -9
- package/dist/managers/workflowManager.js +6 -0
- package/dist/prompts/index.d.ts +0 -1
- package/dist/prompts/index.js +0 -4
- package/dist/services/MarketplaceService.js +36 -14
- package/dist/services/configurationService.d.ts +34 -2
- package/dist/services/configurationService.js +123 -16
- package/dist/services/initializationService.js +2 -2
- package/dist/services/jsonlHandler.d.ts +14 -0
- package/dist/services/jsonlHandler.js +44 -1
- package/dist/services/memory.d.ts +14 -0
- package/dist/services/memory.js +33 -0
- package/dist/services/officialMarketplaceMirror.d.ts +85 -0
- package/dist/services/officialMarketplaceMirror.js +289 -0
- package/dist/services/remoteSettingsService.js +4 -4
- package/dist/services/session.js +30 -13
- package/dist/services/worktreeHooks.js +6 -1
- package/dist/stdio/index.d.ts +10 -0
- package/dist/stdio/index.js +10 -0
- package/dist/stdio/notificationRouter.d.ts +38 -0
- package/dist/stdio/notificationRouter.js +96 -0
- package/dist/stdio/rpcClient.d.ts +18 -0
- package/dist/stdio/rpcClient.js +10 -0
- package/dist/stdio/stdioAgent.d.ts +222 -0
- package/dist/stdio/stdioAgent.js +341 -0
- package/dist/tools/bashTool.js +2 -0
- package/dist/tools/exitPlanMode.js +10 -2
- package/dist/types/agent.d.ts +8 -0
- package/dist/types/commands.d.ts +7 -0
- package/dist/types/configuration.d.ts +6 -1
- package/dist/types/hooks.d.ts +1 -0
- package/dist/types/hooks.js +19 -0
- package/dist/types/mcp.d.ts +3 -0
- package/dist/types/messaging.d.ts +1 -8
- package/dist/types/skills.d.ts +11 -0
- package/dist/utils/bashParser.d.ts +17 -0
- package/dist/utils/bashParser.js +72 -0
- package/dist/utils/bashStructure/bashLexer.d.ts +96 -0
- package/dist/utils/bashStructure/bashLexer.js +676 -0
- package/dist/utils/bashStructure/bashParser.d.ts +144 -0
- package/dist/utils/bashStructure/bashParser.js +606 -0
- package/dist/utils/bashStructure/bashSemantics.d.ts +70 -0
- package/dist/utils/bashStructure/bashSemantics.js +477 -0
- package/dist/utils/bashStructure/index.d.ts +26 -0
- package/dist/utils/bashStructure/index.js +27 -0
- package/dist/utils/bashStructure/types.d.ts +62 -0
- package/dist/utils/bashStructure/types.js +47 -0
- package/dist/utils/container.d.ts +6 -0
- package/dist/utils/container.js +9 -0
- package/dist/utils/containerSetup.d.ts +11 -1
- package/dist/utils/containerSetup.js +26 -11
- package/dist/utils/fileUtils.d.ts +11 -0
- package/dist/utils/fileUtils.js +37 -0
- package/dist/utils/messageOperations.d.ts +0 -18
- package/dist/utils/messageOperations.js +0 -62
- package/dist/utils/subagentParser.js +9 -2
- package/dist/utils/tokenCalculation.js +0 -8
- package/dist/utils/worktreeUtils.d.ts +2 -1
- package/dist/utils/worktreeUtils.js +64 -34
- package/package.json +6 -1
- package/dist/managers/bangManager.d.ts +0 -26
- 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
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
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(
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
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
|
|
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
|
|
464
|
-
|
|
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
|
-
|
|
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.
|
|
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[]
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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,
|
|
404
|
-
// Update in-memory state
|
|
405
|
-
|
|
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
|
-
//
|
|
741
|
-
// only sees messages from the latest
|
|
742
|
-
// the compact and resume behaviors
|
|
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.
|