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.
- package/builtin/plugins/sdd/.wave-plugin/plugin.json +8 -0
- package/builtin/plugins/sdd/hooks/hooks.json +14 -0
- package/builtin/plugins/sdd/scripts/session-start.js +24 -0
- package/builtin/plugins/sdd/scripts/spec-count.js +77 -0
- package/builtin/plugins/sdd/skills/specify/SKILL.md +47 -0
- package/builtin/plugins/sdd/skills/specify/templates/spec-template.md +47 -0
- package/builtin/skills/settings/ENV.md +15 -9
- package/builtin/skills/settings/HOOKS.md +27 -2
- package/dist/agent.d.ts +1 -0
- package/dist/agent.js +26 -12
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/managers/aiManager.d.ts +25 -0
- package/dist/managers/aiManager.js +172 -51
- package/dist/managers/backgroundTaskManager.d.ts +6 -0
- package/dist/managers/backgroundTaskManager.js +11 -0
- package/dist/managers/bangManager.d.ts +6 -0
- package/dist/managers/bangManager.js +11 -0
- package/dist/managers/hookManager.d.ts +8 -2
- package/dist/managers/hookManager.js +14 -4
- package/dist/managers/mcpManager.d.ts +18 -4
- package/dist/managers/mcpManager.js +40 -18
- package/dist/managers/permissionManager.d.ts +7 -0
- package/dist/managers/permissionManager.js +102 -142
- package/dist/managers/pluginManager.d.ts +7 -0
- package/dist/managers/pluginManager.js +31 -0
- package/dist/managers/toolManager.js +5 -0
- package/dist/prompts/index.d.ts +12 -1
- package/dist/prompts/index.js +133 -45
- package/dist/services/aiService.d.ts +1 -17
- package/dist/services/aiService.js +3 -85
- package/dist/services/configurationService.d.ts +21 -2
- package/dist/services/configurationService.js +72 -23
- package/dist/services/initializationService.js +14 -4
- package/dist/services/interactionService.js +35 -7
- package/dist/services/remoteSettingsService.d.ts +12 -0
- package/dist/services/remoteSettingsService.js +15 -1
- package/dist/services/session.d.ts +3 -1
- package/dist/services/session.js +12 -4
- package/dist/services/taskManager.d.ts +1 -0
- package/dist/services/taskManager.js +41 -6
- package/dist/tools/bashTool.js +1 -0
- package/dist/tools/editTool.js +24 -10
- package/dist/tools/enterWorktreeTool.js +14 -3
- package/dist/tools/exitWorktreeTool.js +11 -10
- package/dist/tools/grepTool.js +8 -2
- package/dist/tools/types.d.ts +7 -0
- package/dist/tools/writeTool.js +36 -0
- package/dist/types/config.d.ts +2 -0
- package/dist/types/hooks.d.ts +2 -2
- package/dist/utils/bashParser.d.ts +25 -0
- package/dist/utils/bashParser.js +103 -0
- package/dist/utils/configPaths.d.ts +4 -0
- package/dist/utils/configPaths.js +6 -0
- package/dist/utils/containerSetup.js +1 -1
- package/dist/utils/fileSearch.js +4 -2
- package/dist/utils/openaiClient.js +2 -1
- package/dist/utils/pathEncoder.js +7 -2
- package/dist/utils/worktreeUtils.d.ts +17 -0
- package/dist/utils/worktreeUtils.js +339 -1
- package/package.json +1 -1
- package/src/agent.ts +26 -12
- package/src/index.ts +1 -0
- package/src/managers/aiManager.ts +238 -66
- package/src/managers/backgroundTaskManager.ts +15 -0
- package/src/managers/bangManager.ts +15 -0
- package/src/managers/hookManager.ts +20 -5
- package/src/managers/mcpManager.ts +60 -18
- package/src/managers/permissionManager.ts +116 -168
- package/src/managers/pluginManager.ts +29 -0
- package/src/managers/toolManager.ts +7 -0
- package/src/prompts/index.ts +144 -37
- package/src/services/aiService.ts +9 -128
- package/src/services/configurationService.ts +84 -23
- package/src/services/initializationService.ts +17 -4
- package/src/services/interactionService.ts +49 -6
- package/src/services/remoteSettingsService.ts +16 -1
- package/src/services/session.ts +18 -4
- package/src/services/taskManager.ts +56 -8
- package/src/tools/bashTool.ts +1 -0
- package/src/tools/editTool.ts +29 -11
- package/src/tools/enterWorktreeTool.ts +19 -2
- package/src/tools/exitWorktreeTool.ts +15 -12
- package/src/tools/grepTool.ts +11 -2
- package/src/tools/types.ts +7 -0
- package/src/tools/writeTool.ts +43 -0
- package/src/types/config.ts +2 -0
- package/src/types/hooks.ts +2 -2
- package/src/utils/bashParser.ts +106 -0
- package/src/utils/configPaths.ts +7 -0
- package/src/utils/containerSetup.ts +3 -1
- package/src/utils/fileSearch.ts +6 -2
- package/src/utils/openaiClient.ts +2 -0
- package/src/utils/pathEncoder.ts +7 -2
- 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(
|
|
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.
|
|
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
|
-
//
|
|
189
|
+
// 5. Clean current state
|
|
170
190
|
abortMessage(); // Abort any running operations
|
|
171
191
|
subagentManager.cleanup(); // Clean up active subagents
|
|
172
192
|
|
|
173
|
-
//
|
|
193
|
+
// 6. Rebuild usage (in correct order)
|
|
174
194
|
messageManager.rebuildUsageFromMessages(sessionData.messages);
|
|
175
195
|
|
|
176
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
package/src/services/session.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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");
|
package/src/tools/bashTool.ts
CHANGED
package/src/tools/editTool.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
141
|
-
|
|
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()
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
package/src/tools/grepTool.ts
CHANGED
|
@@ -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
|
-
|
|
239
|
-
|
|
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 {
|
package/src/tools/types.ts
CHANGED
|
@@ -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
|
}
|
package/src/tools/writeTool.ts
CHANGED
|
@@ -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 {
|
package/src/types/config.ts
CHANGED
|
@@ -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 {
|
package/src/types/hooks.ts
CHANGED
|
@@ -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 {
|
package/src/utils/bashParser.ts
CHANGED
|
@@ -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.
|