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
@@ -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
- onContentUpdate ||
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;
@@ -825,7 +825,9 @@ export async function handleSessionRestoration(
825
825
  /**
826
826
  * Load the full message thread for a session.
827
827
  * With append-only compaction, all messages are in a single file.
828
- * Returns the active messages (post-compact boundary).
828
+ * Unlike loadSessionFromJsonl, this returns every message in the file,
829
+ * including those before the last compact boundary — rewind needs the
830
+ * complete history to allow rewinding past compaction points.
829
831
  * @param currentSessionId - The ID of the current session
830
832
  * @param workdir - Working directory for the session
831
833
  * @returns Promise that resolves to an array of all messages in the thread
@@ -834,7 +836,19 @@ export async function loadFullMessageThread(
834
836
  currentSessionId: string,
835
837
  workdir: string,
836
838
  ): Promise<{ messages: Message[]; sessionIds: string[] }> {
837
- const sessionData = await loadSessionFromJsonl(currentSessionId, workdir);
838
- if (!sessionData) return { messages: [], sessionIds: [] };
839
- return { messages: sessionData.messages, sessionIds: [currentSessionId] };
839
+ const jsonlHandler = new JsonlHandler();
840
+ const filePath = await generateSessionFilePath(
841
+ currentSessionId,
842
+ workdir,
843
+ "main",
844
+ );
845
+
846
+ try {
847
+ await fs.access(filePath);
848
+ } catch {
849
+ return { messages: [], sessionIds: [] };
850
+ }
851
+
852
+ const messages = await jsonlHandler.read(filePath);
853
+ return { messages, sessionIds: [currentSessionId] };
840
854
  }
@@ -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
- if ((error as NodeJS.ErrnoException).code === "EEXIST") {
83
- if (i === maxRetries - 1) {
84
- throw new Error(
85
- `Could not acquire lock for task list ${this.taskListId} after ${maxRetries} retries`,
86
- );
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
- throw error;
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");
@@ -114,9 +114,14 @@ Usage:
114
114
 
115
115
  // Enforce read-before-edit: the file must have been read or written first.
116
116
  // readFileState is populated by Read, Write, and Edit tools — single source
117
- // of truth, aligned with Claude Code's readFileState approach.
117
+ // of truth, aligned with Claude Code's readFileState approach. Skipped in
118
+ // plan mode: permissionManager enforces a plan-file-only gate whose denial
119
+ // message must surface instead of being masked by a read-state rejection.
118
120
  const resolvedPath = resolvePath(filePath, context.workdir);
119
- if (!context.readFileState?.has(resolvedPath)) {
121
+ if (
122
+ context.permissionMode !== "plan" &&
123
+ !context.readFileState?.has(resolvedPath)
124
+ ) {
120
125
  return {
121
126
  success: false,
122
127
  content: "",
@@ -137,18 +142,31 @@ Usage:
137
142
  };
138
143
  }
139
144
 
140
- // Staleness check: file must not have been modified since last Read
141
- if (context.readFileState) {
145
+ // Staleness check (aligned with Claude Code): only flag when the file got
146
+ // newer since last read. For full reads, a content-hash fallback avoids
147
+ // false positives when mtime changed but content didn't (git checkout,
148
+ // editor round-trip save, cloud sync, antivirus). Partial reads get no
149
+ // fallback since only a slice was cached. Skipped in plan mode (see
150
+ // read-before-edit note above) so the plan-file-only denial wins.
151
+ if (context.permissionMode !== "plan" && context.readFileState) {
142
152
  const state = context.readFileState.get(resolvedPath);
143
153
  if (state) {
144
154
  const currentStats = await stat(resolvedPath);
145
- if (currentStats.mtime.getTime() !== state.mtime) {
146
- return {
147
- success: false,
148
- content: "",
149
- error:
150
- "File has been unexpectedly modified since last read. Read it again before editing it.",
151
- };
155
+ if (currentStats.mtime.getTime() > state.mtime) {
156
+ const isFullRead =
157
+ state.offset === undefined && state.limit === undefined;
158
+ const contentUnchanged =
159
+ isFullRead &&
160
+ createHash("sha256").update(originalContent).digest("hex") ===
161
+ state.hash;
162
+ if (!contentUnchanged) {
163
+ return {
164
+ success: false,
165
+ content: "",
166
+ error:
167
+ "File has been unexpectedly modified since last read. Read it again before editing it.",
168
+ };
169
+ }
152
170
  }
153
171
  }
154
172
  }
@@ -2,6 +2,7 @@ import type { ToolPlugin, ToolResult, ToolContext } from "./types.js";
2
2
  import { spawn } from "child_process";
3
3
  import { rgPath } from "../utils/ripgrep.js";
4
4
  import { getDisplayPath } from "../utils/path.js";
5
+ import { logger } from "../utils/globalLogger.js";
5
6
  import {
6
7
  GREP_TOOL_NAME,
7
8
  BASH_TOOL_NAME,
@@ -235,8 +236,10 @@ export const grepTool: ToolPlugin = {
235
236
 
236
237
  const result = await executeCommand(rgPath, rgArgs, workdir);
237
238
 
238
- if (result.error && result.exitCode !== 1) {
239
- // rg returns 1 for no matches, not an error
239
+ // Only a process-level spawn failure (exitCode null) is a hard error.
240
+ // rg exit 2 means some files were unreadable (e.g. device-name files
241
+ // like "nul" on Windows); stdout still holds usable partial results.
242
+ if (result.exitCode === null) {
240
243
  return {
241
244
  success: false,
242
245
  content: "",
@@ -244,6 +247,12 @@ export const grepTool: ToolPlugin = {
244
247
  };
245
248
  }
246
249
 
250
+ if (result.exitCode !== 0 && result.exitCode !== 1) {
251
+ logger.debug(
252
+ `ripgrep exited with code ${result.exitCode}, keeping partial results: ${result.stderr.trim()}`,
253
+ );
254
+ }
255
+
247
256
  const output = result.stdout.trim();
248
257
  if (!output) {
249
258
  return {
@@ -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 {
@@ -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.
@@ -51,6 +51,13 @@ export function getBuiltinSubagentsDir(): string {
51
51
  return join(getPackageRoot(), "builtin", "subagents");
52
52
  }
53
53
 
54
+ /**
55
+ * Get the builtin plugins directory path
56
+ */
57
+ export function getBuiltinPluginsDir(): string {
58
+ return join(getPackageRoot(), "builtin", "plugins");
59
+ }
60
+
54
61
  /**
55
62
  * Get the user-specific configuration file path (legacy function)
56
63
  * @deprecated Use getUserConfigPaths() for better priority support
@@ -34,9 +34,13 @@ async function getAllFiles(workingDirectory: string): Promise<string[]> {
34
34
  });
35
35
 
36
36
  child.on("close", (code) => {
37
+ // Exit 2 = some files were unreadable (e.g. device-name files like
38
+ // "nul" on Windows); stdout still holds usable partial results.
39
+ // Spawn failures surface via the 'error' listener instead.
37
40
  if (code !== 0 && code !== 1) {
38
- reject(new Error(`ripgrep failed: ${stderr}`));
39
- return;
41
+ logger.warn(
42
+ `ripgrep exited with code ${code}, keeping partial results: ${stderr.trim()}`,
43
+ );
40
44
  }
41
45
  const files = stdout
42
46
  .trim()