@koda-sl/baker-bridge 0.108.0-dev.fe3d33fa3 → 0.109.0-dev.3bafd569e

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 (321) hide show
  1. package/README.md +107 -51
  2. package/dist/broker/baker-api.d.ts +23 -0
  3. package/dist/broker/baker-api.d.ts.map +1 -0
  4. package/dist/broker/baker-api.js +55 -0
  5. package/dist/broker/baker-api.js.map +1 -0
  6. package/dist/broker/broker-client.d.ts +10 -0
  7. package/dist/broker/broker-client.d.ts.map +1 -0
  8. package/dist/broker/broker-client.js +60 -0
  9. package/dist/broker/broker-client.js.map +1 -0
  10. package/dist/broker/broker-process.d.ts +10 -0
  11. package/dist/broker/broker-process.d.ts.map +1 -0
  12. package/dist/broker/broker-process.js +20 -0
  13. package/dist/broker/broker-process.js.map +1 -0
  14. package/dist/broker/broker-process.test.d.ts +2 -0
  15. package/dist/broker/broker-process.test.d.ts.map +1 -0
  16. package/dist/broker/broker-process.test.js +13 -0
  17. package/dist/broker/broker-process.test.js.map +1 -0
  18. package/dist/broker/ca.d.ts +38 -0
  19. package/dist/broker/ca.d.ts.map +1 -0
  20. package/dist/broker/ca.js +80 -0
  21. package/dist/broker/ca.js.map +1 -0
  22. package/dist/broker/ca.test.d.ts +2 -0
  23. package/dist/broker/ca.test.d.ts.map +1 -0
  24. package/dist/broker/ca.test.js +58 -0
  25. package/dist/broker/ca.test.js.map +1 -0
  26. package/dist/broker/credential-cache.d.ts +24 -0
  27. package/dist/broker/credential-cache.d.ts.map +1 -0
  28. package/dist/broker/credential-cache.js +76 -0
  29. package/dist/broker/credential-cache.js.map +1 -0
  30. package/dist/broker/credential-cache.test.d.ts +2 -0
  31. package/dist/broker/credential-cache.test.d.ts.map +1 -0
  32. package/dist/broker/credential-cache.test.js +136 -0
  33. package/dist/broker/credential-cache.test.js.map +1 -0
  34. package/dist/broker/credential-text.d.ts +45 -0
  35. package/dist/broker/credential-text.d.ts.map +1 -0
  36. package/dist/broker/credential-text.js +103 -0
  37. package/dist/broker/credential-text.js.map +1 -0
  38. package/dist/broker/credential-text.test.d.ts +2 -0
  39. package/dist/broker/credential-text.test.d.ts.map +1 -0
  40. package/dist/broker/credential-text.test.js +54 -0
  41. package/dist/broker/credential-text.test.js.map +1 -0
  42. package/dist/broker/env.d.ts +21 -0
  43. package/dist/broker/env.d.ts.map +1 -0
  44. package/dist/broker/env.js +20 -0
  45. package/dist/broker/env.js.map +1 -0
  46. package/dist/broker/main.d.ts +3 -0
  47. package/dist/broker/main.d.ts.map +1 -0
  48. package/dist/broker/main.js +57 -0
  49. package/dist/broker/main.js.map +1 -0
  50. package/dist/broker/proxy.d.ts +24 -0
  51. package/dist/broker/proxy.d.ts.map +1 -0
  52. package/dist/broker/proxy.integration.test.d.ts +2 -0
  53. package/dist/broker/proxy.integration.test.d.ts.map +1 -0
  54. package/dist/broker/proxy.integration.test.js +197 -0
  55. package/dist/broker/proxy.integration.test.js.map +1 -0
  56. package/dist/broker/proxy.js +147 -0
  57. package/dist/broker/proxy.js.map +1 -0
  58. package/dist/broker/rewrite.d.ts +44 -0
  59. package/dist/broker/rewrite.d.ts.map +1 -0
  60. package/dist/broker/rewrite.js +291 -0
  61. package/dist/broker/rewrite.js.map +1 -0
  62. package/dist/broker/rewrite.test.d.ts +2 -0
  63. package/dist/broker/rewrite.test.d.ts.map +1 -0
  64. package/dist/broker/rewrite.test.js +171 -0
  65. package/dist/broker/rewrite.test.js.map +1 -0
  66. package/dist/broker/socket-service.d.ts +18 -0
  67. package/dist/broker/socket-service.d.ts.map +1 -0
  68. package/dist/broker/socket-service.js +113 -0
  69. package/dist/broker/socket-service.js.map +1 -0
  70. package/dist/broker/socket-service.test.d.ts +2 -0
  71. package/dist/broker/socket-service.test.d.ts.map +1 -0
  72. package/dist/broker/socket-service.test.js +80 -0
  73. package/dist/broker/socket-service.test.js.map +1 -0
  74. package/dist/broker/supervisor-main.d.ts +3 -0
  75. package/dist/broker/supervisor-main.d.ts.map +1 -0
  76. package/dist/broker/supervisor-main.js +70 -0
  77. package/dist/broker/supervisor-main.js.map +1 -0
  78. package/dist/broker/supervisor.d.ts +25 -0
  79. package/dist/broker/supervisor.d.ts.map +1 -0
  80. package/dist/broker/supervisor.js +88 -0
  81. package/dist/broker/supervisor.js.map +1 -0
  82. package/dist/broker/supervisor.test.d.ts +2 -0
  83. package/dist/broker/supervisor.test.d.ts.map +1 -0
  84. package/dist/broker/supervisor.test.js +161 -0
  85. package/dist/broker/supervisor.test.js.map +1 -0
  86. package/dist/broker/token-bucket.d.ts +9 -0
  87. package/dist/broker/token-bucket.d.ts.map +1 -0
  88. package/dist/broker/token-bucket.js +18 -0
  89. package/dist/broker/token-bucket.js.map +1 -0
  90. package/dist/broker/usage-reporter.d.ts +18 -0
  91. package/dist/broker/usage-reporter.d.ts.map +1 -0
  92. package/dist/broker/usage-reporter.js +31 -0
  93. package/dist/broker/usage-reporter.js.map +1 -0
  94. package/dist/broker/usage-reporter.test.d.ts +2 -0
  95. package/dist/broker/usage-reporter.test.d.ts.map +1 -0
  96. package/dist/broker/usage-reporter.test.js +20 -0
  97. package/dist/broker/usage-reporter.test.js.map +1 -0
  98. package/dist/cli.d.ts.map +1 -1
  99. package/dist/cli.js +2 -0
  100. package/dist/cli.js.map +1 -1
  101. package/dist/hono/agent-session.d.ts +4 -0
  102. package/dist/hono/agent-session.d.ts.map +1 -1
  103. package/dist/hono/agent-session.js +28 -2
  104. package/dist/hono/agent-session.js.map +1 -1
  105. package/dist/hono/agent-session.test.js +37 -0
  106. package/dist/hono/agent-session.test.js.map +1 -1
  107. package/dist/hono/agent-tasks.d.ts.map +1 -1
  108. package/dist/hono/agent-tasks.js +11 -7
  109. package/dist/hono/agent-tasks.js.map +1 -1
  110. package/dist/hono/agent-tasks.test.js +15 -0
  111. package/dist/hono/agent-tasks.test.js.map +1 -1
  112. package/dist/hono/agent.d.ts +2 -0
  113. package/dist/hono/agent.d.ts.map +1 -1
  114. package/dist/hono/agent.js +22 -4
  115. package/dist/hono/agent.js.map +1 -1
  116. package/dist/hono/agent.test.js +8 -2
  117. package/dist/hono/agent.test.js.map +1 -1
  118. package/dist/hono/attachments.d.ts +6 -1
  119. package/dist/hono/attachments.d.ts.map +1 -1
  120. package/dist/hono/attachments.js +9 -4
  121. package/dist/hono/attachments.js.map +1 -1
  122. package/dist/hono/attachments.test.js +5 -0
  123. package/dist/hono/attachments.test.js.map +1 -1
  124. package/dist/hono/avatar-accent.d.ts +23 -5
  125. package/dist/hono/avatar-accent.d.ts.map +1 -1
  126. package/dist/hono/avatar-accent.js +54 -63
  127. package/dist/hono/avatar-accent.js.map +1 -1
  128. package/dist/hono/avatar-accent.test.js +64 -35
  129. package/dist/hono/avatar-accent.test.js.map +1 -1
  130. package/dist/hono/avatar-casting.d.ts +1 -0
  131. package/dist/hono/avatar-casting.d.ts.map +1 -1
  132. package/dist/hono/avatar-casting.js +18 -4
  133. package/dist/hono/avatar-casting.js.map +1 -1
  134. package/dist/hono/avatar-casting.test.js +5 -0
  135. package/dist/hono/avatar-casting.test.js.map +1 -1
  136. package/dist/hono/avatar-face-question.d.ts +23 -6
  137. package/dist/hono/avatar-face-question.d.ts.map +1 -1
  138. package/dist/hono/avatar-face-question.js +48 -59
  139. package/dist/hono/avatar-face-question.js.map +1 -1
  140. package/dist/hono/avatar-face-question.test.js +46 -101
  141. package/dist/hono/avatar-face-question.test.js.map +1 -1
  142. package/dist/hono/baker-ui-tool-schemas.test.js +30 -4
  143. package/dist/hono/baker-ui-tool-schemas.test.js.map +1 -1
  144. package/dist/hono/blocking-card.d.ts +12 -0
  145. package/dist/hono/blocking-card.d.ts.map +1 -0
  146. package/dist/hono/blocking-card.js +12 -0
  147. package/dist/hono/blocking-card.js.map +1 -0
  148. package/dist/hono/bridge-job.d.ts +78 -3
  149. package/dist/hono/bridge-job.d.ts.map +1 -1
  150. package/dist/hono/bridge-job.js +322 -61
  151. package/dist/hono/bridge-job.js.map +1 -1
  152. package/dist/hono/bridge-job.test.js +551 -26
  153. package/dist/hono/bridge-job.test.js.map +1 -1
  154. package/dist/hono/check-content.d.ts +49 -0
  155. package/dist/hono/check-content.d.ts.map +1 -0
  156. package/dist/hono/check-content.js +182 -0
  157. package/dist/hono/check-content.js.map +1 -0
  158. package/dist/hono/check-content.test.d.ts +2 -0
  159. package/dist/hono/check-content.test.d.ts.map +1 -0
  160. package/dist/hono/check-content.test.js +87 -0
  161. package/dist/hono/check-content.test.js.map +1 -0
  162. package/dist/hono/connection-input.d.ts +163 -12
  163. package/dist/hono/connection-input.d.ts.map +1 -1
  164. package/dist/hono/connection-input.js +162 -12
  165. package/dist/hono/connection-input.js.map +1 -1
  166. package/dist/hono/connection-input.test.js +112 -9
  167. package/dist/hono/connection-input.test.js.map +1 -1
  168. package/dist/hono/convex.d.ts +8 -0
  169. package/dist/hono/convex.d.ts.map +1 -1
  170. package/dist/hono/convex.js +8 -0
  171. package/dist/hono/convex.js.map +1 -1
  172. package/dist/hono/convex.test.js +4 -1
  173. package/dist/hono/convex.test.js.map +1 -1
  174. package/dist/hono/credential-input.d.ts +39 -0
  175. package/dist/hono/credential-input.d.ts.map +1 -0
  176. package/dist/hono/credential-input.js +78 -0
  177. package/dist/hono/credential-input.js.map +1 -0
  178. package/dist/hono/credential-scrub.d.ts +42 -0
  179. package/dist/hono/credential-scrub.d.ts.map +1 -0
  180. package/dist/hono/credential-scrub.js +173 -0
  181. package/dist/hono/credential-scrub.js.map +1 -0
  182. package/dist/hono/credential-scrub.test.d.ts +2 -0
  183. package/dist/hono/credential-scrub.test.d.ts.map +1 -0
  184. package/dist/hono/credential-scrub.test.js +130 -0
  185. package/dist/hono/credential-scrub.test.js.map +1 -0
  186. package/dist/hono/env.d.ts +1 -0
  187. package/dist/hono/env.d.ts.map +1 -1
  188. package/dist/hono/env.js +2 -0
  189. package/dist/hono/env.js.map +1 -1
  190. package/dist/hono/flow-crypto.d.ts +2 -1
  191. package/dist/hono/flow-crypto.d.ts.map +1 -1
  192. package/dist/hono/flow-crypto.js +50 -26
  193. package/dist/hono/flow-crypto.js.map +1 -1
  194. package/dist/hono/flow-crypto.test.js +32 -0
  195. package/dist/hono/flow-crypto.test.js.map +1 -1
  196. package/dist/hono/flow-input-card-fields.test.d.ts +10 -0
  197. package/dist/hono/flow-input-card-fields.test.d.ts.map +1 -0
  198. package/dist/hono/flow-input-card-fields.test.js +26 -0
  199. package/dist/hono/flow-input-card-fields.test.js.map +1 -0
  200. package/dist/hono/flow-input-fields.test.js +1 -1
  201. package/dist/hono/flow-input-fields.test.js.map +1 -1
  202. package/dist/hono/flow-input.d.ts +23 -2
  203. package/dist/hono/flow-input.d.ts.map +1 -1
  204. package/dist/hono/flow-input.js +63 -14
  205. package/dist/hono/flow-input.js.map +1 -1
  206. package/dist/hono/flow-input.test.js +67 -11
  207. package/dist/hono/flow-input.test.js.map +1 -1
  208. package/dist/hono/jev.d.ts +16 -0
  209. package/dist/hono/jev.d.ts.map +1 -0
  210. package/dist/hono/jev.js +60 -0
  211. package/dist/hono/jev.js.map +1 -0
  212. package/dist/hono/jev.test.d.ts +2 -0
  213. package/dist/hono/jev.test.d.ts.map +1 -0
  214. package/dist/hono/jev.test.js +15 -0
  215. package/dist/hono/jev.test.js.map +1 -0
  216. package/dist/hono/knowledge-content.d.ts.map +1 -1
  217. package/dist/hono/knowledge-content.js +4 -2
  218. package/dist/hono/knowledge-content.js.map +1 -1
  219. package/dist/hono/knowledge-content.test.js +6 -5
  220. package/dist/hono/knowledge-content.test.js.map +1 -1
  221. package/dist/hono/mcp-servers.d.ts +8 -1
  222. package/dist/hono/mcp-servers.d.ts.map +1 -1
  223. package/dist/hono/mcp-servers.js +17 -6
  224. package/dist/hono/mcp-servers.js.map +1 -1
  225. package/dist/hono/mcp-servers.test.js +25 -0
  226. package/dist/hono/mcp-servers.test.js.map +1 -1
  227. package/dist/hono/model-capabilities.d.ts.map +1 -1
  228. package/dist/hono/model-capabilities.js +20 -9
  229. package/dist/hono/model-capabilities.js.map +1 -1
  230. package/dist/hono/model-capabilities.test.js +16 -8
  231. package/dist/hono/model-capabilities.test.js.map +1 -1
  232. package/dist/hono/oom-priority.agent.test.d.ts +2 -0
  233. package/dist/hono/oom-priority.agent.test.d.ts.map +1 -0
  234. package/dist/hono/oom-priority.agent.test.js +138 -0
  235. package/dist/hono/oom-priority.agent.test.js.map +1 -0
  236. package/dist/hono/oom-priority.d.ts +39 -0
  237. package/dist/hono/oom-priority.d.ts.map +1 -0
  238. package/dist/hono/oom-priority.js +138 -0
  239. package/dist/hono/oom-priority.js.map +1 -0
  240. package/dist/hono/oom-priority.test.d.ts +2 -0
  241. package/dist/hono/oom-priority.test.d.ts.map +1 -0
  242. package/dist/hono/oom-priority.test.js +52 -0
  243. package/dist/hono/oom-priority.test.js.map +1 -0
  244. package/dist/hono/openrouter-session.d.ts.map +1 -1
  245. package/dist/hono/openrouter-session.js +12 -1
  246. package/dist/hono/openrouter-session.js.map +1 -1
  247. package/dist/hono/openrouter-session.test.js +9 -3
  248. package/dist/hono/openrouter-session.test.js.map +1 -1
  249. package/dist/hono/prompt-context.d.ts +8 -1
  250. package/dist/hono/prompt-context.d.ts.map +1 -1
  251. package/dist/hono/prompt-context.js +45 -7
  252. package/dist/hono/prompt-context.js.map +1 -1
  253. package/dist/hono/prompt-context.test.js +32 -1
  254. package/dist/hono/prompt-context.test.js.map +1 -1
  255. package/dist/hono/publish-recovery.d.ts +1 -1
  256. package/dist/hono/publish-recovery.d.ts.map +1 -1
  257. package/dist/hono/publish-recovery.js +9 -6
  258. package/dist/hono/publish-recovery.js.map +1 -1
  259. package/dist/hono/publish-recovery.test.js +11 -2
  260. package/dist/hono/publish-recovery.test.js.map +1 -1
  261. package/dist/hono/relay.d.ts +10 -1
  262. package/dist/hono/relay.d.ts.map +1 -1
  263. package/dist/hono/relay.js +6 -0
  264. package/dist/hono/relay.js.map +1 -1
  265. package/dist/hono/sandbox-content.d.ts +2 -0
  266. package/dist/hono/sandbox-content.d.ts.map +1 -1
  267. package/dist/hono/sandbox-content.js +8 -4
  268. package/dist/hono/sandbox-content.js.map +1 -1
  269. package/dist/hono/sandbox-content.test.js +20 -1
  270. package/dist/hono/sandbox-content.test.js.map +1 -1
  271. package/dist/hono/server.d.ts.map +1 -1
  272. package/dist/hono/server.js +32 -0
  273. package/dist/hono/server.js.map +1 -1
  274. package/dist/hono/server.test.js +21 -1
  275. package/dist/hono/server.test.js.map +1 -1
  276. package/dist/hono/session-continuity.integration.test.js +10 -1
  277. package/dist/hono/session-continuity.integration.test.js.map +1 -1
  278. package/dist/hono/tag-fields.d.ts.map +1 -1
  279. package/dist/hono/tag-fields.js +2 -0
  280. package/dist/hono/tag-fields.js.map +1 -1
  281. package/dist/hono/tag-input.d.ts +124 -3
  282. package/dist/hono/tag-input.d.ts.map +1 -1
  283. package/dist/hono/tag-input.js +27 -17
  284. package/dist/hono/tag-input.js.map +1 -1
  285. package/dist/hono/tag-input.test.js +19 -1
  286. package/dist/hono/tag-input.test.js.map +1 -1
  287. package/dist/hono/types.d.ts +15 -0
  288. package/dist/hono/types.d.ts.map +1 -1
  289. package/dist/hono/workspace-script.d.ts +10 -0
  290. package/dist/hono/workspace-script.d.ts.map +1 -0
  291. package/dist/hono/workspace-script.js +19 -0
  292. package/dist/hono/workspace-script.js.map +1 -0
  293. package/dist/hono/workspace-settle.d.ts +20 -0
  294. package/dist/hono/workspace-settle.d.ts.map +1 -0
  295. package/dist/hono/workspace-settle.js +50 -0
  296. package/dist/hono/workspace-settle.js.map +1 -0
  297. package/dist/review/digest.d.ts +15 -2
  298. package/dist/review/digest.d.ts.map +1 -1
  299. package/dist/review/digest.js +90 -59
  300. package/dist/review/digest.js.map +1 -1
  301. package/dist/review/digest.test.js +94 -6
  302. package/dist/review/digest.test.js.map +1 -1
  303. package/dist/review/evidence-block.js +1 -1
  304. package/dist/review/evidence-block.js.map +1 -1
  305. package/dist/review/evidence-block.test.js +0 -10
  306. package/dist/review/evidence-block.test.js.map +1 -1
  307. package/dist/review/shell-policy.d.ts +6 -0
  308. package/dist/review/shell-policy.d.ts.map +1 -1
  309. package/dist/review/shell-policy.js +240 -72
  310. package/dist/review/shell-policy.js.map +1 -1
  311. package/dist/review/shell-policy.test.js +114 -0
  312. package/dist/review/shell-policy.test.js.map +1 -1
  313. package/package.json +10 -5
  314. package/dist/hono/latitude-telemetry.d.ts +0 -17
  315. package/dist/hono/latitude-telemetry.d.ts.map +0 -1
  316. package/dist/hono/latitude-telemetry.js +0 -52
  317. package/dist/hono/latitude-telemetry.js.map +0 -1
  318. package/dist/hono/latitude-telemetry.test.d.ts +0 -2
  319. package/dist/hono/latitude-telemetry.test.d.ts.map +0 -1
  320. package/dist/hono/latitude-telemetry.test.js +0 -18
  321. package/dist/hono/latitude-telemetry.test.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
@@ -16,8 +17,10 @@ vi.mock("./runtime-files.ts", async (importOriginal) => {
16
17
  };
17
18
  });
18
19
  import { AVATAR_CASTING_TOOL_NAME } from "./avatar-casting.js";
20
+ import { FACE_PICK_QUESTION } from "./avatar-face-question.js";
19
21
  import { BridgeJobExecutor, createStallDetector, isContextOverflowRefusal, isControlChannelDeny, parsePlanDecision, remapAnswersToQuestionText, } from "./bridge-job.js";
20
22
  import { CONNECTION_INPUT_TOOL_NAME } from "./connection-input.js";
23
+ import { CREDENTIAL_INPUT_TOOL_NAME } from "./credential-input.js";
21
24
  import { FLOW_INPUT_TOOL_NAME } from "./flow-input.js";
22
25
  import { bridgeRuntimeFiles } from "./runtime-files.js";
23
26
  import { TAG_INPUT_TOOL_NAME } from "./tag-input.js";
@@ -49,6 +52,7 @@ function activeControls() {
49
52
  pendingTagInputs: new Map(),
50
53
  pendingFlowInputs: new Map(),
51
54
  pendingConnectionInputs: new Map(),
55
+ pendingCredentialInputs: new Map(),
52
56
  pendingVoiceCasts: new Map(),
53
57
  pendingAvatarCastings: new Map(),
54
58
  isActive: () => active,
@@ -82,6 +86,8 @@ function recordingRelay() {
82
86
  flowInputResolved: () => Promise.resolve(),
83
87
  connectionInputRequest: () => Promise.resolve(true),
84
88
  connectionInputResolved: () => Promise.resolve(),
89
+ credentialInputRequest: () => Promise.resolve(true),
90
+ credentialInputResolved: () => Promise.resolve(),
85
91
  avatarCastingRequest: () => Promise.resolve(true),
86
92
  avatarCastingResolved: () => Promise.resolve(),
87
93
  assistantEvent: (target, event, model) => {
@@ -222,13 +228,17 @@ function createExecutor(options) {
222
228
  stallTimeoutMs: options.stallTimeoutMs,
223
229
  stallCheckIntervalMs: options.stallCheckIntervalMs,
224
230
  turnEndSettleMs: options.turnEndSettleMs,
231
+ // A test that ends on a silent settle waits no longer than its settle window.
232
+ turnEndWakeMs: options.turnEndWakeMs ?? options.turnEndSettleMs,
225
233
  turnEndExitGuardMs: options.turnEndExitGuardMs,
234
+ settleWorkspace: options.settleWorkspace ?? (() => Promise.resolve()),
226
235
  ...(options.turnEvents
227
236
  ? { recordTurnEvent: (event) => options.turnEvents?.push(event) }
228
237
  : {}),
229
238
  now: options.now,
230
239
  readUsageBaseline: async (sessionId) => options.usageBaselines?.[sessionId],
231
240
  stalledCliExitMs: 0,
241
+ prepareAttachments: options.prepareAttachments,
232
242
  });
233
243
  }
234
244
  function resultMessage(costUsd, sessionId, modelUsage) {
@@ -314,12 +324,12 @@ describe("isControlChannelDeny", () => {
314
324
  })).toBe(false);
315
325
  });
316
326
  it("does not match a PreToolUse hook block that quotes the envelope in its explanation", () => {
317
- // Live-run finding: the scaffold's guard-background-commands hook explains
318
- // WHY it blocks a backgrounded subagent by quoting the failure it prevents.
327
+ // Live-run finding: the scaffold's guard-background-commands hook, when it
328
+ // still blocked backgrounded subagents, explained why by quoting the failure.
319
329
  // Its block arrives as an is_error tool_result, so an envelope-substring
320
330
  // match counted it as a deny — three blocked spawns in one turn would have
321
331
  // tripped the breaker and errored a perfectly healthy turn.
322
- expect(isControlChannelDeny(controlChannelDenyMessage("PreToolUse:Agent hook error: [node __tooling__/hooks/base/guard-background-commands.mjs]: " +
332
+ expect(isControlChannelDeny(controlChannelDenyMessage("PreToolUse:Agent hook error: [node __tooling__/checks/base/guard-background-commands/guard-background-commands.mjs]: " +
323
333
  "✗ guard-background-commands — blocked: subagent spawned in the background.\n" +
324
334
  " A subagent that outlives the turn loses its permission channel and orphans\n" +
325
335
  " any work it started (a render inside it fails 'Tool permission request failed')."))).toBe(false);
@@ -1095,7 +1105,10 @@ describe("BridgeJobExecutor", () => {
1095
1105
  // web-plugin opt-out at their integration point: all must actually reach the
1096
1106
  // subprocess body via CLAUDE_CODE_EXTRA_BODY.
1097
1107
  expect(JSON.parse(env?.CLAUDE_CODE_EXTRA_BODY ?? "{}")).toEqual({
1098
- provider: { order: ["amazon-bedrock"], allow_fallbacks: true },
1108
+ provider: {
1109
+ order: ["amazon-bedrock", "amazon-bedrock/eu-west-1", "amazon-bedrock/us-east-1"],
1110
+ allow_fallbacks: true,
1111
+ },
1099
1112
  session_id: "thread_env",
1100
1113
  plugins: [{ id: "web", enabled: false }],
1101
1114
  });
@@ -1123,7 +1136,31 @@ describe("BridgeJobExecutor", () => {
1123
1136
  NO_COLOR: "1",
1124
1137
  });
1125
1138
  });
1126
- // The three shared subagents pin themselves to Sonnet 5 in their own
1139
+ // Without the wrapper, a burst of parallel commands can fill the sandbox's
1140
+ // memory and the OOM killer takes the agent or the credential broker instead.
1141
+ it("runs every agent command through the kill-first wrapper, whatever started the bridge", async () => {
1142
+ const queryInputs = [];
1143
+ const executor = createExecutor({
1144
+ queryInputs,
1145
+ queryResults: [scriptedMessages(resultMessage(0, "session_wrapped"))],
1146
+ });
1147
+ await executor.execute({ threadId: "thread_wrapped", turnId: "turn_wrapped", prompt: "Research competitors", history: history([]) }, activeControls());
1148
+ const prefix = queryInputs[0]?.options.env?.CLAUDE_CODE_SHELL_PREFIX ?? "";
1149
+ expect(readFileSync(prefix, "utf8")).toContain("oom_score_adj");
1150
+ });
1151
+ // The CLI clamps a Bash call's timeout to 10 minutes by default, so the
1152
+ // workspace hook's 30 minutes for `baker canvas run` became 600 s and a
1153
+ // render with a repair round was moved to the background (dev, Oct 2026).
1154
+ it("lets a Bash call run the 30 minutes the workspace's timeout hook gives a render", async () => {
1155
+ const queryInputs = [];
1156
+ const executor = createExecutor({
1157
+ queryInputs,
1158
+ queryResults: [scriptedMessages(resultMessage(0, "session_long"))],
1159
+ });
1160
+ await executor.execute({ threadId: "thread_long", turnId: "turn_long", prompt: "Render the ad", history: history([]) }, activeControls());
1161
+ expect(Number(queryInputs[0]?.options.env?.BASH_MAX_TIMEOUT_MS)).toBeGreaterThanOrEqual(30 * 60_000);
1162
+ });
1163
+ // The four shared subagents pin themselves to Sonnet 5.5 in their own
1127
1164
  // frontmatter. Since CLI 2.1.251 CLAUDE_CODE_SUBAGENT_MODEL is only the default
1128
1165
  // under that pin, so the override needs the FORCE flag to outrank it.
1129
1166
  it("sends the Runtime's model override down to the fan-out workers", async () => {
@@ -1140,7 +1177,7 @@ describe("BridgeJobExecutor", () => {
1140
1177
  });
1141
1178
  });
1142
1179
  // Production pins nothing, and the variable must then be absent rather than
1143
- // empty: sending it would strip every worker's own Sonnet 5 pin.
1180
+ // empty: sending it would strip every worker's own Sonnet 5.5 pin.
1144
1181
  it("leaves the workers on their own pinned model when nothing is overridden", async () => {
1145
1182
  vi.stubEnv("BAKER_AGENT_MODEL", "");
1146
1183
  const queryInputs = [];
@@ -1169,8 +1206,7 @@ describe("BridgeJobExecutor", () => {
1169
1206
  // Read off a session's own `init` tool list, the CLI offers these to every
1170
1207
  // Session. Each one reaches past what a marketer's chat is for: the worktree
1171
1208
  // pair rearranges the branches Publish owns, `Workflow` fans out a fleet on
1172
- // the organization's OpenRouter key, and `SendMessage` addresses peers a
1173
- // single-session Runtime does not have.
1209
+ // the organization's OpenRouter key.
1174
1210
  it("removes the tools that reach outside what a Session is", async () => {
1175
1211
  const queryInputs = [];
1176
1212
  const executor = createExecutor({
@@ -1179,10 +1215,12 @@ describe("BridgeJobExecutor", () => {
1179
1215
  });
1180
1216
  await executor.execute({ threadId: "thread_reach", turnId: "turn_reach", prompt: "Build a landing", history: history([]) }, activeControls());
1181
1217
  const disallowed = queryInputs[0]?.options.disallowedTools ?? [];
1182
- expect(disallowed).toEqual(expect.arrayContaining(["EnterWorktree", "ExitWorktree", "Workflow", "SendMessage"]));
1218
+ expect(disallowed).toEqual(expect.arrayContaining(["EnterWorktree", "ExitWorktree", "Workflow"]));
1183
1219
  // `Task` is the subagent spawn itself — the scaffold's own fan-out workers
1184
- // ride it, so blocking multi-agent orchestration must not catch it.
1220
+ // ride it, so blocking multi-agent orchestration must not catch it. And
1221
+ // `SendMessage` is how the agent follows up with a subagent it already ran.
1185
1222
  expect(disallowed).not.toContain("Task");
1223
+ expect(disallowed).not.toContain("SendMessage");
1186
1224
  });
1187
1225
  it("allowlists the workspace's own skills so the CLI's built-in developer skills stay out of the session", async () => {
1188
1226
  const workspace = await mkdtemp(join(tmpdir(), "baker-job-skills-"));
@@ -1370,6 +1408,24 @@ describe("BridgeJobExecutor", () => {
1370
1408
  await executor.execute({ threadId: "thread_uw", turnId: "turn_uw", prompt: "Hi", history: history([]) }, activeControls());
1371
1409
  expect((queryInputs[0]?.options.settings ?? {})).not.toHaveProperty("autoCompactWindow");
1372
1410
  });
1411
+ it("asks for manual approvals outside plan mode, so canUseTool decides every tool call instead of auto mode", async () => {
1412
+ const queryInputs = [];
1413
+ const executor = createExecutor({
1414
+ queryInputs,
1415
+ queryResults: [scriptedMessages(resultMessage(0, "session_permission_mode"))],
1416
+ });
1417
+ await executor.execute({ threadId: "thread_pm", turnId: "turn_pm", prompt: "Hi", history: history([]) }, activeControls());
1418
+ expect(queryInputs[0]?.options.permissionMode).toBe("default");
1419
+ });
1420
+ it("keeps subagent prompt caches for an hour, so a subagent outliving a long render still reads its cache", async () => {
1421
+ const queryInputs = [];
1422
+ const executor = createExecutor({
1423
+ queryInputs,
1424
+ queryResults: [scriptedMessages(resultMessage(0, "session_subagent_ttl"))],
1425
+ });
1426
+ await executor.execute({ threadId: "thread_ttl", turnId: "turn_ttl", prompt: "Hi", history: history([]) }, activeControls());
1427
+ expect(queryInputs[0]?.options.settings).toMatchObject({ subagentPromptCacheTtl: "1h" });
1428
+ });
1373
1429
  // No output style is sent. `Concise` was pinned against Opus 5's preamble; on
1374
1430
  // Opus 5.5 it suppressed the one-line progress notes a marketer reads while a
1375
1431
  // long turn works. The voice rules live in the scaffold CLAUDE.md instead.
@@ -1382,20 +1438,6 @@ describe("BridgeJobExecutor", () => {
1382
1438
  await executor.execute({ threadId: "thread_style", turnId: "turn_style_1", prompt: "Hi", history: history([]) }, activeControls());
1383
1439
  expect((queryInputs[0]?.options.settings ?? {})).not.toHaveProperty("outputStyle");
1384
1440
  });
1385
- // Nothing to put in the settings tier (unknown model window, no chat id for the
1386
- // memory directory) means no `settings` key at all: the SDK forwards even `{}`
1387
- // to the CLI as a `--settings` flag tier.
1388
- it("omits the settings tier when nothing in it applies", async () => {
1389
- vi.stubEnv("BAKER_CHAT_ID", "");
1390
- const queryInputs = [];
1391
- const executor = createExecutor({
1392
- queryInputs,
1393
- model: "claude-unknown",
1394
- queryResults: [scriptedMessages(resultMessage(0, "session_no_settings"))],
1395
- });
1396
- await executor.execute({ threadId: "thread_ns", turnId: "turn_ns", prompt: "Hi", history: history([]) }, activeControls());
1397
- expect(queryInputs[0]?.options).not.toHaveProperty("settings");
1398
- });
1399
1441
  it("runs exact publish commands with the prompt passed through verbatim", async () => {
1400
1442
  const queryInputs = [];
1401
1443
  const executor = createExecutor({
@@ -1440,6 +1482,24 @@ describe("BridgeJobExecutor", () => {
1440
1482
  expect(queryInputs[0]?.prompt).toBe("make the headline bigger");
1441
1483
  expect(queryInputs[0]?.reminder).toContain('landing "Homepage" (slug: homepage)');
1442
1484
  });
1485
+ it("explains a credential the message names beside it, together with the viewing reference", async () => {
1486
+ const queryInputs = [];
1487
+ const executor = createExecutor({
1488
+ queryInputs,
1489
+ queryResults: [scriptedMessages(resultMessage(0, "session_credential"))],
1490
+ });
1491
+ await executor.execute({
1492
+ threadId: "thread_credential",
1493
+ turnId: "turn_credential",
1494
+ prompt: "log in with user admin and password {{credential:password}}",
1495
+ history: history([]),
1496
+ currentReference: { type: "landing", slug: "homepage", title: "Homepage" },
1497
+ credentials: [{ name: "password", label: "Login password", allowedHosts: [] }],
1498
+ }, activeControls());
1499
+ expect(queryInputs[0]?.prompt).toBe("log in with user admin and password {{credential:password}}");
1500
+ expect(queryInputs[0]?.reminder).toContain('landing "Homepage"');
1501
+ expect(queryInputs[0]?.reminder).toContain("`{{credential:password}}` is a credential the user saved");
1502
+ });
1443
1503
  it("strips newlines from the viewing reference so a crafted title can't inject instructions", async () => {
1444
1504
  const queryInputs = [];
1445
1505
  const executor = createExecutor({
@@ -1493,6 +1553,67 @@ describe("BridgeJobExecutor", () => {
1493
1553
  expect(queryInputs[1]?.prompt).not.toContain("preview pane");
1494
1554
  expect(queryInputs[1]?.reminder).toContain('landing "Homepage" (slug: homepage)');
1495
1555
  });
1556
+ // A late answer to a blocking prompt continues a request whose files the
1557
+ // Session received with it. A rebuilt Session only has that request's history,
1558
+ // which names the files but cannot open them, so they are handed over again.
1559
+ it("hands over the continued request's files when the Session is rebuilt", async () => {
1560
+ const queryInputs = [];
1561
+ const executor = createExecutor({
1562
+ queryInputs,
1563
+ prepareAttachments: async (attachments, attachedTo) => `\n\nFILES for ${attachedTo}: ${attachments.map((attachment) => attachment.filename).join(", ")}`,
1564
+ });
1565
+ await executor.execute({
1566
+ threadId: "thread_continued_files",
1567
+ turnId: "turn_continued_files",
1568
+ prompt: "They have now replied: HubSpot",
1569
+ continuedAttachments: [
1570
+ { url: "https://files.example/logo.png", contentType: "image/png", filename: "logo.png" },
1571
+ ],
1572
+ history: history([{ role: "user", text: "Build a page with my logo", createdAt: 1 }]),
1573
+ }, activeControls());
1574
+ expect(queryInputs[0]?.prompt).toContain("FILES for continued_request: logo.png");
1575
+ });
1576
+ it("does not hand the continued request's files over again to a resumed Session", async () => {
1577
+ const queryInputs = [];
1578
+ const executor = createExecutor({
1579
+ loadedBoundary: cleanBoundary("session_holds_files"),
1580
+ queryInputs,
1581
+ prepareAttachments: async (attachments) => `\n\nFILES: ${attachments.map((attachment) => attachment.filename).join(", ")}`,
1582
+ });
1583
+ await executor.execute({
1584
+ threadId: "thread_resumed_files",
1585
+ turnId: "turn_resumed_files",
1586
+ prompt: "They have now replied: HubSpot",
1587
+ continuedAttachments: [
1588
+ { url: "https://files.example/logo.png", contentType: "image/png", filename: "logo.png" },
1589
+ ],
1590
+ history: history([{ role: "user", text: "Build a page with my logo", createdAt: 1 }]),
1591
+ }, activeControls());
1592
+ expect(queryInputs[0]?.prompt).toBe("They have now replied: HubSpot");
1593
+ });
1594
+ it("hands the continued request's files over when a stale Session falls back to history", async () => {
1595
+ const queryInputs = [];
1596
+ const executor = createExecutor({
1597
+ loadedBoundary: cleanBoundary("session_stale_files"),
1598
+ queryInputs,
1599
+ queryResults: [
1600
+ scriptedError(new Error("Cannot resume: session not found")),
1601
+ scriptedMessages(resultMessage(0, "session_rebuilt_files")),
1602
+ ],
1603
+ prepareAttachments: async (attachments) => `\n\nFILES: ${attachments.map((attachment) => attachment.filename).join(", ")}`,
1604
+ });
1605
+ await executor.execute({
1606
+ threadId: "thread_stale_files",
1607
+ turnId: "turn_stale_files",
1608
+ prompt: "They have now replied: HubSpot",
1609
+ continuedAttachments: [
1610
+ { url: "https://files.example/logo.png", contentType: "image/png", filename: "logo.png" },
1611
+ ],
1612
+ history: history([{ role: "user", text: "Build a page with my logo", createdAt: 1 }]),
1613
+ }, activeControls());
1614
+ expect(queryInputs[0]?.prompt).not.toContain("FILES");
1615
+ expect(queryInputs[1]?.prompt).toContain("Current user request\nThey have now replied: HubSpot\n\nFILES: logo.png");
1616
+ });
1496
1617
  it("clears an invalid clean boundary and retries once with rehydrated history", async () => {
1497
1618
  const relay = recordingRelay();
1498
1619
  const clearCalls = [];
@@ -2050,6 +2171,224 @@ describe("BridgeJobExecutor", () => {
2050
2171
  expect(connectionRequests).toHaveLength(1);
2051
2172
  expect(decision).toMatchObject({ behavior: "allow" });
2052
2173
  });
2174
+ it("loads a server the user connected from the card into this same turn, and tells the agent it is ready", async () => {
2175
+ const relay = recordingRelay();
2176
+ relay.connectionInputRequest = () => Promise.resolve(true);
2177
+ const controls = activeControls();
2178
+ const acme = { type: "http", url: "https://acme.dev/mcp" };
2179
+ const asks = [];
2180
+ const loaded = [];
2181
+ let decision;
2182
+ const executor = new BridgeJobExecutor({
2183
+ sdk: {
2184
+ query: (input) => ({
2185
+ async *[Symbol.asyncIterator]() {
2186
+ const asked = input.options.canUseTool?.(CONNECTION_INPUT_TOOL_NAME, { connections: [{ server: { name: "acme", url: "https://acme.dev/mcp", auth: "none" }, reason: "r" }] }, { toolUseID: "t1", requestId: "req_t1", signal: new AbortController().signal });
2187
+ await new Promise((resolve) => setTimeout(resolve, 10));
2188
+ for (const [, pending] of controls.pendingConnectionInputs) {
2189
+ pending.resolve({ results: [{ status: "connected", server: "acme", scope: "company" }] });
2190
+ }
2191
+ decision = await asked;
2192
+ yield resultMessage(0, "session_after_connect");
2193
+ },
2194
+ setMcpServers: (servers) => {
2195
+ loaded.push(servers);
2196
+ return Promise.resolve({ added: ["acme"], removed: [], errors: {} });
2197
+ },
2198
+ }),
2199
+ },
2200
+ model: "claude-test",
2201
+ sessionStore: { load: async () => undefined, save: async () => undefined, clear: async () => undefined },
2202
+ relay,
2203
+ mcpServersProvider: (_threadId, _actor, options) => {
2204
+ asks.push(options ?? {});
2205
+ const servers = options?.fresh ? { acme } : {};
2206
+ return Promise.resolve(servers);
2207
+ },
2208
+ });
2209
+ await executor.execute({ threadId: "thread_mcp", turnId: "turn_mcp", prompt: "Connect Acme" }, controls);
2210
+ expect(asks).toEqual([{}, { fresh: true }]);
2211
+ expect(Object.keys(loaded[0] ?? {}).sort()).toEqual(["acme", "baker_ui"]);
2212
+ expect(decision).toMatchObject({ behavior: "allow", updatedInput: { resolution: { toolsReady: true } } });
2213
+ });
2214
+ it("names a server that would not start, so the agent does not promise it for the next message", async () => {
2215
+ const relay = recordingRelay();
2216
+ relay.connectionInputRequest = () => Promise.resolve(true);
2217
+ const controls = activeControls();
2218
+ let decision;
2219
+ const executor = new BridgeJobExecutor({
2220
+ sdk: {
2221
+ query: (input) => ({
2222
+ async *[Symbol.asyncIterator]() {
2223
+ const asked = input.options.canUseTool?.(CONNECTION_INPUT_TOOL_NAME, { connections: [{ server: { name: "acme", url: "https://acme.dev/mcp" }, reason: "r" }] }, { toolUseID: "t1", requestId: "req_t1", signal: new AbortController().signal });
2224
+ await new Promise((resolve) => setTimeout(resolve, 10));
2225
+ for (const [, pending] of controls.pendingConnectionInputs) {
2226
+ pending.resolve({ results: [{ status: "connected", server: "acme", scope: "company" }] });
2227
+ }
2228
+ decision = await asked;
2229
+ yield resultMessage(0, "session_after_failed_load");
2230
+ },
2231
+ setMcpServers: () => Promise.resolve({ added: [], removed: [], errors: { acme: "401 Unauthorized" } }),
2232
+ }),
2233
+ },
2234
+ model: "claude-test",
2235
+ sessionStore: { load: async () => undefined, save: async () => undefined, clear: async () => undefined },
2236
+ relay,
2237
+ mcpServersProvider: () => Promise.resolve({ acme: { type: "http", url: "https://acme.dev/mcp" } }),
2238
+ });
2239
+ await executor.execute({ threadId: "thread_bad", turnId: "turn_bad", prompt: "Connect Acme" }, controls);
2240
+ expect(decision).toMatchObject({
2241
+ updatedInput: { resolution: { toolsReady: false, failedToLoad: ["acme"] } },
2242
+ });
2243
+ });
2244
+ it("does not call an app ready when the reloaded tools do not include it", async () => {
2245
+ const relay = recordingRelay();
2246
+ relay.connectionInputRequest = () => Promise.resolve(true);
2247
+ const controls = activeControls();
2248
+ let decision;
2249
+ const executor = new BridgeJobExecutor({
2250
+ sdk: {
2251
+ query: (input) => ({
2252
+ async *[Symbol.asyncIterator]() {
2253
+ const asked = input.options.canUseTool?.(CONNECTION_INPUT_TOOL_NAME, { connections: [{ app: "notion", reason: "r" }] }, { toolUseID: "t1", requestId: "req_t1", signal: new AbortController().signal });
2254
+ await new Promise((resolve) => setTimeout(resolve, 10));
2255
+ for (const [, pending] of controls.pendingConnectionInputs) {
2256
+ pending.resolve({ results: [{ status: "connected", app: "notion", scope: "company" }] });
2257
+ }
2258
+ decision = await asked;
2259
+ yield resultMessage(0, "session_after_missing_app");
2260
+ },
2261
+ setMcpServers: () => Promise.resolve({ added: [], removed: [], errors: {} }),
2262
+ }),
2263
+ },
2264
+ model: "claude-test",
2265
+ sessionStore: { load: async () => undefined, save: async () => undefined, clear: async () => undefined },
2266
+ relay,
2267
+ mcpServersProvider: () => Promise.resolve({}),
2268
+ });
2269
+ await executor.execute({ threadId: "thread_app", turnId: "turn_app", prompt: "Connect Notion" }, controls);
2270
+ expect(decision).toMatchObject({
2271
+ updatedInput: { resolution: { toolsReady: false, failedToLoad: ["notion"] } },
2272
+ });
2273
+ });
2274
+ it("leaves the turn's tools alone when only a platform was connected", async () => {
2275
+ const relay = recordingRelay();
2276
+ relay.connectionInputRequest = () => Promise.resolve(true);
2277
+ const controls = activeControls();
2278
+ let reloads = 0;
2279
+ const executor = new BridgeJobExecutor({
2280
+ sdk: {
2281
+ query: (input) => ({
2282
+ async *[Symbol.asyncIterator]() {
2283
+ const asked = input.options.canUseTool?.(CONNECTION_INPUT_TOOL_NAME, { connections: [{ platform: "hubspot", reason: "r" }] }, { toolUseID: "t1", requestId: "req_t1", signal: new AbortController().signal });
2284
+ await new Promise((resolve) => setTimeout(resolve, 10));
2285
+ for (const [, pending] of controls.pendingConnectionInputs) {
2286
+ pending.resolve({ results: [{ status: "connected", platform: "hubspot", selected: [] }] });
2287
+ }
2288
+ await asked;
2289
+ yield resultMessage(0, "session_after_platform");
2290
+ },
2291
+ setMcpServers: () => {
2292
+ reloads += 1;
2293
+ return Promise.resolve({ added: [], removed: [], errors: {} });
2294
+ },
2295
+ }),
2296
+ },
2297
+ model: "claude-test",
2298
+ sessionStore: { load: async () => undefined, save: async () => undefined, clear: async () => undefined },
2299
+ relay,
2300
+ });
2301
+ await executor.execute({ threadId: "thread_pl", turnId: "turn_pl", prompt: "Connect HubSpot" }, controls);
2302
+ expect(reloads).toBe(0);
2303
+ });
2304
+ it("turns request_credential away in a Runtime without Credentials", async () => {
2305
+ const relay = recordingRelay();
2306
+ const requests = [];
2307
+ relay.credentialInputRequest = (_target, request) => {
2308
+ requests.push(request);
2309
+ return Promise.resolve(true);
2310
+ };
2311
+ let decision;
2312
+ const executor = new BridgeJobExecutor({
2313
+ sdk: {
2314
+ query: (input) => ({
2315
+ async *[Symbol.asyncIterator]() {
2316
+ decision = await input.options.canUseTool?.(CREDENTIAL_INPUT_TOOL_NAME, {
2317
+ label: "Shopify admin token",
2318
+ reason: "so I can update the theme",
2319
+ allowedHosts: ["acme.myshopify.com"],
2320
+ }, {
2321
+ toolUseID: "t1",
2322
+ requestId: "req_t1",
2323
+ signal: input.options.abortController?.signal ?? new AbortController().signal,
2324
+ });
2325
+ yield resultMessage(0, "session_credential_off");
2326
+ },
2327
+ }),
2328
+ },
2329
+ model: "claude-test",
2330
+ sessionStore: { load: async () => undefined, save: async () => undefined, clear: async () => undefined },
2331
+ relay,
2332
+ });
2333
+ await executor.execute({ threadId: "thread_co", turnId: "turn_co", prompt: "Fix my theme" }, activeControls());
2334
+ expect(requests).toEqual([]);
2335
+ expect(decision).toMatchObject({ behavior: "deny", message: expect.stringContaining("not available") });
2336
+ });
2337
+ it("parks a credential request and hands the agent only the placeholder the user's answer stored", async () => {
2338
+ vi.stubEnv("BAKER_CREDENTIALS", "1");
2339
+ const relay = recordingRelay();
2340
+ const requests = [];
2341
+ relay.credentialInputRequest = (_target, request) => {
2342
+ requests.push(request);
2343
+ return Promise.resolve(true);
2344
+ };
2345
+ const controls = activeControls();
2346
+ let decision;
2347
+ const executor = new BridgeJobExecutor({
2348
+ sdk: {
2349
+ query: (input) => ({
2350
+ async *[Symbol.asyncIterator]() {
2351
+ const asked = input.options.canUseTool?.(CREDENTIAL_INPUT_TOOL_NAME, {
2352
+ label: "Shopify admin token",
2353
+ reason: "so I can update the theme",
2354
+ allowedHosts: ["acme.myshopify.com"],
2355
+ }, {
2356
+ toolUseID: "t1",
2357
+ requestId: "req_t1",
2358
+ signal: input.options.abortController?.signal ?? new AbortController().signal,
2359
+ });
2360
+ await new Promise((resolve) => setTimeout(resolve, 10));
2361
+ for (const [, pending] of controls.pendingCredentialInputs) {
2362
+ pending.resolve({
2363
+ status: "stored",
2364
+ name: "shopify-admin-token",
2365
+ placeholder: "{{credential:shopify-admin-token}}",
2366
+ allowedHosts: ["acme.myshopify.com"],
2367
+ });
2368
+ }
2369
+ decision = await asked;
2370
+ yield resultMessage(0, "session_credential");
2371
+ },
2372
+ }),
2373
+ },
2374
+ model: "claude-test",
2375
+ sessionStore: { load: async () => undefined, save: async () => undefined, clear: async () => undefined },
2376
+ relay,
2377
+ });
2378
+ await executor.execute({ threadId: "thread_cr", turnId: "turn_cr", prompt: "Fix my theme" }, controls);
2379
+ expect(requests).toEqual([
2380
+ {
2381
+ toolUseId: "t1",
2382
+ label: "Shopify admin token",
2383
+ reason: "so I can update the theme",
2384
+ allowedHosts: ["acme.myshopify.com"],
2385
+ },
2386
+ ]);
2387
+ expect(decision).toMatchObject({
2388
+ behavior: "allow",
2389
+ updatedInput: { resolution: { status: "stored", placeholder: "{{credential:shopify-admin-token}}" } },
2390
+ });
2391
+ });
2053
2392
  it("refuses to ask for an avatar's face in a tool that renders only text", async () => {
2054
2393
  // Shipped three times, in both modes, after the agent had just read the doc saying
2055
2394
  // not to: "Elige el rostro para Clara, tu presentadora de placas solares" with
@@ -2090,6 +2429,8 @@ describe("BridgeJobExecutor", () => {
2090
2429
  model: "claude-test",
2091
2430
  sessionStore: { load: async () => undefined, save: async () => undefined, clear: async () => undefined },
2092
2431
  relay,
2432
+ // The decision model reads it as a face pick, and not as a question about a page.
2433
+ judge: async (questions) => questions.map(({ instructions }) => (instructions.question === FACE_PICK_QUESTION ? 0.95 : 0.02)),
2093
2434
  });
2094
2435
  await executor.execute({ threadId: "thread_face", turnId: "turn_face", prompt: "Make a TikTok ad" }, controls);
2095
2436
  expect(decision).toMatchObject({ behavior: "deny" });
@@ -2906,6 +3247,30 @@ describe("createStallDetector", () => {
2906
3247
  h.detector.dispose();
2907
3248
  }
2908
3249
  });
3250
+ // Claude Code 2.1.287+ delivers a background task's heartbeats while the model's
3251
+ // stream is stalled; they say nothing about the model, so they must not disarm it.
3252
+ it("still stalls when a background task's heartbeat arrives during the model's silence", async () => {
3253
+ vi.useFakeTimers();
3254
+ const h = harness();
3255
+ try {
3256
+ await h.advance(20);
3257
+ h.detector.observeMessage({
3258
+ type: "tool_progress",
3259
+ tool_use_id: "toolu_bg",
3260
+ tool_name: "Bash",
3261
+ parent_tool_use_id: null,
3262
+ elapsed_time_seconds: 20,
3263
+ heartbeat: true,
3264
+ uuid: "uuid_hb",
3265
+ session_id: "session_a",
3266
+ });
3267
+ await h.advance(30);
3268
+ expect(h.stalls).toHaveLength(1);
3269
+ }
3270
+ finally {
3271
+ h.detector.dispose();
3272
+ }
3273
+ });
2909
3274
  it("does not stall while the silence is still inside the window", async () => {
2910
3275
  vi.useFakeTimers();
2911
3276
  const h = harness();
@@ -3090,6 +3455,29 @@ describe("BridgeJobExecutor turn end with background tasks", () => {
3090
3455
  expect(outcome).toMatchObject({ kind: "completed", costUsd: 0.2 });
3091
3456
  expect(endInputAt.count).toBe(4);
3092
3457
  });
3458
+ it("tells the session watchdog while a tool call or background task is running", async () => {
3459
+ const endInputAt = { count: -1 };
3460
+ const executor = createExecutor({
3461
+ turnEndSettleMs: 20,
3462
+ queryResults: [
3463
+ cliLikeQuery(endInputAt, toolUseAssistantMessage("Bash", { command: "baker canvas run x.canvas.json" }), {
3464
+ type: "user",
3465
+ session_id: "session_bg",
3466
+ message: { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_stream", content: "ok" }] },
3467
+ }, taskStarted("task_render"), resultMessage(0.1, "session_bg"), taskSettled("task_render"), resultMessage(0.2, "session_bg")),
3468
+ ],
3469
+ });
3470
+ const reported = [];
3471
+ const controls = {
3472
+ ...activeControls(),
3473
+ setWorkInFlight: (inFlight) => {
3474
+ if (reported.at(-1) !== inFlight)
3475
+ reported.push(inFlight);
3476
+ },
3477
+ };
3478
+ await executor.execute({ threadId: "thread_bg", turnId: "turn_bg", prompt: "Render", history: history([]) }, controls);
3479
+ expect(reported).toEqual([true, false, true, false]);
3480
+ });
3093
3481
  it("ends the turn when the last task settles without waking the model into a second result", async () => {
3094
3482
  // A settle is not guaranteed to produce another result. If only a result
3095
3483
  // could arm the end, this shape would strand in `for await` until the
@@ -3105,6 +3493,120 @@ describe("BridgeJobExecutor turn end with background tasks", () => {
3105
3493
  expect(outcome).toMatchObject({ kind: "completed", costUsd: 0.1 });
3106
3494
  expect(endInputAt.count).toBe(3);
3107
3495
  });
3496
+ it("bills what a task spent after the last result when it settles without waking the model", async () => {
3497
+ // The last result reported $0.10; the task kept working after it. With no
3498
+ // second result, only the CLI's own running total can say what it cost.
3499
+ const endInputAt = { count: -1 };
3500
+ const query = cliLikeQuery(endInputAt, taskStarted("task_silent"), resultMessage(0.1, "session_bg"), taskSettled("task_silent"));
3501
+ const executor = createExecutor({
3502
+ turnEndSettleMs: 20,
3503
+ queryResults: [
3504
+ { ...query, usage: () => Promise.resolve({ totalCostUsd: 0.35, modelUsage: {} }) },
3505
+ ],
3506
+ });
3507
+ const outcome = await executor.execute({ threadId: "thread_silent", turnId: "turn_silent", prompt: "Fan out one producer", history: history([]) }, activeControls());
3508
+ expect(outcome).toMatchObject({ kind: "completed", costUsd: 0.35 });
3509
+ });
3510
+ it("settles the workspace itself when a turn that ran tasks ends, since its last Stop may never come", async () => {
3511
+ // A task settling without waking the model ends the turn with no further
3512
+ // Stop, so the end-of-turn hooks that deferred to a later Stop never run.
3513
+ let settled = 0;
3514
+ const executor = createExecutor({
3515
+ turnEndSettleMs: 5,
3516
+ settleWorkspace: () => {
3517
+ settled += 1;
3518
+ return Promise.resolve();
3519
+ },
3520
+ queryResults: [
3521
+ cliLikeQuery({ count: -1 }, taskStarted("task_silent"), resultMessage(0.1, "session_bg"), taskSettled("task_silent")),
3522
+ cliLikeQuery({ count: -1 }, resultMessage(0.1, "session_plain")),
3523
+ ],
3524
+ });
3525
+ await executor.execute({ threadId: "thread_silent", turnId: "turn_silent", prompt: "Fan out", history: history([]) }, activeControls());
3526
+ expect(settled).toBe(1);
3527
+ await executor.execute({ threadId: "thread_plain", turnId: "turn_plain", prompt: "Say hi", history: history([]) }, activeControls());
3528
+ expect(settled).toBe(1);
3529
+ });
3530
+ it("leaves the workspace untouched after a Session review that ran tasks", async () => {
3531
+ // A review runs with every hook off and may not change anything; the
3532
+ // settle steps format files and reconcile a publish.
3533
+ let settled = 0;
3534
+ const executor = createExecutor({
3535
+ turnEndSettleMs: 5,
3536
+ settleWorkspace: () => {
3537
+ settled += 1;
3538
+ return Promise.resolve();
3539
+ },
3540
+ queryResults: [
3541
+ cliLikeQuery({ count: -1 }, taskStarted("task_read"), taskSettled("task_read"), {
3542
+ ...resultMessage(0.1, "session_review"),
3543
+ structured_output: { summary: "fine", findings: [] },
3544
+ }),
3545
+ ],
3546
+ });
3547
+ const outcome = await executor.execute({
3548
+ threadId: "thread_review",
3549
+ turnId: "turn_review",
3550
+ prompt: "You are reviewing…",
3551
+ review: {
3552
+ reviewId: "rev_1",
3553
+ apiKey: "bk_review_key",
3554
+ modelAuthToken: "sk-or-platform",
3555
+ model: "claude-review-model",
3556
+ outputSchema: { type: "object" },
3557
+ threadIds: ["thread_visible"],
3558
+ },
3559
+ }, activeControls());
3560
+ expect(outcome).toMatchObject({ kind: "completed" });
3561
+ expect(settled).toBe(0);
3562
+ });
3563
+ it("keeps input open for a wake that lands while the usage reading is in flight", async () => {
3564
+ // The usage reading sits between deciding to end and ending. A wake queued
3565
+ // in the CLI can land inside it; closing stdin then would deny the woken
3566
+ // turn's first permission-gated tool call.
3567
+ const endInputAt = { count: -1 };
3568
+ const query = cliLikeQuery(endInputAt, taskStarted("task_bg"), resultMessage(0.1, "session_bg"), taskSettled("task_bg"), { pauseMs: 40 }, wakeMessage(),
3569
+ // The woken turn is still on time-to-first-token when the reading returns.
3570
+ { pauseMs: 150 }, resultMessage(0.2, "session_bg"));
3571
+ const executor = createExecutor({
3572
+ turnEndSettleMs: 5,
3573
+ queryResults: [
3574
+ {
3575
+ ...query,
3576
+ usage: () => new Promise((resolveUsage) => setTimeout(() => resolveUsage({ totalCostUsd: 0.2, modelUsage: {} }), 100)),
3577
+ },
3578
+ ],
3579
+ });
3580
+ await executor.execute({ threadId: "thread_wake", turnId: "turn_wake", prompt: "Fan out", history: history([]) }, activeControls());
3581
+ expect(endInputAt.count).toBe(5);
3582
+ });
3583
+ it("leaves a query that ended during the usage reading alone when the reading returns", async () => {
3584
+ // A Stop can close the CLI while the reading is in flight. The run is over
3585
+ // by the time it answers; ending the turn then would log a turn end that
3586
+ // never happened and arm an exit guard against a query nobody is draining.
3587
+ const query = cliLikeQuery({ count: -1 }, taskStarted("task_bg"), resultMessage(0.1, "session_bg"), taskSettled("task_bg"));
3588
+ let answerUsage;
3589
+ const turnEvents = [];
3590
+ const executor = createExecutor({
3591
+ turnEndSettleMs: 5,
3592
+ turnEvents,
3593
+ queryResults: [
3594
+ {
3595
+ ...query,
3596
+ usage: () => {
3597
+ query.close?.();
3598
+ return new Promise((resolveUsage) => {
3599
+ answerUsage = () => resolveUsage({ totalCostUsd: 0.2, modelUsage: {} });
3600
+ });
3601
+ },
3602
+ },
3603
+ ],
3604
+ });
3605
+ await executor.execute({ threadId: "thread_stop", turnId: "turn_stop", prompt: "Fan out", history: history([]) }, activeControls());
3606
+ answerUsage();
3607
+ await new Promise((resolveTick) => setImmediate(resolveTick));
3608
+ expect(turnEvents.map((event) => event.event)).not.toContain("turn_end");
3609
+ });
3108
3610
  it("re-arms the settle window when a passive system event trails the result", async () => {
3109
3611
  // Passive trailing events (files persisted, session state) must not cancel
3110
3612
  // a scheduled end — only genuine model activity does. A cancel with no
@@ -3174,6 +3676,24 @@ describe("BridgeJobExecutor turn end with background tasks", () => {
3174
3676
  expect(outcome).toMatchObject({ kind: "completed", costUsd: 0.1 });
3175
3677
  expect(endInputAt.count).toBe(3);
3176
3678
  });
3679
+ // Dev chat, Oct 2026: an auto-backgrounded render settled, input ended one
3680
+ // settle window later, and the model woke ~3 s after the settle on a closed
3681
+ // channel; it started a second render in the background, and the CLI's exit
3682
+ // at its result killed it. The CLI streams nothing between a settle and the
3683
+ // woken turn's first token, so only a longer wait after a settle covers it.
3684
+ it("keeps input open for the model a settled background command wakes, though nothing streams for a while", async () => {
3685
+ const endInputAt = { count: -1 };
3686
+ const executor = createExecutor({
3687
+ turnEndSettleMs: 20,
3688
+ turnEndWakeMs: 5_000,
3689
+ queryResults: [
3690
+ cliLikeQuery(endInputAt, backgroundTasksChanged(["bash_render"]), resultMessage(0.1, "session_bg"), backgroundTasksChanged([]), { pauseMs: 60 }, assistantMessage("msg_wake", "The third render is ready", "session_bg"), resultMessage(0.3, "session_bg")),
3691
+ ],
3692
+ });
3693
+ const outcome = await executor.execute({ threadId: "thread_wake_late", turnId: "turn_wake_late", prompt: "Render three", history: history([]) }, activeControls());
3694
+ expect(outcome).toMatchObject({ kind: "completed", costUsd: 0.3 });
3695
+ expect(endInputAt.count).toBe(5);
3696
+ });
3177
3697
  // The production shape: the model refused, the CLI answered with an error
3178
3698
  // result and then waited on stdin like after any other result. The error was
3179
3699
  // no longer thrown, and only a clean result ended input, so the turn sat idle
@@ -3288,7 +3808,10 @@ describe("BridgeJobExecutor review mode", () => {
3288
3808
  const queryInputs = [];
3289
3809
  const executor = createExecutor({ queryInputs });
3290
3810
  await executor.execute(reviewJob(), activeControls());
3291
- expect((queryInputs[0]?.options).outputFormat).toEqual({
3811
+ const [reviewQuery] = queryInputs;
3812
+ if (!reviewQuery)
3813
+ throw new Error("expected the review to start one query");
3814
+ expect(reviewQuery.options.outputFormat).toEqual({
3292
3815
  type: "json_schema",
3293
3816
  schema: review.outputSchema,
3294
3817
  });
@@ -3325,7 +3848,9 @@ describe("BridgeJobExecutor review mode", () => {
3325
3848
  const options = queryInputs[0]?.options;
3326
3849
  expect(options.model).toBe("claude-session-model");
3327
3850
  expect(options.outputFormat).toBeUndefined();
3328
- expect(options.hooks).toBeUndefined();
3851
+ // Only the credential scrub, which every turn runs; none of the review's hooks.
3852
+ expect(Object.keys(options.hooks ?? {})).toEqual(["PostToolUse"]);
3853
+ expect(options.hooks?.PostToolUse).toHaveLength(1);
3329
3854
  expect(options.env?.MAX_STRUCTURED_OUTPUT_RETRIES).toBeUndefined();
3330
3855
  expect(options.disallowedTools).not.toContain("Write");
3331
3856
  expect(options.env?.BAKER_DEBUG_LOG).toBeUndefined();