wave-agent-sdk 0.19.8 → 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.
Files changed (45) hide show
  1. package/builtin/plugins/sdd/.wave-plugin/plugin.json +8 -0
  2. package/builtin/plugins/sdd/hooks/hooks.json +14 -0
  3. package/builtin/plugins/sdd/scripts/session-start.js +24 -0
  4. package/builtin/plugins/sdd/scripts/spec-count.js +77 -0
  5. package/builtin/plugins/sdd/skills/specify/SKILL.md +48 -0
  6. package/builtin/plugins/sdd/skills/specify/templates/spec-template.md +47 -0
  7. package/dist/agent.d.ts +1 -0
  8. package/dist/agent.js +21 -10
  9. package/dist/managers/aiManager.d.ts +17 -0
  10. package/dist/managers/aiManager.js +152 -46
  11. package/dist/managers/permissionManager.d.ts +7 -0
  12. package/dist/managers/permissionManager.js +102 -142
  13. package/dist/managers/pluginManager.d.ts +7 -0
  14. package/dist/managers/pluginManager.js +31 -0
  15. package/dist/prompts/index.d.ts +12 -1
  16. package/dist/prompts/index.js +133 -45
  17. package/dist/services/aiService.d.ts +1 -17
  18. package/dist/services/aiService.js +3 -85
  19. package/dist/services/session.d.ts +3 -1
  20. package/dist/services/session.js +12 -4
  21. package/dist/services/taskManager.d.ts +1 -0
  22. package/dist/services/taskManager.js +34 -5
  23. package/dist/tools/editTool.js +24 -10
  24. package/dist/tools/grepTool.js +8 -2
  25. package/dist/tools/writeTool.js +36 -0
  26. package/dist/utils/bashParser.d.ts +25 -0
  27. package/dist/utils/bashParser.js +103 -0
  28. package/dist/utils/configPaths.d.ts +4 -0
  29. package/dist/utils/configPaths.js +6 -0
  30. package/dist/utils/fileSearch.js +4 -2
  31. package/package.json +1 -1
  32. package/src/agent.ts +19 -10
  33. package/src/managers/aiManager.ts +215 -61
  34. package/src/managers/permissionManager.ts +116 -168
  35. package/src/managers/pluginManager.ts +29 -0
  36. package/src/prompts/index.ts +144 -37
  37. package/src/services/aiService.ts +9 -128
  38. package/src/services/session.ts +18 -4
  39. package/src/services/taskManager.ts +46 -7
  40. package/src/tools/editTool.ts +29 -11
  41. package/src/tools/grepTool.ts +11 -2
  42. package/src/tools/writeTool.ts +43 -0
  43. package/src/utils/bashParser.ts +106 -0
  44. package/src/utils/configPaths.ts +7 -0
  45. package/src/utils/fileSearch.ts +6 -2
@@ -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 { COMPACT_MESSAGES_SYSTEM_PROMPT, WEB_CONTENT_SYSTEM_PROMPT, BTW_SYSTEM_PROMPT, } from "../prompts/index.js";
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 = !!(onContentUpdate ||
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
@@ -184,7 +184,9 @@ export declare function handleSessionRestoration(restoreSessionId?: string, cont
184
184
  /**
185
185
  * Load the full message thread for a session.
186
186
  * With append-only compaction, all messages are in a single file.
187
- * Returns the active messages (post-compact boundary).
187
+ * Unlike loadSessionFromJsonl, this returns every message in the file,
188
+ * including those before the last compact boundary — rewind needs the
189
+ * complete history to allow rewinding past compaction points.
188
190
  * @param currentSessionId - The ID of the current session
189
191
  * @param workdir - Working directory for the session
190
192
  * @returns Promise that resolves to an array of all messages in the thread
@@ -646,14 +646,22 @@ export async function handleSessionRestoration(restoreSessionId, continueLastSes
646
646
  /**
647
647
  * Load the full message thread for a session.
648
648
  * With append-only compaction, all messages are in a single file.
649
- * Returns the active messages (post-compact boundary).
649
+ * Unlike loadSessionFromJsonl, this returns every message in the file,
650
+ * including those before the last compact boundary — rewind needs the
651
+ * complete history to allow rewinding past compaction points.
650
652
  * @param currentSessionId - The ID of the current session
651
653
  * @param workdir - Working directory for the session
652
654
  * @returns Promise that resolves to an array of all messages in the thread
653
655
  */
654
656
  export async function loadFullMessageThread(currentSessionId, workdir) {
655
- const sessionData = await loadSessionFromJsonl(currentSessionId, workdir);
656
- if (!sessionData)
657
+ const jsonlHandler = new JsonlHandler();
658
+ const filePath = await generateSessionFilePath(currentSessionId, workdir, "main");
659
+ try {
660
+ await fs.access(filePath);
661
+ }
662
+ catch {
657
663
  return { messages: [], sessionIds: [] };
658
- return { messages: sessionData.messages, sessionIds: [currentSessionId] };
664
+ }
665
+ const messages = await jsonlHandler.read(filePath);
666
+ return { messages, sessionIds: [currentSessionId] };
659
667
  }
@@ -18,6 +18,7 @@ export declare class TaskManager extends EventEmitter {
18
18
  private getLockPath;
19
19
  ensureSessionDir(): Promise<void>;
20
20
  private withLock;
21
+ private isLockStale;
21
22
  private validateTask;
22
23
  createTask(task: Omit<Task, "id">): Promise<string>;
23
24
  getTask(taskId: string): Promise<Task | null>;
@@ -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
- if (error.code === "EEXIST") {
65
- if (i === maxRetries - 1) {
66
- throw new Error(`Could not acquire lock for task list ${this.taskListId} after ${maxRetries} retries`);
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
- throw error;
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");
@@ -97,9 +97,12 @@ Usage:
97
97
  context.messageManager?.triggerFileRead(filePath);
98
98
  // Enforce read-before-edit: the file must have been read or written first.
99
99
  // readFileState is populated by Read, Write, and Edit tools — single source
100
- // of truth, aligned with Claude Code's readFileState approach.
100
+ // of truth, aligned with Claude Code's readFileState approach. Skipped in
101
+ // plan mode: permissionManager enforces a plan-file-only gate whose denial
102
+ // message must surface instead of being masked by a read-state rejection.
101
103
  const resolvedPath = resolvePath(filePath, context.workdir);
102
- if (!context.readFileState?.has(resolvedPath)) {
104
+ if (context.permissionMode !== "plan" &&
105
+ !context.readFileState?.has(resolvedPath)) {
103
106
  return {
104
107
  success: false,
105
108
  content: "",
@@ -119,17 +122,28 @@ Usage:
119
122
  error: `Failed to read file: ${readError instanceof Error ? readError.message : String(readError)}`,
120
123
  };
121
124
  }
122
- // Staleness check: file must not have been modified since last Read
123
- if (context.readFileState) {
125
+ // Staleness check (aligned with Claude Code): only flag when the file got
126
+ // newer since last read. For full reads, a content-hash fallback avoids
127
+ // false positives when mtime changed but content didn't (git checkout,
128
+ // editor round-trip save, cloud sync, antivirus). Partial reads get no
129
+ // fallback since only a slice was cached. Skipped in plan mode (see
130
+ // read-before-edit note above) so the plan-file-only denial wins.
131
+ if (context.permissionMode !== "plan" && context.readFileState) {
124
132
  const state = context.readFileState.get(resolvedPath);
125
133
  if (state) {
126
134
  const currentStats = await stat(resolvedPath);
127
- if (currentStats.mtime.getTime() !== state.mtime) {
128
- return {
129
- success: false,
130
- content: "",
131
- error: "File has been unexpectedly modified since last read. Read it again before editing it.",
132
- };
135
+ if (currentStats.mtime.getTime() > state.mtime) {
136
+ const isFullRead = state.offset === undefined && state.limit === undefined;
137
+ const contentUnchanged = isFullRead &&
138
+ createHash("sha256").update(originalContent).digest("hex") ===
139
+ state.hash;
140
+ if (!contentUnchanged) {
141
+ return {
142
+ success: false,
143
+ content: "",
144
+ error: "File has been unexpectedly modified since last read. Read it again before editing it.",
145
+ };
146
+ }
133
147
  }
134
148
  }
135
149
  }
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "child_process";
2
2
  import { rgPath } from "../utils/ripgrep.js";
3
3
  import { getDisplayPath } from "../utils/path.js";
4
+ import { logger } from "../utils/globalLogger.js";
4
5
  import { GREP_TOOL_NAME, BASH_TOOL_NAME, AGENT_TOOL_NAME, } from "../constants/tools.js";
5
6
  // Version control system directories to exclude from searches.
6
7
  // These are excluded automatically because they create noise in search results.
@@ -199,14 +200,19 @@ export const grepTool = {
199
200
  rgArgs.push(".");
200
201
  }
201
202
  const result = await executeCommand(rgPath, rgArgs, workdir);
202
- if (result.error && result.exitCode !== 1) {
203
- // rg returns 1 for no matches, not an error
203
+ // Only a process-level spawn failure (exitCode null) is a hard error.
204
+ // rg exit 2 means some files were unreadable (e.g. device-name files
205
+ // like "nul" on Windows); stdout still holds usable partial results.
206
+ if (result.exitCode === null) {
204
207
  return {
205
208
  success: false,
206
209
  content: "",
207
210
  error: `ripgrep failed: ${result.stderr}`,
208
211
  };
209
212
  }
213
+ if (result.exitCode !== 0 && result.exitCode !== 1) {
214
+ logger.debug(`ripgrep exited with code ${result.exitCode}, keeping partial results: ${result.stderr.trim()}`);
215
+ }
210
216
  const output = result.stdout.trim();
211
217
  if (!output) {
212
218
  return {
@@ -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 {
@@ -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.
@@ -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.
@@ -18,6 +18,10 @@ export declare function getBuiltinSkillsDir(): string;
18
18
  * Get the builtin subagents directory path
19
19
  */
20
20
  export declare function getBuiltinSubagentsDir(): string;
21
+ /**
22
+ * Get the builtin plugins directory path
23
+ */
24
+ export declare function getBuiltinPluginsDir(): string;
21
25
  /**
22
26
  * Get the user-specific configuration file path (legacy function)
23
27
  * @deprecated Use getUserConfigPaths() for better priority support
@@ -46,6 +46,12 @@ export function getBuiltinSkillsDir() {
46
46
  export function getBuiltinSubagentsDir() {
47
47
  return join(getPackageRoot(), "builtin", "subagents");
48
48
  }
49
+ /**
50
+ * Get the builtin plugins directory path
51
+ */
52
+ export function getBuiltinPluginsDir() {
53
+ return join(getPackageRoot(), "builtin", "plugins");
54
+ }
49
55
  /**
50
56
  * Get the user-specific configuration file path (legacy function)
51
57
  * @deprecated Use getUserConfigPaths() for better priority support
@@ -25,9 +25,11 @@ async function getAllFiles(workingDirectory) {
25
25
  stderr += data.toString();
26
26
  });
27
27
  child.on("close", (code) => {
28
+ // Exit 2 = some files were unreadable (e.g. device-name files like
29
+ // "nul" on Windows); stdout still holds usable partial results.
30
+ // Spawn failures surface via the 'error' listener instead.
28
31
  if (code !== 0 && code !== 1) {
29
- reject(new Error(`ripgrep failed: ${stderr}`));
30
- return;
32
+ logger.warn(`ripgrep exited with code ${code}, keeping partial results: ${stderr.trim()}`);
31
33
  }
32
34
  const files = stdout
33
35
  .trim()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-agent-sdk",
3
- "version": "0.19.8",
3
+ "version": "0.19.9",
4
4
  "description": "SDK for building AI-powered development tools and agents",
5
5
  "keywords": [
6
6
  "ai",
package/src/agent.ts CHANGED
@@ -78,6 +78,7 @@ export class Agent {
78
78
  private reversionManager: ReversionManager;
79
79
  private messageQueue: MessageQueue; // Unified queue for messages, bang commands, and notifications
80
80
  private dispatchPromise: Promise<void> | null = null; // Track current dispatch for teardown
81
+ private isAborting = false; // Guard: prevents tryDispatch from firing during abortMessage
81
82
  private memoryRuleManager: MemoryRuleManager; // Add memory rule manager instance
82
83
  private liveConfigManager: LiveConfigManager; // Add live configuration manager
83
84
  private taskManager: TaskManager;
@@ -412,6 +413,7 @@ export class Agent {
412
413
  * onLoadingChange(false), and onCommandRunningChange(false).
413
414
  */
414
415
  private tryDispatch(): void {
416
+ if (this.isAborting) return; // Suppress dispatch during abort to prevent queued notifications from being dispatched as a side-effect
415
417
  if (this.messageQueue.state !== "idle") return;
416
418
  if (!this.messageQueue.hasPending()) return;
417
419
  if (this.aiManager.isLoading || this.isCommandRunning) return;
@@ -847,17 +849,24 @@ export class Agent {
847
849
 
848
850
  /** Unified interrupt method, interrupts both AI messages and command execution */
849
851
  public abortMessage(): void {
850
- if (this.aiManager.isLoading || this.isCommandRunning) {
851
- // Clear user-facing queue items first to prevent processQueuedMessage
852
- // from dequeuing when abortAIMessage triggers onLoadingChange(false).
853
- // Notifications are preserved so background task results aren't lost.
854
- this.messageQueue.clear();
855
- this.options.callbacks?.onQueuedMessagesChange?.(this.queuedMessages);
852
+ // Guard: prevent tryDispatch (triggered by abortAIMessage → setIsLoading(false))
853
+ // from dispatching preserved notifications as a new AI turn during the abort.
854
+ this.isAborting = true;
855
+ try {
856
+ if (this.aiManager.isLoading || this.isCommandRunning) {
857
+ // Clear user-facing queue items first to prevent processQueuedMessage
858
+ // from dequeuing when abortAIMessage triggers onLoadingChange(false).
859
+ // Notifications are preserved so background task results aren't lost.
860
+ this.messageQueue.clear();
861
+ this.options.callbacks?.onQueuedMessagesChange?.(this.queuedMessages);
862
+ }
863
+ this.messageQueue.transitionTo("idle"); // Reset state on abort
864
+ this.abortAIMessage(); // This will abort tools including Agent tool (subagents)
865
+ this.abortBashCommand();
866
+ this.abortSlashCommand();
867
+ } finally {
868
+ this.isAborting = false;
856
869
  }
857
- this.messageQueue.transitionTo("idle"); // Reset state on abort
858
- this.abortAIMessage(); // This will abort tools including Agent tool (subagents)
859
- this.abortBashCommand();
860
- this.abortSlashCommand();
861
870
  }
862
871
 
863
872
  /** Interrupt bash command execution */