wave-agent-sdk 0.17.9 → 0.17.11

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 (85) hide show
  1. package/builtin/skills/settings/ENV.md +1 -1
  2. package/dist/managers/aiManager.d.ts +11 -4
  3. package/dist/managers/aiManager.d.ts.map +1 -1
  4. package/dist/managers/aiManager.js +232 -200
  5. package/dist/managers/backgroundTaskManager.js +1 -1
  6. package/dist/managers/bangManager.js +1 -1
  7. package/dist/managers/hookManager.js +2 -4
  8. package/dist/managers/mcpManager.d.ts.map +1 -1
  9. package/dist/managers/mcpManager.js +1 -3
  10. package/dist/managers/messageManager.d.ts.map +1 -1
  11. package/dist/managers/messageManager.js +1 -8
  12. package/dist/managers/toolManager.d.ts +6 -0
  13. package/dist/managers/toolManager.d.ts.map +1 -1
  14. package/dist/managers/toolManager.js +17 -12
  15. package/dist/services/aiService.d.ts.map +1 -1
  16. package/dist/services/aiService.js +20 -12
  17. package/dist/services/authService.d.ts +0 -1
  18. package/dist/services/authService.d.ts.map +1 -1
  19. package/dist/services/authService.js +1 -29
  20. package/dist/services/configurationService.d.ts.map +1 -1
  21. package/dist/services/configurationService.js +13 -0
  22. package/dist/services/fileWatcher.d.ts +2 -0
  23. package/dist/services/fileWatcher.d.ts.map +1 -1
  24. package/dist/services/fileWatcher.js +12 -1
  25. package/dist/services/jsonlHandler.d.ts +1 -1
  26. package/dist/services/jsonlHandler.d.ts.map +1 -1
  27. package/dist/services/jsonlHandler.js +7 -22
  28. package/dist/services/session.d.ts +2 -9
  29. package/dist/services/session.d.ts.map +1 -1
  30. package/dist/services/session.js +16 -46
  31. package/dist/tools/bashTool.d.ts.map +1 -1
  32. package/dist/tools/bashTool.js +3 -1
  33. package/dist/tools/editTool.d.ts.map +1 -1
  34. package/dist/tools/editTool.js +28 -1
  35. package/dist/tools/enterPlanMode.d.ts.map +1 -1
  36. package/dist/tools/enterPlanMode.js +8 -0
  37. package/dist/tools/exitPlanMode.d.ts.map +1 -1
  38. package/dist/tools/exitPlanMode.js +8 -0
  39. package/dist/tools/readTool.d.ts.map +1 -1
  40. package/dist/tools/readTool.js +18 -10
  41. package/dist/tools/types.d.ts +8 -2
  42. package/dist/tools/types.d.ts.map +1 -1
  43. package/dist/tools/writeTool.d.ts.map +1 -1
  44. package/dist/tools/writeTool.js +14 -1
  45. package/dist/types/agent.d.ts +0 -2
  46. package/dist/types/agent.d.ts.map +1 -1
  47. package/dist/types/config.d.ts +1 -0
  48. package/dist/types/config.d.ts.map +1 -1
  49. package/dist/utils/cacheControlUtils.d.ts +20 -15
  50. package/dist/utils/cacheControlUtils.d.ts.map +1 -1
  51. package/dist/utils/cacheControlUtils.js +69 -66
  52. package/dist/utils/constants.d.ts +1 -1
  53. package/dist/utils/constants.js +1 -1
  54. package/dist/utils/containerSetup.d.ts.map +1 -1
  55. package/dist/utils/containerSetup.js +1 -6
  56. package/dist/utils/taskReminder.d.ts +17 -0
  57. package/dist/utils/taskReminder.d.ts.map +1 -0
  58. package/dist/utils/taskReminder.js +76 -0
  59. package/package.json +1 -1
  60. package/src/managers/aiManager.ts +326 -262
  61. package/src/managers/backgroundTaskManager.ts +1 -1
  62. package/src/managers/bangManager.ts +1 -1
  63. package/src/managers/hookManager.ts +6 -6
  64. package/src/managers/mcpManager.ts +2 -4
  65. package/src/managers/messageManager.ts +1 -9
  66. package/src/managers/toolManager.ts +18 -12
  67. package/src/services/aiService.ts +23 -12
  68. package/src/services/authService.ts +1 -31
  69. package/src/services/configurationService.ts +21 -0
  70. package/src/services/fileWatcher.ts +15 -1
  71. package/src/services/jsonlHandler.ts +7 -27
  72. package/src/services/session.ts +16 -57
  73. package/src/tools/bashTool.ts +3 -1
  74. package/src/tools/editTool.ts +31 -1
  75. package/src/tools/enterPlanMode.ts +9 -0
  76. package/src/tools/exitPlanMode.ts +10 -0
  77. package/src/tools/readTool.ts +20 -10
  78. package/src/tools/types.ts +15 -3
  79. package/src/tools/writeTool.ts +15 -1
  80. package/src/types/agent.ts +0 -2
  81. package/src/types/config.ts +1 -0
  82. package/src/utils/cacheControlUtils.ts +77 -95
  83. package/src/utils/constants.ts +1 -1
  84. package/src/utils/containerSetup.ts +3 -7
  85. package/src/utils/taskReminder.ts +108 -0
@@ -68,7 +68,7 @@ export class BackgroundTaskManager {
68
68
  stdio: "pipe",
69
69
  detached: true,
70
70
  cwd: this.workdir,
71
- env: this.container.get<Record<string, string>>("MergedEnv") || {
71
+ env: {
72
72
  ...process.env,
73
73
  },
74
74
  });
@@ -48,7 +48,7 @@ export class BangManager {
48
48
  shell: true,
49
49
  stdio: "pipe",
50
50
  cwd: this.workdir,
51
- env: this.container.get<Record<string, string>>("MergedEnv") || {
51
+ env: {
52
52
  ...process.env,
53
53
  },
54
54
  });
@@ -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
  */
@@ -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)
@@ -19,16 +19,12 @@ import * as os from "os";
19
19
  import { randomBytes } from "crypto";
20
20
  import { createServer, Server } from "http";
21
21
  import { URL } from "url";
22
- import { execFile } from "child_process";
23
- import { promisify } from "util";
24
22
  import type { AuthConfig, AuthUser, TokenResponse } from "../types/auth.js";
25
23
  import { logger } from "../utils/globalLogger.js";
26
24
 
27
25
  /** Persistent anonymous ID for telemetry fallback when SSO is not authenticated. */
28
26
  let _anonymousId: string | undefined;
29
27
 
30
- const execFileAsync = promisify(execFile);
31
-
32
28
  export class AuthService {
33
29
  private static instance: AuthService;
34
30
  private _serverUrl: string | undefined;
@@ -274,15 +270,8 @@ export class AuthService {
274
270
  const callbackUrl = `http://127.0.0.1:${port}`;
275
271
  const authUrl = `${serverUrl}/login?callback_url=${encodeURIComponent(callbackUrl)}`;
276
272
 
277
- // Notify caller of the auth URL
273
+ // Notify caller of the auth URL; caller is responsible for opening browser
278
274
  options?.onAuthUrl?.(authUrl);
279
-
280
- // Try to open browser; if it fails, keep server alive for manual visit
281
- try {
282
- await this.openBrowser(authUrl);
283
- } catch {
284
- // Browser not available — server stays alive
285
- }
286
275
  });
287
276
 
288
277
  // If manual code reading is provided, race between server callback and user input
@@ -315,25 +304,6 @@ export class AuthService {
315
304
  });
316
305
  }
317
306
 
318
- private async openBrowser(url: string): Promise<void> {
319
- const platform = process.platform;
320
- let command: string;
321
- let args: string[];
322
-
323
- if (platform === "darwin") {
324
- command = "open";
325
- args = [url];
326
- } else if (platform === "win32") {
327
- command = "cmd";
328
- args = ["/c", "start", "", url];
329
- } else {
330
- command = "xdg-open";
331
- args = [url];
332
- }
333
-
334
- await execFileAsync(command, args);
335
- }
336
-
337
307
  isSSOAuthenticated(): boolean {
338
308
  const config = this.loadAuth();
339
309
  if (!config.SSO_TOKEN) return false;
@@ -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 &&
@@ -22,6 +22,8 @@ export interface FileWatcherConfig {
22
22
  maxRetries: number; // Default: 3
23
23
  fallbackPolling: boolean; // Default: false
24
24
  ignoreTempFiles: boolean; // Default: true
25
+ depth: number; // default: 5
26
+ ignored: string[]; // default: ['**/node_modules/**', '**/.git/**', '**/*.swp', '**/*~']
25
27
  }
26
28
 
27
29
  export interface FileWatcherStatus {
@@ -59,6 +61,8 @@ export class FileWatcherService extends EventEmitter {
59
61
  maxRetries: 3,
60
62
  fallbackPolling: false,
61
63
  ignoreTempFiles: true,
64
+ depth: 5,
65
+ ignored: ["**/node_modules/**", "**/.git/**", "**/*.swp", "**/*~"],
62
66
  ...config,
63
67
  };
64
68
  }
@@ -185,6 +189,8 @@ export class FileWatcherService extends EventEmitter {
185
189
  this.globalWatcher = chokidar.watch([], {
186
190
  persistent: true,
187
191
  ignoreInitial: true,
192
+ depth: entry.config.depth,
193
+ ignored: entry.config.ignored,
188
194
  awaitWriteFinish: {
189
195
  stabilityThreshold: entry.config.stabilityThreshold,
190
196
  pollInterval: entry.config.pollInterval,
@@ -262,7 +268,15 @@ export class FileWatcherService extends EventEmitter {
262
268
 
263
269
  this.globalWatcher.on("error", (err: unknown) => {
264
270
  const error = err instanceof Error ? err : new Error(String(err));
265
- this.logger?.error(`FileWatcher: File watcher error: ${error.message}`);
271
+ const isEMFILE = (error as NodeJS.ErrnoException).code === "EMFILE";
272
+ if (isEMFILE) {
273
+ this.logger?.warn(
274
+ "FileWatcher: Too many open files (EMFILE). " +
275
+ "Consider increasing the limit: `ulimit -n 10240` (macOS) or `ulimit -n 65536` (Linux).",
276
+ );
277
+ } else {
278
+ this.logger?.error(`FileWatcher: File watcher error: ${error.message}`);
279
+ }
266
280
  this.emit("watcherError", error);
267
281
  });
268
282
  }
@@ -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,
@@ -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,