wave-agent-sdk 0.17.8 → 0.17.10

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 (76) hide show
  1. package/builtin/skills/settings/ENV.md +1 -1
  2. package/dist/agent.d.ts +5 -0
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +7 -0
  5. package/dist/managers/aiManager.d.ts +10 -4
  6. package/dist/managers/aiManager.d.ts.map +1 -1
  7. package/dist/managers/aiManager.js +214 -200
  8. package/dist/managers/backgroundTaskManager.js +1 -1
  9. package/dist/managers/bangManager.js +1 -1
  10. package/dist/managers/hookManager.js +2 -4
  11. package/dist/managers/mcpManager.d.ts.map +1 -1
  12. package/dist/managers/mcpManager.js +1 -3
  13. package/dist/managers/messageManager.d.ts.map +1 -1
  14. package/dist/managers/messageManager.js +1 -8
  15. package/dist/managers/toolManager.d.ts +13 -0
  16. package/dist/managers/toolManager.d.ts.map +1 -1
  17. package/dist/managers/toolManager.js +33 -12
  18. package/dist/services/aiService.d.ts.map +1 -1
  19. package/dist/services/aiService.js +20 -12
  20. package/dist/services/configurationService.d.ts.map +1 -1
  21. package/dist/services/configurationService.js +13 -0
  22. package/dist/services/jsonlHandler.d.ts +1 -1
  23. package/dist/services/jsonlHandler.d.ts.map +1 -1
  24. package/dist/services/jsonlHandler.js +7 -22
  25. package/dist/services/session.d.ts +2 -9
  26. package/dist/services/session.d.ts.map +1 -1
  27. package/dist/services/session.js +16 -46
  28. package/dist/tools/bashTool.d.ts.map +1 -1
  29. package/dist/tools/bashTool.js +3 -1
  30. package/dist/tools/editTool.d.ts.map +1 -1
  31. package/dist/tools/editTool.js +28 -1
  32. package/dist/tools/enterPlanMode.d.ts.map +1 -1
  33. package/dist/tools/enterPlanMode.js +14 -4
  34. package/dist/tools/exitPlanMode.d.ts.map +1 -1
  35. package/dist/tools/exitPlanMode.js +8 -0
  36. package/dist/tools/readTool.d.ts.map +1 -1
  37. package/dist/tools/readTool.js +18 -10
  38. package/dist/tools/types.d.ts +8 -2
  39. package/dist/tools/types.d.ts.map +1 -1
  40. package/dist/tools/writeTool.d.ts.map +1 -1
  41. package/dist/tools/writeTool.js +14 -1
  42. package/dist/types/agent.d.ts +0 -2
  43. package/dist/types/agent.d.ts.map +1 -1
  44. package/dist/types/config.d.ts +1 -0
  45. package/dist/types/config.d.ts.map +1 -1
  46. package/dist/utils/cacheControlUtils.d.ts +20 -15
  47. package/dist/utils/cacheControlUtils.d.ts.map +1 -1
  48. package/dist/utils/cacheControlUtils.js +69 -66
  49. package/dist/utils/constants.d.ts +1 -1
  50. package/dist/utils/constants.js +1 -1
  51. package/dist/utils/containerSetup.js +5 -6
  52. package/package.json +1 -1
  53. package/src/agent.ts +8 -0
  54. package/src/managers/aiManager.ts +292 -263
  55. package/src/managers/backgroundTaskManager.ts +1 -1
  56. package/src/managers/bangManager.ts +1 -1
  57. package/src/managers/hookManager.ts +6 -6
  58. package/src/managers/mcpManager.ts +2 -4
  59. package/src/managers/messageManager.ts +1 -9
  60. package/src/managers/toolManager.ts +37 -12
  61. package/src/services/aiService.ts +23 -12
  62. package/src/services/configurationService.ts +21 -0
  63. package/src/services/jsonlHandler.ts +7 -27
  64. package/src/services/session.ts +16 -57
  65. package/src/tools/bashTool.ts +3 -1
  66. package/src/tools/editTool.ts +31 -1
  67. package/src/tools/enterPlanMode.ts +15 -4
  68. package/src/tools/exitPlanMode.ts +10 -0
  69. package/src/tools/readTool.ts +20 -10
  70. package/src/tools/types.ts +15 -3
  71. package/src/tools/writeTool.ts +15 -1
  72. package/src/types/agent.ts +0 -2
  73. package/src/types/config.ts +1 -0
  74. package/src/utils/cacheControlUtils.ts +77 -95
  75. package/src/utils/constants.ts +1 -1
  76. package/src/utils/containerSetup.ts +7 -7
@@ -988,9 +988,9 @@ export class HookManager {
988
988
  transcriptPath,
989
989
  cwd: this.workdir,
990
990
  endSource: source,
991
- env:
992
- this.container.get<Record<string, string>>("MergedEnv") ||
993
- (process.env as Record<string, string>),
991
+ env: Object.fromEntries(
992
+ Object.entries(process.env).filter((e) => e[1] !== undefined),
993
+ ) as Record<string, string>,
994
994
  };
995
995
 
996
996
  const results = await this.executeHooks("SessionEnd", context);
@@ -1060,9 +1060,9 @@ export class HookManager {
1060
1060
  transcriptPath,
1061
1061
  cwd: this.workdir,
1062
1062
  compactSummary,
1063
- env:
1064
- this.container.get<Record<string, string>>("MergedEnv") ||
1065
- (process.env as Record<string, string>),
1063
+ env: Object.fromEntries(
1064
+ Object.entries(process.env).filter((e) => e[1] !== undefined),
1065
+ ) as Record<string, string>,
1066
1066
  };
1067
1067
 
1068
1068
  const results = await this.executeHooks("PostCompact", context);
@@ -432,11 +432,9 @@ export class McpManager {
432
432
  `MCP server ${name} with type "stdio" requires a 'command'`,
433
433
  );
434
434
  }
435
- const agentEnv =
436
- this.container.get<Record<string, string>>("MergedEnv") ||
437
- (process.env as Record<string, string>);
435
+
438
436
  const env: Record<string, string> = {
439
- ...agentEnv,
437
+ ...(process.env as Record<string, string>),
440
438
  ...(server.config.env || {}),
441
439
  };
442
440
 
@@ -308,14 +308,6 @@ export class MessageManager {
308
308
  return;
309
309
  }
310
310
 
311
- // Filter out meta messages
312
- const meaningfulMessages = unsavedMessages.filter((m) => !m.isMeta);
313
-
314
- if (meaningfulMessages.length === 0) {
315
- // No meaningful messages to save
316
- return;
317
- }
318
-
319
311
  // Create session if needed (only when we have messages to save)
320
312
  if (this.savedMessageCount === 0) {
321
313
  // This is the first time saving messages, so create the session
@@ -325,7 +317,7 @@ export class MessageManager {
325
317
  // Use JSONL format for new sessions
326
318
  await appendMessages(
327
319
  this.sessionId,
328
- meaningfulMessages, // Only append meaningful messages
320
+ unsavedMessages,
329
321
  this.workdir,
330
322
  this.sessionType,
331
323
  this.rootSessionId,
@@ -356,24 +356,12 @@ class ToolManager {
356
356
  }): ChatCompletionFunctionTool[] {
357
357
  const permissionManager =
358
358
  this.container.get<PermissionManager>("PermissionManager");
359
- const effectivePermissionMode = this.getPermissionMode();
360
359
  const builtInToolsConfig = Array.from(this.toolsRegistry.values())
361
360
  .filter((tool) => {
362
361
  // If tool is explicitly denied by name in permission rules, filter it out
363
362
  if (permissionManager?.isToolDenied(tool.name)) {
364
363
  return false;
365
364
  }
366
- if (effectivePermissionMode === "bypassPermissions") {
367
- if (tool.name === "ExitPlanMode") {
368
- return false;
369
- }
370
- }
371
- if (tool.name === "ExitPlanMode") {
372
- return effectivePermissionMode === "plan";
373
- }
374
- if (tool.name === "EnterPlanMode") {
375
- return effectivePermissionMode !== "plan";
376
- }
377
365
  return true;
378
366
  })
379
367
  .map((tool) => {
@@ -408,6 +396,24 @@ class ToolManager {
408
396
  return Array.from(this.toolsRegistry.values());
409
397
  }
410
398
 
399
+ /**
400
+ * Check whether a tool is safe to execute in parallel with other tools.
401
+ * Built-in tools default to safe (parallel); Edit and Write opt out.
402
+ * MCP tools are conservatively non-safe (can't inspect side effects).
403
+ */
404
+ public isConcurrencySafe(name: string): boolean {
405
+ // MCP tools are conservatively non-safe
406
+ if (this.mcpManager.isMcpTool(name)) {
407
+ return false;
408
+ }
409
+ const plugin = this.toolsRegistry.get(name);
410
+ if (plugin) {
411
+ return plugin.isConcurrencySafe ?? true;
412
+ }
413
+ // Unknown tools are conservatively non-safe
414
+ return false;
415
+ }
416
+
411
417
  /**
412
418
  * Get the current permission mode
413
419
  */
@@ -432,6 +438,25 @@ class ToolManager {
432
438
  this.container.register("PermissionMode", mode);
433
439
  }
434
440
 
441
+ /**
442
+ * Request a full permission mode transition (planManager + UI callback).
443
+ * Used by tools like EnterPlanMode in bypass mode where the canUseTool
444
+ * callback (which normally handles the transition) is not invoked.
445
+ * In normal mode, the callback already handles the transition.
446
+ */
447
+ public requestPermissionModeChange(mode: PermissionMode): void {
448
+ if (this.container.has("PermissionModeTransition")) {
449
+ const transition = this.container.get<(mode: PermissionMode) => void>(
450
+ "PermissionModeTransition",
451
+ );
452
+ if (transition) {
453
+ transition(mode);
454
+ return;
455
+ }
456
+ }
457
+ this.setPermissionMode(mode);
458
+ }
459
+
435
460
  /**
436
461
  * Get the permission manager
437
462
  */
@@ -12,8 +12,7 @@ import { addOnceAbortListener } from "../utils/abortUtils.js";
12
12
  import type { GatewayConfig, ModelConfig } from "../types/index.js";
13
13
  import { ConfigurationError, CONFIG_ERRORS } from "../types/index.js";
14
14
  import {
15
- transformMessagesForClaudeCache,
16
- addCacheControlToLastTool,
15
+ transformMessagesForExplicitCache,
17
16
  supportsPromptCaching,
18
17
  extendUsageWithCacheMetrics,
19
18
  type ClaudeUsage,
@@ -282,15 +281,10 @@ export async function callAgent(
282
281
  processedTools = tools;
283
282
 
284
283
  if (supportsPromptCaching(currentModel)) {
285
- openaiMessages = transformMessagesForClaudeCache(
284
+ openaiMessages = transformMessagesForExplicitCache(
286
285
  openaiMessages,
287
286
  currentModel,
288
287
  );
289
-
290
- // Apply cache control to tools separately
291
- if (tools && tools.length > 0) {
292
- processedTools = addCacheControlToLastTool(tools);
293
- }
294
288
  }
295
289
 
296
290
  // Get model configuration - use injected modelConfig with optional override
@@ -845,6 +839,7 @@ export async function compactMessages(
845
839
  fastModel: _fastModel,
846
840
  maxTokens: _maxTokens,
847
841
  permissionMode: _permissionMode,
842
+ fastModelConfig: _fastModelConfig,
848
843
  ...extraParams
849
844
  } = modelConfig;
850
845
  void _model;
@@ -852,10 +847,16 @@ export async function compactMessages(
852
847
  void _maxTokens;
853
848
  void _permissionMode;
854
849
 
850
+ // When a fast model override is provided, use the fast model's hyperparams
851
+ // (if configured); otherwise fall back to the agent model's extraParams.
852
+ const activeExtraParams = options.model
853
+ ? _fastModelConfig || {}
854
+ : extraParams;
855
+
855
856
  const openaiModelConfig = getModelConfig(options.model || modelConfig.model, {
856
857
  temperature: 0.1,
857
858
  max_tokens: 8192,
858
- ...extraParams,
859
+ ...activeExtraParams,
859
860
  });
860
861
 
861
862
  try {
@@ -961,6 +962,7 @@ export async function processWebContent(
961
962
  fastModel: _fastModel,
962
963
  maxTokens: _maxTokens,
963
964
  permissionMode: _permissionMode,
965
+ fastModelConfig: _fastModelConfig,
964
966
  ...extraParams
965
967
  } = modelConfig;
966
968
  void _model;
@@ -968,10 +970,16 @@ export async function processWebContent(
968
970
  void _maxTokens;
969
971
  void _permissionMode;
970
972
 
973
+ // When a fast model override is provided, use the fast model's hyperparams
974
+ // (if configured); otherwise fall back to the agent model's extraParams.
975
+ const activeExtraParams = options.model
976
+ ? _fastModelConfig || {}
977
+ : extraParams;
978
+
971
979
  const openaiModelConfig = getModelConfig(options.model || modelConfig.model, {
972
980
  temperature: 0.1,
973
981
  max_tokens: 4096,
974
- ...extraParams,
982
+ ...activeExtraParams,
975
983
  });
976
984
 
977
985
  try {
@@ -1177,17 +1185,20 @@ export async function evaluateGoal(
1177
1185
  fastModel: _fastModel,
1178
1186
  maxTokens: _maxTokens,
1179
1187
  permissionMode: _permissionMode,
1180
- ...extraParams
1188
+ fastModelConfig: _fastModelConfig,
1181
1189
  } = modelConfig;
1182
1190
  void _model;
1183
1191
  void _fastModel;
1184
1192
  void _maxTokens;
1185
1193
  void _permissionMode;
1186
1194
 
1195
+ // evaluateGoal always uses the fast model; use its hyperparams if configured.
1196
+ const activeExtraParams = _fastModelConfig || {};
1197
+
1187
1198
  const openaiModelConfig = getModelConfig(model, {
1188
1199
  temperature: 0,
1189
1200
  max_tokens: 200,
1190
- ...extraParams,
1201
+ ...activeExtraParams,
1191
1202
  });
1192
1203
 
1193
1204
  // Strip images from messages to reduce token usage (same as compact)
@@ -532,6 +532,27 @@ export class ConfigurationService {
532
532
  permissionMode: permissionMode ?? this.options.permissionMode,
533
533
  };
534
534
 
535
+ // Resolve fast model hyperparams from models[fastModel], stripping
536
+ // structural fields so only hyperparameters remain. Set on baseConfig
537
+ // before the modelSpecificConfig spread so it isn't overwritten.
538
+ const fastModelConfigSource =
539
+ resolvedFastModel &&
540
+ this.currentConfiguration?.models?.[resolvedFastModel];
541
+ if (fastModelConfigSource) {
542
+ const {
543
+ model: _fm,
544
+ fastModel: _ffm,
545
+ maxTokens: _fmt,
546
+ permissionMode: _fpm,
547
+ ...fastHyperparams
548
+ } = fastModelConfigSource;
549
+ void _fm;
550
+ void _ffm;
551
+ void _fmt;
552
+ void _fpm;
553
+ baseConfig.fastModelConfig = fastHyperparams;
554
+ }
555
+
535
556
  // Merge model-specific settings from configuration
536
557
  const modelSpecificConfig =
537
558
  resolvedAgentModel &&
@@ -229,19 +229,7 @@ export class JsonlHandler {
229
229
  // Extract filename from path
230
230
  const filename = filePath.split("/").pop() || "";
231
231
 
232
- // New timestamp-prefixed format
233
- const newSubagentMatch = filename.match(
234
- /^subagent-(\d{14}-[0-9a-f]{8})\.jsonl$/,
235
- );
236
- if (newSubagentMatch) {
237
- return { sessionId: newSubagentMatch[1], sessionType: "subagent" };
238
- }
239
- const newMainMatch = filename.match(/^(\d{14}-[0-9a-f]{8})\.jsonl$/);
240
- if (newMainMatch) {
241
- return { sessionId: newMainMatch[1], sessionType: "main" };
242
- }
243
-
244
- // Old UUID format (backward compat)
232
+ // Check if it's a subagent session
245
233
  const subagentMatch = filename.match(
246
234
  /^subagent-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/,
247
235
  );
@@ -252,6 +240,7 @@ export class JsonlHandler {
252
240
  };
253
241
  }
254
242
 
243
+ // Check if it's a main session
255
244
  const mainMatch = filename.match(
256
245
  /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/,
257
246
  );
@@ -271,26 +260,18 @@ export class JsonlHandler {
271
260
  * @returns True if valid, false otherwise
272
261
  */
273
262
  isValidSessionFilename(filename: string): boolean {
274
- // New timestamp-prefixed format patterns
275
- const newFormatPattern = /^(\d{14}-[0-9a-f]{8})\.jsonl$/;
276
- const newSubagentPattern = /^subagent-(\d{14}-[0-9a-f]{8})\.jsonl$/;
277
- // Old UUID format patterns (backward compat)
263
+ // UUID validation patterns
278
264
  const uuidPattern =
279
265
  /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
280
266
  const subagentPattern =
281
267
  /^subagent-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
282
268
 
283
- return (
284
- newFormatPattern.test(filename) ||
285
- newSubagentPattern.test(filename) ||
286
- uuidPattern.test(filename) ||
287
- subagentPattern.test(filename)
288
- );
269
+ return uuidPattern.test(filename) || subagentPattern.test(filename);
289
270
  }
290
271
 
291
272
  /**
292
273
  * Generate simple filename for sessions
293
- * @param sessionId - Session identifier (timestamp-prefixed or legacy UUID format)
274
+ * @param sessionId - UUID session identifier
294
275
  * @param sessionType - Type of session ("main" or "subagent")
295
276
  * @returns Generated filename
296
277
  */
@@ -298,11 +279,10 @@ export class JsonlHandler {
298
279
  sessionId: string,
299
280
  sessionType: "main" | "subagent",
300
281
  ): string {
301
- // Validate sessionId is either new timestamp-prefixed format or legacy UUID
302
- const newFormatPattern = /^\d{14}-[0-9a-f]{8}$/;
282
+ // Validate sessionId is a valid UUID
303
283
  const uuidPattern =
304
284
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
305
- if (!newFormatPattern.test(sessionId) && !uuidPattern.test(sessionId)) {
285
+ if (!uuidPattern.test(sessionId)) {
306
286
  throw new Error(`Invalid session ID format: ${sessionId}`);
307
287
  }
308
288
 
@@ -63,43 +63,11 @@ export interface SessionIndex {
63
63
  }
64
64
 
65
65
  /**
66
- * Generate a new session ID with a timestamp prefix for sortability
67
- * Format: {YYYYMMDDHHmmss}-{8hex} (e.g. 20260527143025-a1b2c3d4)
68
- * @returns Timestamp-prefixed session ID string
66
+ * Generate a new session ID using Node.js native crypto.randomUUID()
67
+ * @returns UUID string for session identification
69
68
  */
70
69
  export function generateSessionId(): string {
71
- const now = new Date();
72
- const ts = [
73
- now.getFullYear(),
74
- String(now.getMonth() + 1).padStart(2, "0"),
75
- String(now.getDate()).padStart(2, "0"),
76
- String(now.getHours()).padStart(2, "0"),
77
- String(now.getMinutes()).padStart(2, "0"),
78
- String(now.getSeconds()).padStart(2, "0"),
79
- ].join("");
80
- const shortId = randomUUID().slice(0, 8);
81
- return `${ts}-${shortId}`;
82
- }
83
-
84
- /**
85
- * Parse the timestamp prefix from a session ID back into a Date
86
- * Format: {YYYYMMDDHHmmss}-{8hex} (e.g. 20260527143025-a1b2c3d4)
87
- * @returns Date from the session ID timestamp prefix
88
- */
89
- export function parseSessionIdTimestamp(sessionId: string): Date {
90
- const match = sessionId.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/);
91
- if (!match) {
92
- return new Date(); // Fallback to now for legacy UUID-format session IDs
93
- }
94
- const [, year, month, day, hour, minute, second] = match;
95
- return new Date(
96
- Number(year),
97
- Number(month) - 1,
98
- Number(day),
99
- Number(hour),
100
- Number(minute),
101
- Number(second),
102
- );
70
+ return randomUUID();
103
71
  }
104
72
 
105
73
  /**
@@ -293,9 +261,9 @@ export async function appendMessages(
293
261
  (await getFirstMessageContent(sessionId, workdir)) || undefined;
294
262
  }
295
263
 
296
- // Derive createdAt from session ID timestamp if not in index
264
+ // Derive createdAt from session ID if not in index
297
265
  if (!createdAt) {
298
- createdAt = new Date(parseSessionIdTimestamp(sessionId));
266
+ createdAt = new Date();
299
267
  }
300
268
 
301
269
  await updateSessionIndex(projectDir.encodedPath, {
@@ -520,17 +488,15 @@ export async function listSessionsFromJsonl(
520
488
  try {
521
489
  const filePath = join(projectDir.encodedPath, file);
522
490
 
523
- // Validate main session filename format (new timestamp format or legacy UUID)
524
- const newFormatMatch = file.match(/^(\d{14}-[0-9a-f]{8})\.jsonl$/);
491
+ // Validate main session filename format (UUID.jsonl)
525
492
  const uuidMatch = file.match(
526
493
  /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/,
527
494
  );
528
- const match = newFormatMatch || uuidMatch;
529
- if (!match) {
495
+ if (!uuidMatch) {
530
496
  continue; // Skip invalid filenames
531
497
  }
532
498
 
533
- const sessionId = match[1];
499
+ const sessionId = uuidMatch[1];
534
500
 
535
501
  // PERFORMANCE OPTIMIZATION: Only read the last message for timestamps and tokens
536
502
  const jsonlHandler = new JsonlHandler();
@@ -557,7 +523,7 @@ export async function listSessionsFromJsonl(
557
523
  sessionType: "main",
558
524
  subagentType: undefined, // No longer stored in metadata
559
525
  workdir: projectDir.originalPath,
560
- createdAt: new Date(parseSessionIdTimestamp(sessionId)),
526
+ createdAt: new Date(),
561
527
  lastActiveAt,
562
528
  latestTotalTokens: lastMessage?.usage
563
529
  ? extractLatestTotalTokens([lastMessage])
@@ -720,24 +686,17 @@ export async function cleanupExpiredSessionsFromJsonl(
720
686
  );
721
687
  const indexContent = await fs.readFile(indexPath, "utf8");
722
688
  const index = JSON.parse(indexContent) as SessionIndex;
723
- // New timestamp-prefixed format patterns
724
- const newMainMatch = file.match(/^(\d{14}-[0-9a-f]{8})\.jsonl$/);
725
- const newSubagentMatch = file.match(
726
- /^subagent-(\d{14}-[0-9a-f]{8})\.jsonl$/,
727
- );
728
- // Old UUID format patterns (backward compat)
729
689
  const uuidMatch = file.match(
730
- /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/,
690
+ /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/,
731
691
  );
732
692
  const subagentMatch = file.match(
733
- /^subagent-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/,
693
+ /^subagent-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/,
734
694
  );
735
- const sessionId =
736
- newMainMatch?.[1] ??
737
- newSubagentMatch?.[1] ??
738
- uuidMatch?.[1] ??
739
- subagentMatch?.[1] ??
740
- null;
695
+ const sessionId = uuidMatch
696
+ ? uuidMatch[1]
697
+ : subagentMatch
698
+ ? subagentMatch[1]
699
+ : null;
741
700
 
742
701
  if (sessionId && index.sessions[sessionId]) {
743
702
  delete index.sessions[sessionId];
@@ -240,7 +240,9 @@ The working directory persists between commands. Try to maintain your current wo
240
240
  stdio: "pipe",
241
241
  detached: true,
242
242
  cwd: context.workdir,
243
- env: context.env || { ...process.env },
243
+ env: {
244
+ ...process.env,
245
+ },
244
246
  });
245
247
 
246
248
  let outputBuffer = "";
@@ -1,4 +1,5 @@
1
- import { readFile, writeFile } from "fs/promises";
1
+ import { readFile, writeFile, stat } from "fs/promises";
2
+ import { createHash } from "crypto";
2
3
  import { logger } from "../utils/globalLogger.js";
3
4
  import type { ToolPlugin, ToolResult, ToolContext } from "./types.js";
4
5
  import { resolvePath, getDisplayPath } from "../utils/path.js";
@@ -21,6 +22,7 @@ function formatCompactParams(
21
22
  */
22
23
  export const editTool: ToolPlugin = {
23
24
  name: EDIT_TOOL_NAME,
25
+ isConcurrencySafe: false,
24
26
  formatCompactParams,
25
27
  prompt: () =>
26
28
  `Performs exact string replacements in files.
@@ -137,6 +139,22 @@ Usage:
137
139
  };
138
140
  }
139
141
 
142
+ // Staleness check: file must not have been modified since last Read
143
+ if (context.readFileState) {
144
+ const state = context.readFileState.get(resolvedPath);
145
+ if (state) {
146
+ const currentStats = await stat(resolvedPath);
147
+ if (currentStats.mtime.getTime() !== state.mtime) {
148
+ return {
149
+ success: false,
150
+ content: "",
151
+ error:
152
+ "File has been unexpectedly modified since last read. Read it again before editing it.",
153
+ };
154
+ }
155
+ }
156
+ }
157
+
140
158
  // Normalize line endings for matching
141
159
  const normalizedContent = originalContent.replace(/\r\n/g, "\n");
142
160
  const normalizedOldString = oldString.replace(/\r\n/g, "\n");
@@ -240,6 +258,18 @@ Usage:
240
258
  };
241
259
  }
242
260
 
261
+ // Update readFileState so subsequent serialized edits see fresh state
262
+ if (context.readFileState) {
263
+ const newStats = await stat(resolvedPath);
264
+ const hash = createHash("sha256").update(newContent).digest("hex");
265
+ context.readFileState.set(resolvedPath, {
266
+ mtime: newStats.mtime.getTime(),
267
+ hash,
268
+ offset: undefined,
269
+ limit: undefined,
270
+ });
271
+ }
272
+
243
273
  const shortResult = replaceAll
244
274
  ? `Replaced ${replacementCount} instances`
245
275
  : "Text replaced successfully";
@@ -49,6 +49,15 @@ export const enterPlanModeTool: ToolPlugin = {
49
49
  context: ToolContext,
50
50
  ): Promise<ToolResult> => {
51
51
  try {
52
+ // Runtime guard: tool is always visible but only works outside plan mode
53
+ if (context.permissionMode === "plan") {
54
+ return {
55
+ success: false,
56
+ content: "Already in plan mode. Use ExitPlanMode to exit.",
57
+ error: "Already in plan mode",
58
+ };
59
+ }
60
+
52
61
  if (!context.permissionManager) {
53
62
  return {
54
63
  success: false,
@@ -87,10 +96,12 @@ export const enterPlanModeTool: ToolPlugin = {
87
96
  };
88
97
  }
89
98
 
90
- // Ensure permission mode is switched to plan (in bypass mode the
91
- // canUseTool callback is not invoked, so the mode change doesn't happen)
92
- if (context.toolManager) {
93
- context.toolManager.setPermissionMode("plan");
99
+ // Ensure the full plan mode transition happens (planManager + UI callback).
100
+ // In bypass mode the canUseTool callback is not invoked, so the transition
101
+ // never occurs. In normal mode, the callback already handled it
102
+ // (permissionResult.newPermissionMode will be set).
103
+ if (!permissionResult.newPermissionMode && context.toolManager) {
104
+ context.toolManager.requestPermissionModeChange("plan");
94
105
  }
95
106
 
96
107
  return {
@@ -52,6 +52,16 @@ Ensure your plan is complete and unambiguous:
52
52
  context: ToolContext,
53
53
  ): Promise<ToolResult> => {
54
54
  try {
55
+ // Runtime guard: tool is always visible but only works in plan mode
56
+ if (context.permissionMode !== "plan") {
57
+ return {
58
+ success: false,
59
+ content:
60
+ "Not in plan mode. ExitPlanMode can only be used when actively in plan mode.",
61
+ error: "Not in plan mode",
62
+ };
63
+ }
64
+
55
65
  if (!context.permissionManager) {
56
66
  return {
57
67
  success: false,
@@ -244,18 +244,26 @@ Usage:
244
244
 
245
245
  const stats = await stat(actualFilePath);
246
246
 
247
- // Deduplication
247
+ // Deduplication: only dedup if the exact same range was read before
248
248
  if (context.readFileState) {
249
249
  const state = context.readFileState.get(actualFilePath);
250
- if (state && state.mtime === stats.mtime.getTime()) {
251
- return {
252
- success: true,
253
- content: `File ${filePath} has not changed since last read.`,
254
- shortResult: "File unchanged",
255
- metadata: {
256
- type: "file_unchanged",
257
- },
258
- };
250
+ // Only dedup entries from a prior Read with explicit offset (not Edit/Write entries)
251
+ if (
252
+ state &&
253
+ state.mtime === stats.mtime.getTime() &&
254
+ state.offset !== undefined
255
+ ) {
256
+ const rangeMatch = state.offset === offset && state.limit === limit;
257
+ if (rangeMatch) {
258
+ return {
259
+ success: true,
260
+ content: `File ${filePath} has not changed since last read. The content from the earlier Read tool_result in this conversation is still current — refer to that instead of re-reading.`,
261
+ shortResult: "File unchanged",
262
+ metadata: {
263
+ type: "file_unchanged",
264
+ },
265
+ };
266
+ }
259
267
  }
260
268
  }
261
269
 
@@ -287,6 +295,8 @@ Usage:
287
295
  context.readFileState.set(actualFilePath, {
288
296
  mtime: stats.mtime.getTime(),
289
297
  hash,
298
+ offset, // undefined for full reads
299
+ limit, // undefined for full reads
290
300
  });
291
301
  }
292
302
 
@@ -31,6 +31,12 @@ export interface ToolPlugin {
31
31
  workdir?: string;
32
32
  isSubagent?: boolean;
33
33
  }) => string;
34
+ /**
35
+ * Whether this tool is safe to run in parallel with other tools.
36
+ * Default (undefined) = true (parallel). Set to false for tools that
37
+ * perform read-modify-write on shared resources (e.g. Edit, Write).
38
+ */
39
+ isConcurrencySafe?: boolean;
34
40
  }
35
41
 
36
42
  export interface ToolResult {
@@ -104,15 +110,21 @@ export interface ToolContext {
104
110
  maxTokens: number;
105
111
  };
106
112
  /** State of files read in the current session for deduplication */
107
- readFileState?: Map<string, { mtime: number; hash: string }>;
113
+ readFileState?: Map<
114
+ string,
115
+ {
116
+ mtime: number;
117
+ hash: string;
118
+ offset?: number; // undefined = full read or Edit/Write entry (never dedup)
119
+ limit?: number;
120
+ }
121
+ >;
108
122
  /** Hook manager instance for executing hooks */
109
123
  hookManager?: import("../managers/hookManager.js").HookManager;
110
124
  /** Callback to notify when the current working directory changes */
111
125
  onCwdChange?: (newCwd: string) => void;
112
126
  /** Original working directory (before any cd changes) for CWD reset */
113
127
  originalWorkdir?: string;
114
- /** Merged environment variables (process.env + agent env) for child processes */
115
- env?: Record<string, string>;
116
128
  /** Workflow manager instance for workflow orchestration */
117
129
  workflowManager?: import("../managers/workflowManager.js").WorkflowManager;
118
130
  }