wave-agent-sdk 0.19.7 → 0.19.9
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 +48 -0
- package/builtin/plugins/sdd/skills/specify/templates/spec-template.md +47 -0
- package/dist/agent.d.ts +8 -0
- package/dist/agent.js +30 -10
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/managers/aiManager.d.ts +18 -0
- package/dist/managers/aiManager.js +155 -46
- 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/subagentManager.js +6 -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 +6 -0
- package/dist/services/configurationService.js +31 -0
- package/dist/services/remoteSettingsService.js +2 -0
- 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 +34 -5
- package/dist/tools/editTool.js +24 -10
- package/dist/tools/enterWorktreeTool.js +2 -1
- package/dist/tools/grepTool.js +8 -2
- package/dist/tools/writeTool.js +36 -0
- package/dist/types/configuration.d.ts +5 -0
- package/dist/types/permissions.d.ts +0 -2
- package/dist/types/processes.d.ts +27 -0
- package/dist/types/workflow.d.ts +1 -1
- 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 +0 -9
- package/dist/utils/fileSearch.js +4 -2
- package/dist/utils/worktreeSession.d.ts +1 -1
- package/dist/utils/worktreeSession.js +1 -1
- package/dist/utils/worktreeUtils.d.ts +7 -1
- package/dist/utils/worktreeUtils.js +10 -4
- package/dist/workflow/types.d.ts +5 -0
- package/package.json +1 -1
- package/src/agent.ts +29 -10
- package/src/index.ts +1 -0
- package/src/managers/aiManager.ts +219 -61
- package/src/managers/permissionManager.ts +116 -168
- package/src/managers/pluginManager.ts +29 -0
- package/src/managers/subagentManager.ts +6 -0
- package/src/prompts/index.ts +144 -37
- package/src/services/aiService.ts +9 -128
- package/src/services/configurationService.ts +37 -0
- package/src/services/remoteSettingsService.ts +1 -0
- package/src/services/session.ts +18 -4
- package/src/services/taskManager.ts +46 -7
- package/src/tools/editTool.ts +29 -11
- package/src/tools/enterWorktreeTool.ts +2 -1
- package/src/tools/grepTool.ts +11 -2
- package/src/tools/writeTool.ts +43 -0
- package/src/types/configuration.ts +5 -0
- package/src/types/permissions.ts +0 -2
- package/src/types/processes.ts +29 -0
- package/src/types/workflow.ts +1 -0
- package/src/utils/bashParser.ts +106 -0
- package/src/utils/configPaths.ts +7 -0
- package/src/utils/containerSetup.ts +0 -11
- package/src/utils/fileSearch.ts +6 -2
- package/src/utils/worktreeSession.ts +1 -1
- package/src/utils/worktreeUtils.ts +14 -4
- package/src/workflow/types.ts +6 -0
|
@@ -24,7 +24,6 @@ import * as fs from "fs";
|
|
|
24
24
|
import * as path from "path";
|
|
25
25
|
|
|
26
26
|
import {
|
|
27
|
-
COMPACT_MESSAGES_SYSTEM_PROMPT,
|
|
28
27
|
WEB_CONTENT_SYSTEM_PROMPT,
|
|
29
28
|
BTW_SYSTEM_PROMPT,
|
|
30
29
|
type SystemPromptBlock,
|
|
@@ -169,6 +168,12 @@ export interface CallAgentOptions {
|
|
|
169
168
|
| "required"
|
|
170
169
|
| { type: "function"; function: { name: string } }; // Force tool selection
|
|
171
170
|
|
|
171
|
+
// Force SSE streaming independent of callback presence. Long-running
|
|
172
|
+
// non-interactive calls (e.g. the compaction fork) need streaming so a
|
|
173
|
+
// slow reasoning model isn't killed by a gateway idle timeout before the
|
|
174
|
+
// first byte arrives.
|
|
175
|
+
stream?: boolean;
|
|
176
|
+
|
|
172
177
|
// NEW: Streaming callbacks
|
|
173
178
|
onContentUpdate?: (content: string) => void;
|
|
174
179
|
onToolUpdate?: (toolCall: {
|
|
@@ -319,11 +324,9 @@ export async function callAgent(
|
|
|
319
324
|
});
|
|
320
325
|
|
|
321
326
|
// Determine if streaming is needed
|
|
322
|
-
const isStreaming =
|
|
323
|
-
|
|
324
|
-
onToolUpdate ||
|
|
325
|
-
onReasoningUpdate
|
|
326
|
-
);
|
|
327
|
+
const isStreaming =
|
|
328
|
+
options.stream === true ||
|
|
329
|
+
!!(onContentUpdate || onToolUpdate || onReasoningUpdate);
|
|
327
330
|
|
|
328
331
|
// Prepare API call parameters
|
|
329
332
|
createParams = {
|
|
@@ -787,128 +790,6 @@ async function processStreamingResponse(
|
|
|
787
790
|
return result;
|
|
788
791
|
}
|
|
789
792
|
|
|
790
|
-
export interface CompactMessagesOptions {
|
|
791
|
-
// Resolved configuration
|
|
792
|
-
gatewayConfig: GatewayConfig;
|
|
793
|
-
modelConfig: ModelConfig;
|
|
794
|
-
|
|
795
|
-
// Existing parameters
|
|
796
|
-
messages: ChatCompletionMessageParam[];
|
|
797
|
-
abortSignal?: AbortSignal;
|
|
798
|
-
model?: string;
|
|
799
|
-
customInstructions?: string;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
export interface CompactMessagesResult {
|
|
803
|
-
content: string;
|
|
804
|
-
usage?: {
|
|
805
|
-
prompt_tokens: number;
|
|
806
|
-
completion_tokens: number;
|
|
807
|
-
total_tokens: number;
|
|
808
|
-
};
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
export async function compactMessages(
|
|
812
|
-
options: CompactMessagesOptions,
|
|
813
|
-
): Promise<CompactMessagesResult> {
|
|
814
|
-
const { gatewayConfig, modelConfig, messages, abortSignal } = options;
|
|
815
|
-
|
|
816
|
-
// Validate model config at call time
|
|
817
|
-
validateModelConfig(modelConfig);
|
|
818
|
-
|
|
819
|
-
// Apply global 1 QPS rate limit
|
|
820
|
-
if (
|
|
821
|
-
process.env.NODE_ENV !== "test" ||
|
|
822
|
-
modelConfig.model === "rate-limit-test"
|
|
823
|
-
) {
|
|
824
|
-
await acquireSlot(abortSignal);
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
// Strip images from messages before compact API call to reduce token usage
|
|
828
|
-
const cleanedMessages = messages.map((msg) => {
|
|
829
|
-
// Handle user/assistant messages with array content
|
|
830
|
-
if (Array.isArray(msg.content)) {
|
|
831
|
-
const textParts = msg.content.filter(
|
|
832
|
-
(part) => part.type === "text",
|
|
833
|
-
) as import("openai/resources.js").ChatCompletionContentPartText[];
|
|
834
|
-
const text = textParts.map((p) => p.text).join("\n");
|
|
835
|
-
return { ...msg, content: text || "(empty message)" };
|
|
836
|
-
}
|
|
837
|
-
return msg;
|
|
838
|
-
});
|
|
839
|
-
|
|
840
|
-
// Create OpenAI client with injected configuration
|
|
841
|
-
const openai = new OpenAIClient({
|
|
842
|
-
apiKey: gatewayConfig.apiKey,
|
|
843
|
-
baseURL: gatewayConfig.baseURL,
|
|
844
|
-
defaultHeaders: gatewayConfig.defaultHeaders,
|
|
845
|
-
fetchOptions: gatewayConfig.fetchOptions,
|
|
846
|
-
fetch: gatewayConfig.fetch,
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
// When a fast model override is provided, use the fast model's options
|
|
850
|
-
// (if configured); otherwise fall back to the agent model's options.
|
|
851
|
-
const activeExtraParams = options.model
|
|
852
|
-
? modelConfig.fastModelOptions || {}
|
|
853
|
-
: modelConfig.options || {};
|
|
854
|
-
|
|
855
|
-
const openaiModelConfig = getModelConfig(options.model || modelConfig.model, {
|
|
856
|
-
temperature: 0.1,
|
|
857
|
-
max_tokens: 8192,
|
|
858
|
-
...activeExtraParams,
|
|
859
|
-
});
|
|
860
|
-
|
|
861
|
-
try {
|
|
862
|
-
const response = await openai.chat.completions.create(
|
|
863
|
-
{
|
|
864
|
-
...openaiModelConfig,
|
|
865
|
-
messages: [
|
|
866
|
-
{
|
|
867
|
-
role: "system",
|
|
868
|
-
content: COMPACT_MESSAGES_SYSTEM_PROMPT,
|
|
869
|
-
},
|
|
870
|
-
...cleanedMessages,
|
|
871
|
-
{
|
|
872
|
-
role: "user",
|
|
873
|
-
content: options.customInstructions
|
|
874
|
-
? `Please create a detailed summary of the conversation so far. Pay special attention to these instructions: ${options.customInstructions}`
|
|
875
|
-
: `Please create a detailed summary of the conversation so far.`,
|
|
876
|
-
},
|
|
877
|
-
],
|
|
878
|
-
},
|
|
879
|
-
{
|
|
880
|
-
signal: abortSignal,
|
|
881
|
-
},
|
|
882
|
-
);
|
|
883
|
-
|
|
884
|
-
const content = response.choices[0]?.message?.content?.trim();
|
|
885
|
-
if (!content) {
|
|
886
|
-
throw new Error(
|
|
887
|
-
"Failed to compact conversation history: Empty response from AI",
|
|
888
|
-
);
|
|
889
|
-
}
|
|
890
|
-
const usage = response.usage
|
|
891
|
-
? {
|
|
892
|
-
prompt_tokens: response.usage.prompt_tokens,
|
|
893
|
-
completion_tokens: response.usage.completion_tokens,
|
|
894
|
-
total_tokens: response.usage.total_tokens,
|
|
895
|
-
}
|
|
896
|
-
: undefined;
|
|
897
|
-
|
|
898
|
-
return {
|
|
899
|
-
content,
|
|
900
|
-
usage,
|
|
901
|
-
};
|
|
902
|
-
} catch (error) {
|
|
903
|
-
if ((error as Error).name === "AbortError") {
|
|
904
|
-
logger.info("Compaction request was aborted");
|
|
905
|
-
throw new Error("Compaction request was aborted");
|
|
906
|
-
}
|
|
907
|
-
logger.error("Failed to compact messages:", error);
|
|
908
|
-
throw error;
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
|
|
912
793
|
export interface ProcessWebContentOptions {
|
|
913
794
|
// Resolved configuration
|
|
914
795
|
gatewayConfig: GatewayConfig;
|
|
@@ -320,6 +320,23 @@ export class ConfigurationService {
|
|
|
320
320
|
}
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
// Validate worktree if present
|
|
324
|
+
if (config.worktree !== undefined) {
|
|
325
|
+
if (typeof config.worktree !== "object" || config.worktree === null) {
|
|
326
|
+
result.isValid = false;
|
|
327
|
+
result.errors.push("worktree configuration must be an object");
|
|
328
|
+
} else if (
|
|
329
|
+
config.worktree.baseRef !== undefined &&
|
|
330
|
+
config.worktree.baseRef !== "fresh" &&
|
|
331
|
+
config.worktree.baseRef !== "head"
|
|
332
|
+
) {
|
|
333
|
+
result.isValid = false;
|
|
334
|
+
result.errors.push(
|
|
335
|
+
`Invalid worktree.baseRef: "${config.worktree.baseRef}". Must be "fresh" or "head".`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
323
340
|
return result;
|
|
324
341
|
}
|
|
325
342
|
|
|
@@ -640,6 +657,19 @@ export class ConfigurationService {
|
|
|
640
657
|
return true;
|
|
641
658
|
}
|
|
642
659
|
|
|
660
|
+
/**
|
|
661
|
+
* Resolves worktree base ref with fallbacks
|
|
662
|
+
* Resolution priority: settings.json > default ("fresh")
|
|
663
|
+
* @returns Resolved worktree base ref
|
|
664
|
+
*/
|
|
665
|
+
resolveWorktreeBaseRef(): "fresh" | "head" {
|
|
666
|
+
const baseRef = this.currentConfiguration?.worktree?.baseRef;
|
|
667
|
+
if (baseRef === "head") {
|
|
668
|
+
return "head";
|
|
669
|
+
}
|
|
670
|
+
return "fresh";
|
|
671
|
+
}
|
|
672
|
+
|
|
643
673
|
/**
|
|
644
674
|
* Resolves auto-memory extraction frequency with fallbacks
|
|
645
675
|
* Resolution priority: settings.json > WAVE_AUTO_MEMORY_FREQUENCY > default (1)
|
|
@@ -1172,6 +1202,7 @@ export function loadWaveConfigFromFile(
|
|
|
1172
1202
|
: undefined,
|
|
1173
1203
|
models: config.models || undefined,
|
|
1174
1204
|
marketplaces: config.marketplaces || undefined,
|
|
1205
|
+
worktree: config.worktree || undefined,
|
|
1175
1206
|
};
|
|
1176
1207
|
} catch (error) {
|
|
1177
1208
|
if (error instanceof SyntaxError) {
|
|
@@ -1320,6 +1351,11 @@ export function loadMergedWaveConfig(
|
|
|
1320
1351
|
Object.assign(mergedConfig.marketplaces, config.marketplaces);
|
|
1321
1352
|
}
|
|
1322
1353
|
|
|
1354
|
+
// Merge worktree (last one wins)
|
|
1355
|
+
if (config.worktree !== undefined) {
|
|
1356
|
+
mergedConfig.worktree = config.worktree;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1323
1359
|
// Merge models
|
|
1324
1360
|
if (config.models) {
|
|
1325
1361
|
if (!mergedConfig.models) mergedConfig.models = {};
|
|
@@ -1363,5 +1399,6 @@ export function loadMergedWaveConfig(
|
|
|
1363
1399
|
mergedConfig.models && Object.keys(mergedConfig.models).length > 0
|
|
1364
1400
|
? mergedConfig.models
|
|
1365
1401
|
: undefined,
|
|
1402
|
+
worktree: mergedConfig.worktree,
|
|
1366
1403
|
};
|
|
1367
1404
|
}
|
|
@@ -326,6 +326,7 @@ export function mergeRemoteSettings(
|
|
|
326
326
|
result.autoMemoryEnabled = remote.autoMemoryEnabled;
|
|
327
327
|
if (remote.autoMemoryFrequency !== undefined)
|
|
328
328
|
result.autoMemoryFrequency = remote.autoMemoryFrequency;
|
|
329
|
+
if (remote.worktree !== undefined) result.worktree = remote.worktree;
|
|
329
330
|
if (remote.models !== undefined) result.models = remote.models;
|
|
330
331
|
if (remote.marketplaces !== undefined)
|
|
331
332
|
result.marketplaces = remote.marketplaces;
|
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
|
}
|
|
@@ -71,6 +71,7 @@ export class TaskManager extends EventEmitter {
|
|
|
71
71
|
let lockHandle;
|
|
72
72
|
const maxRetries = 100;
|
|
73
73
|
const retryDelay = process.env.NODE_ENV === "test" ? 1 : 100;
|
|
74
|
+
const staleThreshold = 10000;
|
|
74
75
|
|
|
75
76
|
await this.ensureSessionDir();
|
|
76
77
|
|
|
@@ -79,16 +80,41 @@ export class TaskManager extends EventEmitter {
|
|
|
79
80
|
lockHandle = await fs.open(lockPath, "wx");
|
|
80
81
|
break;
|
|
81
82
|
} catch (error) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
84
|
+
|
|
85
|
+
// Only EEXIST (lock held) and EPERM (Windows pending-delete window)
|
|
86
|
+
// are transient lock-contention errors. EACCES/ENOENT are real failures
|
|
87
|
+
// and must not be retried as lock competition.
|
|
88
|
+
if (code !== "EEXIST" && code !== "EPERM") {
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (i === maxRetries - 1) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Could not acquire lock for task list ${this.taskListId} after ${maxRetries} retries`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Stale recovery: if the lock holder crashed without releasing, the
|
|
99
|
+
// lock file remains forever. Check mtime — if older than the threshold,
|
|
100
|
+
// remove it. Aligns with proper-lockfile's default 10s stale detection.
|
|
101
|
+
// The lock file is empty (no PID content), so mtime is the only signal.
|
|
102
|
+
if (
|
|
103
|
+
code === "EEXIST" &&
|
|
104
|
+
(await this.isLockStale(lockPath, staleThreshold))
|
|
105
|
+
) {
|
|
106
|
+
logger.warn(
|
|
107
|
+
`TaskManager: removing stale lock for task list ${this.taskListId}`,
|
|
108
|
+
);
|
|
109
|
+
try {
|
|
110
|
+
await fs.unlink(lockPath);
|
|
111
|
+
} catch {
|
|
112
|
+
// Another waiter may have already removed it — retry anyway
|
|
87
113
|
}
|
|
88
|
-
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
89
114
|
continue;
|
|
90
115
|
}
|
|
91
|
-
|
|
116
|
+
|
|
117
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
92
118
|
}
|
|
93
119
|
}
|
|
94
120
|
|
|
@@ -109,6 +135,19 @@ export class TaskManager extends EventEmitter {
|
|
|
109
135
|
}
|
|
110
136
|
}
|
|
111
137
|
|
|
138
|
+
private async isLockStale(
|
|
139
|
+
lockPath: string,
|
|
140
|
+
threshold: number,
|
|
141
|
+
): Promise<boolean> {
|
|
142
|
+
try {
|
|
143
|
+
const stats = await fs.stat(lockPath);
|
|
144
|
+
return Date.now() - stats.mtimeMs > threshold;
|
|
145
|
+
} catch {
|
|
146
|
+
// Lock was removed between our EEXIST and stat — not stale, just retry
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
112
151
|
private validateTask(task: Task): void {
|
|
113
152
|
if (!task.id || typeof task.id !== "string")
|
|
114
153
|
throw new Error("Invalid task ID");
|
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
|
}
|
|
@@ -102,7 +102,8 @@ export const enterWorktreeTool: ToolPlugin = {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
// Create the worktree (captures originalHeadCommit internally)
|
|
105
|
-
const
|
|
105
|
+
const baseRef = context.aiManager?.getWorktreeBaseRef?.();
|
|
106
|
+
const worktreeInfo = createWorktree(name, mainRepoRoot, { baseRef });
|
|
106
107
|
|
|
107
108
|
// Build session state
|
|
108
109
|
const session: WorktreeSession = {
|
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/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 {
|
|
@@ -54,6 +54,11 @@ export interface WaveConfiguration {
|
|
|
54
54
|
monitoring?: {
|
|
55
55
|
telemetry?: Partial<TelemetryConfig>;
|
|
56
56
|
};
|
|
57
|
+
/** Worktree configuration */
|
|
58
|
+
worktree?: {
|
|
59
|
+
/** Base ref for new worktrees: "fresh" (origin/<default-branch>, default) | "head" (local HEAD) */
|
|
60
|
+
baseRef?: "fresh" | "head";
|
|
61
|
+
};
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
/**
|
package/src/types/permissions.ts
CHANGED
|
@@ -35,8 +35,6 @@ export interface PermissionDecision {
|
|
|
35
35
|
newPermissionMode?: PermissionMode;
|
|
36
36
|
/** Signal to persist a new allowed rule */
|
|
37
37
|
newPermissionRule?: string;
|
|
38
|
-
/** Signal to clear the conversation context and proceed with the plan */
|
|
39
|
-
clearContext?: boolean;
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
/** Callback function for custom permission logic */
|
package/src/types/processes.ts
CHANGED
|
@@ -59,6 +59,35 @@ export type BackgroundTask =
|
|
|
59
59
|
| BackgroundSubagent
|
|
60
60
|
| BackgroundWorkflow;
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Serializable summary of a BackgroundTask, used for notifications where the
|
|
64
|
+
* full stdout/stderr and non-serializable process/onStop fields must be
|
|
65
|
+
* stripped to control payload size. Output is fetched on demand via
|
|
66
|
+
* getBackgroundTaskOutput.
|
|
67
|
+
*/
|
|
68
|
+
export interface BackgroundTaskSummary {
|
|
69
|
+
id: string;
|
|
70
|
+
type: BackgroundTaskType;
|
|
71
|
+
status: BackgroundTaskStatus;
|
|
72
|
+
startTime: number;
|
|
73
|
+
endTime?: number;
|
|
74
|
+
command?: string;
|
|
75
|
+
description?: string;
|
|
76
|
+
exitCode?: number;
|
|
77
|
+
runtime?: number;
|
|
78
|
+
outputPath?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Output snapshot returned by getBackgroundTaskOutput. */
|
|
82
|
+
export interface BackgroundTaskOutput {
|
|
83
|
+
stdout: string;
|
|
84
|
+
stderr: string;
|
|
85
|
+
status: BackgroundTaskStatus;
|
|
86
|
+
outputPath?: string;
|
|
87
|
+
type: BackgroundTaskType;
|
|
88
|
+
exitCode?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
62
91
|
export interface ForegroundTask {
|
|
63
92
|
id: string;
|
|
64
93
|
backgroundHandler: () => Promise<void>;
|