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
|
@@ -7,7 +7,7 @@ import { supportsPromptCaching } from "../utils/modelCapabilities.js";
|
|
|
7
7
|
import * as os from "os";
|
|
8
8
|
import * as fs from "fs";
|
|
9
9
|
import * as path from "path";
|
|
10
|
-
import {
|
|
10
|
+
import { WEB_CONTENT_SYSTEM_PROMPT, BTW_SYSTEM_PROMPT, } from "../prompts/index.js";
|
|
11
11
|
import { GOAL_EVALUATION_SYSTEM_PROMPT } from "../constants/goalPrompts.js";
|
|
12
12
|
// Global rate limiter state for 1 QPS
|
|
13
13
|
let nextAllowedTime = 0;
|
|
@@ -149,9 +149,8 @@ export async function callAgent(options) {
|
|
|
149
149
|
...(modelConfig.options || {}),
|
|
150
150
|
});
|
|
151
151
|
// Determine if streaming is needed
|
|
152
|
-
const isStreaming =
|
|
153
|
-
onToolUpdate ||
|
|
154
|
-
onReasoningUpdate);
|
|
152
|
+
const isStreaming = options.stream === true ||
|
|
153
|
+
!!(onContentUpdate || onToolUpdate || onReasoningUpdate);
|
|
155
154
|
// Prepare API call parameters
|
|
156
155
|
createParams = {
|
|
157
156
|
...openaiModelConfig,
|
|
@@ -488,87 +487,6 @@ async function processStreamingResponse(stream, onContentUpdate, onToolUpdate, o
|
|
|
488
487
|
}
|
|
489
488
|
return result;
|
|
490
489
|
}
|
|
491
|
-
export async function compactMessages(options) {
|
|
492
|
-
const { gatewayConfig, modelConfig, messages, abortSignal } = options;
|
|
493
|
-
// Validate model config at call time
|
|
494
|
-
validateModelConfig(modelConfig);
|
|
495
|
-
// Apply global 1 QPS rate limit
|
|
496
|
-
if (process.env.NODE_ENV !== "test" ||
|
|
497
|
-
modelConfig.model === "rate-limit-test") {
|
|
498
|
-
await acquireSlot(abortSignal);
|
|
499
|
-
}
|
|
500
|
-
// Strip images from messages before compact API call to reduce token usage
|
|
501
|
-
const cleanedMessages = messages.map((msg) => {
|
|
502
|
-
// Handle user/assistant messages with array content
|
|
503
|
-
if (Array.isArray(msg.content)) {
|
|
504
|
-
const textParts = msg.content.filter((part) => part.type === "text");
|
|
505
|
-
const text = textParts.map((p) => p.text).join("\n");
|
|
506
|
-
return { ...msg, content: text || "(empty message)" };
|
|
507
|
-
}
|
|
508
|
-
return msg;
|
|
509
|
-
});
|
|
510
|
-
// Create OpenAI client with injected configuration
|
|
511
|
-
const openai = new OpenAIClient({
|
|
512
|
-
apiKey: gatewayConfig.apiKey,
|
|
513
|
-
baseURL: gatewayConfig.baseURL,
|
|
514
|
-
defaultHeaders: gatewayConfig.defaultHeaders,
|
|
515
|
-
fetchOptions: gatewayConfig.fetchOptions,
|
|
516
|
-
fetch: gatewayConfig.fetch,
|
|
517
|
-
});
|
|
518
|
-
// When a fast model override is provided, use the fast model's options
|
|
519
|
-
// (if configured); otherwise fall back to the agent model's options.
|
|
520
|
-
const activeExtraParams = options.model
|
|
521
|
-
? modelConfig.fastModelOptions || {}
|
|
522
|
-
: modelConfig.options || {};
|
|
523
|
-
const openaiModelConfig = getModelConfig(options.model || modelConfig.model, {
|
|
524
|
-
temperature: 0.1,
|
|
525
|
-
max_tokens: 8192,
|
|
526
|
-
...activeExtraParams,
|
|
527
|
-
});
|
|
528
|
-
try {
|
|
529
|
-
const response = await openai.chat.completions.create({
|
|
530
|
-
...openaiModelConfig,
|
|
531
|
-
messages: [
|
|
532
|
-
{
|
|
533
|
-
role: "system",
|
|
534
|
-
content: COMPACT_MESSAGES_SYSTEM_PROMPT,
|
|
535
|
-
},
|
|
536
|
-
...cleanedMessages,
|
|
537
|
-
{
|
|
538
|
-
role: "user",
|
|
539
|
-
content: options.customInstructions
|
|
540
|
-
? `Please create a detailed summary of the conversation so far. Pay special attention to these instructions: ${options.customInstructions}`
|
|
541
|
-
: `Please create a detailed summary of the conversation so far.`,
|
|
542
|
-
},
|
|
543
|
-
],
|
|
544
|
-
}, {
|
|
545
|
-
signal: abortSignal,
|
|
546
|
-
});
|
|
547
|
-
const content = response.choices[0]?.message?.content?.trim();
|
|
548
|
-
if (!content) {
|
|
549
|
-
throw new Error("Failed to compact conversation history: Empty response from AI");
|
|
550
|
-
}
|
|
551
|
-
const usage = response.usage
|
|
552
|
-
? {
|
|
553
|
-
prompt_tokens: response.usage.prompt_tokens,
|
|
554
|
-
completion_tokens: response.usage.completion_tokens,
|
|
555
|
-
total_tokens: response.usage.total_tokens,
|
|
556
|
-
}
|
|
557
|
-
: undefined;
|
|
558
|
-
return {
|
|
559
|
-
content,
|
|
560
|
-
usage,
|
|
561
|
-
};
|
|
562
|
-
}
|
|
563
|
-
catch (error) {
|
|
564
|
-
if (error.name === "AbortError") {
|
|
565
|
-
logger.info("Compaction request was aborted");
|
|
566
|
-
throw new Error("Compaction request was aborted");
|
|
567
|
-
}
|
|
568
|
-
logger.error("Failed to compact messages:", error);
|
|
569
|
-
throw error;
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
490
|
export async function processWebContent(options) {
|
|
573
491
|
const { gatewayConfig, modelConfig, content, prompt, abortSignal } = options;
|
|
574
492
|
// Validate model config at call time
|
|
@@ -85,6 +85,12 @@ export declare class ConfigurationService {
|
|
|
85
85
|
* @returns Resolved auto-memory enabled state
|
|
86
86
|
*/
|
|
87
87
|
resolveAutoMemoryEnabled(): boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Resolves worktree base ref with fallbacks
|
|
90
|
+
* Resolution priority: settings.json > default ("fresh")
|
|
91
|
+
* @returns Resolved worktree base ref
|
|
92
|
+
*/
|
|
93
|
+
resolveWorktreeBaseRef(): "fresh" | "head";
|
|
88
94
|
/**
|
|
89
95
|
* Resolves auto-memory extraction frequency with fallbacks
|
|
90
96
|
* Resolution priority: settings.json > WAVE_AUTO_MEMORY_FREQUENCY > default (1)
|
|
@@ -241,6 +241,19 @@ export class ConfigurationService {
|
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
|
+
// Validate worktree if present
|
|
245
|
+
if (config.worktree !== undefined) {
|
|
246
|
+
if (typeof config.worktree !== "object" || config.worktree === null) {
|
|
247
|
+
result.isValid = false;
|
|
248
|
+
result.errors.push("worktree configuration must be an object");
|
|
249
|
+
}
|
|
250
|
+
else if (config.worktree.baseRef !== undefined &&
|
|
251
|
+
config.worktree.baseRef !== "fresh" &&
|
|
252
|
+
config.worktree.baseRef !== "head") {
|
|
253
|
+
result.isValid = false;
|
|
254
|
+
result.errors.push(`Invalid worktree.baseRef: "${config.worktree.baseRef}". Must be "fresh" or "head".`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
244
257
|
return result;
|
|
245
258
|
}
|
|
246
259
|
/**
|
|
@@ -506,6 +519,18 @@ export class ConfigurationService {
|
|
|
506
519
|
// 3. Default (true)
|
|
507
520
|
return true;
|
|
508
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* Resolves worktree base ref with fallbacks
|
|
524
|
+
* Resolution priority: settings.json > default ("fresh")
|
|
525
|
+
* @returns Resolved worktree base ref
|
|
526
|
+
*/
|
|
527
|
+
resolveWorktreeBaseRef() {
|
|
528
|
+
const baseRef = this.currentConfiguration?.worktree?.baseRef;
|
|
529
|
+
if (baseRef === "head") {
|
|
530
|
+
return "head";
|
|
531
|
+
}
|
|
532
|
+
return "fresh";
|
|
533
|
+
}
|
|
509
534
|
/**
|
|
510
535
|
* Resolves auto-memory extraction frequency with fallbacks
|
|
511
536
|
* Resolution priority: settings.json > WAVE_AUTO_MEMORY_FREQUENCY > default (1)
|
|
@@ -944,6 +969,7 @@ export function loadWaveConfigFromFile(filePath) {
|
|
|
944
969
|
: undefined,
|
|
945
970
|
models: config.models || undefined,
|
|
946
971
|
marketplaces: config.marketplaces || undefined,
|
|
972
|
+
worktree: config.worktree || undefined,
|
|
947
973
|
};
|
|
948
974
|
}
|
|
949
975
|
catch (error) {
|
|
@@ -1074,6 +1100,10 @@ export function loadMergedWaveConfig(workdir) {
|
|
|
1074
1100
|
mergedConfig.marketplaces = {};
|
|
1075
1101
|
Object.assign(mergedConfig.marketplaces, config.marketplaces);
|
|
1076
1102
|
}
|
|
1103
|
+
// Merge worktree (last one wins)
|
|
1104
|
+
if (config.worktree !== undefined) {
|
|
1105
|
+
mergedConfig.worktree = config.worktree;
|
|
1106
|
+
}
|
|
1077
1107
|
// Merge models
|
|
1078
1108
|
if (config.models) {
|
|
1079
1109
|
if (!mergedConfig.models)
|
|
@@ -1111,5 +1141,6 @@ export function loadMergedWaveConfig(workdir) {
|
|
|
1111
1141
|
models: mergedConfig.models && Object.keys(mergedConfig.models).length > 0
|
|
1112
1142
|
? mergedConfig.models
|
|
1113
1143
|
: undefined,
|
|
1144
|
+
worktree: mergedConfig.worktree,
|
|
1114
1145
|
};
|
|
1115
1146
|
}
|
|
@@ -286,6 +286,8 @@ export function mergeRemoteSettings(localMerged, remote) {
|
|
|
286
286
|
result.autoMemoryEnabled = remote.autoMemoryEnabled;
|
|
287
287
|
if (remote.autoMemoryFrequency !== undefined)
|
|
288
288
|
result.autoMemoryFrequency = remote.autoMemoryFrequency;
|
|
289
|
+
if (remote.worktree !== undefined)
|
|
290
|
+
result.worktree = remote.worktree;
|
|
289
291
|
if (remote.models !== undefined)
|
|
290
292
|
result.models = remote.models;
|
|
291
293
|
if (remote.marketplaces !== undefined)
|
|
@@ -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
|
-
*
|
|
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
|
package/dist/services/session.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
656
|
-
|
|
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
|
-
|
|
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>;
|
|
@@ -54,6 +54,7 @@ export class TaskManager extends EventEmitter {
|
|
|
54
54
|
let lockHandle;
|
|
55
55
|
const maxRetries = 100;
|
|
56
56
|
const retryDelay = process.env.NODE_ENV === "test" ? 1 : 100;
|
|
57
|
+
const staleThreshold = 10000;
|
|
57
58
|
await this.ensureSessionDir();
|
|
58
59
|
for (let i = 0; i < maxRetries; i++) {
|
|
59
60
|
try {
|
|
@@ -61,14 +62,32 @@ export class TaskManager extends EventEmitter {
|
|
|
61
62
|
break;
|
|
62
63
|
}
|
|
63
64
|
catch (error) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
const code = error.code;
|
|
66
|
+
// Only EEXIST (lock held) and EPERM (Windows pending-delete window)
|
|
67
|
+
// are transient lock-contention errors. EACCES/ENOENT are real failures
|
|
68
|
+
// and must not be retried as lock competition.
|
|
69
|
+
if (code !== "EEXIST" && code !== "EPERM") {
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
if (i === maxRetries - 1) {
|
|
73
|
+
throw new Error(`Could not acquire lock for task list ${this.taskListId} after ${maxRetries} retries`);
|
|
74
|
+
}
|
|
75
|
+
// Stale recovery: if the lock holder crashed without releasing, the
|
|
76
|
+
// lock file remains forever. Check mtime — if older than the threshold,
|
|
77
|
+
// remove it. Aligns with proper-lockfile's default 10s stale detection.
|
|
78
|
+
// The lock file is empty (no PID content), so mtime is the only signal.
|
|
79
|
+
if (code === "EEXIST" &&
|
|
80
|
+
(await this.isLockStale(lockPath, staleThreshold))) {
|
|
81
|
+
logger.warn(`TaskManager: removing stale lock for task list ${this.taskListId}`);
|
|
82
|
+
try {
|
|
83
|
+
await fs.unlink(lockPath);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Another waiter may have already removed it — retry anyway
|
|
67
87
|
}
|
|
68
|
-
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
69
88
|
continue;
|
|
70
89
|
}
|
|
71
|
-
|
|
90
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
72
91
|
}
|
|
73
92
|
}
|
|
74
93
|
try {
|
|
@@ -86,6 +105,16 @@ export class TaskManager extends EventEmitter {
|
|
|
86
105
|
}
|
|
87
106
|
}
|
|
88
107
|
}
|
|
108
|
+
async isLockStale(lockPath, threshold) {
|
|
109
|
+
try {
|
|
110
|
+
const stats = await fs.stat(lockPath);
|
|
111
|
+
return Date.now() - stats.mtimeMs > threshold;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Lock was removed between our EEXIST and stat — not stale, just retry
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
89
118
|
validateTask(task) {
|
|
90
119
|
if (!task.id || typeof task.id !== "string")
|
|
91
120
|
throw new Error("Invalid task ID");
|
package/dist/tools/editTool.js
CHANGED
|
@@ -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 (
|
|
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
|
|
123
|
-
|
|
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()
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
}
|
|
@@ -83,7 +83,8 @@ export const enterWorktreeTool = {
|
|
|
83
83
|
};
|
|
84
84
|
}
|
|
85
85
|
// Create the worktree (captures originalHeadCommit internally)
|
|
86
|
-
const
|
|
86
|
+
const baseRef = context.aiManager?.getWorktreeBaseRef?.();
|
|
87
|
+
const worktreeInfo = createWorktree(name, mainRepoRoot, { baseRef });
|
|
87
88
|
// Build session state
|
|
88
89
|
const session = {
|
|
89
90
|
originalCwd: context.workdir,
|
package/dist/tools/grepTool.js
CHANGED
|
@@ -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
|
-
|
|
203
|
-
|
|
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 {
|
package/dist/tools/writeTool.js
CHANGED
|
@@ -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 {
|
|
@@ -50,6 +50,11 @@ export interface WaveConfiguration {
|
|
|
50
50
|
monitoring?: {
|
|
51
51
|
telemetry?: Partial<TelemetryConfig>;
|
|
52
52
|
};
|
|
53
|
+
/** Worktree configuration */
|
|
54
|
+
worktree?: {
|
|
55
|
+
/** Base ref for new worktrees: "fresh" (origin/<default-branch>, default) | "head" (local HEAD) */
|
|
56
|
+
baseRef?: "fresh" | "head";
|
|
57
|
+
};
|
|
53
58
|
}
|
|
54
59
|
/**
|
|
55
60
|
* Legacy alias for backward compatibility - will be deprecated
|
|
@@ -15,8 +15,6 @@ export interface PermissionDecision {
|
|
|
15
15
|
newPermissionMode?: PermissionMode;
|
|
16
16
|
/** Signal to persist a new allowed rule */
|
|
17
17
|
newPermissionRule?: string;
|
|
18
|
-
/** Signal to clear the conversation context and proceed with the plan */
|
|
19
|
-
clearContext?: boolean;
|
|
20
18
|
}
|
|
21
19
|
/** Callback function for custom permission logic */
|
|
22
20
|
export type PermissionCallback = (context: ToolPermissionContext) => Promise<PermissionDecision>;
|
|
@@ -44,6 +44,33 @@ export interface BackgroundWorkflow extends BackgroundTaskBase {
|
|
|
44
44
|
runId: string;
|
|
45
45
|
}
|
|
46
46
|
export type BackgroundTask = BackgroundShell | BackgroundSubagent | BackgroundWorkflow;
|
|
47
|
+
/**
|
|
48
|
+
* Serializable summary of a BackgroundTask, used for notifications where the
|
|
49
|
+
* full stdout/stderr and non-serializable process/onStop fields must be
|
|
50
|
+
* stripped to control payload size. Output is fetched on demand via
|
|
51
|
+
* getBackgroundTaskOutput.
|
|
52
|
+
*/
|
|
53
|
+
export interface BackgroundTaskSummary {
|
|
54
|
+
id: string;
|
|
55
|
+
type: BackgroundTaskType;
|
|
56
|
+
status: BackgroundTaskStatus;
|
|
57
|
+
startTime: number;
|
|
58
|
+
endTime?: number;
|
|
59
|
+
command?: string;
|
|
60
|
+
description?: string;
|
|
61
|
+
exitCode?: number;
|
|
62
|
+
runtime?: number;
|
|
63
|
+
outputPath?: string;
|
|
64
|
+
}
|
|
65
|
+
/** Output snapshot returned by getBackgroundTaskOutput. */
|
|
66
|
+
export interface BackgroundTaskOutput {
|
|
67
|
+
stdout: string;
|
|
68
|
+
stderr: string;
|
|
69
|
+
status: BackgroundTaskStatus;
|
|
70
|
+
outputPath?: string;
|
|
71
|
+
type: BackgroundTaskType;
|
|
72
|
+
exitCode?: number;
|
|
73
|
+
}
|
|
47
74
|
export interface ForegroundTask {
|
|
48
75
|
id: string;
|
|
49
76
|
backgroundHandler: () => Promise<void>;
|
package/dist/types/workflow.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export type { WorkflowRun, WorkflowMeta, WorkflowPhaseState, } from "../workflow/types.js";
|
|
1
|
+
export type { WorkflowRun, WorkflowMeta, WorkflowPhaseState, SerializableWorkflowRun, } from "../workflow/types.js";
|
|
@@ -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.
|
package/dist/utils/bashParser.js
CHANGED
|
@@ -430,6 +430,76 @@ export const DANGEROUS_COMMANDS = [
|
|
|
430
430
|
"nc",
|
|
431
431
|
"netcat",
|
|
432
432
|
];
|
|
433
|
+
/**
|
|
434
|
+
* Read-only command set: commands that only read/transform data and write to stdout.
|
|
435
|
+
* When a command in this set is used without write redirections, command substitution,
|
|
436
|
+
* or dangerous flags (e.g. sed -i), it is auto-allowed without a confirmation dialog.
|
|
437
|
+
* Aligned with Claude Code's SEMANTIC_READ_ONLY_COMMANDS, excluding interactive pagers
|
|
438
|
+
* (less, more, man, info), command executors (xargs), and infinite-output generators (yes).
|
|
439
|
+
* FR-019.2 through FR-019.7 in tool-permission-system.md.
|
|
440
|
+
*/
|
|
441
|
+
export const READ_ONLY_COMMANDS = [
|
|
442
|
+
"ls",
|
|
443
|
+
"cat",
|
|
444
|
+
"head",
|
|
445
|
+
"tail",
|
|
446
|
+
"wc",
|
|
447
|
+
"sort",
|
|
448
|
+
"uniq",
|
|
449
|
+
"grep",
|
|
450
|
+
"egrep",
|
|
451
|
+
"fgrep",
|
|
452
|
+
"rg",
|
|
453
|
+
"find",
|
|
454
|
+
"which",
|
|
455
|
+
"whereis",
|
|
456
|
+
"file",
|
|
457
|
+
"stat",
|
|
458
|
+
"du",
|
|
459
|
+
"df",
|
|
460
|
+
"free",
|
|
461
|
+
"uptime",
|
|
462
|
+
"uname",
|
|
463
|
+
"hostname",
|
|
464
|
+
"whoami",
|
|
465
|
+
"id",
|
|
466
|
+
"groups",
|
|
467
|
+
"env",
|
|
468
|
+
"printenv",
|
|
469
|
+
"echo",
|
|
470
|
+
"printf",
|
|
471
|
+
"date",
|
|
472
|
+
"true",
|
|
473
|
+
"false",
|
|
474
|
+
"pwd",
|
|
475
|
+
"tree",
|
|
476
|
+
"diff",
|
|
477
|
+
"cmp",
|
|
478
|
+
"md5sum",
|
|
479
|
+
"sha256sum",
|
|
480
|
+
"sha1sum",
|
|
481
|
+
"xxd",
|
|
482
|
+
"od",
|
|
483
|
+
"hexdump",
|
|
484
|
+
"strings",
|
|
485
|
+
"readlink",
|
|
486
|
+
"realpath",
|
|
487
|
+
"basename",
|
|
488
|
+
"dirname",
|
|
489
|
+
"seq",
|
|
490
|
+
"column",
|
|
491
|
+
"jq",
|
|
492
|
+
"yq",
|
|
493
|
+
"cut",
|
|
494
|
+
"paste",
|
|
495
|
+
"tr",
|
|
496
|
+
"awk",
|
|
497
|
+
"sed",
|
|
498
|
+
"test",
|
|
499
|
+
"expr",
|
|
500
|
+
"bc",
|
|
501
|
+
"sleep",
|
|
502
|
+
];
|
|
433
503
|
export const TOOL_RULES = {
|
|
434
504
|
// Node/JS
|
|
435
505
|
npm: { depth: 2, scopeFlags: ["--prefix", "-C", "--registry"] },
|
|
@@ -567,6 +637,39 @@ export function isDangerousFind(command) {
|
|
|
567
637
|
return dangerousFlags.includes(unquoted);
|
|
568
638
|
});
|
|
569
639
|
}
|
|
640
|
+
/**
|
|
641
|
+
* Detects command substitution $(...) or backticks `...` in a command string.
|
|
642
|
+
* Commands with substitution are never auto-allowed because the substituted
|
|
643
|
+
* command may be dangerous (e.g. cat $(rm x)). FR-019.6.
|
|
644
|
+
*/
|
|
645
|
+
export function hasCommandSubstitution(command) {
|
|
646
|
+
// Remove quoted strings first so $() or backticks inside quotes don't trigger
|
|
647
|
+
const stripped = command
|
|
648
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
|
649
|
+
.replace(/'(?:[^'\\]|\\.)*'/g, "''");
|
|
650
|
+
return /\$\([^)]*\)/.test(stripped) || /`[^`]*`/.test(stripped);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Detects process substitution <(...) or >(...) in a command string.
|
|
654
|
+
* These can execute side effects and are never auto-allowed. FR-019.6.
|
|
655
|
+
*/
|
|
656
|
+
export function hasProcessSubstitution(command) {
|
|
657
|
+
const stripped = command
|
|
658
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
|
659
|
+
.replace(/'(?:[^'\\]|\\.)*'/g, "''");
|
|
660
|
+
return /[<>]\([^)]*\)/.test(stripped);
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Detects sed in-place edit flag (-i, with optional backup suffix like -i.bak).
|
|
664
|
+
* sed -i modifies files in place and must NOT be auto-allowed. FR-019.5.
|
|
665
|
+
*/
|
|
666
|
+
export function hasSedInPlace(command) {
|
|
667
|
+
const stripped = stripRedirections(stripEnvVars(command));
|
|
668
|
+
const tokens = stripped.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
|
|
669
|
+
if (tokens.length === 0 || tokens[0] !== "sed")
|
|
670
|
+
return false;
|
|
671
|
+
return tokens.some((token) => /^-i(\..*)?$/.test(token));
|
|
672
|
+
}
|
|
570
673
|
/**
|
|
571
674
|
* Extracts a "smart prefix" from a bash command based on common developer tools.
|
|
572
675
|
* Returns null if the command is blacklisted or cannot be safely prefix-matched.
|