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
@@ -93,7 +93,9 @@ export class InteractionService {
93
93
  cwd: workdir,
94
94
  userPrompt: content,
95
95
  env: Object.fromEntries(
96
- Object.entries(process.env).filter((e) => e[1] !== undefined),
96
+ Object.entries(
97
+ context.configurationService.getMergedEnv(),
98
+ ).filter((e) => e[1] !== undefined),
97
99
  ) as Record<string, string>, // Include environment variables
98
100
  },
99
101
  );
@@ -137,6 +139,7 @@ export class InteractionService {
137
139
  ): Promise<void> {
138
140
  const {
139
141
  messageManager,
142
+ hookManager,
140
143
  logger,
141
144
  subagentManager,
142
145
  taskManager,
@@ -157,7 +160,24 @@ export class InteractionService {
157
160
  // Continue with restoration even if save fails
158
161
  }
159
162
 
160
- // 3. Load target session
163
+ // 3. Run SessionEnd hooks for the current session (cleanup before switching)
164
+ const currentSessionId = messageManager.getSessionId();
165
+ const currentTranscriptPath = messageManager.getTranscriptPath();
166
+ if (hookManager) {
167
+ try {
168
+ await hookManager.executeSessionEndHooks(
169
+ "resume",
170
+ currentSessionId,
171
+ currentTranscriptPath,
172
+ );
173
+ } catch (error) {
174
+ logger?.warn(
175
+ `SessionEnd hooks on restore failed: ${(error as Error).message}`,
176
+ );
177
+ }
178
+ }
179
+
180
+ // 4. Load target session
161
181
  const sessionData = await loadSessionFromJsonl(
162
182
  sessionId,
163
183
  messageManager.getWorkdir(),
@@ -166,20 +186,43 @@ export class InteractionService {
166
186
  throw new Error(`Session not found: ${sessionId}`);
167
187
  }
168
188
 
169
- // 4. Clean current state
189
+ // 5. Clean current state
170
190
  abortMessage(); // Abort any running operations
171
191
  subagentManager.cleanup(); // Clean up active subagents
172
192
 
173
- // 5. Rebuild usage (in correct order)
193
+ // 6. Rebuild usage (in correct order)
174
194
  messageManager.rebuildUsageFromMessages(sessionData.messages);
175
195
 
176
- // 6. Initialize session state last
196
+ // 7. Initialize session state last
177
197
  messageManager.initializeFromSession(sessionData);
178
198
 
199
+ // 8. Run SessionStart hooks for the restored session and inject additional
200
+ // context as a meta user message (matches Claude Code's resume behavior:
201
+ // SessionEnd then SessionStart, hook messages appended to the conversation)
202
+ if (hookManager) {
203
+ try {
204
+ const sessionStartResult = await hookManager.executeSessionStartHooks(
205
+ "resume",
206
+ sessionData.id,
207
+ messageManager.getTranscriptPath(),
208
+ );
209
+ if (sessionStartResult.additionalContext) {
210
+ messageManager.addUserMessage({
211
+ content: `<system-reminder>\nSessionStart hook additional context: ${sessionStartResult.additionalContext}\n</system-reminder>`,
212
+ isMeta: true,
213
+ });
214
+ }
215
+ } catch (error) {
216
+ logger?.warn(
217
+ `SessionStart hooks on restore failed: ${(error as Error).message}`,
218
+ );
219
+ }
220
+ }
221
+
179
222
  // Update task manager with the root session ID to ensure continuity across compactions
180
223
  taskManager.setTaskListId(sessionData.id);
181
224
 
182
- // 7. Load tasks for the restored session
225
+ // 9. Load tasks for the restored session
183
226
  const tasks = await taskManager.listTasks();
184
227
  options.callbacks?.onTasksChange?.(tasks);
185
228
  }
@@ -198,8 +198,22 @@ function startPolling(): void {
198
198
  }
199
199
 
200
200
  export function initialize(): void {
201
+ // Load disk cache synchronously so getRemoteSettingsSync() returns cached
202
+ // managed settings during loadMergedConfiguration() (must run BEFORE it).
201
203
  loadCacheFromDisk();
202
- // Fire-and-forget the initial fetch, then start background polling
204
+ }
205
+
206
+ /**
207
+ * Start the fire-and-forget initial network fetch + background polling.
208
+ *
209
+ * Must be called AFTER loadMergedConfiguration() so disk-cached managed
210
+ * settings are merged before the fetch, and so settings `env` WAVE_SERVER_URL
211
+ * is mirrored to process.env (by setEnvironmentVars) before the fetch. The
212
+ * fetch uses authService.getServerUrl(), which reads process.env.WAVE_SERVER_URL
213
+ * — the settings value is visible there, so there is no init-ordering race
214
+ * that would fall back to DEFAULT_SERVER_URL (prod) and hit a test endpoint (401).
215
+ */
216
+ export function startBackgroundFetch(): void {
203
217
  fetchRemoteSettings()
204
218
  .then(() => startPolling())
205
219
  .catch((err) => {
@@ -342,6 +356,7 @@ export function mergeRemoteSettings(
342
356
  */
343
357
  export const remoteSettingsService = {
344
358
  initialize,
359
+ startBackgroundFetch,
345
360
  getRemoteSettingsSync,
346
361
  refresh,
347
362
  clear,
@@ -825,7 +825,9 @@ export async function handleSessionRestoration(
825
825
  /**
826
826
  * Load the full message thread for a session.
827
827
  * With append-only compaction, all messages are in a single file.
828
- * Returns the active messages (post-compact boundary).
828
+ * Unlike loadSessionFromJsonl, this returns every message in the file,
829
+ * including those before the last compact boundary — rewind needs the
830
+ * complete history to allow rewinding past compaction points.
829
831
  * @param currentSessionId - The ID of the current session
830
832
  * @param workdir - Working directory for the session
831
833
  * @returns Promise that resolves to an array of all messages in the thread
@@ -834,7 +836,19 @@ export async function loadFullMessageThread(
834
836
  currentSessionId: string,
835
837
  workdir: string,
836
838
  ): Promise<{ messages: Message[]; sessionIds: string[] }> {
837
- const sessionData = await loadSessionFromJsonl(currentSessionId, workdir);
838
- if (!sessionData) return { messages: [], sessionIds: [] };
839
- return { messages: sessionData.messages, sessionIds: [currentSessionId] };
839
+ const jsonlHandler = new JsonlHandler();
840
+ const filePath = await generateSessionFilePath(
841
+ currentSessionId,
842
+ workdir,
843
+ "main",
844
+ );
845
+
846
+ try {
847
+ await fs.access(filePath);
848
+ } catch {
849
+ return { messages: [], sessionIds: [] };
850
+ }
851
+
852
+ const messages = await jsonlHandler.read(filePath);
853
+ return { messages, sessionIds: [currentSessionId] };
840
854
  }
@@ -6,6 +6,7 @@ import { Task } from "../types/tasks.js";
6
6
  import { logger } from "../utils/globalLogger.js";
7
7
  import { Container } from "../utils/container.js";
8
8
  import type { MessageManager } from "../managers/messageManager.js";
9
+ import type { ConfigurationService } from "./configurationService.js";
9
10
 
10
11
  function byIdAsc(a: Task, b: Task) {
11
12
  const aNum = parseInt(a.id, 10);
@@ -44,7 +45,15 @@ export class TaskManager extends EventEmitter {
44
45
  if (!messageManager) return;
45
46
 
46
47
  const rootSessionId = messageManager.getRootSessionId();
47
- if (this.taskListId !== rootSessionId && !process.env.WAVE_TASK_LIST_ID) {
48
+ // Read the per-session snapshot (not process.env) so multiple sessions in
49
+ // one `wave --stdio` process don't cross-pollute this flag.
50
+ const envSnap =
51
+ this.container
52
+ .get<ConfigurationService>("ConfigurationService")
53
+ ?.getEnvSnapshot() ?? {};
54
+ const pinnedTaskListId =
55
+ envSnap.WAVE_TASK_LIST_ID ?? process.env.WAVE_TASK_LIST_ID;
56
+ if (this.taskListId !== rootSessionId && !pinnedTaskListId) {
48
57
  this.setTaskListId(rootSessionId);
49
58
  await this.refreshTasks();
50
59
  }
@@ -71,6 +80,7 @@ export class TaskManager extends EventEmitter {
71
80
  let lockHandle;
72
81
  const maxRetries = 100;
73
82
  const retryDelay = process.env.NODE_ENV === "test" ? 1 : 100;
83
+ const staleThreshold = 10000;
74
84
 
75
85
  await this.ensureSessionDir();
76
86
 
@@ -79,16 +89,41 @@ export class TaskManager extends EventEmitter {
79
89
  lockHandle = await fs.open(lockPath, "wx");
80
90
  break;
81
91
  } catch (error) {
82
- if ((error as NodeJS.ErrnoException).code === "EEXIST") {
83
- if (i === maxRetries - 1) {
84
- throw new Error(
85
- `Could not acquire lock for task list ${this.taskListId} after ${maxRetries} retries`,
86
- );
92
+ const code = (error as NodeJS.ErrnoException).code;
93
+
94
+ // Only EEXIST (lock held) and EPERM (Windows pending-delete window)
95
+ // are transient lock-contention errors. EACCES/ENOENT are real failures
96
+ // and must not be retried as lock competition.
97
+ if (code !== "EEXIST" && code !== "EPERM") {
98
+ throw error;
99
+ }
100
+
101
+ if (i === maxRetries - 1) {
102
+ throw new Error(
103
+ `Could not acquire lock for task list ${this.taskListId} after ${maxRetries} retries`,
104
+ );
105
+ }
106
+
107
+ // Stale recovery: if the lock holder crashed without releasing, the
108
+ // lock file remains forever. Check mtime — if older than the threshold,
109
+ // remove it. Aligns with proper-lockfile's default 10s stale detection.
110
+ // The lock file is empty (no PID content), so mtime is the only signal.
111
+ if (
112
+ code === "EEXIST" &&
113
+ (await this.isLockStale(lockPath, staleThreshold))
114
+ ) {
115
+ logger.warn(
116
+ `TaskManager: removing stale lock for task list ${this.taskListId}`,
117
+ );
118
+ try {
119
+ await fs.unlink(lockPath);
120
+ } catch {
121
+ // Another waiter may have already removed it — retry anyway
87
122
  }
88
- await new Promise((resolve) => setTimeout(resolve, retryDelay));
89
123
  continue;
90
124
  }
91
- throw error;
125
+
126
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
92
127
  }
93
128
  }
94
129
 
@@ -109,6 +144,19 @@ export class TaskManager extends EventEmitter {
109
144
  }
110
145
  }
111
146
 
147
+ private async isLockStale(
148
+ lockPath: string,
149
+ threshold: number,
150
+ ): Promise<boolean> {
151
+ try {
152
+ const stats = await fs.stat(lockPath);
153
+ return Date.now() - stats.mtimeMs > threshold;
154
+ } catch {
155
+ // Lock was removed between our EEXIST and stat — not stale, just retry
156
+ return false;
157
+ }
158
+ }
159
+
112
160
  private validateTask(task: Task): void {
113
161
  if (!task.id || typeof task.id !== "string")
114
162
  throw new Error("Invalid task ID");
@@ -285,6 +285,7 @@ The working directory persists between commands. Try to maintain your current wo
285
285
  cwd: context.workdir,
286
286
  env: {
287
287
  ...process.env,
288
+ ...context.sessionEnv,
288
289
  },
289
290
  });
290
291
 
@@ -114,9 +114,14 @@ Usage:
114
114
 
115
115
  // Enforce read-before-edit: the file must have been read or written first.
116
116
  // readFileState is populated by Read, Write, and Edit tools — single source
117
- // of truth, aligned with Claude Code's readFileState approach.
117
+ // of truth, aligned with Claude Code's readFileState approach. Skipped in
118
+ // plan mode: permissionManager enforces a plan-file-only gate whose denial
119
+ // message must surface instead of being masked by a read-state rejection.
118
120
  const resolvedPath = resolvePath(filePath, context.workdir);
119
- if (!context.readFileState?.has(resolvedPath)) {
121
+ if (
122
+ context.permissionMode !== "plan" &&
123
+ !context.readFileState?.has(resolvedPath)
124
+ ) {
120
125
  return {
121
126
  success: false,
122
127
  content: "",
@@ -137,18 +142,31 @@ Usage:
137
142
  };
138
143
  }
139
144
 
140
- // Staleness check: file must not have been modified since last Read
141
- if (context.readFileState) {
145
+ // Staleness check (aligned with Claude Code): only flag when the file got
146
+ // newer since last read. For full reads, a content-hash fallback avoids
147
+ // false positives when mtime changed but content didn't (git checkout,
148
+ // editor round-trip save, cloud sync, antivirus). Partial reads get no
149
+ // fallback since only a slice was cached. Skipped in plan mode (see
150
+ // read-before-edit note above) so the plan-file-only denial wins.
151
+ if (context.permissionMode !== "plan" && context.readFileState) {
142
152
  const state = context.readFileState.get(resolvedPath);
143
153
  if (state) {
144
154
  const currentStats = await stat(resolvedPath);
145
- if (currentStats.mtime.getTime() !== state.mtime) {
146
- return {
147
- success: false,
148
- content: "",
149
- error:
150
- "File has been unexpectedly modified since last read. Read it again before editing it.",
151
- };
155
+ if (currentStats.mtime.getTime() > state.mtime) {
156
+ const isFullRead =
157
+ state.offset === undefined && state.limit === undefined;
158
+ const contentUnchanged =
159
+ isFullRead &&
160
+ createHash("sha256").update(originalContent).digest("hex") ===
161
+ state.hash;
162
+ if (!contentUnchanged) {
163
+ return {
164
+ success: false,
165
+ content: "",
166
+ error:
167
+ "File has been unexpectedly modified since last read. Read it again before editing it.",
168
+ };
169
+ }
152
170
  }
153
171
  }
154
172
  }
@@ -9,6 +9,7 @@ import {
9
9
  createWorktree,
10
10
  validateWorktreeName,
11
11
  generateWorktreeName,
12
+ performPostCreationSetup,
12
13
  } from "../utils/worktreeUtils.js";
13
14
  import { getGitMainRepoRoot } from "../utils/gitUtils.js";
14
15
  import { ENTER_WORKTREE_TOOL_NAME } from "../constants/tools.js";
@@ -105,6 +106,20 @@ export const enterWorktreeTool: ToolPlugin = {
105
106
  const baseRef = context.aiManager?.getWorktreeBaseRef?.();
106
107
  const worktreeInfo = createWorktree(name, mainRepoRoot, { baseRef });
107
108
 
109
+ // Copy local settings (.wave/settings.local.json) and gitignored project
110
+ // files (.worktreeinclude, e.g. .env/.mcp.json) into a new worktree —
111
+ // mirrors the CLI createWorktree path. Best-effort, never fails the tool.
112
+ if (worktreeInfo.isNew) {
113
+ try {
114
+ await performPostCreationSetup(
115
+ worktreeInfo.path,
116
+ worktreeInfo.repoRoot,
117
+ );
118
+ } catch (error) {
119
+ logger?.warn("Worktree post-creation setup failed:", error);
120
+ }
121
+ }
122
+
108
123
  // Build session state
109
124
  const session: WorktreeSession = {
110
125
  originalCwd: context.workdir,
@@ -134,11 +149,13 @@ export const enterWorktreeTool: ToolPlugin = {
134
149
  projectDir: worktreeInfo.path,
135
150
  timestamp: new Date(),
136
151
  sessionId: context.sessionId ?? "",
137
- transcriptPath: context.messageManager?.getTranscriptPath() ?? "",
152
+ transcriptPath: context.messageManager?.getTranscriptPath?.() ?? "",
138
153
  cwd: worktreeInfo.path,
139
154
  worktreeName: worktreeInfo.name,
140
155
  env: Object.fromEntries(
141
- Object.entries(process.env).filter((e) => e[1] !== undefined),
156
+ Object.entries(context.sessionEnv ?? process.env).filter(
157
+ (e) => e[1] !== undefined,
158
+ ),
142
159
  ) as Record<string, string>,
143
160
  },
144
161
  );
@@ -162,16 +162,8 @@ export const exitWorktreeTool: ToolPlugin = {
162
162
  session.originalHeadCommit,
163
163
  ) ?? { changedFiles: 0, commits: 0 };
164
164
 
165
- removeWorktree(worktreeInfo);
166
-
167
- // Clear session state and restore CWD
168
- const aiManager = context.aiManager;
169
- if (aiManager) {
170
- aiManager.setWorktreeSession(null);
171
- aiManager.setWorkdir(originalCwd);
172
- }
173
-
174
- // Trigger WorktreeRemove hook (non-blocking)
165
+ // Trigger WorktreeRemove hook (non-blocking) BEFORE git removal so hooks
166
+ // can still read files inside the worktree to clean up external resources.
175
167
  let hookTriggered = false;
176
168
  if (context.hookManager) {
177
169
  try {
@@ -182,11 +174,13 @@ export const exitWorktreeTool: ToolPlugin = {
182
174
  projectDir: originalCwd,
183
175
  timestamp: new Date(),
184
176
  sessionId: context.sessionId ?? "",
185
- transcriptPath: context.messageManager?.getTranscriptPath() ?? "",
177
+ transcriptPath: context.messageManager?.getTranscriptPath?.() ?? "",
186
178
  cwd: originalCwd,
187
179
  worktreePath,
188
180
  env: Object.fromEntries(
189
- Object.entries(process.env).filter((e) => e[1] !== undefined),
181
+ Object.entries(context.sessionEnv ?? process.env).filter(
182
+ (e) => e[1] !== undefined,
183
+ ),
190
184
  ) as Record<string, string>,
191
185
  },
192
186
  );
@@ -206,6 +200,15 @@ export const exitWorktreeTool: ToolPlugin = {
206
200
  }
207
201
  }
208
202
 
203
+ removeWorktree(worktreeInfo);
204
+
205
+ // Clear session state and restore CWD
206
+ const aiManager = context.aiManager;
207
+ if (aiManager) {
208
+ aiManager.setWorktreeSession(null);
209
+ aiManager.setWorkdir(originalCwd);
210
+ }
211
+
209
212
  const discardParts: string[] = [];
210
213
  if (summary.commits > 0) {
211
214
  discardParts.push(
@@ -2,6 +2,7 @@ import type { ToolPlugin, ToolResult, ToolContext } from "./types.js";
2
2
  import { spawn } from "child_process";
3
3
  import { rgPath } from "../utils/ripgrep.js";
4
4
  import { getDisplayPath } from "../utils/path.js";
5
+ import { logger } from "../utils/globalLogger.js";
5
6
  import {
6
7
  GREP_TOOL_NAME,
7
8
  BASH_TOOL_NAME,
@@ -235,8 +236,10 @@ export const grepTool: ToolPlugin = {
235
236
 
236
237
  const result = await executeCommand(rgPath, rgArgs, workdir);
237
238
 
238
- if (result.error && result.exitCode !== 1) {
239
- // rg returns 1 for no matches, not an error
239
+ // Only a process-level spawn failure (exitCode null) is a hard error.
240
+ // rg exit 2 means some files were unreadable (e.g. device-name files
241
+ // like "nul" on Windows); stdout still holds usable partial results.
242
+ if (result.exitCode === null) {
240
243
  return {
241
244
  success: false,
242
245
  content: "",
@@ -244,6 +247,12 @@ export const grepTool: ToolPlugin = {
244
247
  };
245
248
  }
246
249
 
250
+ if (result.exitCode !== 0 && result.exitCode !== 1) {
251
+ logger.debug(
252
+ `ripgrep exited with code ${result.exitCode}, keeping partial results: ${result.stderr.trim()}`,
253
+ );
254
+ }
255
+
247
256
  const output = result.stdout.trim();
248
257
  if (!output) {
249
258
  return {
@@ -127,4 +127,11 @@ export interface ToolContext {
127
127
  originalWorkdir?: string;
128
128
  /** Workflow manager instance for workflow orchestration */
129
129
  workflowManager?: import("../managers/workflowManager.js").WorkflowManager;
130
+ /**
131
+ * Per-session merged environment (OS env overlaid with the settings env
132
+ * snapshot) for this session. Tools that spawn subprocesses (Bash, hooks)
133
+ * should merge this on top of `process.env` so settings `env` vars reach
134
+ * the subprocess without polluting other sessions in one stdio process.
135
+ */
136
+ sessionEnv?: Record<string, string>;
130
137
  }
@@ -86,6 +86,49 @@ Usage:
86
86
  isExistingFile = false;
87
87
  }
88
88
 
89
+ // Read-before-write + staleness guards (aligned with Claude Code).
90
+ // Only enforced for existing files when readFileState is available
91
+ // (production always injects it; new-file creation always bypasses).
92
+ // Grep does not register a file as read, so Grep-then-Write on an
93
+ // existing file is still rejected. Staleness uses the same `>` + full-
94
+ // read content-hash fallback as editTool to avoid false positives from
95
+ // git checkout / editor round-trip save / cloud sync / antivirus.
96
+ // Plan mode is excluded: it has its own stricter write gate (plan-file-
97
+ // only, enforced in permissionManager) whose denial message must surface
98
+ // instead of being masked by a read-state rejection.
99
+ if (
100
+ isExistingFile &&
101
+ context.readFileState &&
102
+ context.permissionMode !== "plan"
103
+ ) {
104
+ const state = context.readFileState.get(resolvedPath);
105
+ if (!state) {
106
+ return {
107
+ success: false,
108
+ content: "",
109
+ error:
110
+ "File has not been read yet. Read it first before writing to it.",
111
+ };
112
+ }
113
+ const currentStats = await stat(resolvedPath);
114
+ if (currentStats.mtime.getTime() > state.mtime) {
115
+ const isFullRead =
116
+ state.offset === undefined && state.limit === undefined;
117
+ const contentUnchanged =
118
+ isFullRead &&
119
+ createHash("sha256").update(originalContent).digest("hex") ===
120
+ state.hash;
121
+ if (!contentUnchanged) {
122
+ return {
123
+ success: false,
124
+ content: "",
125
+ error:
126
+ "File has been unexpectedly modified since last read. Read it again before writing to it.",
127
+ };
128
+ }
129
+ }
130
+ }
131
+
89
132
  // Check if overwriting existing file but content is the same
90
133
  if (isExistingFile && originalContent === content) {
91
134
  return {
@@ -12,6 +12,8 @@ export interface GatewayConfig {
12
12
  defaultHeaders?: Record<string, string>;
13
13
  fetchOptions?: OpenAI["fetchOptions"];
14
14
  fetch?: OpenAI["fetch"];
15
+ /** Session identifier, sent as the `x-session-id` request header for backend correlation. */
16
+ sessionId?: string;
15
17
  }
16
18
 
17
19
  export interface ModelCapabilities {
@@ -102,9 +102,9 @@ export class HookConfigurationError extends Error {
102
102
  }
103
103
  }
104
104
 
105
- export type SessionStartSource = "startup" | "compact" | "clear";
105
+ export type SessionStartSource = "startup" | "resume" | "compact" | "clear";
106
106
 
107
- export type SessionEndSource = "exit" | "stop" | "compact" | "clear";
107
+ export type SessionEndSource = "exit" | "resume" | "stop" | "compact" | "clear";
108
108
 
109
109
  // Type guards for runtime validation
110
110
  export function isValidHookEvent(event: string): event is HookEvent {
@@ -476,6 +476,77 @@ export const DANGEROUS_COMMANDS = [
476
476
  "netcat",
477
477
  ];
478
478
 
479
+ /**
480
+ * Read-only command set: commands that only read/transform data and write to stdout.
481
+ * When a command in this set is used without write redirections, command substitution,
482
+ * or dangerous flags (e.g. sed -i), it is auto-allowed without a confirmation dialog.
483
+ * Aligned with Claude Code's SEMANTIC_READ_ONLY_COMMANDS, excluding interactive pagers
484
+ * (less, more, man, info), command executors (xargs), and infinite-output generators (yes).
485
+ * FR-019.2 through FR-019.7 in tool-permission-system.md.
486
+ */
487
+ export const READ_ONLY_COMMANDS = [
488
+ "ls",
489
+ "cat",
490
+ "head",
491
+ "tail",
492
+ "wc",
493
+ "sort",
494
+ "uniq",
495
+ "grep",
496
+ "egrep",
497
+ "fgrep",
498
+ "rg",
499
+ "find",
500
+ "which",
501
+ "whereis",
502
+ "file",
503
+ "stat",
504
+ "du",
505
+ "df",
506
+ "free",
507
+ "uptime",
508
+ "uname",
509
+ "hostname",
510
+ "whoami",
511
+ "id",
512
+ "groups",
513
+ "env",
514
+ "printenv",
515
+ "echo",
516
+ "printf",
517
+ "date",
518
+ "true",
519
+ "false",
520
+ "pwd",
521
+ "tree",
522
+ "diff",
523
+ "cmp",
524
+ "md5sum",
525
+ "sha256sum",
526
+ "sha1sum",
527
+ "xxd",
528
+ "od",
529
+ "hexdump",
530
+ "strings",
531
+ "readlink",
532
+ "realpath",
533
+ "basename",
534
+ "dirname",
535
+ "seq",
536
+ "column",
537
+ "jq",
538
+ "yq",
539
+ "cut",
540
+ "paste",
541
+ "tr",
542
+ "awk",
543
+ "sed",
544
+ "test",
545
+ "expr",
546
+ "bc",
547
+ "sleep",
548
+ ];
549
+
479
550
  /**
480
551
  * Registry of commands and their expected subcommand depth for smart prefix extraction.
481
552
  * For example, 'git: 2' means 'git commit' is a valid prefix, but 'git' alone is not.
@@ -634,6 +705,41 @@ export function isDangerousFind(command: string): boolean {
634
705
  });
635
706
  }
636
707
 
708
+ /**
709
+ * Detects command substitution $(...) or backticks `...` in a command string.
710
+ * Commands with substitution are never auto-allowed because the substituted
711
+ * command may be dangerous (e.g. cat $(rm x)). FR-019.6.
712
+ */
713
+ export function hasCommandSubstitution(command: string): boolean {
714
+ // Remove quoted strings first so $() or backticks inside quotes don't trigger
715
+ const stripped = command
716
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""')
717
+ .replace(/'(?:[^'\\]|\\.)*'/g, "''");
718
+ return /\$\([^)]*\)/.test(stripped) || /`[^`]*`/.test(stripped);
719
+ }
720
+
721
+ /**
722
+ * Detects process substitution <(...) or >(...) in a command string.
723
+ * These can execute side effects and are never auto-allowed. FR-019.6.
724
+ */
725
+ export function hasProcessSubstitution(command: string): boolean {
726
+ const stripped = command
727
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""')
728
+ .replace(/'(?:[^'\\]|\\.)*'/g, "''");
729
+ return /[<>]\([^)]*\)/.test(stripped);
730
+ }
731
+
732
+ /**
733
+ * Detects sed in-place edit flag (-i, with optional backup suffix like -i.bak).
734
+ * sed -i modifies files in place and must NOT be auto-allowed. FR-019.5.
735
+ */
736
+ export function hasSedInPlace(command: string): boolean {
737
+ const stripped = stripRedirections(stripEnvVars(command));
738
+ const tokens = stripped.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
739
+ if (tokens.length === 0 || tokens[0] !== "sed") return false;
740
+ return tokens.some((token) => /^-i(\..*)?$/.test(token));
741
+ }
742
+
637
743
  /**
638
744
  * Extracts a "smart prefix" from a bash command based on common developer tools.
639
745
  * Returns null if the command is blacklisted or cannot be safely prefix-matched.