@hybridaione/hybridclaw 0.2.2 → 0.2.6

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 (277) hide show
  1. package/.github/workflows/ci.yml +70 -0
  2. package/.husky/pre-commit +1 -0
  3. package/CHANGELOG.md +85 -0
  4. package/CONTRIBUTING.md +33 -0
  5. package/README.md +41 -16
  6. package/SECURITY.md +17 -0
  7. package/biome.json +35 -0
  8. package/config.example.json +71 -8
  9. package/container/package-lock.json +2 -2
  10. package/container/package.json +1 -1
  11. package/container/src/approval-policy.ts +1303 -0
  12. package/container/src/browser-tools.ts +431 -136
  13. package/container/src/extensions.ts +36 -12
  14. package/container/src/hybridai-client.ts +34 -13
  15. package/container/src/index.ts +451 -109
  16. package/container/src/ipc.ts +5 -3
  17. package/container/src/token-usage.ts +20 -10
  18. package/container/src/tools.ts +599 -225
  19. package/container/src/types.ts +32 -2
  20. package/container/src/web-fetch.ts +89 -32
  21. package/dist/agent.d.ts.map +1 -1
  22. package/dist/agent.js +10 -2
  23. package/dist/agent.js.map +1 -1
  24. package/dist/audit-cli.d.ts.map +1 -1
  25. package/dist/audit-cli.js +4 -2
  26. package/dist/audit-cli.js.map +1 -1
  27. package/dist/audit-events.d.ts.map +1 -1
  28. package/dist/audit-events.js +53 -3
  29. package/dist/audit-events.js.map +1 -1
  30. package/dist/audit-trail.d.ts.map +1 -1
  31. package/dist/audit-trail.js +17 -8
  32. package/dist/audit-trail.js.map +1 -1
  33. package/dist/channels/discord/attachments.d.ts.map +1 -1
  34. package/dist/channels/discord/attachments.js +14 -7
  35. package/dist/channels/discord/attachments.js.map +1 -1
  36. package/dist/channels/discord/debounce.d.ts +9 -0
  37. package/dist/channels/discord/debounce.d.ts.map +1 -0
  38. package/dist/channels/discord/debounce.js +20 -0
  39. package/dist/channels/discord/debounce.js.map +1 -0
  40. package/dist/channels/discord/delivery.d.ts +4 -1
  41. package/dist/channels/discord/delivery.d.ts.map +1 -1
  42. package/dist/channels/discord/delivery.js +19 -3
  43. package/dist/channels/discord/delivery.js.map +1 -1
  44. package/dist/channels/discord/human-delay.d.ts +16 -0
  45. package/dist/channels/discord/human-delay.d.ts.map +1 -0
  46. package/dist/channels/discord/human-delay.js +29 -0
  47. package/dist/channels/discord/human-delay.js.map +1 -0
  48. package/dist/channels/discord/inbound.d.ts +4 -0
  49. package/dist/channels/discord/inbound.d.ts.map +1 -1
  50. package/dist/channels/discord/inbound.js +45 -4
  51. package/dist/channels/discord/inbound.js.map +1 -1
  52. package/dist/channels/discord/mentions.d.ts.map +1 -1
  53. package/dist/channels/discord/mentions.js +16 -4
  54. package/dist/channels/discord/mentions.js.map +1 -1
  55. package/dist/channels/discord/presence.d.ts +33 -0
  56. package/dist/channels/discord/presence.d.ts.map +1 -0
  57. package/dist/channels/discord/presence.js +111 -0
  58. package/dist/channels/discord/presence.js.map +1 -0
  59. package/dist/channels/discord/rate-limiter.d.ts +14 -0
  60. package/dist/channels/discord/rate-limiter.d.ts.map +1 -0
  61. package/dist/channels/discord/rate-limiter.js +49 -0
  62. package/dist/channels/discord/rate-limiter.js.map +1 -0
  63. package/dist/channels/discord/reactions.d.ts +38 -0
  64. package/dist/channels/discord/reactions.d.ts.map +1 -0
  65. package/dist/channels/discord/reactions.js +151 -0
  66. package/dist/channels/discord/reactions.js.map +1 -0
  67. package/dist/channels/discord/runtime.d.ts +6 -3
  68. package/dist/channels/discord/runtime.d.ts.map +1 -1
  69. package/dist/channels/discord/runtime.js +621 -125
  70. package/dist/channels/discord/runtime.js.map +1 -1
  71. package/dist/channels/discord/stream.d.ts +4 -1
  72. package/dist/channels/discord/stream.d.ts.map +1 -1
  73. package/dist/channels/discord/stream.js +16 -8
  74. package/dist/channels/discord/stream.js.map +1 -1
  75. package/dist/channels/discord/tool-actions.d.ts.map +1 -1
  76. package/dist/channels/discord/tool-actions.js +24 -12
  77. package/dist/channels/discord/tool-actions.js.map +1 -1
  78. package/dist/channels/discord/typing.d.ts +15 -0
  79. package/dist/channels/discord/typing.d.ts.map +1 -0
  80. package/dist/channels/discord/typing.js +106 -0
  81. package/dist/channels/discord/typing.js.map +1 -0
  82. package/dist/chunk.d.ts.map +1 -1
  83. package/dist/chunk.js +4 -2
  84. package/dist/chunk.js.map +1 -1
  85. package/dist/cli.js +47 -22
  86. package/dist/cli.js.map +1 -1
  87. package/dist/config.d.ts +19 -0
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +103 -18
  90. package/dist/config.js.map +1 -1
  91. package/dist/container-runner.d.ts.map +1 -1
  92. package/dist/container-runner.js +58 -26
  93. package/dist/container-runner.js.map +1 -1
  94. package/dist/container-setup.d.ts.map +1 -1
  95. package/dist/container-setup.js +10 -9
  96. package/dist/container-setup.js.map +1 -1
  97. package/dist/conversation.d.ts +2 -2
  98. package/dist/conversation.d.ts.map +1 -1
  99. package/dist/conversation.js +1 -1
  100. package/dist/conversation.js.map +1 -1
  101. package/dist/db.d.ts +118 -2
  102. package/dist/db.d.ts.map +1 -1
  103. package/dist/db.js +1568 -50
  104. package/dist/db.js.map +1 -1
  105. package/dist/delegation-manager.d.ts.map +1 -1
  106. package/dist/delegation-manager.js +3 -2
  107. package/dist/delegation-manager.js.map +1 -1
  108. package/dist/gateway-client.d.ts +2 -2
  109. package/dist/gateway-client.d.ts.map +1 -1
  110. package/dist/gateway-client.js +10 -4
  111. package/dist/gateway-client.js.map +1 -1
  112. package/dist/gateway-service.d.ts +3 -3
  113. package/dist/gateway-service.d.ts.map +1 -1
  114. package/dist/gateway-service.js +563 -73
  115. package/dist/gateway-service.js.map +1 -1
  116. package/dist/gateway-types.d.ts +24 -0
  117. package/dist/gateway-types.d.ts.map +1 -1
  118. package/dist/gateway-types.js.map +1 -1
  119. package/dist/gateway.js +179 -24
  120. package/dist/gateway.js.map +1 -1
  121. package/dist/health.d.ts.map +1 -1
  122. package/dist/health.js +20 -10
  123. package/dist/health.js.map +1 -1
  124. package/dist/heartbeat.d.ts +4 -0
  125. package/dist/heartbeat.d.ts.map +1 -1
  126. package/dist/heartbeat.js +48 -20
  127. package/dist/heartbeat.js.map +1 -1
  128. package/dist/hybridai-bots.d.ts.map +1 -1
  129. package/dist/hybridai-bots.js +4 -2
  130. package/dist/hybridai-bots.js.map +1 -1
  131. package/dist/instruction-approval-audit.d.ts.map +1 -1
  132. package/dist/instruction-approval-audit.js.map +1 -1
  133. package/dist/instruction-integrity.d.ts.map +1 -1
  134. package/dist/instruction-integrity.js +8 -2
  135. package/dist/instruction-integrity.js.map +1 -1
  136. package/dist/ipc.d.ts.map +1 -1
  137. package/dist/ipc.js +6 -1
  138. package/dist/ipc.js.map +1 -1
  139. package/dist/logger.js.map +1 -1
  140. package/dist/memory-consolidation.d.ts +17 -0
  141. package/dist/memory-consolidation.d.ts.map +1 -0
  142. package/dist/memory-consolidation.js +25 -0
  143. package/dist/memory-consolidation.js.map +1 -0
  144. package/dist/memory-service.d.ts +200 -0
  145. package/dist/memory-service.d.ts.map +1 -0
  146. package/dist/memory-service.js +294 -0
  147. package/dist/memory-service.js.map +1 -0
  148. package/dist/mount-security.d.ts.map +1 -1
  149. package/dist/mount-security.js +31 -7
  150. package/dist/mount-security.js.map +1 -1
  151. package/dist/observability-ingest.d.ts.map +1 -1
  152. package/dist/observability-ingest.js +32 -11
  153. package/dist/observability-ingest.js.map +1 -1
  154. package/dist/onboarding.d.ts.map +1 -1
  155. package/dist/onboarding.js +32 -9
  156. package/dist/onboarding.js.map +1 -1
  157. package/dist/proactive-policy.d.ts.map +1 -1
  158. package/dist/proactive-policy.js +2 -1
  159. package/dist/proactive-policy.js.map +1 -1
  160. package/dist/prompt-hooks.d.ts.map +1 -1
  161. package/dist/prompt-hooks.js +9 -7
  162. package/dist/prompt-hooks.js.map +1 -1
  163. package/dist/runtime-config.d.ts +98 -1
  164. package/dist/runtime-config.d.ts.map +1 -1
  165. package/dist/runtime-config.js +477 -23
  166. package/dist/runtime-config.js.map +1 -1
  167. package/dist/scheduled-task-runner.d.ts +1 -0
  168. package/dist/scheduled-task-runner.d.ts.map +1 -1
  169. package/dist/scheduled-task-runner.js +29 -10
  170. package/dist/scheduled-task-runner.js.map +1 -1
  171. package/dist/scheduler.d.ts +43 -4
  172. package/dist/scheduler.d.ts.map +1 -1
  173. package/dist/scheduler.js +530 -56
  174. package/dist/scheduler.js.map +1 -1
  175. package/dist/session-export.d.ts +26 -0
  176. package/dist/session-export.d.ts.map +1 -0
  177. package/dist/session-export.js +149 -0
  178. package/dist/session-export.js.map +1 -0
  179. package/dist/session-maintenance.d.ts.map +1 -1
  180. package/dist/session-maintenance.js +75 -13
  181. package/dist/session-maintenance.js.map +1 -1
  182. package/dist/session-transcripts.d.ts.map +1 -1
  183. package/dist/session-transcripts.js.map +1 -1
  184. package/dist/side-effects.d.ts.map +1 -1
  185. package/dist/side-effects.js +14 -2
  186. package/dist/side-effects.js.map +1 -1
  187. package/dist/skills-guard.d.ts.map +1 -1
  188. package/dist/skills-guard.js +893 -130
  189. package/dist/skills-guard.js.map +1 -1
  190. package/dist/skills.d.ts +5 -0
  191. package/dist/skills.d.ts.map +1 -1
  192. package/dist/skills.js +29 -15
  193. package/dist/skills.js.map +1 -1
  194. package/dist/token-efficiency.d.ts.map +1 -1
  195. package/dist/token-efficiency.js.map +1 -1
  196. package/dist/tui.js +92 -11
  197. package/dist/tui.js.map +1 -1
  198. package/dist/types.d.ts +146 -0
  199. package/dist/types.d.ts.map +1 -1
  200. package/dist/types.js +24 -1
  201. package/dist/types.js.map +1 -1
  202. package/dist/update.d.ts.map +1 -1
  203. package/dist/update.js +42 -14
  204. package/dist/update.js.map +1 -1
  205. package/dist/workspace.d.ts.map +1 -1
  206. package/dist/workspace.js +49 -9
  207. package/dist/workspace.js.map +1 -1
  208. package/docs/chat.html +9 -3
  209. package/docs/index.html +37 -13
  210. package/package.json +8 -2
  211. package/src/agent.ts +16 -3
  212. package/src/audit-cli.ts +44 -16
  213. package/src/audit-events.ts +69 -5
  214. package/src/audit-trail.ts +41 -15
  215. package/src/channels/discord/attachments.ts +81 -27
  216. package/src/channels/discord/debounce.ts +25 -0
  217. package/src/channels/discord/delivery.ts +57 -13
  218. package/src/channels/discord/human-delay.ts +48 -0
  219. package/src/channels/discord/inbound.ts +66 -7
  220. package/src/channels/discord/mentions.ts +42 -18
  221. package/src/channels/discord/presence.ts +148 -0
  222. package/src/channels/discord/rate-limiter.ts +58 -0
  223. package/src/channels/discord/reactions.ts +211 -0
  224. package/src/channels/discord/runtime.ts +1048 -182
  225. package/src/channels/discord/stream.ts +73 -27
  226. package/src/channels/discord/tool-actions.ts +78 -37
  227. package/src/channels/discord/typing.ts +140 -0
  228. package/src/chunk.ts +12 -4
  229. package/src/cli.ts +141 -56
  230. package/src/config.ts +192 -34
  231. package/src/container-runner.ts +132 -42
  232. package/src/container-setup.ts +57 -22
  233. package/src/conversation.ts +9 -7
  234. package/src/db.ts +2217 -84
  235. package/src/delegation-manager.ts +6 -2
  236. package/src/gateway-client.ts +41 -17
  237. package/src/gateway-service.ts +1019 -201
  238. package/src/gateway-types.ts +33 -0
  239. package/src/gateway.ts +321 -48
  240. package/src/health.ts +66 -26
  241. package/src/heartbeat.ts +84 -22
  242. package/src/hybridai-bots.ts +14 -5
  243. package/src/instruction-approval-audit.ts +4 -1
  244. package/src/instruction-integrity.ts +30 -9
  245. package/src/ipc.ts +23 -5
  246. package/src/logger.ts +4 -1
  247. package/src/memory-consolidation.ts +41 -0
  248. package/src/memory-service.ts +606 -0
  249. package/src/mount-security.ts +58 -13
  250. package/src/observability-ingest.ts +134 -35
  251. package/src/onboarding.ts +126 -35
  252. package/src/proactive-policy.ts +3 -1
  253. package/src/prompt-hooks.ts +40 -17
  254. package/src/runtime-config.ts +1114 -99
  255. package/src/scheduled-task-runner.ts +63 -11
  256. package/src/scheduler.ts +683 -60
  257. package/src/session-export.ts +196 -0
  258. package/src/session-maintenance.ts +125 -22
  259. package/src/session-transcripts.ts +12 -3
  260. package/src/side-effects.ts +28 -5
  261. package/src/skills-guard.ts +1067 -219
  262. package/src/skills.ts +163 -65
  263. package/src/token-efficiency.ts +31 -9
  264. package/src/tui.ts +166 -25
  265. package/src/types.ts +195 -2
  266. package/src/update.ts +79 -23
  267. package/src/workspace.ts +63 -11
  268. package/tests/approval-policy.test.ts +224 -0
  269. package/tests/discord.basic.test.ts +82 -2
  270. package/tests/discord.human-presence.test.ts +85 -0
  271. package/tests/gateway-service.media-routing.test.ts +8 -2
  272. package/tests/memory-service.test.ts +1114 -0
  273. package/tests/token-efficiency.basic.test.ts +8 -2
  274. package/vitest.e2e.config.ts +3 -1
  275. package/vitest.integration.config.ts +3 -1
  276. package/vitest.live.config.ts +3 -1
  277. package/vitest.unit.config.ts +9 -0
@@ -1,9 +1,18 @@
1
- import path from 'path';
2
- import fs from 'fs';
3
- import { URL } from 'url';
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { URL } from 'node:url';
4
4
 
5
- import { emitRuntimeEvent, runAfterToolHooks, runBeforeToolHooks } from './extensions.js';
6
- import { callHybridAI, callHybridAIStream, HybridAIRequestError } from './hybridai-client.js';
5
+ import { TrustedCoworkerApprovalRuntime } from './approval-policy.js';
6
+ import {
7
+ emitRuntimeEvent,
8
+ runAfterToolHooks,
9
+ runBeforeToolHooks,
10
+ } from './extensions.js';
11
+ import {
12
+ callHybridAI,
13
+ callHybridAIStream,
14
+ HybridAIRequestError,
15
+ } from './hybridai-client.js';
7
16
  import { waitForInput, writeOutput } from './ipc.js';
8
17
  import {
9
18
  accumulateApiUsage,
@@ -36,16 +45,33 @@ import type {
36
45
  } from './types.js';
37
46
 
38
47
  const MAX_ITERATIONS = 20;
39
- const IDLE_TIMEOUT_MS = parseInt(process.env.CONTAINER_IDLE_TIMEOUT || '300000', 10); // 5 min
48
+ const IDLE_TIMEOUT_MS = parseInt(
49
+ process.env.CONTAINER_IDLE_TIMEOUT || '300000',
50
+ 10,
51
+ ); // 5 min
40
52
  const RETRY_ENABLED = process.env.HYBRIDCLAW_RETRY_ENABLED !== 'false';
41
- const RETRY_MAX_ATTEMPTS = Math.max(1, parseInt(process.env.HYBRIDCLAW_RETRY_MAX_ATTEMPTS || '3', 10));
42
- const RETRY_BASE_DELAY_MS = Math.max(100, parseInt(process.env.HYBRIDCLAW_RETRY_BASE_DELAY_MS || '2000', 10));
43
- const RETRY_MAX_DELAY_MS = Math.max(RETRY_BASE_DELAY_MS, parseInt(process.env.HYBRIDCLAW_RETRY_MAX_DELAY_MS || '8000', 10));
44
- const RAW_RALPH_MAX_EXTRA_ITERATIONS = Number.parseInt(process.env.HYBRIDCLAW_RALPH_MAX_ITERATIONS || '0', 10);
45
- const RALPH_MAX_EXTRA_ITERATIONS = Number.isFinite(RAW_RALPH_MAX_EXTRA_ITERATIONS)
46
- ? (RAW_RALPH_MAX_EXTRA_ITERATIONS === -1
53
+ const RETRY_MAX_ATTEMPTS = Math.max(
54
+ 1,
55
+ parseInt(process.env.HYBRIDCLAW_RETRY_MAX_ATTEMPTS || '3', 10),
56
+ );
57
+ const RETRY_BASE_DELAY_MS = Math.max(
58
+ 100,
59
+ parseInt(process.env.HYBRIDCLAW_RETRY_BASE_DELAY_MS || '2000', 10),
60
+ );
61
+ const RETRY_MAX_DELAY_MS = Math.max(
62
+ RETRY_BASE_DELAY_MS,
63
+ parseInt(process.env.HYBRIDCLAW_RETRY_MAX_DELAY_MS || '8000', 10),
64
+ );
65
+ const RAW_RALPH_MAX_EXTRA_ITERATIONS = Number.parseInt(
66
+ process.env.HYBRIDCLAW_RALPH_MAX_ITERATIONS || '0',
67
+ 10,
68
+ );
69
+ const RALPH_MAX_EXTRA_ITERATIONS = Number.isFinite(
70
+ RAW_RALPH_MAX_EXTRA_ITERATIONS,
71
+ )
72
+ ? RAW_RALPH_MAX_EXTRA_ITERATIONS === -1
47
73
  ? -1
48
- : Math.max(0, Math.min(64, RAW_RALPH_MAX_EXTRA_ITERATIONS)))
74
+ : Math.max(0, Math.min(64, RAW_RALPH_MAX_EXTRA_ITERATIONS))
49
75
  : 0;
50
76
  const RALPH_ENABLED = RALPH_MAX_EXTRA_ITERATIONS !== 0;
51
77
  const WORKSPACE_ROOT = '/workspace';
@@ -67,6 +93,7 @@ const DISCORD_CDN_HOST_PATTERNS: RegExp[] = [
67
93
  /^cdn\.discordapp\.net$/i,
68
94
  /^images-ext-\d+\.discordapp\.net$/i,
69
95
  ];
96
+ const approvalRuntime = new TrustedCoworkerApprovalRuntime();
70
97
 
71
98
  /** API key received once via stdin, held in memory for the container lifetime. */
72
99
  let storedApiKey = '';
@@ -97,16 +124,25 @@ function normalizeAllowedLocalImagePath(rawPath: string): string | null {
97
124
 
98
125
  const candidate = trimmed.startsWith('/')
99
126
  ? path.posix.normalize(normalizePathSlashes(trimmed))
100
- : path.posix.normalize(path.posix.join(workspace, normalizePathSlashes(trimmed)));
127
+ : path.posix.normalize(
128
+ path.posix.join(workspace, normalizePathSlashes(trimmed)),
129
+ );
101
130
 
102
- const underWorkspace = candidate === workspace || candidate.startsWith(`${workspace}/`);
103
- const underMediaRoot = candidate === mediaRoot || candidate.startsWith(`${mediaRoot}/`);
131
+ const underWorkspace =
132
+ candidate === workspace || candidate.startsWith(`${workspace}/`);
133
+ const underMediaRoot =
134
+ candidate === mediaRoot || candidate.startsWith(`${mediaRoot}/`);
104
135
  if (!underWorkspace && !underMediaRoot) return null;
105
136
  return candidate;
106
137
  }
107
138
 
108
- function inferImageMimeType(filePath: string, fallbackMime: string | null | undefined): string {
109
- const normalizedFallback = String(fallbackMime || '').trim().toLowerCase();
139
+ function inferImageMimeType(
140
+ filePath: string,
141
+ fallbackMime: string | null | undefined,
142
+ ): string {
143
+ const normalizedFallback = String(fallbackMime || '')
144
+ .trim()
145
+ .toLowerCase();
110
146
  if (normalizedFallback.startsWith('image/')) return normalizedFallback;
111
147
  const ext = path.posix.extname(filePath).toLowerCase();
112
148
  return ARTIFACT_MIME_TYPES[ext] || 'image/png';
@@ -120,46 +156,58 @@ function isSafeDiscordCdnUrl(raw: string): boolean {
120
156
  return false;
121
157
  }
122
158
  if (parsed.protocol !== 'https:') return false;
123
- return DISCORD_CDN_HOST_PATTERNS.some((pattern) => pattern.test(parsed.hostname));
159
+ return DISCORD_CDN_HOST_PATTERNS.some((pattern) =>
160
+ pattern.test(parsed.hostname),
161
+ );
124
162
  }
125
163
 
126
164
  function modelSupportsNativeVision(model: string): boolean {
127
165
  const normalized = model.toLowerCase();
128
166
  if (!normalized) return false;
129
167
  if (
130
- normalized.includes('gpt-5')
131
- || normalized.includes('gpt-4o')
132
- || normalized.includes('gpt-4.1')
133
- || normalized.includes('o1')
134
- || normalized.includes('o3')
135
- || normalized.includes('vision')
136
- || normalized.includes('multimodal')
137
- || normalized.includes('gemini')
138
- || normalized.includes('claude-3')
168
+ normalized.includes('gpt-5') ||
169
+ normalized.includes('gpt-4o') ||
170
+ normalized.includes('gpt-4.1') ||
171
+ normalized.includes('o1') ||
172
+ normalized.includes('o3') ||
173
+ normalized.includes('vision') ||
174
+ normalized.includes('multimodal') ||
175
+ normalized.includes('gemini') ||
176
+ normalized.includes('claude-3')
139
177
  ) {
140
178
  return true;
141
179
  }
142
180
  return false;
143
181
  }
144
182
 
145
- async function resolveMediaImagePartUrl(item: MediaContextItem): Promise<string | null> {
146
- const localPath = item.path ? normalizeAllowedLocalImagePath(item.path) : null;
183
+ async function resolveMediaImagePartUrl(
184
+ item: MediaContextItem,
185
+ ): Promise<string | null> {
186
+ const localPath = item.path
187
+ ? normalizeAllowedLocalImagePath(item.path)
188
+ : null;
147
189
  if (localPath) {
148
190
  try {
149
191
  const image = await fs.promises.readFile(localPath);
150
192
  if (image.length > NATIVE_VISION_MAX_IMAGE_BYTES) {
151
- console.error(`[media] skipping ${localPath}: ${image.length}B exceeds native vision max`);
193
+ console.error(
194
+ `[media] skipping ${localPath}: ${image.length}B exceeds native vision max`,
195
+ );
152
196
  } else {
153
197
  const mimeType = inferImageMimeType(localPath, item.mimeType);
154
198
  const base64 = image.toString('base64');
155
199
  return `data:${mimeType};base64,${base64}`;
156
200
  }
157
201
  } catch (err) {
158
- console.error(`[media] failed to read local media ${localPath}: ${err instanceof Error ? err.message : String(err)}`);
202
+ console.error(
203
+ `[media] failed to read local media ${localPath}: ${err instanceof Error ? err.message : String(err)}`,
204
+ );
159
205
  }
160
206
  }
161
207
 
162
- const fallbackCandidates = [item.url, item.originalUrl].map((value) => String(value || '').trim()).filter(Boolean);
208
+ const fallbackCandidates = [item.url, item.originalUrl]
209
+ .map((value) => String(value || '').trim())
210
+ .filter(Boolean);
163
211
  for (const candidate of fallbackCandidates) {
164
212
  if (!isSafeDiscordCdnUrl(candidate)) continue;
165
213
  return candidate;
@@ -193,12 +241,17 @@ async function injectNativeVisionContent(
193
241
  if (latestUserIndex < 0) return messages;
194
242
 
195
243
  const cloned = messages.map((msg) => ({ ...msg }));
196
- const existingText = normalizeMessageContentToText(cloned[latestUserIndex].content);
244
+ const existingText = normalizeMessageContentToText(
245
+ cloned[latestUserIndex].content,
246
+ );
197
247
  const contentParts: ChatContentPart[] = [];
198
248
  const nativeVisionHint =
199
249
  '[NativeVision] Image parts are attached in this message. Analyze them directly and skip extra vision tool pre-analysis unless explicitly required.';
200
250
  if (existingText) {
201
- contentParts.push({ type: 'text', text: `${existingText}\n\n${nativeVisionHint}` });
251
+ contentParts.push({
252
+ type: 'text',
253
+ text: `${existingText}\n\n${nativeVisionHint}`,
254
+ });
202
255
  } else {
203
256
  contentParts.push({ type: 'text', text: nativeVisionHint });
204
257
  }
@@ -207,7 +260,9 @@ async function injectNativeVisionContent(
207
260
  ...cloned[latestUserIndex],
208
261
  content: contentParts,
209
262
  };
210
- console.error(`[media] injected ${imageParts.length} native vision image part(s) for model ${model}`);
263
+ console.error(
264
+ `[media] injected ${imageParts.length} native vision image part(s) for model ${model}`,
265
+ );
211
266
  return cloned;
212
267
  }
213
268
 
@@ -254,26 +309,63 @@ function isRetryableError(err: unknown): boolean {
254
309
  return err.status === 429 || (err.status >= 500 && err.status <= 504);
255
310
  }
256
311
  const message = err instanceof Error ? err.message : String(err);
257
- return /fetch failed|network|socket|timeout|timed out|ECONNRESET|ECONNREFUSED|EAI_AGAIN/i.test(message);
312
+ return /fetch failed|network|socket|timeout|timed out|ECONNRESET|ECONNREFUSED|EAI_AGAIN/i.test(
313
+ message,
314
+ );
258
315
  }
259
316
 
260
317
  function inferToolError(result: string, blockedReason: string | null): boolean {
261
318
  if (blockedReason) return true;
262
- return /\b(error|failed|denied|forbidden|timed out|timeout|exception|invalid)\b/i.test(result);
319
+ return /\b(error|failed|denied|forbidden|timed out|timeout|exception|invalid)\b/i.test(
320
+ result,
321
+ );
263
322
  }
264
323
 
265
324
  function latestUserPrompt(messages: ChatMessage[]): string {
266
325
  for (let i = messages.length - 1; i >= 0; i--) {
267
326
  const message = messages[i];
268
327
  if (message.role !== 'user') continue;
269
- const text = normalizeMessageContentToText(message.content).replace(/\s+/g, ' ').trim();
328
+ const text = normalizeMessageContentToText(message.content)
329
+ .replace(/\s+/g, ' ')
330
+ .trim();
270
331
  if (!text) continue;
271
332
  return text.slice(0, 1_200);
272
333
  }
273
334
  return 'Continue the task';
274
335
  }
275
336
 
276
- function parseRalphChoice(content: ChatMessageContent): 'CONTINUE' | 'STOP' | null {
337
+ function cloneMessageWithTextContent(
338
+ message: ChatMessage,
339
+ text: string,
340
+ ): ChatMessage {
341
+ if (typeof message.content === 'string' || message.content == null) {
342
+ return {
343
+ ...message,
344
+ content: text,
345
+ };
346
+ }
347
+ return {
348
+ ...message,
349
+ content: [{ type: 'text', text }],
350
+ };
351
+ }
352
+
353
+ function replaceLatestUserPrompt(
354
+ messages: ChatMessage[],
355
+ prompt: string,
356
+ ): ChatMessage[] {
357
+ for (let i = messages.length - 1; i >= 0; i--) {
358
+ if (messages[i].role !== 'user') continue;
359
+ const cloned = messages.map((entry) => ({ ...entry }));
360
+ cloned[i] = cloneMessageWithTextContent(cloned[i], prompt);
361
+ return cloned;
362
+ }
363
+ return [...messages, { role: 'user', content: prompt }];
364
+ }
365
+
366
+ function parseRalphChoice(
367
+ content: ChatMessageContent,
368
+ ): 'CONTINUE' | 'STOP' | null {
277
369
  const normalizedContent = normalizeMessageContentToText(content);
278
370
  if (!normalizedContent) return null;
279
371
  const re = /<choice>\s*([^<]*)\s*<\/choice>/gi;
@@ -299,7 +391,9 @@ function stripRalphChoiceTags(content: ChatMessageContent): string | null {
299
391
  }
300
392
 
301
393
  function buildRalphPrompt(taskPrompt: string, missingChoice: boolean): string {
302
- const punctuatedPrompt = /[.!?]$/.test(taskPrompt) ? taskPrompt : `${taskPrompt}.`;
394
+ const punctuatedPrompt = /[.!?]$/.test(taskPrompt)
395
+ ? taskPrompt
396
+ : `${taskPrompt}.`;
303
397
  const lines = [
304
398
  `${punctuatedPrompt} (You are running in an automated loop where the same prompt is fed repeatedly. Only choose STOP when the task is fully complete. Including it will stop further iterations. If you are not 100% sure, choose CONTINUE.)`,
305
399
  '',
@@ -311,7 +405,9 @@ function buildRalphPrompt(taskPrompt: string, missingChoice: boolean): string {
311
405
  ];
312
406
  if (missingChoice) {
313
407
  lines.push('');
314
- lines.push('Your last response did not include a valid choice. Include exactly one: CONTINUE or STOP.');
408
+ lines.push(
409
+ 'Your last response did not include a valid choice. Include exactly one: CONTINUE or STOP.',
410
+ );
315
411
  }
316
412
  return lines.join('\n');
317
413
  }
@@ -333,7 +429,10 @@ function normalizeArtifactPath(rawPath: unknown): string | null {
333
429
  const normalized = value.replace(/\\/g, '/');
334
430
  if (path.posix.isAbsolute(normalized)) {
335
431
  const cleanAbs = path.posix.normalize(normalized);
336
- if (cleanAbs === WORKSPACE_ROOT || cleanAbs.startsWith(`${WORKSPACE_ROOT}/`)) {
432
+ if (
433
+ cleanAbs === WORKSPACE_ROOT ||
434
+ cleanAbs.startsWith(`${WORKSPACE_ROOT}/`)
435
+ ) {
337
436
  return cleanAbs;
338
437
  }
339
438
  return null;
@@ -344,7 +443,10 @@ function normalizeArtifactPath(rawPath: unknown): string | null {
344
443
  return path.posix.join(WORKSPACE_ROOT, clean);
345
444
  }
346
445
 
347
- function extractToolArtifacts(toolName: string, result: string): ArtifactMetadata[] {
446
+ function extractToolArtifacts(
447
+ toolName: string,
448
+ result: string,
449
+ ): ArtifactMetadata[] {
348
450
  let parsed: Record<string, unknown> | null = null;
349
451
  try {
350
452
  const value = JSON.parse(result) as unknown;
@@ -358,7 +460,11 @@ function extractToolArtifacts(toolName: string, result: string): ArtifactMetadat
358
460
  if (!parsed || parsed.success !== true) return [];
359
461
  const artifacts: ArtifactMetadata[] = [];
360
462
 
361
- const addArtifact = (rawPath: unknown, rawFilename?: unknown, rawMimeType?: unknown): void => {
463
+ const addArtifact = (
464
+ rawPath: unknown,
465
+ rawFilename?: unknown,
466
+ rawMimeType?: unknown,
467
+ ): void => {
362
468
  const normalizedPath = normalizeArtifactPath(rawPath);
363
469
  if (!normalizedPath) return;
364
470
  const filename =
@@ -414,7 +520,7 @@ async function callHybridAIWithRetry(params: {
414
520
  attempt += 1;
415
521
  await emitRuntimeEvent({ event: 'before_model_call', attempt });
416
522
  try {
417
- let response;
523
+ let response: Awaited<ReturnType<typeof callHybridAI>>;
418
524
  if (onTextDelta) {
419
525
  try {
420
526
  response = await callHybridAIStream(
@@ -429,20 +535,41 @@ async function callHybridAIWithRetry(params: {
429
535
  );
430
536
  } catch (streamErr) {
431
537
  const fallbackEligible =
432
- streamErr instanceof HybridAIRequestError
433
- && streamErr.status >= 400
434
- && streamErr.status < 500
435
- && streamErr.status !== 429;
538
+ streamErr instanceof HybridAIRequestError &&
539
+ streamErr.status >= 400 &&
540
+ streamErr.status < 500 &&
541
+ streamErr.status !== 429;
436
542
  if (!fallbackEligible) throw streamErr;
437
- response = await callHybridAI(baseUrl, apiKey, model, chatbotId, enableRag, history, tools);
543
+ response = await callHybridAI(
544
+ baseUrl,
545
+ apiKey,
546
+ model,
547
+ chatbotId,
548
+ enableRag,
549
+ history,
550
+ tools,
551
+ );
438
552
  }
439
553
  } else {
440
- response = await callHybridAI(baseUrl, apiKey, model, chatbotId, enableRag, history, tools);
554
+ response = await callHybridAI(
555
+ baseUrl,
556
+ apiKey,
557
+ model,
558
+ chatbotId,
559
+ enableRag,
560
+ history,
561
+ tools,
562
+ );
441
563
  }
442
- await emitRuntimeEvent({ event: 'after_model_call', attempt, toolCallCount: response.choices[0]?.message?.tool_calls?.length || 0 });
564
+ await emitRuntimeEvent({
565
+ event: 'after_model_call',
566
+ attempt,
567
+ toolCallCount: response.choices[0]?.message?.tool_calls?.length || 0,
568
+ });
443
569
  return response;
444
570
  } catch (err) {
445
- const retryable = RETRY_ENABLED && isRetryableError(err) && attempt < RETRY_MAX_ATTEMPTS;
571
+ const retryable =
572
+ RETRY_ENABLED && isRetryableError(err) && attempt < RETRY_MAX_ATTEMPTS;
446
573
  await emitRuntimeEvent({
447
574
  event: retryable ? 'model_retry' : 'model_error',
448
575
  attempt,
@@ -467,15 +594,21 @@ async function processRequest(
467
594
  chatbotId: string,
468
595
  enableRag: boolean,
469
596
  tools: ToolDefinition[],
597
+ effectiveUserPromptOverride?: string,
470
598
  ): Promise<ContainerOutput> {
471
- await emitRuntimeEvent({ event: 'before_agent_start', messageCount: messages.length });
599
+ await emitRuntimeEvent({
600
+ event: 'before_agent_start',
601
+ messageCount: messages.length,
602
+ });
472
603
  const history: ChatMessage[] = [...messages];
473
604
  const toolsUsed: string[] = [];
474
605
  const toolExecutions: ToolExecution[] = [];
475
606
  const artifacts: ArtifactMetadata[] = [];
476
607
  const artifactPaths = new Set<string>();
477
608
  const tokenUsage = createTokenUsageStats();
478
- const ralphSeedPrompt = RALPH_ENABLED ? latestUserPrompt(messages) : '';
609
+ const effectiveUserPrompt =
610
+ effectiveUserPromptOverride || latestUserPrompt(messages);
611
+ const ralphSeedPrompt = RALPH_ENABLED ? effectiveUserPrompt : '';
479
612
  const maxModelTurns = resolveMaxModelTurns();
480
613
  let ralphExtraIterations = 0;
481
614
  let iterations = 0;
@@ -485,7 +618,7 @@ async function processRequest(
485
618
  tokenUsage.modelCalls += 1;
486
619
  tokenUsage.estimatedPromptTokens += estimateMessageTokens(history);
487
620
 
488
- let response;
621
+ let response: Awaited<ReturnType<typeof callHybridAIWithRetry>>;
489
622
  try {
490
623
  response = await callHybridAIWithRetry({
491
624
  baseUrl,
@@ -507,7 +640,11 @@ async function processRequest(
507
640
  tokenUsage: finalizeTokenUsage(tokenUsage),
508
641
  error: `API error: ${err instanceof Error ? err.message : String(err)}`,
509
642
  };
510
- await emitRuntimeEvent({ event: 'turn_end', status: failed.status, toolsUsed });
643
+ await emitRuntimeEvent({
644
+ event: 'turn_end',
645
+ status: failed.status,
646
+ toolsUsed,
647
+ });
511
648
  return failed;
512
649
  }
513
650
 
@@ -524,13 +661,21 @@ async function processRequest(
524
661
  tokenUsage: finalizeTokenUsage(tokenUsage),
525
662
  error: 'No response from API',
526
663
  };
527
- await emitRuntimeEvent({ event: 'turn_end', status: failed.status, toolsUsed });
664
+ await emitRuntimeEvent({
665
+ event: 'turn_end',
666
+ status: failed.status,
667
+ toolsUsed,
668
+ });
528
669
  return failed;
529
670
  }
530
671
 
531
- tokenUsage.estimatedCompletionTokens += estimateTextTokens(choice.message.content);
672
+ tokenUsage.estimatedCompletionTokens += estimateTextTokens(
673
+ choice.message.content,
674
+ );
532
675
  if (choice.message.tool_calls?.length) {
533
- tokenUsage.estimatedCompletionTokens += estimateTextTokens(JSON.stringify(choice.message.tool_calls));
676
+ tokenUsage.estimatedCompletionTokens += estimateTextTokens(
677
+ JSON.stringify(choice.message.tool_calls),
678
+ );
534
679
  }
535
680
 
536
681
  const assistantMessage: ChatMessage = {
@@ -556,12 +701,19 @@ async function processRequest(
556
701
  ...(artifacts.length > 0 ? { artifacts } : {}),
557
702
  toolExecutions,
558
703
  tokenUsage: finalizeTokenUsage(tokenUsage),
704
+ effectiveUserPrompt,
559
705
  };
560
- await emitRuntimeEvent({ event: 'turn_end', status: completed.status, toolsUsed: completed.toolsUsed });
706
+ await emitRuntimeEvent({
707
+ event: 'turn_end',
708
+ status: completed.status,
709
+ toolsUsed: completed.toolsUsed,
710
+ });
561
711
  return completed;
562
712
  }
563
713
 
564
- const canContinue = RALPH_MAX_EXTRA_ITERATIONS < 0 || ralphExtraIterations < RALPH_MAX_EXTRA_ITERATIONS;
714
+ const canContinue =
715
+ RALPH_MAX_EXTRA_ITERATIONS < 0 ||
716
+ ralphExtraIterations < RALPH_MAX_EXTRA_ITERATIONS;
565
717
  if (canContinue) {
566
718
  ralphExtraIterations += 1;
567
719
  history.push({
@@ -569,8 +721,10 @@ async function processRequest(
569
721
  content: buildRalphPrompt(ralphSeedPrompt, branchChoice == null),
570
722
  });
571
723
  console.error(
572
- `[ralph] continue ${ralphExtraIterations}`
573
- + (RALPH_MAX_EXTRA_ITERATIONS < 0 ? '' : `/${RALPH_MAX_EXTRA_ITERATIONS}`),
724
+ `[ralph] continue ${ralphExtraIterations}` +
725
+ (RALPH_MAX_EXTRA_ITERATIONS < 0
726
+ ? ''
727
+ : `/${RALPH_MAX_EXTRA_ITERATIONS}`),
574
728
  );
575
729
  continue;
576
730
  }
@@ -583,24 +737,121 @@ async function processRequest(
583
737
  ...(artifacts.length > 0 ? { artifacts } : {}),
584
738
  toolExecutions,
585
739
  tokenUsage: finalizeTokenUsage(tokenUsage),
740
+ effectiveUserPrompt,
586
741
  };
587
- await emitRuntimeEvent({ event: 'turn_end', status: completed.status, toolsUsed: completed.toolsUsed });
742
+ await emitRuntimeEvent({
743
+ event: 'turn_end',
744
+ status: completed.status,
745
+ toolsUsed: completed.toolsUsed,
746
+ });
588
747
  return completed;
589
748
  }
590
749
 
591
750
  for (const call of toolCalls) {
592
751
  const toolName = call.function.name;
752
+ const approval = approvalRuntime.evaluateToolCall({
753
+ toolName,
754
+ argsJson: call.function.arguments,
755
+ latestUserPrompt: effectiveUserPrompt,
756
+ });
757
+
593
758
  toolsUsed.push(toolName);
594
- console.error(`[tool] ${toolName}: ${call.function.arguments.slice(0, 100)}`);
759
+ const toolPreview =
760
+ approval.tier === 'yellow'
761
+ ? approvalRuntime.formatYellowNarration(approval)
762
+ : call.function.arguments.slice(0, 100);
763
+ console.error(`[tool] ${toolName}: ${toolPreview}`);
595
764
  const toolStart = Date.now();
596
- const blockedReason = await runBeforeToolHooks(toolName, call.function.arguments);
765
+ if (approval.decision === 'required') {
766
+ const toolDuration = Date.now() - toolStart;
767
+ const prompt = approvalRuntime.formatApprovalRequest(approval);
768
+ toolExecutions.push({
769
+ name: toolName,
770
+ arguments: call.function.arguments,
771
+ result: prompt,
772
+ durationMs: toolDuration,
773
+ isError: false,
774
+ blocked: true,
775
+ blockedReason: approval.reason,
776
+ approvalTier: approval.tier,
777
+ approvalBaseTier: approval.baseTier,
778
+ approvalDecision: approval.decision,
779
+ approvalActionKey: approval.actionKey,
780
+ approvalReason: approval.reason,
781
+ approvalRequestId: approval.requestId,
782
+ });
783
+ const waitingForApproval: ContainerOutput = {
784
+ status: 'success',
785
+ result: prompt,
786
+ toolsUsed: [...new Set(toolsUsed)],
787
+ ...(artifacts.length > 0 ? { artifacts } : {}),
788
+ toolExecutions,
789
+ tokenUsage: finalizeTokenUsage(tokenUsage),
790
+ effectiveUserPrompt,
791
+ };
792
+ await emitRuntimeEvent({
793
+ event: 'turn_end',
794
+ status: waitingForApproval.status,
795
+ toolsUsed: waitingForApproval.toolsUsed,
796
+ });
797
+ return waitingForApproval;
798
+ }
799
+ if (approval.decision === 'denied') {
800
+ const toolDuration = Date.now() - toolStart;
801
+ const denialText = `Approval denied: ${approval.reason}`;
802
+ toolExecutions.push({
803
+ name: toolName,
804
+ arguments: call.function.arguments,
805
+ result: denialText,
806
+ durationMs: toolDuration,
807
+ isError: true,
808
+ blocked: true,
809
+ blockedReason: approval.reason,
810
+ approvalTier: approval.tier,
811
+ approvalBaseTier: approval.baseTier,
812
+ approvalDecision: approval.decision,
813
+ approvalActionKey: approval.actionKey,
814
+ approvalReason: approval.reason,
815
+ approvalRequestId: approval.requestId,
816
+ });
817
+ const denied: ContainerOutput = {
818
+ status: 'success',
819
+ result: denialText,
820
+ toolsUsed: [...new Set(toolsUsed)],
821
+ ...(artifacts.length > 0 ? { artifacts } : {}),
822
+ toolExecutions,
823
+ tokenUsage: finalizeTokenUsage(tokenUsage),
824
+ effectiveUserPrompt,
825
+ };
826
+ await emitRuntimeEvent({
827
+ event: 'turn_end',
828
+ status: denied.status,
829
+ toolsUsed: denied.toolsUsed,
830
+ });
831
+ return denied;
832
+ }
833
+ if (
834
+ approval.tier === 'yellow' &&
835
+ approval.implicitDelayMs &&
836
+ approval.implicitDelayMs > 0
837
+ ) {
838
+ await sleep(approval.implicitDelayMs);
839
+ }
840
+ const blockedReason = await runBeforeToolHooks(
841
+ toolName,
842
+ call.function.arguments,
843
+ );
597
844
  const result = blockedReason
598
845
  ? `Tool blocked by security hook: ${blockedReason}`
599
846
  : await executeTool(toolName, call.function.arguments);
600
847
  const toolDuration = Date.now() - toolStart;
601
848
  const isError = inferToolError(result, blockedReason);
849
+ const succeeded = !blockedReason && !isError;
850
+ approvalRuntime.afterToolExecution(approval, succeeded);
602
851
  await runAfterToolHooks(toolName, call.function.arguments, result);
603
- console.error(`[tool] ${toolName} result (${toolDuration}ms): ${result.slice(0, 100)}`);
852
+ console.error(
853
+ `[tool] ${toolName} result (${toolDuration}ms): ${result.slice(0, 100)}`,
854
+ );
604
855
  toolExecutions.push({
605
856
  name: toolName,
606
857
  arguments: call.function.arguments,
@@ -609,6 +860,12 @@ async function processRequest(
609
860
  isError,
610
861
  blocked: Boolean(blockedReason),
611
862
  blockedReason: blockedReason || undefined,
863
+ approvalTier: approval.tier,
864
+ approvalBaseTier: approval.baseTier,
865
+ approvalDecision: blockedReason ? 'denied' : approval.decision,
866
+ approvalActionKey: approval.actionKey,
867
+ approvalReason: approval.reason,
868
+ approvalRequestId: approval.requestId,
612
869
  });
613
870
  for (const artifact of extractToolArtifacts(toolName, result)) {
614
871
  if (artifactPaths.has(artifact.path)) continue;
@@ -627,8 +884,13 @@ async function processRequest(
627
884
  toolExecutions,
628
885
  tokenUsage: finalizeTokenUsage(tokenUsage),
629
886
  error: result,
887
+ effectiveUserPrompt,
630
888
  };
631
- await emitRuntimeEvent({ event: 'turn_end', status: failed.status, toolsUsed });
889
+ await emitRuntimeEvent({
890
+ event: 'turn_end',
891
+ status: failed.status,
892
+ toolsUsed,
893
+ });
632
894
  return failed;
633
895
  }
634
896
  }
@@ -637,13 +899,20 @@ async function processRequest(
637
899
  const lastAssistant = history.filter((m) => m.role === 'assistant').pop();
638
900
  const completed: ContainerOutput = {
639
901
  status: 'success',
640
- result: stripRalphChoiceTags(lastAssistant?.content || null) || 'Max tool iterations reached.',
902
+ result:
903
+ stripRalphChoiceTags(lastAssistant?.content || null) ||
904
+ 'Max tool iterations reached.',
641
905
  toolsUsed: [...new Set(toolsUsed)],
642
906
  ...(artifacts.length > 0 ? { artifacts } : {}),
643
907
  toolExecutions,
644
908
  tokenUsage: finalizeTokenUsage(tokenUsage),
909
+ effectiveUserPrompt,
645
910
  };
646
- await emitRuntimeEvent({ event: 'turn_end', status: completed.status, toolsUsed: completed.toolsUsed });
911
+ await emitRuntimeEvent({
912
+ event: 'turn_end',
913
+ status: completed.status,
914
+ toolsUsed: completed.toolsUsed,
915
+ });
647
916
  return completed;
648
917
  }
649
918
 
@@ -652,7 +921,9 @@ async function processRequest(
652
921
  */
653
922
  function resolveTools(input: ContainerInput): ToolDefinition[] {
654
923
  let tools = input.allowedTools
655
- ? TOOL_DEFINITIONS.filter((t) => input.allowedTools!.includes(t.function.name))
924
+ ? TOOL_DEFINITIONS.filter((t) =>
925
+ input.allowedTools?.includes(t.function.name),
926
+ )
656
927
  : [...TOOL_DEFINITIONS];
657
928
  if (Array.isArray(input.blockedTools) && input.blockedTools.length > 0) {
658
929
  const blocked = new Set(
@@ -671,66 +942,105 @@ function shouldRetryWithoutNativeVision(error: string | undefined): boolean {
671
942
  const normalized = String(error || '').toLowerCase();
672
943
  if (!normalized) return false;
673
944
  return (
674
- normalized.includes('image_url')
675
- || normalized.includes('unsupported image')
676
- || normalized.includes('unsupported content')
677
- || normalized.includes('vision')
678
- || normalized.includes('multimodal')
679
- || normalized.includes('content part')
945
+ normalized.includes('image_url') ||
946
+ normalized.includes('unsupported image') ||
947
+ normalized.includes('unsupported content') ||
948
+ normalized.includes('vision') ||
949
+ normalized.includes('multimodal') ||
950
+ normalized.includes('content part')
680
951
  );
681
952
  }
682
953
 
683
954
  async function main(): Promise<void> {
684
- console.error(`[hybridclaw-agent] started, idle timeout ${IDLE_TIMEOUT_MS}ms`);
955
+ console.error(
956
+ `[hybridclaw-agent] started, idle timeout ${IDLE_TIMEOUT_MS}ms`,
957
+ );
685
958
 
686
959
  // First request arrives via stdin (contains apiKey — never written to disk)
687
960
  const stdinData = await readStdinLine();
688
961
  const firstInput: ContainerInput = JSON.parse(stdinData);
689
962
  storedApiKey = firstInput.apiKey;
690
963
 
691
- console.error(`[hybridclaw-agent] processing first request (${firstInput.messages.length} messages)`);
964
+ console.error(
965
+ `[hybridclaw-agent] processing first request (${firstInput.messages.length} messages)`,
966
+ );
692
967
 
693
968
  resetSideEffects();
694
969
  setScheduledTasks(firstInput.scheduledTasks);
695
970
  setSessionContext(firstInput.sessionId);
696
- setGatewayContext(firstInput.gatewayBaseUrl, firstInput.gatewayApiToken, firstInput.channelId);
697
- setModelContext(firstInput.baseUrl, storedApiKey, firstInput.model, firstInput.chatbotId);
971
+ setGatewayContext(
972
+ firstInput.gatewayBaseUrl,
973
+ firstInput.gatewayApiToken,
974
+ firstInput.channelId,
975
+ );
976
+ setModelContext(
977
+ firstInput.baseUrl,
978
+ storedApiKey,
979
+ firstInput.model,
980
+ firstInput.chatbotId,
981
+ );
698
982
  setMediaContext(firstInput.media);
699
983
  const firstMessages = await injectNativeVisionContent(
700
984
  firstInput.messages,
701
985
  firstInput.model,
702
986
  firstInput.media,
703
987
  );
704
-
705
- let firstOutput = await processRequest(
706
- firstMessages,
707
- storedApiKey,
708
- firstInput.baseUrl,
709
- firstInput.model,
710
- firstInput.chatbotId,
711
- firstInput.enableRag,
712
- resolveTools(firstInput),
713
- );
714
- if (
715
- firstMessages !== firstInput.messages
716
- && firstOutput.status === 'error'
717
- && shouldRetryWithoutNativeVision(firstOutput.error)
718
- ) {
719
- console.error('[media] native vision injection rejected by model; retrying without image parts');
988
+ const firstPrelude = approvalRuntime.handleApprovalResponse(firstMessages);
989
+ const firstPromptOverride = firstPrelude?.replayPrompt;
990
+ const firstPreparedMessages = firstPromptOverride
991
+ ? replaceLatestUserPrompt(firstMessages, firstPromptOverride)
992
+ : firstMessages;
993
+
994
+ let firstOutput: ContainerOutput;
995
+ if (firstPrelude?.immediateMessage && !firstPromptOverride) {
996
+ firstOutput = {
997
+ status: 'success',
998
+ result: firstPrelude.immediateMessage,
999
+ toolsUsed: [],
1000
+ toolExecutions: [],
1001
+ effectiveUserPrompt: latestUserPrompt(firstPreparedMessages),
1002
+ };
1003
+ console.error('[approval] resolved user response without model run');
1004
+ } else {
720
1005
  firstOutput = await processRequest(
721
- firstInput.messages,
1006
+ firstPreparedMessages,
722
1007
  storedApiKey,
723
1008
  firstInput.baseUrl,
724
1009
  firstInput.model,
725
1010
  firstInput.chatbotId,
726
1011
  firstInput.enableRag,
727
1012
  resolveTools(firstInput),
1013
+ firstPromptOverride,
728
1014
  );
1015
+ if (
1016
+ firstPreparedMessages !== firstInput.messages &&
1017
+ firstOutput.status === 'error' &&
1018
+ shouldRetryWithoutNativeVision(firstOutput.error)
1019
+ ) {
1020
+ console.error(
1021
+ '[media] native vision injection rejected by model; retrying without image parts',
1022
+ );
1023
+ const firstRetryMessages = firstPromptOverride
1024
+ ? replaceLatestUserPrompt(firstInput.messages, firstPromptOverride)
1025
+ : firstInput.messages;
1026
+ firstOutput = await processRequest(
1027
+ firstRetryMessages,
1028
+ storedApiKey,
1029
+ firstInput.baseUrl,
1030
+ firstInput.model,
1031
+ firstInput.chatbotId,
1032
+ firstInput.enableRag,
1033
+ resolveTools(firstInput),
1034
+ firstPromptOverride,
1035
+ );
1036
+ }
729
1037
  }
730
1038
 
731
1039
  firstOutput.sideEffects = getPendingSideEffects();
732
1040
  writeOutput(firstOutput);
733
- console.error(`[hybridclaw-agent] first request complete: ${firstOutput.status}`);
1041
+ console.error(
1042
+ `[hybridclaw-agent] first request complete: ${firstOutput.status}`,
1043
+ );
734
1044
 
735
1045
  // Subsequent requests come via IPC file polling
736
1046
  while (true) {
@@ -744,12 +1054,18 @@ async function main(): Promise<void> {
744
1054
  // Use stored apiKey — IPC file no longer contains it
745
1055
  const apiKey = input.apiKey || storedApiKey;
746
1056
 
747
- console.error(`[hybridclaw-agent] processing request (${input.messages.length} messages)`);
1057
+ console.error(
1058
+ `[hybridclaw-agent] processing request (${input.messages.length} messages)`,
1059
+ );
748
1060
 
749
1061
  resetSideEffects();
750
1062
  setScheduledTasks(input.scheduledTasks);
751
1063
  setSessionContext(input.sessionId);
752
- setGatewayContext(input.gatewayBaseUrl, input.gatewayApiToken, input.channelId);
1064
+ setGatewayContext(
1065
+ input.gatewayBaseUrl,
1066
+ input.gatewayApiToken,
1067
+ input.channelId,
1068
+ );
753
1069
  setModelContext(input.baseUrl, apiKey, input.model, input.chatbotId);
754
1070
  setMediaContext(input.media);
755
1071
  const preparedMessages = await injectNativeVisionContent(
@@ -757,30 +1073,56 @@ async function main(): Promise<void> {
757
1073
  input.model,
758
1074
  input.media,
759
1075
  );
1076
+ const prelude = approvalRuntime.handleApprovalResponse(preparedMessages);
1077
+ const promptOverride = prelude?.replayPrompt;
1078
+ const messagesForRequest = promptOverride
1079
+ ? replaceLatestUserPrompt(preparedMessages, promptOverride)
1080
+ : preparedMessages;
1081
+
1082
+ if (prelude?.immediateMessage && !promptOverride) {
1083
+ const immediate: ContainerOutput = {
1084
+ status: 'success',
1085
+ result: prelude.immediateMessage,
1086
+ toolsUsed: [],
1087
+ toolExecutions: [],
1088
+ effectiveUserPrompt: latestUserPrompt(messagesForRequest),
1089
+ };
1090
+ immediate.sideEffects = getPendingSideEffects();
1091
+ writeOutput(immediate);
1092
+ console.error('[approval] resolved user response without model run');
1093
+ continue;
1094
+ }
760
1095
 
761
1096
  let output = await processRequest(
762
- preparedMessages,
1097
+ messagesForRequest,
763
1098
  apiKey,
764
1099
  input.baseUrl,
765
1100
  input.model,
766
1101
  input.chatbotId,
767
1102
  input.enableRag,
768
1103
  resolveTools(input),
1104
+ promptOverride,
769
1105
  );
770
1106
  if (
771
- preparedMessages !== input.messages
772
- && output.status === 'error'
773
- && shouldRetryWithoutNativeVision(output.error)
1107
+ messagesForRequest !== input.messages &&
1108
+ output.status === 'error' &&
1109
+ shouldRetryWithoutNativeVision(output.error)
774
1110
  ) {
775
- console.error('[media] native vision injection rejected by model; retrying without image parts');
1111
+ console.error(
1112
+ '[media] native vision injection rejected by model; retrying without image parts',
1113
+ );
1114
+ const retryMessages = promptOverride
1115
+ ? replaceLatestUserPrompt(input.messages, promptOverride)
1116
+ : input.messages;
776
1117
  output = await processRequest(
777
- input.messages,
1118
+ retryMessages,
778
1119
  apiKey,
779
1120
  input.baseUrl,
780
1121
  input.model,
781
1122
  input.chatbotId,
782
1123
  input.enableRag,
783
1124
  resolveTools(input),
1125
+ promptOverride,
784
1126
  );
785
1127
  }
786
1128