@hybridaione/hybridclaw 0.2.3 → 0.2.7

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 (285) hide show
  1. package/.github/workflows/ci.yml +70 -0
  2. package/.husky/pre-commit +1 -0
  3. package/CHANGELOG.md +95 -0
  4. package/CONTRIBUTING.md +33 -0
  5. package/README.md +57 -19
  6. package/SECURITY.md +17 -0
  7. package/biome.json +35 -0
  8. package/config.example.json +72 -7
  9. package/container/package-lock.json +2 -2
  10. package/container/package.json +1 -1
  11. package/container/src/approval-policy.ts +1405 -0
  12. package/container/src/browser-tools.ts +672 -136
  13. package/container/src/extensions.ts +36 -12
  14. package/container/src/hybridai-client.ts +65 -16
  15. package/container/src/index.ts +463 -110
  16. package/container/src/ipc.ts +5 -3
  17. package/container/src/token-usage.ts +107 -18
  18. package/container/src/tools.ts +600 -225
  19. package/container/src/types.ts +49 -2
  20. package/container/src/web-fetch.ts +89 -32
  21. package/dist/agent.d.ts.map +1 -1
  22. package/dist/agent.js +15 -4
  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 +24 -4
  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 +31 -0
  49. package/dist/channels/discord/inbound.d.ts.map +1 -1
  50. package/dist/channels/discord/inbound.js +156 -6
  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 +7 -4
  68. package/dist/channels/discord/runtime.d.ts.map +1 -1
  69. package/dist/channels/discord/runtime.js +755 -150
  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 +19 -12
  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 +77 -23
  86. package/dist/cli.js.map +1 -1
  87. package/dist/config.d.ts +24 -0
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +113 -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 +59 -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 +624 -87
  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 +303 -26
  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 +62 -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/hybridai-models.d.ts +8 -0
  132. package/dist/hybridai-models.d.ts.map +1 -0
  133. package/dist/hybridai-models.js +94 -0
  134. package/dist/hybridai-models.js.map +1 -0
  135. package/dist/instruction-approval-audit.d.ts.map +1 -1
  136. package/dist/instruction-approval-audit.js.map +1 -1
  137. package/dist/instruction-integrity.d.ts.map +1 -1
  138. package/dist/instruction-integrity.js +8 -2
  139. package/dist/instruction-integrity.js.map +1 -1
  140. package/dist/ipc.d.ts.map +1 -1
  141. package/dist/ipc.js +6 -1
  142. package/dist/ipc.js.map +1 -1
  143. package/dist/logger.js.map +1 -1
  144. package/dist/memory-consolidation.d.ts +17 -0
  145. package/dist/memory-consolidation.d.ts.map +1 -0
  146. package/dist/memory-consolidation.js +25 -0
  147. package/dist/memory-consolidation.js.map +1 -0
  148. package/dist/memory-service.d.ts +200 -0
  149. package/dist/memory-service.d.ts.map +1 -0
  150. package/dist/memory-service.js +294 -0
  151. package/dist/memory-service.js.map +1 -0
  152. package/dist/mount-security.d.ts.map +1 -1
  153. package/dist/mount-security.js +31 -7
  154. package/dist/mount-security.js.map +1 -1
  155. package/dist/observability-ingest.d.ts.map +1 -1
  156. package/dist/observability-ingest.js +32 -11
  157. package/dist/observability-ingest.js.map +1 -1
  158. package/dist/onboarding.d.ts.map +1 -1
  159. package/dist/onboarding.js +32 -9
  160. package/dist/onboarding.js.map +1 -1
  161. package/dist/proactive-policy.d.ts.map +1 -1
  162. package/dist/proactive-policy.js +2 -1
  163. package/dist/proactive-policy.js.map +1 -1
  164. package/dist/prompt-hooks.d.ts.map +1 -1
  165. package/dist/prompt-hooks.js +11 -7
  166. package/dist/prompt-hooks.js.map +1 -1
  167. package/dist/runtime-config.d.ts +104 -1
  168. package/dist/runtime-config.d.ts.map +1 -1
  169. package/dist/runtime-config.js +503 -24
  170. package/dist/runtime-config.js.map +1 -1
  171. package/dist/scheduled-task-runner.d.ts +1 -0
  172. package/dist/scheduled-task-runner.d.ts.map +1 -1
  173. package/dist/scheduled-task-runner.js +43 -10
  174. package/dist/scheduled-task-runner.js.map +1 -1
  175. package/dist/scheduler.d.ts +43 -4
  176. package/dist/scheduler.d.ts.map +1 -1
  177. package/dist/scheduler.js +530 -56
  178. package/dist/scheduler.js.map +1 -1
  179. package/dist/session-export.d.ts +26 -0
  180. package/dist/session-export.d.ts.map +1 -0
  181. package/dist/session-export.js +149 -0
  182. package/dist/session-export.js.map +1 -0
  183. package/dist/session-maintenance.d.ts.map +1 -1
  184. package/dist/session-maintenance.js +75 -13
  185. package/dist/session-maintenance.js.map +1 -1
  186. package/dist/session-transcripts.d.ts.map +1 -1
  187. package/dist/session-transcripts.js.map +1 -1
  188. package/dist/side-effects.d.ts.map +1 -1
  189. package/dist/side-effects.js +14 -2
  190. package/dist/side-effects.js.map +1 -1
  191. package/dist/skills-guard.d.ts.map +1 -1
  192. package/dist/skills-guard.js +893 -130
  193. package/dist/skills-guard.js.map +1 -1
  194. package/dist/skills.d.ts +5 -0
  195. package/dist/skills.d.ts.map +1 -1
  196. package/dist/skills.js +29 -15
  197. package/dist/skills.js.map +1 -1
  198. package/dist/token-efficiency.d.ts.map +1 -1
  199. package/dist/token-efficiency.js.map +1 -1
  200. package/dist/tui.js +92 -11
  201. package/dist/tui.js.map +1 -1
  202. package/dist/types.d.ts +150 -0
  203. package/dist/types.d.ts.map +1 -1
  204. package/dist/types.js +24 -1
  205. package/dist/types.js.map +1 -1
  206. package/dist/update.d.ts.map +1 -1
  207. package/dist/update.js +42 -14
  208. package/dist/update.js.map +1 -1
  209. package/dist/workspace.d.ts.map +1 -1
  210. package/dist/workspace.js +49 -9
  211. package/dist/workspace.js.map +1 -1
  212. package/docs/chat.html +9 -3
  213. package/docs/index.html +72 -13
  214. package/package.json +8 -2
  215. package/src/agent.ts +31 -4
  216. package/src/audit-cli.ts +44 -16
  217. package/src/audit-events.ts +69 -5
  218. package/src/audit-trail.ts +41 -15
  219. package/src/channels/discord/attachments.ts +81 -27
  220. package/src/channels/discord/debounce.ts +25 -0
  221. package/src/channels/discord/delivery.ts +65 -14
  222. package/src/channels/discord/human-delay.ts +48 -0
  223. package/src/channels/discord/inbound.ts +190 -8
  224. package/src/channels/discord/mentions.ts +42 -18
  225. package/src/channels/discord/presence.ts +148 -0
  226. package/src/channels/discord/rate-limiter.ts +58 -0
  227. package/src/channels/discord/reactions.ts +211 -0
  228. package/src/channels/discord/runtime.ts +1161 -205
  229. package/src/channels/discord/stream.ts +85 -31
  230. package/src/channels/discord/tool-actions.ts +78 -37
  231. package/src/channels/discord/typing.ts +140 -0
  232. package/src/chunk.ts +12 -4
  233. package/src/cli.ts +174 -57
  234. package/src/config.ts +208 -36
  235. package/src/container-runner.ts +134 -42
  236. package/src/container-setup.ts +57 -22
  237. package/src/conversation.ts +9 -7
  238. package/src/db.ts +2217 -84
  239. package/src/delegation-manager.ts +6 -2
  240. package/src/gateway-client.ts +41 -17
  241. package/src/gateway-service.ts +996 -220
  242. package/src/gateway-types.ts +33 -0
  243. package/src/gateway.ts +487 -52
  244. package/src/health.ts +66 -26
  245. package/src/heartbeat.ts +99 -22
  246. package/src/hybridai-bots.ts +14 -5
  247. package/src/hybridai-models.ts +158 -0
  248. package/src/instruction-approval-audit.ts +4 -1
  249. package/src/instruction-integrity.ts +30 -9
  250. package/src/ipc.ts +23 -5
  251. package/src/logger.ts +4 -1
  252. package/src/memory-consolidation.ts +41 -0
  253. package/src/memory-service.ts +606 -0
  254. package/src/mount-security.ts +58 -13
  255. package/src/observability-ingest.ts +134 -35
  256. package/src/onboarding.ts +126 -35
  257. package/src/proactive-policy.ts +3 -1
  258. package/src/prompt-hooks.ts +42 -17
  259. package/src/runtime-config.ts +1112 -122
  260. package/src/scheduled-task-runner.ts +77 -11
  261. package/src/scheduler.ts +683 -60
  262. package/src/session-export.ts +196 -0
  263. package/src/session-maintenance.ts +125 -22
  264. package/src/session-transcripts.ts +12 -3
  265. package/src/side-effects.ts +28 -5
  266. package/src/skills-guard.ts +1067 -219
  267. package/src/skills.ts +163 -65
  268. package/src/token-efficiency.ts +31 -9
  269. package/src/tui.ts +166 -25
  270. package/src/types.ts +199 -2
  271. package/src/update.ts +79 -23
  272. package/src/workspace.ts +63 -11
  273. package/tests/approval-policy.test.ts +335 -0
  274. package/tests/discord.basic.test.ts +213 -4
  275. package/tests/discord.human-presence.test.ts +85 -0
  276. package/tests/gateway-service.media-routing.test.ts +8 -2
  277. package/tests/hybridai-client.test.ts +112 -0
  278. package/tests/hybridai-models.test.ts +46 -0
  279. package/tests/memory-service.test.ts +1114 -0
  280. package/tests/token-efficiency.basic.test.ts +8 -2
  281. package/tests/token-usage.cache.test.ts +128 -0
  282. package/vitest.e2e.config.ts +3 -1
  283. package/vitest.integration.config.ts +3 -1
  284. package/vitest.live.config.ts +3 -1
  285. 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 =
@@ -396,6 +502,7 @@ async function callHybridAIWithRetry(params: {
396
502
  history: ChatMessage[];
397
503
  tools: ToolDefinition[];
398
504
  onTextDelta?: (delta: string) => void;
505
+ maxTokens?: number;
399
506
  }): Promise<Awaited<ReturnType<typeof callHybridAI>>> {
400
507
  const {
401
508
  baseUrl,
@@ -406,6 +513,7 @@ async function callHybridAIWithRetry(params: {
406
513
  history,
407
514
  tools,
408
515
  onTextDelta,
516
+ maxTokens,
409
517
  } = params;
410
518
  let attempt = 0;
411
519
  let delayMs = RETRY_BASE_DELAY_MS;
@@ -414,7 +522,7 @@ async function callHybridAIWithRetry(params: {
414
522
  attempt += 1;
415
523
  await emitRuntimeEvent({ event: 'before_model_call', attempt });
416
524
  try {
417
- let response;
525
+ let response: Awaited<ReturnType<typeof callHybridAI>>;
418
526
  if (onTextDelta) {
419
527
  try {
420
528
  response = await callHybridAIStream(
@@ -426,23 +534,47 @@ async function callHybridAIWithRetry(params: {
426
534
  history,
427
535
  tools,
428
536
  onTextDelta,
537
+ maxTokens,
429
538
  );
430
539
  } catch (streamErr) {
431
540
  const fallbackEligible =
432
- streamErr instanceof HybridAIRequestError
433
- && streamErr.status >= 400
434
- && streamErr.status < 500
435
- && streamErr.status !== 429;
541
+ streamErr instanceof HybridAIRequestError &&
542
+ streamErr.status >= 400 &&
543
+ streamErr.status < 500 &&
544
+ streamErr.status !== 429;
436
545
  if (!fallbackEligible) throw streamErr;
437
- response = await callHybridAI(baseUrl, apiKey, model, chatbotId, enableRag, history, tools);
546
+ response = await callHybridAI(
547
+ baseUrl,
548
+ apiKey,
549
+ model,
550
+ chatbotId,
551
+ enableRag,
552
+ history,
553
+ tools,
554
+ maxTokens,
555
+ );
438
556
  }
439
557
  } else {
440
- response = await callHybridAI(baseUrl, apiKey, model, chatbotId, enableRag, history, tools);
558
+ response = await callHybridAI(
559
+ baseUrl,
560
+ apiKey,
561
+ model,
562
+ chatbotId,
563
+ enableRag,
564
+ history,
565
+ tools,
566
+ maxTokens,
567
+ );
441
568
  }
442
- await emitRuntimeEvent({ event: 'after_model_call', attempt, toolCallCount: response.choices[0]?.message?.tool_calls?.length || 0 });
569
+ await emitRuntimeEvent({
570
+ event: 'after_model_call',
571
+ attempt,
572
+ toolCallCount: response.choices[0]?.message?.tool_calls?.length || 0,
573
+ });
443
574
  return response;
444
575
  } catch (err) {
445
- const retryable = RETRY_ENABLED && isRetryableError(err) && attempt < RETRY_MAX_ATTEMPTS;
576
+ const retryable =
577
+ RETRY_ENABLED && isRetryableError(err) && attempt < RETRY_MAX_ATTEMPTS;
446
578
  await emitRuntimeEvent({
447
579
  event: retryable ? 'model_retry' : 'model_error',
448
580
  attempt,
@@ -467,15 +599,22 @@ async function processRequest(
467
599
  chatbotId: string,
468
600
  enableRag: boolean,
469
601
  tools: ToolDefinition[],
602
+ maxTokens?: number,
603
+ effectiveUserPromptOverride?: string,
470
604
  ): Promise<ContainerOutput> {
471
- await emitRuntimeEvent({ event: 'before_agent_start', messageCount: messages.length });
605
+ await emitRuntimeEvent({
606
+ event: 'before_agent_start',
607
+ messageCount: messages.length,
608
+ });
472
609
  const history: ChatMessage[] = [...messages];
473
610
  const toolsUsed: string[] = [];
474
611
  const toolExecutions: ToolExecution[] = [];
475
612
  const artifacts: ArtifactMetadata[] = [];
476
613
  const artifactPaths = new Set<string>();
477
614
  const tokenUsage = createTokenUsageStats();
478
- const ralphSeedPrompt = RALPH_ENABLED ? latestUserPrompt(messages) : '';
615
+ const effectiveUserPrompt =
616
+ effectiveUserPromptOverride || latestUserPrompt(messages);
617
+ const ralphSeedPrompt = RALPH_ENABLED ? effectiveUserPrompt : '';
479
618
  const maxModelTurns = resolveMaxModelTurns();
480
619
  let ralphExtraIterations = 0;
481
620
  let iterations = 0;
@@ -485,7 +624,7 @@ async function processRequest(
485
624
  tokenUsage.modelCalls += 1;
486
625
  tokenUsage.estimatedPromptTokens += estimateMessageTokens(history);
487
626
 
488
- let response;
627
+ let response: Awaited<ReturnType<typeof callHybridAIWithRetry>>;
489
628
  try {
490
629
  response = await callHybridAIWithRetry({
491
630
  baseUrl,
@@ -496,6 +635,7 @@ async function processRequest(
496
635
  history,
497
636
  tools,
498
637
  onTextDelta: emitStreamDelta,
638
+ maxTokens,
499
639
  });
500
640
  } catch (err) {
501
641
  const failed: ContainerOutput = {
@@ -507,7 +647,11 @@ async function processRequest(
507
647
  tokenUsage: finalizeTokenUsage(tokenUsage),
508
648
  error: `API error: ${err instanceof Error ? err.message : String(err)}`,
509
649
  };
510
- await emitRuntimeEvent({ event: 'turn_end', status: failed.status, toolsUsed });
650
+ await emitRuntimeEvent({
651
+ event: 'turn_end',
652
+ status: failed.status,
653
+ toolsUsed,
654
+ });
511
655
  return failed;
512
656
  }
513
657
 
@@ -524,13 +668,21 @@ async function processRequest(
524
668
  tokenUsage: finalizeTokenUsage(tokenUsage),
525
669
  error: 'No response from API',
526
670
  };
527
- await emitRuntimeEvent({ event: 'turn_end', status: failed.status, toolsUsed });
671
+ await emitRuntimeEvent({
672
+ event: 'turn_end',
673
+ status: failed.status,
674
+ toolsUsed,
675
+ });
528
676
  return failed;
529
677
  }
530
678
 
531
- tokenUsage.estimatedCompletionTokens += estimateTextTokens(choice.message.content);
679
+ tokenUsage.estimatedCompletionTokens += estimateTextTokens(
680
+ choice.message.content,
681
+ );
532
682
  if (choice.message.tool_calls?.length) {
533
- tokenUsage.estimatedCompletionTokens += estimateTextTokens(JSON.stringify(choice.message.tool_calls));
683
+ tokenUsage.estimatedCompletionTokens += estimateTextTokens(
684
+ JSON.stringify(choice.message.tool_calls),
685
+ );
534
686
  }
535
687
 
536
688
  const assistantMessage: ChatMessage = {
@@ -556,12 +708,19 @@ async function processRequest(
556
708
  ...(artifacts.length > 0 ? { artifacts } : {}),
557
709
  toolExecutions,
558
710
  tokenUsage: finalizeTokenUsage(tokenUsage),
711
+ effectiveUserPrompt,
559
712
  };
560
- await emitRuntimeEvent({ event: 'turn_end', status: completed.status, toolsUsed: completed.toolsUsed });
713
+ await emitRuntimeEvent({
714
+ event: 'turn_end',
715
+ status: completed.status,
716
+ toolsUsed: completed.toolsUsed,
717
+ });
561
718
  return completed;
562
719
  }
563
720
 
564
- const canContinue = RALPH_MAX_EXTRA_ITERATIONS < 0 || ralphExtraIterations < RALPH_MAX_EXTRA_ITERATIONS;
721
+ const canContinue =
722
+ RALPH_MAX_EXTRA_ITERATIONS < 0 ||
723
+ ralphExtraIterations < RALPH_MAX_EXTRA_ITERATIONS;
565
724
  if (canContinue) {
566
725
  ralphExtraIterations += 1;
567
726
  history.push({
@@ -569,8 +728,10 @@ async function processRequest(
569
728
  content: buildRalphPrompt(ralphSeedPrompt, branchChoice == null),
570
729
  });
571
730
  console.error(
572
- `[ralph] continue ${ralphExtraIterations}`
573
- + (RALPH_MAX_EXTRA_ITERATIONS < 0 ? '' : `/${RALPH_MAX_EXTRA_ITERATIONS}`),
731
+ `[ralph] continue ${ralphExtraIterations}` +
732
+ (RALPH_MAX_EXTRA_ITERATIONS < 0
733
+ ? ''
734
+ : `/${RALPH_MAX_EXTRA_ITERATIONS}`),
574
735
  );
575
736
  continue;
576
737
  }
@@ -583,24 +744,121 @@ async function processRequest(
583
744
  ...(artifacts.length > 0 ? { artifacts } : {}),
584
745
  toolExecutions,
585
746
  tokenUsage: finalizeTokenUsage(tokenUsage),
747
+ effectiveUserPrompt,
586
748
  };
587
- await emitRuntimeEvent({ event: 'turn_end', status: completed.status, toolsUsed: completed.toolsUsed });
749
+ await emitRuntimeEvent({
750
+ event: 'turn_end',
751
+ status: completed.status,
752
+ toolsUsed: completed.toolsUsed,
753
+ });
588
754
  return completed;
589
755
  }
590
756
 
591
757
  for (const call of toolCalls) {
592
758
  const toolName = call.function.name;
759
+ const approval = approvalRuntime.evaluateToolCall({
760
+ toolName,
761
+ argsJson: call.function.arguments,
762
+ latestUserPrompt: effectiveUserPrompt,
763
+ });
764
+
593
765
  toolsUsed.push(toolName);
594
- console.error(`[tool] ${toolName}: ${call.function.arguments.slice(0, 100)}`);
766
+ const toolPreview =
767
+ approval.tier === 'yellow'
768
+ ? approvalRuntime.formatYellowNarration(approval)
769
+ : call.function.arguments.slice(0, 100);
770
+ console.error(`[tool] ${toolName}: ${toolPreview}`);
595
771
  const toolStart = Date.now();
596
- const blockedReason = await runBeforeToolHooks(toolName, call.function.arguments);
772
+ if (approval.decision === 'required') {
773
+ const toolDuration = Date.now() - toolStart;
774
+ const prompt = approvalRuntime.formatApprovalRequest(approval);
775
+ toolExecutions.push({
776
+ name: toolName,
777
+ arguments: call.function.arguments,
778
+ result: prompt,
779
+ durationMs: toolDuration,
780
+ isError: false,
781
+ blocked: true,
782
+ blockedReason: approval.reason,
783
+ approvalTier: approval.tier,
784
+ approvalBaseTier: approval.baseTier,
785
+ approvalDecision: approval.decision,
786
+ approvalActionKey: approval.actionKey,
787
+ approvalReason: approval.reason,
788
+ approvalRequestId: approval.requestId,
789
+ });
790
+ const waitingForApproval: ContainerOutput = {
791
+ status: 'success',
792
+ result: prompt,
793
+ toolsUsed: [...new Set(toolsUsed)],
794
+ ...(artifacts.length > 0 ? { artifacts } : {}),
795
+ toolExecutions,
796
+ tokenUsage: finalizeTokenUsage(tokenUsage),
797
+ effectiveUserPrompt,
798
+ };
799
+ await emitRuntimeEvent({
800
+ event: 'turn_end',
801
+ status: waitingForApproval.status,
802
+ toolsUsed: waitingForApproval.toolsUsed,
803
+ });
804
+ return waitingForApproval;
805
+ }
806
+ if (approval.decision === 'denied') {
807
+ const toolDuration = Date.now() - toolStart;
808
+ const denialText = `Approval denied: ${approval.reason}`;
809
+ toolExecutions.push({
810
+ name: toolName,
811
+ arguments: call.function.arguments,
812
+ result: denialText,
813
+ durationMs: toolDuration,
814
+ isError: true,
815
+ blocked: true,
816
+ blockedReason: approval.reason,
817
+ approvalTier: approval.tier,
818
+ approvalBaseTier: approval.baseTier,
819
+ approvalDecision: approval.decision,
820
+ approvalActionKey: approval.actionKey,
821
+ approvalReason: approval.reason,
822
+ approvalRequestId: approval.requestId,
823
+ });
824
+ const denied: ContainerOutput = {
825
+ status: 'success',
826
+ result: denialText,
827
+ toolsUsed: [...new Set(toolsUsed)],
828
+ ...(artifacts.length > 0 ? { artifacts } : {}),
829
+ toolExecutions,
830
+ tokenUsage: finalizeTokenUsage(tokenUsage),
831
+ effectiveUserPrompt,
832
+ };
833
+ await emitRuntimeEvent({
834
+ event: 'turn_end',
835
+ status: denied.status,
836
+ toolsUsed: denied.toolsUsed,
837
+ });
838
+ return denied;
839
+ }
840
+ if (
841
+ approval.tier === 'yellow' &&
842
+ approval.implicitDelayMs &&
843
+ approval.implicitDelayMs > 0
844
+ ) {
845
+ await sleep(approval.implicitDelayMs);
846
+ }
847
+ const blockedReason = await runBeforeToolHooks(
848
+ toolName,
849
+ call.function.arguments,
850
+ );
597
851
  const result = blockedReason
598
852
  ? `Tool blocked by security hook: ${blockedReason}`
599
853
  : await executeTool(toolName, call.function.arguments);
600
854
  const toolDuration = Date.now() - toolStart;
601
855
  const isError = inferToolError(result, blockedReason);
856
+ const succeeded = !blockedReason && !isError;
857
+ approvalRuntime.afterToolExecution(approval, succeeded);
602
858
  await runAfterToolHooks(toolName, call.function.arguments, result);
603
- console.error(`[tool] ${toolName} result (${toolDuration}ms): ${result.slice(0, 100)}`);
859
+ console.error(
860
+ `[tool] ${toolName} result (${toolDuration}ms): ${result.slice(0, 100)}`,
861
+ );
604
862
  toolExecutions.push({
605
863
  name: toolName,
606
864
  arguments: call.function.arguments,
@@ -609,6 +867,12 @@ async function processRequest(
609
867
  isError,
610
868
  blocked: Boolean(blockedReason),
611
869
  blockedReason: blockedReason || undefined,
870
+ approvalTier: approval.tier,
871
+ approvalBaseTier: approval.baseTier,
872
+ approvalDecision: blockedReason ? 'denied' : approval.decision,
873
+ approvalActionKey: approval.actionKey,
874
+ approvalReason: approval.reason,
875
+ approvalRequestId: approval.requestId,
612
876
  });
613
877
  for (const artifact of extractToolArtifacts(toolName, result)) {
614
878
  if (artifactPaths.has(artifact.path)) continue;
@@ -627,8 +891,13 @@ async function processRequest(
627
891
  toolExecutions,
628
892
  tokenUsage: finalizeTokenUsage(tokenUsage),
629
893
  error: result,
894
+ effectiveUserPrompt,
630
895
  };
631
- await emitRuntimeEvent({ event: 'turn_end', status: failed.status, toolsUsed });
896
+ await emitRuntimeEvent({
897
+ event: 'turn_end',
898
+ status: failed.status,
899
+ toolsUsed,
900
+ });
632
901
  return failed;
633
902
  }
634
903
  }
@@ -637,13 +906,20 @@ async function processRequest(
637
906
  const lastAssistant = history.filter((m) => m.role === 'assistant').pop();
638
907
  const completed: ContainerOutput = {
639
908
  status: 'success',
640
- result: stripRalphChoiceTags(lastAssistant?.content || null) || 'Max tool iterations reached.',
909
+ result:
910
+ stripRalphChoiceTags(lastAssistant?.content || null) ||
911
+ 'Max tool iterations reached.',
641
912
  toolsUsed: [...new Set(toolsUsed)],
642
913
  ...(artifacts.length > 0 ? { artifacts } : {}),
643
914
  toolExecutions,
644
915
  tokenUsage: finalizeTokenUsage(tokenUsage),
916
+ effectiveUserPrompt,
645
917
  };
646
- await emitRuntimeEvent({ event: 'turn_end', status: completed.status, toolsUsed: completed.toolsUsed });
918
+ await emitRuntimeEvent({
919
+ event: 'turn_end',
920
+ status: completed.status,
921
+ toolsUsed: completed.toolsUsed,
922
+ });
647
923
  return completed;
648
924
  }
649
925
 
@@ -652,7 +928,9 @@ async function processRequest(
652
928
  */
653
929
  function resolveTools(input: ContainerInput): ToolDefinition[] {
654
930
  let tools = input.allowedTools
655
- ? TOOL_DEFINITIONS.filter((t) => input.allowedTools!.includes(t.function.name))
931
+ ? TOOL_DEFINITIONS.filter((t) =>
932
+ input.allowedTools?.includes(t.function.name),
933
+ )
656
934
  : [...TOOL_DEFINITIONS];
657
935
  if (Array.isArray(input.blockedTools) && input.blockedTools.length > 0) {
658
936
  const blocked = new Set(
@@ -662,7 +940,7 @@ function resolveTools(input: ContainerInput): ToolDefinition[] {
662
940
  );
663
941
  tools = tools.filter((tool) => !blocked.has(tool.function.name));
664
942
  }
665
- // Sort alphabetically for deterministic system-prompt ordering (KV cache stability)
943
+ // Sort alphabetically for deterministic tool ordering (request/cache stability)
666
944
  tools.sort((a, b) => a.function.name.localeCompare(b.function.name));
667
945
  return tools;
668
946
  }
@@ -671,66 +949,107 @@ function shouldRetryWithoutNativeVision(error: string | undefined): boolean {
671
949
  const normalized = String(error || '').toLowerCase();
672
950
  if (!normalized) return false;
673
951
  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')
952
+ normalized.includes('image_url') ||
953
+ normalized.includes('unsupported image') ||
954
+ normalized.includes('unsupported content') ||
955
+ normalized.includes('vision') ||
956
+ normalized.includes('multimodal') ||
957
+ normalized.includes('content part')
680
958
  );
681
959
  }
682
960
 
683
961
  async function main(): Promise<void> {
684
- console.error(`[hybridclaw-agent] started, idle timeout ${IDLE_TIMEOUT_MS}ms`);
962
+ console.error(
963
+ `[hybridclaw-agent] started, idle timeout ${IDLE_TIMEOUT_MS}ms`,
964
+ );
685
965
 
686
966
  // First request arrives via stdin (contains apiKey — never written to disk)
687
967
  const stdinData = await readStdinLine();
688
968
  const firstInput: ContainerInput = JSON.parse(stdinData);
689
969
  storedApiKey = firstInput.apiKey;
690
970
 
691
- console.error(`[hybridclaw-agent] processing first request (${firstInput.messages.length} messages)`);
971
+ console.error(
972
+ `[hybridclaw-agent] processing first request (${firstInput.messages.length} messages)`,
973
+ );
692
974
 
693
975
  resetSideEffects();
694
976
  setScheduledTasks(firstInput.scheduledTasks);
695
977
  setSessionContext(firstInput.sessionId);
696
- setGatewayContext(firstInput.gatewayBaseUrl, firstInput.gatewayApiToken, firstInput.channelId);
697
- setModelContext(firstInput.baseUrl, storedApiKey, firstInput.model, firstInput.chatbotId);
978
+ setGatewayContext(
979
+ firstInput.gatewayBaseUrl,
980
+ firstInput.gatewayApiToken,
981
+ firstInput.channelId,
982
+ );
983
+ setModelContext(
984
+ firstInput.baseUrl,
985
+ storedApiKey,
986
+ firstInput.model,
987
+ firstInput.chatbotId,
988
+ );
698
989
  setMediaContext(firstInput.media);
699
990
  const firstMessages = await injectNativeVisionContent(
700
991
  firstInput.messages,
701
992
  firstInput.model,
702
993
  firstInput.media,
703
994
  );
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');
995
+ const firstPrelude = approvalRuntime.handleApprovalResponse(firstMessages);
996
+ const firstPromptOverride = firstPrelude?.replayPrompt;
997
+ const firstPreparedMessages = firstPromptOverride
998
+ ? replaceLatestUserPrompt(firstMessages, firstPromptOverride)
999
+ : firstMessages;
1000
+
1001
+ let firstOutput: ContainerOutput;
1002
+ if (firstPrelude?.immediateMessage && !firstPromptOverride) {
1003
+ firstOutput = {
1004
+ status: 'success',
1005
+ result: firstPrelude.immediateMessage,
1006
+ toolsUsed: [],
1007
+ toolExecutions: [],
1008
+ effectiveUserPrompt: latestUserPrompt(firstPreparedMessages),
1009
+ };
1010
+ console.error('[approval] resolved user response without model run');
1011
+ } else {
720
1012
  firstOutput = await processRequest(
721
- firstInput.messages,
1013
+ firstPreparedMessages,
722
1014
  storedApiKey,
723
1015
  firstInput.baseUrl,
724
1016
  firstInput.model,
725
1017
  firstInput.chatbotId,
726
1018
  firstInput.enableRag,
727
1019
  resolveTools(firstInput),
1020
+ firstInput.maxTokens,
1021
+ firstPromptOverride,
728
1022
  );
1023
+ if (
1024
+ firstPreparedMessages !== firstInput.messages &&
1025
+ firstOutput.status === 'error' &&
1026
+ shouldRetryWithoutNativeVision(firstOutput.error)
1027
+ ) {
1028
+ console.error(
1029
+ '[media] native vision injection rejected by model; retrying without image parts',
1030
+ );
1031
+ const firstRetryMessages = firstPromptOverride
1032
+ ? replaceLatestUserPrompt(firstInput.messages, firstPromptOverride)
1033
+ : firstInput.messages;
1034
+ firstOutput = await processRequest(
1035
+ firstRetryMessages,
1036
+ storedApiKey,
1037
+ firstInput.baseUrl,
1038
+ firstInput.model,
1039
+ firstInput.chatbotId,
1040
+ firstInput.enableRag,
1041
+ resolveTools(firstInput),
1042
+ firstInput.maxTokens,
1043
+ firstPromptOverride,
1044
+ );
1045
+ }
729
1046
  }
730
1047
 
731
1048
  firstOutput.sideEffects = getPendingSideEffects();
732
1049
  writeOutput(firstOutput);
733
- console.error(`[hybridclaw-agent] first request complete: ${firstOutput.status}`);
1050
+ console.error(
1051
+ `[hybridclaw-agent] first request complete: ${firstOutput.status}`,
1052
+ );
734
1053
 
735
1054
  // Subsequent requests come via IPC file polling
736
1055
  while (true) {
@@ -744,12 +1063,18 @@ async function main(): Promise<void> {
744
1063
  // Use stored apiKey — IPC file no longer contains it
745
1064
  const apiKey = input.apiKey || storedApiKey;
746
1065
 
747
- console.error(`[hybridclaw-agent] processing request (${input.messages.length} messages)`);
1066
+ console.error(
1067
+ `[hybridclaw-agent] processing request (${input.messages.length} messages)`,
1068
+ );
748
1069
 
749
1070
  resetSideEffects();
750
1071
  setScheduledTasks(input.scheduledTasks);
751
1072
  setSessionContext(input.sessionId);
752
- setGatewayContext(input.gatewayBaseUrl, input.gatewayApiToken, input.channelId);
1073
+ setGatewayContext(
1074
+ input.gatewayBaseUrl,
1075
+ input.gatewayApiToken,
1076
+ input.channelId,
1077
+ );
753
1078
  setModelContext(input.baseUrl, apiKey, input.model, input.chatbotId);
754
1079
  setMediaContext(input.media);
755
1080
  const preparedMessages = await injectNativeVisionContent(
@@ -757,30 +1082,58 @@ async function main(): Promise<void> {
757
1082
  input.model,
758
1083
  input.media,
759
1084
  );
1085
+ const prelude = approvalRuntime.handleApprovalResponse(preparedMessages);
1086
+ const promptOverride = prelude?.replayPrompt;
1087
+ const messagesForRequest = promptOverride
1088
+ ? replaceLatestUserPrompt(preparedMessages, promptOverride)
1089
+ : preparedMessages;
1090
+
1091
+ if (prelude?.immediateMessage && !promptOverride) {
1092
+ const immediate: ContainerOutput = {
1093
+ status: 'success',
1094
+ result: prelude.immediateMessage,
1095
+ toolsUsed: [],
1096
+ toolExecutions: [],
1097
+ effectiveUserPrompt: latestUserPrompt(messagesForRequest),
1098
+ };
1099
+ immediate.sideEffects = getPendingSideEffects();
1100
+ writeOutput(immediate);
1101
+ console.error('[approval] resolved user response without model run');
1102
+ continue;
1103
+ }
760
1104
 
761
1105
  let output = await processRequest(
762
- preparedMessages,
1106
+ messagesForRequest,
763
1107
  apiKey,
764
1108
  input.baseUrl,
765
1109
  input.model,
766
1110
  input.chatbotId,
767
1111
  input.enableRag,
768
1112
  resolveTools(input),
1113
+ input.maxTokens,
1114
+ promptOverride,
769
1115
  );
770
1116
  if (
771
- preparedMessages !== input.messages
772
- && output.status === 'error'
773
- && shouldRetryWithoutNativeVision(output.error)
1117
+ messagesForRequest !== input.messages &&
1118
+ output.status === 'error' &&
1119
+ shouldRetryWithoutNativeVision(output.error)
774
1120
  ) {
775
- console.error('[media] native vision injection rejected by model; retrying without image parts');
1121
+ console.error(
1122
+ '[media] native vision injection rejected by model; retrying without image parts',
1123
+ );
1124
+ const retryMessages = promptOverride
1125
+ ? replaceLatestUserPrompt(input.messages, promptOverride)
1126
+ : input.messages;
776
1127
  output = await processRequest(
777
- input.messages,
1128
+ retryMessages,
778
1129
  apiKey,
779
1130
  input.baseUrl,
780
1131
  input.model,
781
1132
  input.chatbotId,
782
1133
  input.enableRag,
783
1134
  resolveTools(input),
1135
+ input.maxTokens,
1136
+ promptOverride,
784
1137
  );
785
1138
  }
786
1139