wave-agent-sdk 1.1.5 → 1.3.0

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 (210) hide show
  1. package/dist/agent.d.ts +128 -28
  2. package/dist/agent.js +201 -49
  3. package/dist/builtin/index.js +2 -0
  4. package/dist/builtin/plugins.js +11 -20
  5. package/dist/builtin/skills/settings.js +7 -20
  6. package/dist/builtin/skills/wave-daemon.d.ts +1 -0
  7. package/dist/builtin/skills/wave-daemon.js +194 -0
  8. package/dist/constants/images.d.ts +26 -0
  9. package/dist/constants/images.js +26 -0
  10. package/dist/constants/index.d.ts +16 -0
  11. package/dist/constants/index.js +16 -0
  12. package/dist/constants/memory.d.ts +26 -0
  13. package/dist/constants/memory.js +34 -0
  14. package/dist/constants/messages.d.ts +11 -0
  15. package/dist/constants/messages.js +11 -0
  16. package/dist/constants/plugins.d.ts +8 -0
  17. package/dist/constants/plugins.js +8 -0
  18. package/dist/constants/tools.d.ts +1 -0
  19. package/dist/constants/tools.js +1 -0
  20. package/dist/core/plugin.d.ts +54 -10
  21. package/dist/core/plugin.js +137 -23
  22. package/dist/core/session.d.ts +1 -1
  23. package/dist/core/session.js +1 -1
  24. package/dist/exec/catalog.d.ts +140 -0
  25. package/dist/exec/catalog.js +470 -0
  26. package/dist/exec/catalogAnnouncement.d.ts +89 -0
  27. package/dist/exec/catalogAnnouncement.js +293 -0
  28. package/dist/exec/constants.d.ts +51 -0
  29. package/dist/exec/constants.js +51 -0
  30. package/dist/exec/execRuntime.d.ts +55 -0
  31. package/dist/exec/execRuntime.js +217 -0
  32. package/dist/exec/workerSource.d.ts +28 -0
  33. package/dist/exec/workerSource.js +299 -0
  34. package/dist/host/index.d.ts +23 -0
  35. package/dist/host/index.js +23 -0
  36. package/dist/index.d.ts +7 -1
  37. package/dist/index.js +8 -1
  38. package/dist/managers/MemoryRuleManager.d.ts +6 -0
  39. package/dist/managers/MemoryRuleManager.js +12 -0
  40. package/dist/managers/aiManager.d.ts +35 -25
  41. package/dist/managers/aiManager.js +204 -202
  42. package/dist/managers/backgroundTaskManager.js +14 -0
  43. package/dist/managers/bashModeManager.d.ts +33 -0
  44. package/dist/managers/bashModeManager.js +110 -0
  45. package/dist/managers/hookManager.d.ts +18 -0
  46. package/dist/managers/hookManager.js +37 -3
  47. package/dist/managers/liveConfigManager.d.ts +33 -0
  48. package/dist/managers/liveConfigManager.js +106 -11
  49. package/dist/managers/lspManager.d.ts +9 -0
  50. package/dist/managers/lspManager.js +47 -18
  51. package/dist/managers/mcpManager.d.ts +68 -10
  52. package/dist/managers/mcpManager.js +265 -15
  53. package/dist/managers/messageManager.d.ts +60 -18
  54. package/dist/managers/messageManager.js +170 -81
  55. package/dist/managers/permissionManager.d.ts +69 -0
  56. package/dist/managers/permissionManager.js +221 -78
  57. package/dist/managers/planManager.d.ts +9 -0
  58. package/dist/managers/planManager.js +19 -1
  59. package/dist/managers/pluginManager.d.ts +46 -2
  60. package/dist/managers/pluginManager.js +117 -11
  61. package/dist/managers/pluginScopeManager.d.ts +15 -2
  62. package/dist/managers/pluginScopeManager.js +20 -1
  63. package/dist/managers/skillManager.d.ts +50 -0
  64. package/dist/managers/skillManager.js +166 -12
  65. package/dist/managers/slashCommandManager.d.ts +10 -0
  66. package/dist/managers/slashCommandManager.js +44 -31
  67. package/dist/managers/subagentManager.d.ts +15 -0
  68. package/dist/managers/subagentManager.js +81 -9
  69. package/dist/managers/toolManager.d.ts +29 -3
  70. package/dist/managers/toolManager.js +87 -13
  71. package/dist/managers/workflowManager.js +6 -0
  72. package/dist/prompts/autoMemory.d.ts +9 -0
  73. package/dist/prompts/autoMemory.js +30 -31
  74. package/dist/prompts/autoMemoryExtraction.d.ts +4 -0
  75. package/dist/prompts/autoMemoryExtraction.js +8 -111
  76. package/dist/prompts/index.d.ts +0 -1
  77. package/dist/prompts/index.js +0 -4
  78. package/dist/prompts/memoryTypes.d.ts +63 -0
  79. package/dist/prompts/memoryTypes.js +191 -0
  80. package/dist/services/GitService.d.ts +7 -0
  81. package/dist/services/GitService.js +23 -0
  82. package/dist/services/MarketplaceService.d.ts +101 -17
  83. package/dist/services/MarketplaceService.js +323 -102
  84. package/dist/services/artifactContent.d.ts +84 -0
  85. package/dist/services/artifactContent.js +204 -0
  86. package/dist/services/artifactSession.d.ts +6 -0
  87. package/dist/services/artifactSession.js +17 -0
  88. package/dist/services/autoMemoryService.js +5 -13
  89. package/dist/services/configurationService.d.ts +92 -9
  90. package/dist/services/configurationService.js +246 -64
  91. package/dist/services/contentSummarizer.d.ts +15 -0
  92. package/dist/services/contentSummarizer.js +45 -0
  93. package/dist/services/execAvailability.d.ts +9 -0
  94. package/dist/services/execAvailability.js +32 -0
  95. package/dist/services/fileWatcher.js +61 -6
  96. package/dist/services/initializationService.js +21 -17
  97. package/dist/services/interactionService.d.ts +9 -1
  98. package/dist/services/interactionService.js +28 -8
  99. package/dist/services/jsonlHandler.d.ts +98 -0
  100. package/dist/services/jsonlHandler.js +250 -12
  101. package/dist/services/memory.d.ts +17 -1
  102. package/dist/services/memory.js +44 -7
  103. package/dist/services/officialMarketplaceMirror.d.ts +85 -0
  104. package/dist/services/officialMarketplaceMirror.js +290 -0
  105. package/dist/services/pluginLoader.d.ts +12 -4
  106. package/dist/services/pluginLoader.js +38 -7
  107. package/dist/services/remoteSettingsService.js +20 -6
  108. package/dist/services/session.d.ts +74 -0
  109. package/dist/services/session.js +174 -16
  110. package/dist/services/sessionEntries.d.ts +2 -0
  111. package/dist/services/sessionEntries.js +20 -0
  112. package/dist/services/worktreeHooks.js +6 -1
  113. package/dist/stdio/index.d.ts +12 -0
  114. package/dist/stdio/index.js +12 -0
  115. package/dist/stdio/notificationRouter.d.ts +38 -0
  116. package/dist/stdio/notificationRouter.js +97 -0
  117. package/dist/stdio/rpcClient.d.ts +18 -0
  118. package/dist/stdio/rpcClient.js +10 -0
  119. package/dist/stdio/stdioAgent.d.ts +229 -0
  120. package/dist/stdio/stdioAgent.js +360 -0
  121. package/dist/tools/artifactTool.js +406 -273
  122. package/dist/tools/bashTool.js +10 -6
  123. package/dist/tools/editTool.js +6 -3
  124. package/dist/tools/execTool.d.ts +2 -0
  125. package/dist/tools/execTool.js +165 -0
  126. package/dist/tools/exitPlanMode.js +10 -2
  127. package/dist/tools/grepTool.js +7 -1
  128. package/dist/tools/readTool.js +30 -2
  129. package/dist/tools/types.d.ts +34 -8
  130. package/dist/tools/webFetchTool.js +15 -166
  131. package/dist/tools/workflowTool.js +40 -8
  132. package/dist/tools/writeTool.js +6 -3
  133. package/dist/types/agent.d.ts +24 -1
  134. package/dist/types/commands.d.ts +7 -0
  135. package/dist/types/configuration.d.ts +45 -2
  136. package/dist/types/hooks.d.ts +1 -0
  137. package/dist/types/hooks.js +19 -0
  138. package/dist/types/marketplace.d.ts +40 -2
  139. package/dist/types/mcp.d.ts +42 -0
  140. package/dist/types/messaging.d.ts +1 -8
  141. package/dist/types/permissions.d.ts +22 -0
  142. package/dist/types/permissions.js +17 -0
  143. package/dist/types/plugins.d.ts +26 -2
  144. package/dist/types/skills.d.ts +26 -0
  145. package/dist/utils/bashParser.d.ts +17 -0
  146. package/dist/utils/bashParser.js +72 -0
  147. package/dist/utils/bashStructure/bashLexer.d.ts +96 -0
  148. package/dist/utils/bashStructure/bashLexer.js +676 -0
  149. package/dist/utils/bashStructure/bashParser.d.ts +144 -0
  150. package/dist/utils/bashStructure/bashParser.js +606 -0
  151. package/dist/utils/bashStructure/bashSemantics.d.ts +70 -0
  152. package/dist/utils/bashStructure/bashSemantics.js +477 -0
  153. package/dist/utils/bashStructure/index.d.ts +26 -0
  154. package/dist/utils/bashStructure/index.js +27 -0
  155. package/dist/utils/bashStructure/types.d.ts +62 -0
  156. package/dist/utils/bashStructure/types.js +47 -0
  157. package/dist/utils/constants.d.ts +10 -0
  158. package/dist/utils/constants.js +10 -0
  159. package/dist/utils/containerSetup.js +48 -6
  160. package/dist/utils/convertMessagesForAPI.d.ts +7 -1
  161. package/dist/utils/convertMessagesForAPI.js +64 -14
  162. package/dist/utils/fileChangeReminder.d.ts +20 -0
  163. package/dist/utils/fileChangeReminder.js +153 -0
  164. package/dist/utils/fileSearch.js +4 -3
  165. package/dist/utils/fileUtils.d.ts +44 -0
  166. package/dist/utils/fileUtils.js +118 -0
  167. package/dist/utils/frontmatterYaml.d.ts +33 -0
  168. package/dist/utils/frontmatterYaml.js +192 -0
  169. package/dist/utils/imageBudget.d.ts +85 -0
  170. package/dist/utils/imageBudget.js +109 -0
  171. package/dist/utils/imageDimensions.d.ts +83 -0
  172. package/dist/utils/imageDimensions.js +232 -0
  173. package/dist/utils/imageProcessor.d.ts +66 -0
  174. package/dist/utils/imageProcessor.js +84 -0
  175. package/dist/utils/imageRewrite.d.ts +29 -0
  176. package/dist/utils/imageRewrite.js +251 -0
  177. package/dist/utils/markdownParser.d.ts +5 -1
  178. package/dist/utils/markdownParser.js +9 -51
  179. package/dist/utils/mcpInstructions.d.ts +61 -0
  180. package/dist/utils/mcpInstructions.js +126 -0
  181. package/dist/utils/mcpUtils.d.ts +7 -0
  182. package/dist/utils/mcpUtils.js +11 -2
  183. package/dist/utils/memoryAge.d.ts +32 -0
  184. package/dist/utils/memoryAge.js +47 -0
  185. package/dist/utils/memoryEntrypoint.d.ts +20 -0
  186. package/dist/utils/memoryEntrypoint.js +49 -0
  187. package/dist/utils/memoryIndex.d.ts +30 -0
  188. package/dist/utils/memoryIndex.js +76 -0
  189. package/dist/utils/messageOperations.d.ts +6 -20
  190. package/dist/utils/messageOperations.js +40 -91
  191. package/dist/utils/nestedMemory.d.ts +22 -0
  192. package/dist/utils/nestedMemory.js +61 -0
  193. package/dist/utils/npmTarball.d.ts +19 -0
  194. package/dist/utils/npmTarball.js +92 -0
  195. package/dist/utils/pluginSource.d.ts +37 -0
  196. package/dist/utils/pluginSource.js +73 -0
  197. package/dist/utils/ripgrep.d.ts +18 -4
  198. package/dist/utils/ripgrep.js +56 -4
  199. package/dist/utils/runtimeDeps.d.ts +35 -0
  200. package/dist/utils/runtimeDeps.js +426 -0
  201. package/dist/utils/skillParser.js +22 -52
  202. package/dist/utils/subagentParser.js +48 -45
  203. package/dist/utils/tokenCalculation.js +0 -8
  204. package/dist/utils/userSettings.d.ts +90 -0
  205. package/dist/utils/userSettings.js +291 -0
  206. package/dist/utils/worktreeUtils.d.ts +2 -1
  207. package/dist/utils/worktreeUtils.js +64 -34
  208. package/package.json +12 -4
  209. package/dist/managers/bangManager.d.ts +0 -26
  210. package/dist/managers/bangManager.js +0 -78
@@ -14,7 +14,7 @@ import { HookManager } from "../managers/hookManager.js";
14
14
  import { SkillManager } from "../managers/skillManager.js";
15
15
  import { SlashCommandManager } from "../managers/slashCommandManager.js";
16
16
  import { PluginManager } from "../managers/pluginManager.js";
17
- import { BangManager } from "../managers/bangManager.js";
17
+ import { BashModeManager } from "../managers/bashModeManager.js";
18
18
  import { CronManager } from "../managers/cronManager.js";
19
19
  import { WorkflowManager } from "../managers/workflowManager.js";
20
20
  import { MemoryRuleManager } from "../managers/MemoryRuleManager.js";
@@ -28,6 +28,8 @@ import { MemoryService } from "../services/memory.js";
28
28
  import { AutoMemoryService } from "../services/autoMemoryService.js";
29
29
  import { USER_MEMORY_FILE } from "./constants.js";
30
30
  import { getGitMainRepoRoot } from "./gitUtils.js";
31
+ import { toPosixPath } from "./path.js";
32
+ import { EDIT_TOOL_NAME, WRITE_TOOL_NAME } from "../constants/tools.js";
31
33
  import { AsyncWorkRegistry } from "./asyncWorkRegistry.js";
32
34
  import { logger } from "./globalLogger.js";
33
35
  import { authService } from "../services/authService.js";
@@ -211,6 +213,19 @@ export function setupAgentContainer(setupOptions) {
211
213
  if (decision.newPermissionRule) {
212
214
  await addPermissionRule(decision.newPermissionRule);
213
215
  }
216
+ if (decision.newAdditionalDirectory) {
217
+ // Session-level only: the user approved this directory from the
218
+ // confirmation dialog (never persisted to settings).
219
+ permissionManager.addInstanceAdditionalDirectory(decision.newAdditionalDirectory);
220
+ // The dialog option is "允许本会话编辑 <目录名>/" — it has to cover
221
+ // later edits too, otherwise `default` mode would keep prompting for
222
+ // every Write/Edit in that directory. Forward slashes: minimatch
223
+ // treats the pattern `\` as an escape, while it normalises the input
224
+ // path's separators on Windows.
225
+ const pattern = `${toPosixPath(decision.newAdditionalDirectory)}/**`;
226
+ permissionManager.addInstanceAllowedRule(`${EDIT_TOOL_NAME}(${pattern})`);
227
+ permissionManager.addInstanceAllowedRule(`${WRITE_TOOL_NAME}(${pattern})`);
228
+ }
214
229
  return decision;
215
230
  }
216
231
  : undefined;
@@ -240,22 +255,49 @@ export function setupAgentContainer(setupOptions) {
240
255
  // Same gate for the builtin /artifact skill: refresh emits "refreshed"
241
256
  // so slash-command registration follows enableArtifact. Awaited (via the
242
257
  // awaited onReload) so skills are re-populated before Agent.create()
243
- // returns — reloadFeatureGatedSkills() clears the skill map before
244
- // rediscovering asynchronously, which would otherwise expose an empty
245
- // skill list to callers right after create().
258
+ // returns — otherwise callers right after create() could observe a
259
+ // mid-refresh skill list.
246
260
  await skillManager.reloadFeatureGatedSkills().catch((error) => {
247
261
  logger.error("Failed to reload feature-gated skills:", error);
248
262
  });
249
263
  },
250
264
  });
251
265
  container.register("LiveConfigManager", liveConfigManager);
266
+ // The turn-scoped configuration snapshot is held by LiveConfigManager (it owns
267
+ // the turn boundaries and the live reload) and read by ConfigurationService's
268
+ // resolve chain, so a mid-turn settings.json change applies from the next turn
269
+ // instead of shifting values under the running one.
270
+ configurationService.setTurnSnapshotSource(() => liveConfigManager.getTurnSnapshot());
252
271
  // Wire up remote settings hot-update: when polling detects changed settings,
253
272
  // reload configuration so admin changes propagate to the running agent.
254
273
  // The callback strongly captures the per-agent LiveConfigManager, so it MUST
255
274
  // be unsubscribed in destroy() via teardown — otherwise the module-level
256
275
  // callback array pins the whole agent object graph.
276
+ // Managed plugin config (enabledPlugins / marketplaces) last applied to the
277
+ // plugin subsystem, keyed by a JSON signature: a synced change that touches
278
+ // neither field must not bounce every plugin's MCP servers.
279
+ const remoteAtSetup = remoteSettingsService.getRemoteSettingsSync();
280
+ let managedPluginSignature = JSON.stringify([
281
+ remoteAtSetup?.enabledPlugins ?? null,
282
+ remoteAtSetup?.marketplaces ?? null,
283
+ ]);
257
284
  teardowns.push(remoteSettingsService.onSettingsUpdate(async () => {
258
285
  await liveConfigManager.reload();
286
+ // Managed settings also drive plugin installation and loading: a synced
287
+ // change to `enabledPlugins` / `marketplaces` must re-read the plugins in
288
+ // place, otherwise an admin push would only take effect after a restart
289
+ // (spec enterprise server-managed-config「托管配置下发插件市场与启用列表」
290
+ // 场景 1 与 3).
291
+ const remote = remoteSettingsService.getRemoteSettingsSync();
292
+ const signature = JSON.stringify([
293
+ remote?.enabledPlugins ?? null,
294
+ remote?.marketplaces ?? null,
295
+ ]);
296
+ if (signature === managedPluginSignature) {
297
+ return;
298
+ }
299
+ managedPluginSignature = signature;
300
+ await container.get("PluginManager")?.reloadAllPlugins();
259
301
  }));
260
302
  const subagentManager = new SubagentManager(container, {
261
303
  workdir,
@@ -289,8 +331,8 @@ export function setupAgentContainer(setupOptions) {
289
331
  slashCommandManager.initialize();
290
332
  const pluginManager = new PluginManager(container, { workdir });
291
333
  container.register("PluginManager", pluginManager);
292
- const bangManager = new BangManager(container, { workdir });
293
- container.register("BangManager", bangManager);
334
+ const bashModeManager = new BashModeManager(container, { workdir });
335
+ container.register("BashModeManager", bashModeManager);
294
336
  const cronManager = new CronManager(container, messageManager.getSessionId());
295
337
  container.register("CronManager", cronManager);
296
338
  cronManager.start();
@@ -10,8 +10,14 @@ export interface ConvertMessagesOptions {
10
10
  /**
11
11
  * Convert message format to API call format, stopping when a compacted message is encountered.
12
12
  * Messages with no meaningful content or tool calls are filtered out.
13
+ *
14
+ * Async because every outbound image is routed through `planOutboundImage`,
15
+ * which may have to re-encode an oversized image with the optional `sharp`
16
+ * codec (`utils/imageRewrite.ts`). Images that are already inside the budget —
17
+ * the common case — take a synchronous fast path and come out byte-identical.
18
+ *
13
19
  * @param messages Message list
14
20
  * @param options Optional conversion options (e.g. supportsVision)
15
21
  * @returns Converted API message format list
16
22
  */
17
- export declare function convertMessagesForAPI(messages: Message[], options?: ConvertMessagesOptions): ChatCompletionMessageParam[];
23
+ export declare function convertMessagesForAPI(messages: Message[], options?: ConvertMessagesOptions): Promise<ChatCompletionMessageParam[]>;
@@ -1,3 +1,4 @@
1
+ import { imageFileCacheKey, planOutboundImage } from "./imageRewrite.js";
1
2
  import { convertImageToBase64 } from "./messageOperations.js";
2
3
  import { taskNotificationToXml } from "./notificationXml.js";
3
4
  import { recoverTruncatedJson, stripAnsiColors } from "./stringUtils.js";
@@ -35,11 +36,17 @@ function safeToolArguments(args) {
35
36
  /**
36
37
  * Convert message format to API call format, stopping when a compacted message is encountered.
37
38
  * Messages with no meaningful content or tool calls are filtered out.
39
+ *
40
+ * Async because every outbound image is routed through `planOutboundImage`,
41
+ * which may have to re-encode an oversized image with the optional `sharp`
42
+ * codec (`utils/imageRewrite.ts`). Images that are already inside the budget —
43
+ * the common case — take a synchronous fast path and come out byte-identical.
44
+ *
38
45
  * @param messages Message list
39
46
  * @param options Optional conversion options (e.g. supportsVision)
40
47
  * @returns Converted API message format list
41
48
  */
42
- export function convertMessagesForAPI(messages, options) {
49
+ export async function convertMessagesForAPI(messages, options) {
43
50
  const supportsVision = options?.supportsVision !== false;
44
51
  const recentMessages = [];
45
52
  const startIndex = messages.length - 1;
@@ -70,7 +77,7 @@ export function convertMessagesForAPI(messages, options) {
70
77
  if (toolBlocks.length > 0) {
71
78
  // Collect image user messages to place after all tool messages
72
79
  const imageUserMessages = [];
73
- toolBlocks.forEach((toolBlock) => {
80
+ for (const toolBlock of toolBlocks) {
74
81
  // Only add completed tool blocks (i.e., stage is 'end')
75
82
  if (toolBlock.id && toolBlock.stage === "end") {
76
83
  completedToolIds.add(toolBlock.id);
@@ -84,18 +91,34 @@ export function convertMessagesForAPI(messages, options) {
84
91
  if (toolBlock.images && toolBlock.images.length > 0) {
85
92
  if (supportsVision) {
86
93
  const contentParts = [];
87
- toolBlock.images.forEach((image) => {
94
+ for (const image of toolBlock.images) {
88
95
  const imageUrl = image.data.startsWith("data:")
89
96
  ? image.data
90
97
  : `data:${image.mediaType || "image/png"};base64,${image.data}`;
98
+ // Tool-produced images (Read screenshots, MCP results, Exec
99
+ // output) go through the same outbound budget as attachments:
100
+ // an image the gateway would reject is shrunk, or omitted with
101
+ // an actionable note, instead of failing the whole request.
102
+ const plan = await planOutboundImage({
103
+ dataUrl: imageUrl,
104
+ cacheKey: imageUrl,
105
+ sourcePath: image.path,
106
+ });
107
+ if (plan.kind === "omit") {
108
+ contentParts.push({ type: "text", text: plan.note });
109
+ continue;
110
+ }
91
111
  contentParts.push({
92
112
  type: "image_url",
93
113
  image_url: {
94
- url: imageUrl,
114
+ url: plan.dataUrl,
95
115
  detail: "auto",
96
116
  },
97
117
  });
98
- });
118
+ if (plan.note) {
119
+ contentParts.push({ type: "text", text: plan.note });
120
+ }
121
+ }
99
122
  imageUserMessages.push({
100
123
  role: "user",
101
124
  content: contentParts,
@@ -129,7 +152,7 @@ export function convertMessagesForAPI(messages, options) {
129
152
  }
130
153
  }
131
154
  }
132
- });
155
+ }
133
156
  // Insert image user messages after all tool messages but before the
134
157
  // assistant message (which will be unshifted next). Since tool messages
135
158
  // were unshifted to the front, we splice images right after them.
@@ -218,7 +241,7 @@ export function convertMessagesForAPI(messages, options) {
218
241
  else if (message.role === "user") {
219
242
  // User messages converted to standard format
220
243
  const contentParts = [];
221
- message.blocks.forEach((block) => {
244
+ for (const block of message.blocks) {
222
245
  // Add text content - only if it has meaningful content
223
246
  if (block.type === "text" &&
224
247
  block.content &&
@@ -253,28 +276,55 @@ export function convertMessagesForAPI(messages, options) {
253
276
  });
254
277
  }
255
278
  else {
256
- block.imageUrls.forEach((imageUrl) => {
279
+ for (const imageUrl of block.imageUrls) {
257
280
  // Check if it's already base64, convert if not
258
281
  const isDataUrl = imageUrl.startsWith("data:image/");
259
282
  let finalImageUrl = imageUrl;
260
283
  if (!isDataUrl) {
261
- // If it's a file path, it needs to be converted to base64
284
+ // If it's a file path, it needs to be converted to base64.
285
+ // Unreadable/empty/unknown-format files come back as
286
+ // undefined: skip the image rather than send an empty payload.
287
+ let converted;
262
288
  try {
263
- finalImageUrl = convertImageToBase64(imageUrl);
289
+ converted = convertImageToBase64(imageUrl);
264
290
  }
265
291
  catch (error) {
266
292
  logger.error("Failed to convert image path to base64:", imageUrl, error);
267
293
  // Skip this image, do not add to content
268
- return;
294
+ continue;
269
295
  }
296
+ if (!converted) {
297
+ logger.warn("Skipping unusable image file:", imageUrl);
298
+ continue;
299
+ }
300
+ finalImageUrl = converted;
301
+ }
302
+ // Outbound budget: an image the vision gateway would reject, or
303
+ // one over our own size budget, is shrunk here (or replaced by an
304
+ // actionable note when it cannot be). See utils/imageRewrite.ts.
305
+ const plan = await planOutboundImage({
306
+ dataUrl: finalImageUrl,
307
+ cacheKey: isDataUrl
308
+ ? finalImageUrl
309
+ : imageFileCacheKey(imageUrl),
310
+ sourcePath: isDataUrl ? undefined : imageUrl,
311
+ });
312
+ if (plan.kind === "omit") {
313
+ contentParts.push({ type: "text", text: plan.note });
314
+ continue;
270
315
  }
271
316
  contentParts.push({
272
317
  type: "image_url",
273
318
  image_url: {
274
- url: finalImageUrl,
319
+ url: plan.dataUrl,
275
320
  detail: "auto",
276
321
  },
277
322
  });
323
+ // Tell the model when the image it sees is not the full-size
324
+ // original, so it can map coordinates back.
325
+ if (plan.note) {
326
+ contentParts.push({ type: "text", text: plan.note });
327
+ }
278
328
  // Aligned with Claude Code: when the image comes from a local
279
329
  // file (not an inline dataURL), append its source path as text
280
330
  // metadata so the model can reference the file with tools
@@ -285,7 +335,7 @@ export function convertMessagesForAPI(messages, options) {
285
335
  text: `[Image source: ${imageUrl}]`,
286
336
  });
287
337
  }
288
- });
338
+ }
289
339
  }
290
340
  }
291
341
  // If there is a tool block in user message, add its result
@@ -302,7 +352,7 @@ export function convertMessagesForAPI(messages, options) {
302
352
  text: `A background agent completed a task:\n${taskNotificationToXml(block)}`,
303
353
  });
304
354
  }
305
- });
355
+ }
306
356
  // Only add user message if there is meaningful content
307
357
  if (contentParts.length > 0) {
308
358
  // Filter out empty text parts
@@ -0,0 +1,20 @@
1
+ import type { ReadFileState } from "../tools/types.js";
2
+ /** Max number of changed files reported in a single reminder. */
3
+ export declare const CHANGED_FILES_MAX_FILES = 10;
4
+ /** Max bytes of the changed-lines snippet rendered for a single file. */
5
+ export declare const CHANGED_FILES_SNIPPET_MAX_BYTES = 2048;
6
+ /** Max bytes of the whole reminder (all reported files combined). */
7
+ export declare const CHANGED_FILES_TOTAL_MAX_BYTES = 8192;
8
+ /**
9
+ * Build a compact "what changed" snippet from the new file content: trim the
10
+ * common prefix/suffix, then render the differing region with line numbers.
11
+ * A deletion-only region renders as a removal note. Truncates at a line
12
+ * boundary to `maxBytes`. Returns "" when the contents are identical.
13
+ */
14
+ export declare function buildChangedSnippet(oldContent: string, newContent: string, maxBytes?: number): string;
15
+ /**
16
+ * Detect files that changed on disk since they were read and return a reminder
17
+ * body for the agent, or null when nothing changed. Refreshes the read state
18
+ * for every detected change (see the module comment).
19
+ */
20
+ export declare function getChangedFilesReminder(readFileState: ReadFileState | undefined): Promise<string | null>;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * External file-change detection for the main agent loop (aligned with Claude
3
+ * Code's `changed_files` attachment).
4
+ *
5
+ * For every file recorded in the session read state by a *full* read/write,
6
+ * this compares the on-disk mtime against the mtime recorded at read time and,
7
+ * when the file is newer AND the content actually differs, produces a short
8
+ * changed-lines summary. The read state entry is then refreshed to the new
9
+ * mtime/hash/content so that (a) the same change is reported only once, and
10
+ * (b) a subsequent Edit/Write passes the staleness check — the agent has just
11
+ * been told the new content.
12
+ *
13
+ * The writers this surfaces are the ones other than the main agent: the
14
+ * auto-memory extraction fork, an external editor, another session. The main
15
+ * agent's own Write/Edit refresh the read state on write, so they never show up
16
+ * here.
17
+ */
18
+ import { readFile, stat } from "node:fs/promises";
19
+ import { createHash } from "node:crypto";
20
+ /** Max number of changed files reported in a single reminder. */
21
+ export const CHANGED_FILES_MAX_FILES = 10;
22
+ /** Max bytes of the changed-lines snippet rendered for a single file. */
23
+ export const CHANGED_FILES_SNIPPET_MAX_BYTES = 2048;
24
+ /** Max bytes of the whole reminder (all reported files combined). */
25
+ export const CHANGED_FILES_TOTAL_MAX_BYTES = 8192;
26
+ function sha256(text) {
27
+ return createHash("sha256").update(text).digest("hex");
28
+ }
29
+ /** UTF-8 byte length — CJK content is ~3 bytes/char, so `.length` would under-count. */
30
+ function byteLength(text) {
31
+ return Buffer.byteLength(text, "utf-8");
32
+ }
33
+ /**
34
+ * Build a compact "what changed" snippet from the new file content: trim the
35
+ * common prefix/suffix, then render the differing region with line numbers.
36
+ * A deletion-only region renders as a removal note. Truncates at a line
37
+ * boundary to `maxBytes`. Returns "" when the contents are identical.
38
+ */
39
+ export function buildChangedSnippet(oldContent, newContent, maxBytes = CHANGED_FILES_SNIPPET_MAX_BYTES) {
40
+ const oldLines = oldContent.replace(/\r\n/g, "\n").split("\n");
41
+ const newLines = newContent.replace(/\r\n/g, "\n").split("\n");
42
+ const shared = Math.min(oldLines.length, newLines.length);
43
+ let prefix = 0;
44
+ while (prefix < shared && oldLines[prefix] === newLines[prefix])
45
+ prefix++;
46
+ let suffix = 0;
47
+ const maxSuffix = shared - prefix;
48
+ while (suffix < maxSuffix &&
49
+ oldLines[oldLines.length - 1 - suffix] ===
50
+ newLines[newLines.length - 1 - suffix]) {
51
+ suffix++;
52
+ }
53
+ const changedOld = oldLines.slice(prefix, oldLines.length - suffix);
54
+ const changedNew = newLines.slice(prefix, newLines.length - suffix);
55
+ if (changedOld.length === 0 && changedNew.length === 0)
56
+ return "";
57
+ if (changedNew.length === 0) {
58
+ const plural = changedOld.length === 1 ? "line" : "lines";
59
+ return `(${changedOld.length} ${plural} removed at line ${prefix + 1})`;
60
+ }
61
+ const rendered = [];
62
+ let used = 0;
63
+ let truncated = 0;
64
+ for (let i = 0; i < changedNew.length; i++) {
65
+ const line = `${String(prefix + i + 1).padStart(4, " ")} | ${changedNew[i]}`;
66
+ const cost = byteLength(line) + 1; // + trailing newline
67
+ if (used + cost > maxBytes) {
68
+ truncated = changedNew.length - i;
69
+ break;
70
+ }
71
+ rendered.push(line);
72
+ used += cost;
73
+ }
74
+ let snippet = rendered.join("\n");
75
+ if (truncated > 0) {
76
+ snippet += `\n... [${truncated} lines truncated] ...`;
77
+ }
78
+ return snippet;
79
+ }
80
+ /**
81
+ * Detect files that changed on disk since they were read and return a reminder
82
+ * body for the agent, or null when nothing changed. Refreshes the read state
83
+ * for every detected change (see the module comment).
84
+ */
85
+ export async function getChangedFilesReminder(readFileState) {
86
+ if (!readFileState || readFileState.size === 0)
87
+ return null;
88
+ const blocks = [];
89
+ let totalBytes = 0;
90
+ for (const [filePath, state] of readFileState) {
91
+ if (blocks.length >= CHANGED_FILES_MAX_FILES)
92
+ break;
93
+ // Partial reads cache only a slice — no reliable diff baseline.
94
+ if (state.offset !== undefined || state.limit !== undefined)
95
+ continue;
96
+ let mtimeMs;
97
+ let newContent;
98
+ try {
99
+ const stats = await stat(filePath);
100
+ mtimeMs = stats.mtime.getTime();
101
+ // Not newer than the recorded mtime → untouched, or touched by the main
102
+ // agent's own Write/Edit (which refreshes the entry to the post-write mtime).
103
+ if (mtimeMs <= state.mtime)
104
+ continue;
105
+ newContent = await readFile(filePath, "utf-8");
106
+ }
107
+ catch {
108
+ // Deleted or transiently unreadable — leave the entry alone and move on.
109
+ continue;
110
+ }
111
+ // Refresh the baseline so the same observation is not re-processed on every
112
+ // turn and a subsequent Edit passes the staleness check (the agent has just
113
+ // been told the new content).
114
+ const refresh = () => {
115
+ readFileState.set(filePath, {
116
+ mtime: mtimeMs,
117
+ hash: sha256(newContent),
118
+ source: "changed",
119
+ content: newContent,
120
+ offset: undefined,
121
+ limit: undefined,
122
+ });
123
+ };
124
+ const oldContent = state.content;
125
+ // No baseline, or the file was touched but its content is unchanged
126
+ // (git checkout / editor round-trip save / cloud sync / antivirus).
127
+ if (oldContent === undefined || oldContent === newContent) {
128
+ refresh();
129
+ continue;
130
+ }
131
+ const snippet = buildChangedSnippet(oldContent, newContent);
132
+ if (!snippet) {
133
+ refresh();
134
+ continue;
135
+ }
136
+ const block = `${filePath}\n${snippet}`;
137
+ if (totalBytes + byteLength(block) > CHANGED_FILES_TOTAL_MAX_BYTES) {
138
+ // Leave the entry un-refreshed so the change is reported on a later turn
139
+ // rather than silently dropped.
140
+ break;
141
+ }
142
+ refresh();
143
+ blocks.push(block);
144
+ totalBytes += byteLength(block);
145
+ }
146
+ if (blocks.length === 0)
147
+ return null;
148
+ return [
149
+ "The following files were modified on disk since you last read them — by another process such as a background agent, an external editor, or another session. Your earlier read of these files is out of date; re-read a file before editing it if the changed lines below are not enough.",
150
+ "",
151
+ blocks.join("\n\n"),
152
+ ].join("\n");
153
+ }
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "child_process";
2
- import { rgPath } from "./ripgrep.js";
2
+ import { getRgPath } from "./ripgrep.js";
3
3
  import fuzzysort from "fuzzysort";
4
4
  import { logger } from "./globalLogger.js";
5
5
  const EXCLUDED_FILES = [".git", ".DS_Store"];
@@ -7,12 +7,13 @@ const EXCLUDED_FILES = [".git", ".DS_Store"];
7
7
  * Execute ripgrep to get all file paths
8
8
  */
9
9
  async function getAllFiles(workingDirectory) {
10
- if (!rgPath) {
10
+ const rgBinary = getRgPath();
11
+ if (!rgBinary) {
11
12
  throw new Error("ripgrep is not available");
12
13
  }
13
14
  const rgArgs = ["--files", "--color=never", "--hidden"];
14
15
  return new Promise((resolve, reject) => {
15
- const child = spawn(rgPath, rgArgs, {
16
+ const child = spawn(rgBinary, rgArgs, {
16
17
  cwd: workingDirectory,
17
18
  stdio: ["ignore", "pipe", "pipe"],
18
19
  });
@@ -13,6 +13,25 @@ export declare function readFirstLine(filePath: string): Promise<string>;
13
13
  * @return {Promise<string[]>} - Array of non-empty lines (up to maxLines).
14
14
  */
15
15
  export declare function readFirstNLines(filePath: string, maxLines: number): Promise<string[]>;
16
+ /**
17
+ * Streams a file line by line without holding it in memory.
18
+ *
19
+ * Splits on `\n` and hands each segment to `onLine`; a trailing `\r` is left
20
+ * for the callback's own `trim()`. When the file does not end with a newline,
21
+ * the trailing partial segment is delivered too, so callers can apply the same
22
+ * "interrupted append" tolerance `JsonlHandler.read()` uses — its
23
+ * `endsWithNewline` flag is exactly the return value here.
24
+ *
25
+ * Memory stays at one line: chunks are consumed as they arrive and are not
26
+ * accumulated.
27
+ *
28
+ * @param {string} filePath - The path to the file.
29
+ * @param {(line: string) => void | Promise<void>} onLine - Called per segment, in file order.
30
+ * @return {Promise<{ endsWithNewline: boolean }>} - Whether the file ended with a newline (empty files count as true).
31
+ */
32
+ export declare function forEachLine(filePath: string, onLine: (line: string) => void | Promise<void>): Promise<{
33
+ endsWithNewline: boolean;
34
+ }>;
16
35
  /**
17
36
  * Reads a file from the end and returns the last non-empty line.
18
37
  *
@@ -26,6 +45,31 @@ export declare function readFirstNLines(filePath: string, maxLines: number): Pro
26
45
  * @return {Promise<string>} - The last non-empty line of the file, or an empty string if no non-empty lines found.
27
46
  */
28
47
  export declare function getLastLine(filePath: string, minLength?: number): Promise<string>;
48
+ /**
49
+ * Read up to `maxBytes` from the end of a file and return the lines it holds,
50
+ * oldest first. The window may start in the middle of a line, in which case
51
+ * that partial first line is dropped so every returned line is complete —
52
+ * callers parse them as JSON.
53
+ *
54
+ * @param {string} filePath - The path to the file.
55
+ * @param {number} maxBytes - Size of the tail window (default 64KB).
56
+ * @return {Promise<string[]>} - Trailing non-empty lines, or [] if unreadable.
57
+ */
58
+ export declare function readTailLines(filePath: string, maxBytes?: number): Promise<string[]>;
59
+ /**
60
+ * Synchronously read up to `maxBytes` from the end of a file and return the
61
+ * raw tail text (blank lines preserved). When the window starts mid-line the
62
+ * partial first line is dropped, so the result holds complete lines only.
63
+ *
64
+ * Synchronous on purpose: the caller (`BackgroundTaskManager.getOutput`) sits
65
+ * on the synchronous `getBackgroundTaskOutput` path that hosts and the CLI
66
+ * already consume. The window is capped, so the blocking read stays small.
67
+ *
68
+ * @param {string} filePath - The path to the file.
69
+ * @param {number} maxBytes - Size of the tail window (default 64KB).
70
+ * @return {string} - Tail text, or "" when the file is empty or unreadable.
71
+ */
72
+ export declare function readTailTextSync(filePath: string, maxBytes?: number): string;
29
73
  /**
30
74
  * Suggests similar paths if a file is not found.
31
75
  */