wave-agent-sdk 0.19.8 → 1.0.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 (95) hide show
  1. package/builtin/plugins/sdd/.wave-plugin/plugin.json +8 -0
  2. package/builtin/plugins/sdd/hooks/hooks.json +14 -0
  3. package/builtin/plugins/sdd/scripts/session-start.js +24 -0
  4. package/builtin/plugins/sdd/scripts/spec-count.js +77 -0
  5. package/builtin/plugins/sdd/skills/specify/SKILL.md +47 -0
  6. package/builtin/plugins/sdd/skills/specify/templates/spec-template.md +47 -0
  7. package/builtin/skills/settings/ENV.md +15 -9
  8. package/builtin/skills/settings/HOOKS.md +27 -2
  9. package/dist/agent.d.ts +1 -0
  10. package/dist/agent.js +26 -12
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/managers/aiManager.d.ts +25 -0
  14. package/dist/managers/aiManager.js +172 -51
  15. package/dist/managers/backgroundTaskManager.d.ts +6 -0
  16. package/dist/managers/backgroundTaskManager.js +11 -0
  17. package/dist/managers/bangManager.d.ts +6 -0
  18. package/dist/managers/bangManager.js +11 -0
  19. package/dist/managers/hookManager.d.ts +8 -2
  20. package/dist/managers/hookManager.js +14 -4
  21. package/dist/managers/mcpManager.d.ts +18 -4
  22. package/dist/managers/mcpManager.js +40 -18
  23. package/dist/managers/permissionManager.d.ts +7 -0
  24. package/dist/managers/permissionManager.js +102 -142
  25. package/dist/managers/pluginManager.d.ts +7 -0
  26. package/dist/managers/pluginManager.js +31 -0
  27. package/dist/managers/toolManager.js +5 -0
  28. package/dist/prompts/index.d.ts +12 -1
  29. package/dist/prompts/index.js +133 -45
  30. package/dist/services/aiService.d.ts +1 -17
  31. package/dist/services/aiService.js +3 -85
  32. package/dist/services/configurationService.d.ts +21 -2
  33. package/dist/services/configurationService.js +72 -23
  34. package/dist/services/initializationService.js +14 -4
  35. package/dist/services/interactionService.js +35 -7
  36. package/dist/services/remoteSettingsService.d.ts +12 -0
  37. package/dist/services/remoteSettingsService.js +15 -1
  38. package/dist/services/session.d.ts +3 -1
  39. package/dist/services/session.js +12 -4
  40. package/dist/services/taskManager.d.ts +1 -0
  41. package/dist/services/taskManager.js +41 -6
  42. package/dist/tools/bashTool.js +1 -0
  43. package/dist/tools/editTool.js +24 -10
  44. package/dist/tools/enterWorktreeTool.js +14 -3
  45. package/dist/tools/exitWorktreeTool.js +11 -10
  46. package/dist/tools/grepTool.js +8 -2
  47. package/dist/tools/types.d.ts +7 -0
  48. package/dist/tools/writeTool.js +36 -0
  49. package/dist/types/config.d.ts +2 -0
  50. package/dist/types/hooks.d.ts +2 -2
  51. package/dist/utils/bashParser.d.ts +25 -0
  52. package/dist/utils/bashParser.js +103 -0
  53. package/dist/utils/configPaths.d.ts +4 -0
  54. package/dist/utils/configPaths.js +6 -0
  55. package/dist/utils/containerSetup.js +1 -1
  56. package/dist/utils/fileSearch.js +4 -2
  57. package/dist/utils/openaiClient.js +2 -1
  58. package/dist/utils/pathEncoder.js +7 -2
  59. package/dist/utils/worktreeUtils.d.ts +17 -0
  60. package/dist/utils/worktreeUtils.js +339 -1
  61. package/package.json +1 -1
  62. package/src/agent.ts +26 -12
  63. package/src/index.ts +1 -0
  64. package/src/managers/aiManager.ts +238 -66
  65. package/src/managers/backgroundTaskManager.ts +15 -0
  66. package/src/managers/bangManager.ts +15 -0
  67. package/src/managers/hookManager.ts +20 -5
  68. package/src/managers/mcpManager.ts +60 -18
  69. package/src/managers/permissionManager.ts +116 -168
  70. package/src/managers/pluginManager.ts +29 -0
  71. package/src/managers/toolManager.ts +7 -0
  72. package/src/prompts/index.ts +144 -37
  73. package/src/services/aiService.ts +9 -128
  74. package/src/services/configurationService.ts +84 -23
  75. package/src/services/initializationService.ts +17 -4
  76. package/src/services/interactionService.ts +49 -6
  77. package/src/services/remoteSettingsService.ts +16 -1
  78. package/src/services/session.ts +18 -4
  79. package/src/services/taskManager.ts +56 -8
  80. package/src/tools/bashTool.ts +1 -0
  81. package/src/tools/editTool.ts +29 -11
  82. package/src/tools/enterWorktreeTool.ts +19 -2
  83. package/src/tools/exitWorktreeTool.ts +15 -12
  84. package/src/tools/grepTool.ts +11 -2
  85. package/src/tools/types.ts +7 -0
  86. package/src/tools/writeTool.ts +43 -0
  87. package/src/types/config.ts +2 -0
  88. package/src/types/hooks.ts +2 -2
  89. package/src/utils/bashParser.ts +106 -0
  90. package/src/utils/configPaths.ts +7 -0
  91. package/src/utils/containerSetup.ts +3 -1
  92. package/src/utils/fileSearch.ts +6 -2
  93. package/src/utils/openaiClient.ts +2 -0
  94. package/src/utils/pathEncoder.ts +7 -2
  95. package/src/utils/worktreeUtils.ts +401 -1
@@ -48,7 +48,7 @@ export class InteractionService {
48
48
  transcriptPath: messageManager.getTranscriptPath(),
49
49
  cwd: workdir,
50
50
  userPrompt: content,
51
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)), // Include environment variables
51
+ env: Object.fromEntries(Object.entries(context.configurationService.getMergedEnv()).filter((e) => e[1] !== undefined)), // Include environment variables
52
52
  });
53
53
  // Process hook results and determine if we should continue
54
54
  const processResult = hookManager.processHookResults("UserPromptSubmit", hookResults, messageManager);
@@ -76,7 +76,7 @@ export class InteractionService {
76
76
  }
77
77
  }
78
78
  static async restoreSession(context, sessionId) {
79
- const { messageManager, logger, subagentManager, taskManager, options, abortMessage, } = context;
79
+ const { messageManager, hookManager, logger, subagentManager, taskManager, options, abortMessage, } = context;
80
80
  // 1. Validation
81
81
  if (!sessionId || sessionId === messageManager.getSessionId()) {
82
82
  return; // No-op if session ID is invalid or already current
@@ -89,21 +89,49 @@ export class InteractionService {
89
89
  logger?.warn("Failed to save current session before restore:", error);
90
90
  // Continue with restoration even if save fails
91
91
  }
92
- // 3. Load target session
92
+ // 3. Run SessionEnd hooks for the current session (cleanup before switching)
93
+ const currentSessionId = messageManager.getSessionId();
94
+ const currentTranscriptPath = messageManager.getTranscriptPath();
95
+ if (hookManager) {
96
+ try {
97
+ await hookManager.executeSessionEndHooks("resume", currentSessionId, currentTranscriptPath);
98
+ }
99
+ catch (error) {
100
+ logger?.warn(`SessionEnd hooks on restore failed: ${error.message}`);
101
+ }
102
+ }
103
+ // 4. Load target session
93
104
  const sessionData = await loadSessionFromJsonl(sessionId, messageManager.getWorkdir());
94
105
  if (!sessionData) {
95
106
  throw new Error(`Session not found: ${sessionId}`);
96
107
  }
97
- // 4. Clean current state
108
+ // 5. Clean current state
98
109
  abortMessage(); // Abort any running operations
99
110
  subagentManager.cleanup(); // Clean up active subagents
100
- // 5. Rebuild usage (in correct order)
111
+ // 6. Rebuild usage (in correct order)
101
112
  messageManager.rebuildUsageFromMessages(sessionData.messages);
102
- // 6. Initialize session state last
113
+ // 7. Initialize session state last
103
114
  messageManager.initializeFromSession(sessionData);
115
+ // 8. Run SessionStart hooks for the restored session and inject additional
116
+ // context as a meta user message (matches Claude Code's resume behavior:
117
+ // SessionEnd then SessionStart, hook messages appended to the conversation)
118
+ if (hookManager) {
119
+ try {
120
+ const sessionStartResult = await hookManager.executeSessionStartHooks("resume", sessionData.id, messageManager.getTranscriptPath());
121
+ if (sessionStartResult.additionalContext) {
122
+ messageManager.addUserMessage({
123
+ content: `<system-reminder>\nSessionStart hook additional context: ${sessionStartResult.additionalContext}\n</system-reminder>`,
124
+ isMeta: true,
125
+ });
126
+ }
127
+ }
128
+ catch (error) {
129
+ logger?.warn(`SessionStart hooks on restore failed: ${error.message}`);
130
+ }
131
+ }
104
132
  // Update task manager with the root session ID to ensure continuity across compactions
105
133
  taskManager.setTaskListId(sessionData.id);
106
- // 7. Load tasks for the restored session
134
+ // 9. Load tasks for the restored session
107
135
  const tasks = await taskManager.listTasks();
108
136
  options.callbacks?.onTasksChange?.(tasks);
109
137
  }
@@ -6,6 +6,17 @@ import type { WaveConfiguration } from "../types/configuration.js";
6
6
  */
7
7
  export declare function onSettingsUpdate(callback: () => void | Promise<void>): () => void;
8
8
  export declare function initialize(): void;
9
+ /**
10
+ * Start the fire-and-forget initial network fetch + background polling.
11
+ *
12
+ * Must be called AFTER loadMergedConfiguration() so disk-cached managed
13
+ * settings are merged before the fetch, and so settings `env` WAVE_SERVER_URL
14
+ * is mirrored to process.env (by setEnvironmentVars) before the fetch. The
15
+ * fetch uses authService.getServerUrl(), which reads process.env.WAVE_SERVER_URL
16
+ * — the settings value is visible there, so there is no init-ordering race
17
+ * that would fall back to DEFAULT_SERVER_URL (prod) and hit a test endpoint (401).
18
+ */
19
+ export declare function startBackgroundFetch(): void;
9
20
  export declare function getRemoteSettingsSync(): WaveConfiguration | null;
10
21
  export declare function refresh(): Promise<RemoteSettingsFetchResult>;
11
22
  export declare function clear(): void;
@@ -17,6 +28,7 @@ export declare function mergeRemoteSettings(localMerged: WaveConfiguration, remo
17
28
  */
18
29
  export declare const remoteSettingsService: {
19
30
  readonly initialize: typeof initialize;
31
+ readonly startBackgroundFetch: typeof startBackgroundFetch;
20
32
  readonly getRemoteSettingsSync: typeof getRemoteSettingsSync;
21
33
  readonly refresh: typeof refresh;
22
34
  readonly clear: typeof clear;
@@ -174,8 +174,21 @@ function startPolling() {
174
174
  _pollingTimer.unref();
175
175
  }
176
176
  export function initialize() {
177
+ // Load disk cache synchronously so getRemoteSettingsSync() returns cached
178
+ // managed settings during loadMergedConfiguration() (must run BEFORE it).
177
179
  loadCacheFromDisk();
178
- // Fire-and-forget the initial fetch, then start background polling
180
+ }
181
+ /**
182
+ * Start the fire-and-forget initial network fetch + background polling.
183
+ *
184
+ * Must be called AFTER loadMergedConfiguration() so disk-cached managed
185
+ * settings are merged before the fetch, and so settings `env` WAVE_SERVER_URL
186
+ * is mirrored to process.env (by setEnvironmentVars) before the fetch. The
187
+ * fetch uses authService.getServerUrl(), which reads process.env.WAVE_SERVER_URL
188
+ * — the settings value is visible there, so there is no init-ordering race
189
+ * that would fall back to DEFAULT_SERVER_URL (prod) and hit a test endpoint (401).
190
+ */
191
+ export function startBackgroundFetch() {
179
192
  fetchRemoteSettings()
180
193
  .then(() => startPolling())
181
194
  .catch((err) => {
@@ -302,6 +315,7 @@ export function mergeRemoteSettings(localMerged, remote) {
302
315
  */
303
316
  export const remoteSettingsService = {
304
317
  initialize,
318
+ startBackgroundFetch,
305
319
  getRemoteSettingsSync,
306
320
  refresh,
307
321
  clear,
@@ -184,7 +184,9 @@ export declare function handleSessionRestoration(restoreSessionId?: string, cont
184
184
  /**
185
185
  * Load the full message thread for a session.
186
186
  * With append-only compaction, all messages are in a single file.
187
- * Returns the active messages (post-compact boundary).
187
+ * Unlike loadSessionFromJsonl, this returns every message in the file,
188
+ * including those before the last compact boundary — rewind needs the
189
+ * complete history to allow rewinding past compaction points.
188
190
  * @param currentSessionId - The ID of the current session
189
191
  * @param workdir - Working directory for the session
190
192
  * @returns Promise that resolves to an array of all messages in the thread
@@ -646,14 +646,22 @@ export async function handleSessionRestoration(restoreSessionId, continueLastSes
646
646
  /**
647
647
  * Load the full message thread for a session.
648
648
  * With append-only compaction, all messages are in a single file.
649
- * Returns the active messages (post-compact boundary).
649
+ * Unlike loadSessionFromJsonl, this returns every message in the file,
650
+ * including those before the last compact boundary — rewind needs the
651
+ * complete history to allow rewinding past compaction points.
650
652
  * @param currentSessionId - The ID of the current session
651
653
  * @param workdir - Working directory for the session
652
654
  * @returns Promise that resolves to an array of all messages in the thread
653
655
  */
654
656
  export async function loadFullMessageThread(currentSessionId, workdir) {
655
- const sessionData = await loadSessionFromJsonl(currentSessionId, workdir);
656
- if (!sessionData)
657
+ const jsonlHandler = new JsonlHandler();
658
+ const filePath = await generateSessionFilePath(currentSessionId, workdir, "main");
659
+ try {
660
+ await fs.access(filePath);
661
+ }
662
+ catch {
657
663
  return { messages: [], sessionIds: [] };
658
- return { messages: sessionData.messages, sessionIds: [currentSessionId] };
664
+ }
665
+ const messages = await jsonlHandler.read(filePath);
666
+ return { messages, sessionIds: [currentSessionId] };
659
667
  }
@@ -18,6 +18,7 @@ export declare class TaskManager extends EventEmitter {
18
18
  private getLockPath;
19
19
  ensureSessionDir(): Promise<void>;
20
20
  private withLock;
21
+ private isLockStale;
21
22
  private validateTask;
22
23
  createTask(task: Omit<Task, "id">): Promise<string>;
23
24
  getTask(taskId: string): Promise<Task | null>;
@@ -32,7 +32,13 @@ export class TaskManager extends EventEmitter {
32
32
  if (!messageManager)
33
33
  return;
34
34
  const rootSessionId = messageManager.getRootSessionId();
35
- if (this.taskListId !== rootSessionId && !process.env.WAVE_TASK_LIST_ID) {
35
+ // Read the per-session snapshot (not process.env) so multiple sessions in
36
+ // one `wave --stdio` process don't cross-pollute this flag.
37
+ const envSnap = this.container
38
+ .get("ConfigurationService")
39
+ ?.getEnvSnapshot() ?? {};
40
+ const pinnedTaskListId = envSnap.WAVE_TASK_LIST_ID ?? process.env.WAVE_TASK_LIST_ID;
41
+ if (this.taskListId !== rootSessionId && !pinnedTaskListId) {
36
42
  this.setTaskListId(rootSessionId);
37
43
  await this.refreshTasks();
38
44
  }
@@ -54,6 +60,7 @@ export class TaskManager extends EventEmitter {
54
60
  let lockHandle;
55
61
  const maxRetries = 100;
56
62
  const retryDelay = process.env.NODE_ENV === "test" ? 1 : 100;
63
+ const staleThreshold = 10000;
57
64
  await this.ensureSessionDir();
58
65
  for (let i = 0; i < maxRetries; i++) {
59
66
  try {
@@ -61,14 +68,32 @@ export class TaskManager extends EventEmitter {
61
68
  break;
62
69
  }
63
70
  catch (error) {
64
- if (error.code === "EEXIST") {
65
- if (i === maxRetries - 1) {
66
- throw new Error(`Could not acquire lock for task list ${this.taskListId} after ${maxRetries} retries`);
71
+ const code = error.code;
72
+ // Only EEXIST (lock held) and EPERM (Windows pending-delete window)
73
+ // are transient lock-contention errors. EACCES/ENOENT are real failures
74
+ // and must not be retried as lock competition.
75
+ if (code !== "EEXIST" && code !== "EPERM") {
76
+ throw error;
77
+ }
78
+ if (i === maxRetries - 1) {
79
+ throw new Error(`Could not acquire lock for task list ${this.taskListId} after ${maxRetries} retries`);
80
+ }
81
+ // Stale recovery: if the lock holder crashed without releasing, the
82
+ // lock file remains forever. Check mtime — if older than the threshold,
83
+ // remove it. Aligns with proper-lockfile's default 10s stale detection.
84
+ // The lock file is empty (no PID content), so mtime is the only signal.
85
+ if (code === "EEXIST" &&
86
+ (await this.isLockStale(lockPath, staleThreshold))) {
87
+ logger.warn(`TaskManager: removing stale lock for task list ${this.taskListId}`);
88
+ try {
89
+ await fs.unlink(lockPath);
90
+ }
91
+ catch {
92
+ // Another waiter may have already removed it — retry anyway
67
93
  }
68
- await new Promise((resolve) => setTimeout(resolve, retryDelay));
69
94
  continue;
70
95
  }
71
- throw error;
96
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
72
97
  }
73
98
  }
74
99
  try {
@@ -86,6 +111,16 @@ export class TaskManager extends EventEmitter {
86
111
  }
87
112
  }
88
113
  }
114
+ async isLockStale(lockPath, threshold) {
115
+ try {
116
+ const stats = await fs.stat(lockPath);
117
+ return Date.now() - stats.mtimeMs > threshold;
118
+ }
119
+ catch {
120
+ // Lock was removed between our EEXIST and stat — not stale, just retry
121
+ return false;
122
+ }
123
+ }
89
124
  validateTask(task) {
90
125
  if (!task.id || typeof task.id !== "string")
91
126
  throw new Error("Invalid task ID");
@@ -238,6 +238,7 @@ The working directory persists between commands. Try to maintain your current wo
238
238
  cwd: context.workdir,
239
239
  env: {
240
240
  ...process.env,
241
+ ...context.sessionEnv,
241
242
  },
242
243
  });
243
244
  let outputBuffer = "";
@@ -97,9 +97,12 @@ Usage:
97
97
  context.messageManager?.triggerFileRead(filePath);
98
98
  // Enforce read-before-edit: the file must have been read or written first.
99
99
  // readFileState is populated by Read, Write, and Edit tools — single source
100
- // of truth, aligned with Claude Code's readFileState approach.
100
+ // of truth, aligned with Claude Code's readFileState approach. Skipped in
101
+ // plan mode: permissionManager enforces a plan-file-only gate whose denial
102
+ // message must surface instead of being masked by a read-state rejection.
101
103
  const resolvedPath = resolvePath(filePath, context.workdir);
102
- if (!context.readFileState?.has(resolvedPath)) {
104
+ if (context.permissionMode !== "plan" &&
105
+ !context.readFileState?.has(resolvedPath)) {
103
106
  return {
104
107
  success: false,
105
108
  content: "",
@@ -119,17 +122,28 @@ Usage:
119
122
  error: `Failed to read file: ${readError instanceof Error ? readError.message : String(readError)}`,
120
123
  };
121
124
  }
122
- // Staleness check: file must not have been modified since last Read
123
- if (context.readFileState) {
125
+ // Staleness check (aligned with Claude Code): only flag when the file got
126
+ // newer since last read. For full reads, a content-hash fallback avoids
127
+ // false positives when mtime changed but content didn't (git checkout,
128
+ // editor round-trip save, cloud sync, antivirus). Partial reads get no
129
+ // fallback since only a slice was cached. Skipped in plan mode (see
130
+ // read-before-edit note above) so the plan-file-only denial wins.
131
+ if (context.permissionMode !== "plan" && context.readFileState) {
124
132
  const state = context.readFileState.get(resolvedPath);
125
133
  if (state) {
126
134
  const currentStats = await stat(resolvedPath);
127
- if (currentStats.mtime.getTime() !== state.mtime) {
128
- return {
129
- success: false,
130
- content: "",
131
- error: "File has been unexpectedly modified since last read. Read it again before editing it.",
132
- };
135
+ if (currentStats.mtime.getTime() > state.mtime) {
136
+ const isFullRead = state.offset === undefined && state.limit === undefined;
137
+ const contentUnchanged = isFullRead &&
138
+ createHash("sha256").update(originalContent).digest("hex") ===
139
+ state.hash;
140
+ if (!contentUnchanged) {
141
+ return {
142
+ success: false,
143
+ content: "",
144
+ error: "File has been unexpectedly modified since last read. Read it again before editing it.",
145
+ };
146
+ }
133
147
  }
134
148
  }
135
149
  }
@@ -2,7 +2,7 @@
2
2
  * EnterWorktree tool - creates an isolated git worktree and switches the session into it.
3
3
  * Mirrors Claude Code's EnterWorktree tool behavior and prompt.
4
4
  */
5
- import { createWorktree, validateWorktreeName, generateWorktreeName, } from "../utils/worktreeUtils.js";
5
+ import { createWorktree, validateWorktreeName, generateWorktreeName, performPostCreationSetup, } from "../utils/worktreeUtils.js";
6
6
  import { getGitMainRepoRoot } from "../utils/gitUtils.js";
7
7
  import { ENTER_WORKTREE_TOOL_NAME } from "../constants/tools.js";
8
8
  import { logger } from "../utils/globalLogger.js";
@@ -85,6 +85,17 @@ export const enterWorktreeTool = {
85
85
  // Create the worktree (captures originalHeadCommit internally)
86
86
  const baseRef = context.aiManager?.getWorktreeBaseRef?.();
87
87
  const worktreeInfo = createWorktree(name, mainRepoRoot, { baseRef });
88
+ // Copy local settings (.wave/settings.local.json) and gitignored project
89
+ // files (.worktreeinclude, e.g. .env/.mcp.json) into a new worktree —
90
+ // mirrors the CLI createWorktree path. Best-effort, never fails the tool.
91
+ if (worktreeInfo.isNew) {
92
+ try {
93
+ await performPostCreationSetup(worktreeInfo.path, worktreeInfo.repoRoot);
94
+ }
95
+ catch (error) {
96
+ logger?.warn("Worktree post-creation setup failed:", error);
97
+ }
98
+ }
88
99
  // Build session state
89
100
  const session = {
90
101
  originalCwd: context.workdir,
@@ -110,10 +121,10 @@ export const enterWorktreeTool = {
110
121
  projectDir: worktreeInfo.path,
111
122
  timestamp: new Date(),
112
123
  sessionId: context.sessionId ?? "",
113
- transcriptPath: context.messageManager?.getTranscriptPath() ?? "",
124
+ transcriptPath: context.messageManager?.getTranscriptPath?.() ?? "",
114
125
  cwd: worktreeInfo.path,
115
126
  worktreeName: worktreeInfo.name,
116
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)),
127
+ env: Object.fromEntries(Object.entries(context.sessionEnv ?? process.env).filter((e) => e[1] !== undefined)),
117
128
  });
118
129
  if (context.messageManager) {
119
130
  context.hookManager.processHookResults("WorktreeCreate", hookResults, context.messageManager);
@@ -128,14 +128,8 @@ export const exitWorktreeTool = {
128
128
  };
129
129
  // Count changes BEFORE removing the worktree (directory will be gone after)
130
130
  const summary = countWorktreeChanges(worktreePath, session.originalHeadCommit) ?? { changedFiles: 0, commits: 0 };
131
- removeWorktree(worktreeInfo);
132
- // Clear session state and restore CWD
133
- const aiManager = context.aiManager;
134
- if (aiManager) {
135
- aiManager.setWorktreeSession(null);
136
- aiManager.setWorkdir(originalCwd);
137
- }
138
- // Trigger WorktreeRemove hook (non-blocking)
131
+ // Trigger WorktreeRemove hook (non-blocking) BEFORE git removal so hooks
132
+ // can still read files inside the worktree to clean up external resources.
139
133
  let hookTriggered = false;
140
134
  if (context.hookManager) {
141
135
  try {
@@ -144,10 +138,10 @@ export const exitWorktreeTool = {
144
138
  projectDir: originalCwd,
145
139
  timestamp: new Date(),
146
140
  sessionId: context.sessionId ?? "",
147
- transcriptPath: context.messageManager?.getTranscriptPath() ?? "",
141
+ transcriptPath: context.messageManager?.getTranscriptPath?.() ?? "",
148
142
  cwd: originalCwd,
149
143
  worktreePath,
150
- env: Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined)),
144
+ env: Object.fromEntries(Object.entries(context.sessionEnv ?? process.env).filter((e) => e[1] !== undefined)),
151
145
  });
152
146
  if (context.messageManager) {
153
147
  context.hookManager.processHookResults("WorktreeRemove", hookResults, context.messageManager);
@@ -159,6 +153,13 @@ export const exitWorktreeTool = {
159
153
  logger?.warn("WorktreeRemove hooks execution failed:", error);
160
154
  }
161
155
  }
156
+ removeWorktree(worktreeInfo);
157
+ // Clear session state and restore CWD
158
+ const aiManager = context.aiManager;
159
+ if (aiManager) {
160
+ aiManager.setWorktreeSession(null);
161
+ aiManager.setWorkdir(originalCwd);
162
+ }
162
163
  const discardParts = [];
163
164
  if (summary.commits > 0) {
164
165
  discardParts.push(`${summary.commits} ${summary.commits === 1 ? "commit" : "commits"}`);
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "child_process";
2
2
  import { rgPath } from "../utils/ripgrep.js";
3
3
  import { getDisplayPath } from "../utils/path.js";
4
+ import { logger } from "../utils/globalLogger.js";
4
5
  import { GREP_TOOL_NAME, BASH_TOOL_NAME, AGENT_TOOL_NAME, } from "../constants/tools.js";
5
6
  // Version control system directories to exclude from searches.
6
7
  // These are excluded automatically because they create noise in search results.
@@ -199,14 +200,19 @@ export const grepTool = {
199
200
  rgArgs.push(".");
200
201
  }
201
202
  const result = await executeCommand(rgPath, rgArgs, workdir);
202
- if (result.error && result.exitCode !== 1) {
203
- // rg returns 1 for no matches, not an error
203
+ // Only a process-level spawn failure (exitCode null) is a hard error.
204
+ // rg exit 2 means some files were unreadable (e.g. device-name files
205
+ // like "nul" on Windows); stdout still holds usable partial results.
206
+ if (result.exitCode === null) {
204
207
  return {
205
208
  success: false,
206
209
  content: "",
207
210
  error: `ripgrep failed: ${result.stderr}`,
208
211
  };
209
212
  }
213
+ if (result.exitCode !== 0 && result.exitCode !== 1) {
214
+ logger.debug(`ripgrep exited with code ${result.exitCode}, keeping partial results: ${result.stderr.trim()}`);
215
+ }
210
216
  const output = result.stdout.trim();
211
217
  if (!output) {
212
218
  return {
@@ -104,4 +104,11 @@ export interface ToolContext {
104
104
  originalWorkdir?: string;
105
105
  /** Workflow manager instance for workflow orchestration */
106
106
  workflowManager?: import("../managers/workflowManager.js").WorkflowManager;
107
+ /**
108
+ * Per-session merged environment (OS env overlaid with the settings env
109
+ * snapshot) for this session. Tools that spawn subprocesses (Bash, hooks)
110
+ * should merge this on top of `process.env` so settings `env` vars reach
111
+ * the subprocess without polluting other sessions in one stdio process.
112
+ */
113
+ sessionEnv?: Record<string, string>;
107
114
  }
@@ -74,6 +74,42 @@ Usage:
74
74
  // File doesn't exist, this is normal for new file creation
75
75
  isExistingFile = false;
76
76
  }
77
+ // Read-before-write + staleness guards (aligned with Claude Code).
78
+ // Only enforced for existing files when readFileState is available
79
+ // (production always injects it; new-file creation always bypasses).
80
+ // Grep does not register a file as read, so Grep-then-Write on an
81
+ // existing file is still rejected. Staleness uses the same `>` + full-
82
+ // read content-hash fallback as editTool to avoid false positives from
83
+ // git checkout / editor round-trip save / cloud sync / antivirus.
84
+ // Plan mode is excluded: it has its own stricter write gate (plan-file-
85
+ // only, enforced in permissionManager) whose denial message must surface
86
+ // instead of being masked by a read-state rejection.
87
+ if (isExistingFile &&
88
+ context.readFileState &&
89
+ context.permissionMode !== "plan") {
90
+ const state = context.readFileState.get(resolvedPath);
91
+ if (!state) {
92
+ return {
93
+ success: false,
94
+ content: "",
95
+ error: "File has not been read yet. Read it first before writing to it.",
96
+ };
97
+ }
98
+ const currentStats = await stat(resolvedPath);
99
+ if (currentStats.mtime.getTime() > state.mtime) {
100
+ const isFullRead = state.offset === undefined && state.limit === undefined;
101
+ const contentUnchanged = isFullRead &&
102
+ createHash("sha256").update(originalContent).digest("hex") ===
103
+ state.hash;
104
+ if (!contentUnchanged) {
105
+ return {
106
+ success: false,
107
+ content: "",
108
+ error: "File has been unexpectedly modified since last read. Read it again before writing to it.",
109
+ };
110
+ }
111
+ }
112
+ }
77
113
  // Check if overwriting existing file but content is the same
78
114
  if (isExistingFile && originalContent === content) {
79
115
  return {
@@ -10,6 +10,8 @@ export interface GatewayConfig {
10
10
  defaultHeaders?: Record<string, string>;
11
11
  fetchOptions?: OpenAI["fetchOptions"];
12
12
  fetch?: OpenAI["fetch"];
13
+ /** Session identifier, sent as the `x-session-id` request header for backend correlation. */
14
+ sessionId?: string;
13
15
  }
14
16
  export interface ModelCapabilities {
15
17
  /** Whether the model supports image/vision input. Default: true. */
@@ -54,8 +54,8 @@ export declare class HookConfigurationError extends Error {
54
54
  readonly validationErrors: string[];
55
55
  constructor(configPath: string, validationErrors: string[]);
56
56
  }
57
- export type SessionStartSource = "startup" | "compact" | "clear";
58
- export type SessionEndSource = "exit" | "stop" | "compact" | "clear";
57
+ export type SessionStartSource = "startup" | "resume" | "compact" | "clear";
58
+ export type SessionEndSource = "exit" | "resume" | "stop" | "compact" | "clear";
59
59
  export declare function isValidHookEvent(event: string): event is HookEvent;
60
60
  export declare function isValidHookCommand(cmd: unknown): cmd is HookCommand;
61
61
  export declare function isValidHookEventConfig(config: unknown): config is HookEventConfig;
@@ -28,6 +28,15 @@ export declare function isBashHeredocWrite(command: string): boolean;
28
28
  * and should not have persistent permissions.
29
29
  */
30
30
  export declare const DANGEROUS_COMMANDS: string[];
31
+ /**
32
+ * Read-only command set: commands that only read/transform data and write to stdout.
33
+ * When a command in this set is used without write redirections, command substitution,
34
+ * or dangerous flags (e.g. sed -i), it is auto-allowed without a confirmation dialog.
35
+ * Aligned with Claude Code's SEMANTIC_READ_ONLY_COMMANDS, excluding interactive pagers
36
+ * (less, more, man, info), command executors (xargs), and infinite-output generators (yes).
37
+ * FR-019.2 through FR-019.7 in tool-permission-system.md.
38
+ */
39
+ export declare const READ_ONLY_COMMANDS: string[];
31
40
  /**
32
41
  * Registry of commands and their expected subcommand depth for smart prefix extraction.
33
42
  * For example, 'git: 2' means 'git commit' is a valid prefix, but 'git' alone is not.
@@ -46,6 +55,22 @@ export declare const DANGEROUS_SUBCOMMANDS: Record<string, string[]>;
46
55
  * Checks if a find command is dangerous (e.g., contains -exec, -delete, etc.).
47
56
  */
48
57
  export declare function isDangerousFind(command: string): boolean;
58
+ /**
59
+ * Detects command substitution $(...) or backticks `...` in a command string.
60
+ * Commands with substitution are never auto-allowed because the substituted
61
+ * command may be dangerous (e.g. cat $(rm x)). FR-019.6.
62
+ */
63
+ export declare function hasCommandSubstitution(command: string): boolean;
64
+ /**
65
+ * Detects process substitution <(...) or >(...) in a command string.
66
+ * These can execute side effects and are never auto-allowed. FR-019.6.
67
+ */
68
+ export declare function hasProcessSubstitution(command: string): boolean;
69
+ /**
70
+ * Detects sed in-place edit flag (-i, with optional backup suffix like -i.bak).
71
+ * sed -i modifies files in place and must NOT be auto-allowed. FR-019.5.
72
+ */
73
+ export declare function hasSedInPlace(command: string): boolean;
49
74
  /**
50
75
  * Extracts a "smart prefix" from a bash command based on common developer tools.
51
76
  * Returns null if the command is blacklisted or cannot be safely prefix-matched.