wave-agent-sdk 0.19.9 → 1.0.1

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 (89) hide show
  1. package/builtin/plugins/sdd/scripts/session-start.js +1 -1
  2. package/builtin/plugins/sdd/skills/specify/SKILL.md +3 -4
  3. package/builtin/skills/settings/ENV.md +15 -9
  4. package/builtin/skills/settings/HOOKS.md +27 -2
  5. package/dist/agent.d.ts +9 -20
  6. package/dist/agent.js +28 -99
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +1 -0
  9. package/dist/managers/aiManager.d.ts +71 -8
  10. package/dist/managers/aiManager.js +290 -85
  11. package/dist/managers/backgroundTaskManager.d.ts +6 -0
  12. package/dist/managers/backgroundTaskManager.js +11 -0
  13. package/dist/managers/bangManager.d.ts +6 -0
  14. package/dist/managers/bangManager.js +11 -0
  15. package/dist/managers/hookManager.d.ts +8 -2
  16. package/dist/managers/hookManager.js +14 -4
  17. package/dist/managers/mcpManager.d.ts +18 -4
  18. package/dist/managers/mcpManager.js +40 -18
  19. package/dist/managers/messageManager.d.ts +9 -5
  20. package/dist/managers/messageManager.js +36 -12
  21. package/dist/managers/subagentManager.d.ts +6 -0
  22. package/dist/managers/subagentManager.js +33 -22
  23. package/dist/managers/toolManager.js +5 -0
  24. package/dist/prompts/index.d.ts +0 -1
  25. package/dist/prompts/index.js +0 -4
  26. package/dist/services/aiService.d.ts +1 -34
  27. package/dist/services/aiService.js +18 -130
  28. package/dist/services/autoMemoryService.d.ts +27 -2
  29. package/dist/services/autoMemoryService.js +124 -36
  30. package/dist/services/configurationService.d.ts +21 -2
  31. package/dist/services/configurationService.js +86 -24
  32. package/dist/services/initializationService.js +14 -4
  33. package/dist/services/interactionService.js +35 -7
  34. package/dist/services/remoteSettingsService.d.ts +12 -0
  35. package/dist/services/remoteSettingsService.js +15 -1
  36. package/dist/services/session.d.ts +13 -0
  37. package/dist/services/session.js +64 -0
  38. package/dist/services/taskManager.js +7 -1
  39. package/dist/tools/bashTool.js +1 -0
  40. package/dist/tools/enterWorktreeTool.js +14 -3
  41. package/dist/tools/exitWorktreeTool.js +11 -10
  42. package/dist/tools/types.d.ts +7 -0
  43. package/dist/types/agent.d.ts +0 -2
  44. package/dist/types/config.d.ts +9 -0
  45. package/dist/types/core.d.ts +1 -1
  46. package/dist/types/hooks.d.ts +2 -2
  47. package/dist/utils/containerSetup.js +13 -4
  48. package/dist/utils/openaiClient.js +2 -1
  49. package/dist/utils/pathEncoder.js +7 -2
  50. package/dist/utils/worktreeUtils.d.ts +17 -0
  51. package/dist/utils/worktreeUtils.js +339 -1
  52. package/package.json +1 -1
  53. package/src/agent.ts +43 -112
  54. package/src/index.ts +1 -0
  55. package/src/managers/aiManager.ts +389 -110
  56. package/src/managers/backgroundTaskManager.ts +15 -0
  57. package/src/managers/bangManager.ts +15 -0
  58. package/src/managers/hookManager.ts +20 -5
  59. package/src/managers/mcpManager.ts +60 -18
  60. package/src/managers/messageManager.ts +51 -23
  61. package/src/managers/subagentManager.ts +36 -25
  62. package/src/managers/toolManager.ts +7 -0
  63. package/src/prompts/index.ts +0 -4
  64. package/src/services/aiService.ts +25 -203
  65. package/src/services/autoMemoryService.ts +145 -39
  66. package/src/services/configurationService.ts +100 -24
  67. package/src/services/initializationService.ts +17 -4
  68. package/src/services/interactionService.ts +49 -6
  69. package/src/services/remoteSettingsService.ts +16 -1
  70. package/src/services/session.ts +68 -0
  71. package/src/services/taskManager.ts +10 -1
  72. package/src/tools/bashTool.ts +1 -0
  73. package/src/tools/enterWorktreeTool.ts +19 -2
  74. package/src/tools/exitWorktreeTool.ts +15 -12
  75. package/src/tools/types.ts +7 -0
  76. package/src/types/agent.ts +0 -6
  77. package/src/types/config.ts +9 -0
  78. package/src/types/core.ts +1 -1
  79. package/src/types/hooks.ts +2 -2
  80. package/src/utils/containerSetup.ts +15 -5
  81. package/src/utils/openaiClient.ts +2 -0
  82. package/src/utils/pathEncoder.ts +7 -2
  83. package/src/utils/worktreeUtils.ts +401 -1
  84. package/dist/constants/goalPrompts.d.ts +0 -1
  85. package/dist/constants/goalPrompts.js +0 -10
  86. package/dist/managers/goalManager.d.ts +0 -42
  87. package/dist/managers/goalManager.js +0 -177
  88. package/src/constants/goalPrompts.ts +0 -10
  89. package/src/managers/goalManager.ts +0 -232
@@ -25,10 +25,8 @@ import * as path from "path";
25
25
 
26
26
  import {
27
27
  WEB_CONTENT_SYSTEM_PROMPT,
28
- BTW_SYSTEM_PROMPT,
29
28
  type SystemPromptBlock,
30
29
  } from "../prompts/index.js";
31
- import { GOAL_EVALUATION_SYSTEM_PROMPT } from "../constants/goalPrompts.js";
32
30
 
33
31
  /**
34
32
  * Interface for debug data saved during 400 errors
@@ -148,6 +146,18 @@ function getModelConfig(
148
146
  return config;
149
147
  }
150
148
 
149
+ /**
150
+ * Effective disable-thinking params for a model config. No default: these
151
+ * params are only sent when the user explicitly configures
152
+ * `models[X].disableThinkingOptions` (an empty object clears them), so a
153
+ * gateway that doesn't understand the params is never hit with them.
154
+ */
155
+ function effectiveDisableThinkingOptions(
156
+ modelConfig: ModelConfig,
157
+ ): Record<string, unknown> | undefined {
158
+ return modelConfig.disableThinkingOptions;
159
+ }
160
+
151
161
  export interface CallAgentOptions {
152
162
  // Resolved configuration
153
163
  gatewayConfig: GatewayConfig;
@@ -184,6 +194,10 @@ export interface CallAgentOptions {
184
194
  stage?: "start" | "streaming" | "running" | "end";
185
195
  }) => void;
186
196
  onReasoningUpdate?: (content: string) => void;
197
+
198
+ // Disable-thinking params for fast-model subagent calls (merged into the
199
+ // request; never used in the agent loop).
200
+ disableThinkingOptions?: Record<string, unknown>;
187
201
  }
188
202
 
189
203
  export interface CallAgentResult {
@@ -238,6 +252,7 @@ export async function callAgent(
238
252
  onContentUpdate,
239
253
  onToolUpdate,
240
254
  onReasoningUpdate,
255
+ disableThinkingOptions,
241
256
  } = options;
242
257
 
243
258
  // Validate model config at call time
@@ -321,6 +336,7 @@ export async function callAgent(
321
336
  const openaiModelConfig = getModelConfig(model || modelConfig.model, {
322
337
  max_tokens: resolvedMaxTokens,
323
338
  ...(modelConfig.options || {}),
339
+ ...(disableThinkingOptions ?? {}),
324
340
  });
325
341
 
326
342
  // Determine if streaming is needed
@@ -842,10 +858,17 @@ export async function processWebContent(
842
858
  ? modelConfig.fastModelOptions || {}
843
859
  : modelConfig.options || {};
844
860
 
861
+ // Disable-thinking params only apply to the fast-model override path;
862
+ // the agent-model path is untouched.
863
+ const disableThinking = options.model
864
+ ? effectiveDisableThinkingOptions(modelConfig)
865
+ : undefined;
866
+
845
867
  const openaiModelConfig = getModelConfig(options.model || modelConfig.model, {
846
868
  temperature: 0.1,
847
869
  max_tokens: 4096,
848
870
  ...activeExtraParams,
871
+ ...(disableThinking || {}),
849
872
  });
850
873
 
851
874
  try {
@@ -894,204 +917,3 @@ export async function processWebContent(
894
917
  }
895
918
  }
896
919
 
897
- export interface BtwOptions {
898
- // Resolved configuration
899
- gatewayConfig: GatewayConfig;
900
- modelConfig: ModelConfig;
901
-
902
- // Parameters
903
- messages: ChatCompletionMessageParam[];
904
- question: string;
905
- abortSignal?: AbortSignal;
906
- model?: string;
907
- }
908
-
909
- export interface BtwResult {
910
- content: string;
911
- usage?: {
912
- prompt_tokens: number;
913
- completion_tokens: number;
914
- total_tokens: number;
915
- };
916
- }
917
-
918
- export async function btw(options: BtwOptions): Promise<BtwResult> {
919
- const { gatewayConfig, modelConfig, messages, question, abortSignal } =
920
- options;
921
-
922
- // Validate model config at call time
923
- validateModelConfig(modelConfig);
924
-
925
- // Apply global 1 QPS rate limit
926
- if (
927
- process.env.NODE_ENV !== "test" ||
928
- modelConfig.model === "rate-limit-test"
929
- ) {
930
- await acquireSlot(abortSignal);
931
- }
932
-
933
- // Create OpenAI client with injected configuration
934
- const openai = new OpenAIClient({
935
- apiKey: gatewayConfig.apiKey,
936
- baseURL: gatewayConfig.baseURL,
937
- defaultHeaders: gatewayConfig.defaultHeaders,
938
- fetchOptions: gatewayConfig.fetchOptions,
939
- fetch: gatewayConfig.fetch,
940
- });
941
-
942
- const openaiModelConfig = getModelConfig(options.model || modelConfig.model, {
943
- temperature: 0.1,
944
- max_tokens: 4096,
945
- ...(modelConfig.options || {}),
946
- });
947
-
948
- try {
949
- const response = await openai.chat.completions.create(
950
- {
951
- ...openaiModelConfig,
952
- messages: [
953
- {
954
- role: "system",
955
- content: BTW_SYSTEM_PROMPT,
956
- },
957
- ...messages,
958
- {
959
- role: "user",
960
- content: question,
961
- },
962
- ],
963
- },
964
- {
965
- signal: abortSignal,
966
- },
967
- );
968
-
969
- const result = response.choices[0]?.message?.content?.trim();
970
- if (!result) {
971
- throw new Error(
972
- "Failed to process side question: Empty response from AI",
973
- );
974
- }
975
- const usage = response.usage
976
- ? {
977
- prompt_tokens: response.usage.prompt_tokens,
978
- completion_tokens: response.usage.completion_tokens,
979
- total_tokens: response.usage.total_tokens,
980
- }
981
- : undefined;
982
-
983
- return {
984
- content: result,
985
- usage,
986
- };
987
- } catch (error) {
988
- if ((error as Error).name === "AbortError") {
989
- logger.info("Side question request was aborted");
990
- throw new Error("Side question request was aborted");
991
- }
992
- logger.error("Failed to process side question:", error);
993
- throw error;
994
- }
995
- }
996
-
997
- export interface EvaluateGoalOptions {
998
- gatewayConfig: GatewayConfig;
999
- modelConfig: ModelConfig;
1000
- model: string;
1001
- goalCondition: string;
1002
- messages: ChatCompletionMessageParam[];
1003
- abortSignal?: AbortSignal;
1004
- }
1005
-
1006
- export interface EvaluateGoalResult {
1007
- content: string;
1008
- usage?: {
1009
- prompt_tokens: number;
1010
- completion_tokens: number;
1011
- total_tokens: number;
1012
- };
1013
- }
1014
-
1015
- export async function evaluateGoal(
1016
- options: EvaluateGoalOptions,
1017
- ): Promise<EvaluateGoalResult> {
1018
- const {
1019
- gatewayConfig,
1020
- modelConfig,
1021
- model,
1022
- goalCondition,
1023
- messages,
1024
- abortSignal,
1025
- } = options;
1026
-
1027
- // Create OpenAI client with injected configuration (no rate limiter — bypasses 1 QPS)
1028
- const openai = new OpenAIClient({
1029
- apiKey: gatewayConfig.apiKey,
1030
- baseURL: gatewayConfig.baseURL,
1031
- defaultHeaders: gatewayConfig.defaultHeaders,
1032
- fetchOptions: gatewayConfig.fetchOptions,
1033
- fetch: gatewayConfig.fetch,
1034
- });
1035
-
1036
- const openaiModelConfig = getModelConfig(model, {
1037
- temperature: 0,
1038
- max_tokens: 200,
1039
- ...(modelConfig.fastModelOptions || {}),
1040
- });
1041
-
1042
- // Strip images from messages to reduce token usage (same as compact)
1043
- const cleanedMessages = messages.map((msg) => {
1044
- if (Array.isArray(msg.content)) {
1045
- const textParts = msg.content.filter(
1046
- (part) => part.type === "text",
1047
- ) as import("openai/resources.js").ChatCompletionContentPartText[];
1048
- const text = textParts.map((p) => p.text).join("\n");
1049
- return { ...msg, content: text || "(empty message)" };
1050
- }
1051
- return msg;
1052
- });
1053
-
1054
- try {
1055
- const response = await openai.chat.completions.create(
1056
- {
1057
- ...openaiModelConfig,
1058
- messages: [
1059
- {
1060
- role: "system",
1061
- content: GOAL_EVALUATION_SYSTEM_PROMPT,
1062
- },
1063
- ...cleanedMessages,
1064
- {
1065
- role: "user",
1066
- content: `Goal condition: ${goalCondition}\n\nHas this goal been achieved based on the conversation above?`,
1067
- },
1068
- ],
1069
- },
1070
- {
1071
- signal: abortSignal,
1072
- },
1073
- );
1074
-
1075
- const result = response.choices[0]?.message?.content?.trim();
1076
- if (!result) {
1077
- throw new Error("Goal evaluation returned empty response");
1078
- }
1079
-
1080
- const usage = response.usage
1081
- ? {
1082
- prompt_tokens: response.usage.prompt_tokens,
1083
- completion_tokens: response.usage.completion_tokens,
1084
- total_tokens: response.usage.total_tokens,
1085
- }
1086
- : undefined;
1087
-
1088
- return { content: result, usage };
1089
- } catch (error) {
1090
- if ((error as Error).name === "AbortError") {
1091
- logger.info("Goal evaluation was aborted");
1092
- throw new Error("Goal evaluation was aborted");
1093
- }
1094
- logger.error("Goal evaluation failed:", error);
1095
- throw error;
1096
- }
1097
- }
@@ -2,14 +2,32 @@ import * as path from "node:path";
2
2
  import * as fs from "node:fs/promises";
3
3
  import { Container } from "../utils/container.js";
4
4
  import { MessageManager } from "../managers/messageManager.js";
5
- import { ForkedAgentManager } from "../managers/forkedAgentManager.js";
5
+ import { AIManager } from "../managers/aiManager.js";
6
6
  import { MemoryService } from "./memory.js";
7
7
  import { ConfigurationService } from "./configurationService.js";
8
8
  import { logger } from "../utils/globalLogger.js";
9
9
  import { isPathInside } from "../utils/pathSafety.js";
10
10
  import { buildAutoMemoryExtractionPrompt } from "../prompts/autoMemoryExtraction.js";
11
+ import {
12
+ READ_ONLY_COMMANDS,
13
+ splitBashCommand,
14
+ hasWriteRedirections,
15
+ hasCommandSubstitution,
16
+ hasProcessSubstitution,
17
+ hasSedInPlace,
18
+ isDangerousFind,
19
+ } from "../utils/bashParser.js";
11
20
  import type { Message } from "../types/index.js";
12
21
 
22
+ /**
23
+ * Message fed back to the extraction fork when the model requests a tool
24
+ * outside the allowed set (Bash rm, MCP tools, Agent, out-of-dir writes...).
25
+ */
26
+ const DENIED_TOOL_MESSAGE =
27
+ "This tool call was denied during auto-memory extraction. Available tools: " +
28
+ "Read, Grep, Glob, read-only Bash commands, and Write/Edit inside the " +
29
+ "memory directory only.";
30
+
13
31
  /**
14
32
  * Service responsible for managing the auto-memory background agent lifecycle.
15
33
  * Extracts and updates persistent project-level memory from conversation history.
@@ -17,6 +35,8 @@ import type { Message } from "../types/index.js";
17
35
  export class AutoMemoryService {
18
36
  private lastMemoryMessageId: string | null = null;
19
37
  private turnsSinceLastExtraction: number = 0;
38
+ private extractionInProgress: boolean = false;
39
+ private pendingExtraction: Promise<void> | null = null;
20
40
 
21
41
  constructor(private container: Container) {}
22
42
 
@@ -24,8 +44,8 @@ export class AutoMemoryService {
24
44
  return this.container.get<MessageManager>("MessageManager")!;
25
45
  }
26
46
 
27
- private get forkedAgentManager(): ForkedAgentManager {
28
- return this.container.get<ForkedAgentManager>("ForkedAgentManager")!;
47
+ private get aiManager(): AIManager {
48
+ return this.container.get<AIManager>("AIManager")!;
29
49
  }
30
50
 
31
51
  private get memoryService(): MemoryService {
@@ -71,7 +91,14 @@ export class AutoMemoryService {
71
91
  (m) =>
72
92
  m.role === "assistant" &&
73
93
  m.blocks.some((b) => {
74
- if (b.type === "tool" && (b.name === "Write" || b.name === "Edit")) {
94
+ if (
95
+ b.type === "tool" &&
96
+ (b.name === "Write" || b.name === "Edit") &&
97
+ // Only a successful manual write counts as a manual update. A
98
+ // denied/failed write didn't touch memory, so the extraction fork
99
+ // must still run or the information is lost.
100
+ b.success !== false
101
+ ) {
75
102
  try {
76
103
  const params = b.parameters ? JSON.parse(b.parameters) : null;
77
104
  const filePath = params?.file_path || params?.path;
@@ -98,22 +125,61 @@ export class AutoMemoryService {
98
125
  return;
99
126
  }
100
127
 
101
- // 3. Trigger background extraction using a forked subagent
102
- try {
103
- await this.runExtraction(workdir, messages);
104
- this.lastMemoryMessageId = messages[messages.length - 1].id || null;
105
- this.turnsSinceLastExtraction = 0;
106
- } catch (error) {
107
- logger.error("Auto-memory extraction failed to trigger:", error);
128
+ // 3. Concurrency guard: if an extraction is already in flight, skip this
129
+ // turn. turnsSinceLastExtraction is intentionally NOT reset so the next
130
+ // eligible turn retriggers the extraction.
131
+ if (this.extractionInProgress) {
132
+ logger.debug(
133
+ "Skipping auto-memory extraction: another extraction is still in progress.",
134
+ );
135
+ return;
108
136
  }
137
+
138
+ // 4. Trigger the perfect-fork extraction fire-and-forget. The message
139
+ // snapshot and new-message count are computed now; lastMemoryMessageId
140
+ // advances at trigger time so the next extraction starts from a later
141
+ // window even while this one runs.
142
+ const lastExtractedIndex = this.lastMemoryMessageId
143
+ ? messages.findIndex((m) => m.id === this.lastMemoryMessageId)
144
+ : -1;
145
+ const newMessageCount =
146
+ lastExtractedIndex === -1
147
+ ? messages.length
148
+ : messages.length - 1 - lastExtractedIndex;
149
+
150
+ this.turnsSinceLastExtraction = 0;
151
+ this.lastMemoryMessageId = messages[messages.length - 1].id || null;
152
+ this.extractionInProgress = true;
153
+ const extraction = this.runExtraction(workdir, messages, newMessageCount)
154
+ .catch((error) => {
155
+ logger.error("Auto-memory extraction failed:", error);
156
+ })
157
+ .finally(() => {
158
+ this.extractionInProgress = false;
159
+ this.pendingExtraction = null;
160
+ });
161
+ this.pendingExtraction = extraction;
162
+ }
163
+
164
+ /**
165
+ * Wait for an in-flight extraction to settle. Called from Agent.dispose so
166
+ * the process doesn't exit while the extraction fork is mid-flight.
167
+ */
168
+ async drain(): Promise<void> {
169
+ await this.pendingExtraction;
109
170
  }
110
171
 
111
172
  /**
112
- * Initialize and execute the background extraction subagent.
173
+ * Initialize and execute the extraction in a perfect fork: same system
174
+ * prompt, tools, model, and message prefix as the main conversation, so the
175
+ * prompt cache is reused. A tool gate confines the fork to read-only
176
+ * inspection and memory-directory writes. Runs in-process; callers treat it
177
+ * as fire-and-forget.
113
178
  */
114
179
  private async runExtraction(
115
180
  workdir: string,
116
181
  messages: Message[],
182
+ newMessageCount: number,
117
183
  ): Promise<void> {
118
184
  const memoryDir = this.memoryService.getAutoMemoryDirectory(workdir);
119
185
 
@@ -132,43 +198,83 @@ export class AutoMemoryService {
132
198
  // Ignore if directory doesn't exist yet
133
199
  }
134
200
 
135
- // Calculate how many new messages to analyze
136
- let newMessageCount = messages.length;
137
- if (this.lastMemoryMessageId) {
138
- const lastIndex = messages.findIndex(
139
- (m) => m.id === this.lastMemoryMessageId,
140
- );
141
- if (lastIndex !== -1) {
142
- newMessageCount = messages.length - 1 - lastIndex;
143
- }
144
- }
145
-
146
201
  const prompt = buildAutoMemoryExtractionPrompt(
147
202
  newMessageCount,
148
203
  existingMemoriesManifest,
149
204
  );
150
205
 
151
- // Execute the forked agent in background (fire-and-forget, decoupled from BackgroundTaskManager)
152
- await this.forkedAgentManager.forkAndExecute(
153
- "general-purpose",
206
+ await this.aiManager.runAutoMemoryFork(
154
207
  messages,
208
+ `${prompt}\n\nThe memory directory for this project is: ${memoryDir}`,
155
209
  {
156
- description: "Auto-memory extraction background agent",
157
- allowedTools: [
158
- "Read",
159
- "Glob",
160
- "Grep",
161
- `Write(${memoryDir}/**/*)`,
162
- `Edit(${memoryDir}/**/*)`,
163
- `Bash(rm ${memoryDir}/**/*)`,
164
- ],
165
- model: "fastModel", // Use fast model for background tasks to reduce latency and cost
166
- permissionModeOverride: "dontAsk", // Auto-deny out-of-scope writes without prompting user
167
210
  maxTurns: 5, // Limit turns to prevent verification rabbit-holes
211
+ canUseTool: (name, args) =>
212
+ this.isAllowedForkTool(name, args, memoryDir, workdir),
213
+ deniedToolMessage: DENIED_TOOL_MESSAGE,
168
214
  },
169
- `${prompt}\n\nThe memory directory for this project is: ${memoryDir}`,
170
215
  );
171
216
 
172
- logger.debug("Auto-memory extraction started in background.");
217
+ logger.debug("Auto-memory extraction completed.");
218
+ }
219
+
220
+ /**
221
+ * Tool gate for the extraction fork: Read/Grep/Glob are always allowed;
222
+ * Write/Edit only when the target path is inside the memory directory; Bash
223
+ * only for read-only commands (aligned with the permission manager's
224
+ * read-only bash classification). Everything else — Bash rm, MCP tools,
225
+ * Agent, out-of-dir writes — is denied.
226
+ */
227
+ private isAllowedForkTool(
228
+ name: string,
229
+ args: Record<string, unknown>,
230
+ memoryDir: string,
231
+ workdir: string,
232
+ ): boolean {
233
+ if (name === "Read" || name === "Grep" || name === "Glob") {
234
+ return true;
235
+ }
236
+
237
+ if (name === "Write" || name === "Edit") {
238
+ const filePath = args.file_path ?? args.path;
239
+ if (typeof filePath !== "string" || !filePath) return false;
240
+ const absolutePath = path.isAbsolute(filePath)
241
+ ? filePath
242
+ : path.resolve(workdir, filePath);
243
+ return isPathInside(absolutePath, memoryDir);
244
+ }
245
+
246
+ if (name === "Bash") {
247
+ const command = typeof args.command === "string" ? args.command : "";
248
+ return this.isReadOnlyBashCommand(command);
249
+ }
250
+
251
+ return false;
252
+ }
253
+
254
+ /**
255
+ * A bash command is read-only when every part is a READ_ONLY_COMMANDS entry
256
+ * without write redirections, command/process substitution, sed -i, or
257
+ * dangerous find flags. Mirrors PermissionManager.isAutoAllowedPart.
258
+ */
259
+ private isReadOnlyBashCommand(command: string): boolean {
260
+ if (!command.trim()) return false;
261
+ if (hasWriteRedirections(command)) return false;
262
+ if (hasCommandSubstitution(command)) return false;
263
+ if (hasProcessSubstitution(command)) return false;
264
+ if (hasSedInPlace(command)) return false;
265
+
266
+ const parts = splitBashCommand(command);
267
+ if (parts.length === 0) return false;
268
+
269
+ return parts.every((part) => {
270
+ const trimmed = part.trim();
271
+ if (!trimmed) return true;
272
+ const commandMatch = trimmed.match(/^(\w+)(\s+.*)?$/);
273
+ if (!commandMatch) return false;
274
+ const cmd = commandMatch[1];
275
+ if (!READ_ONLY_COMMANDS.includes(cmd)) return false;
276
+ if (cmd === "find" && isDangerousFind(part)) return false;
277
+ return true;
278
+ });
173
279
  }
174
280
  }