@synap-core/cli 1.12.2 → 1.13.1-next.20261102.ec3d166

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 (354) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -11
  3. package/dist/build-info.json +2 -3
  4. package/dist/commands/agents.d.ts +0 -1
  5. package/dist/commands/agents.js +13 -3
  6. package/dist/commands/agents.js.map +1 -1
  7. package/dist/commands/app.js +2 -1
  8. package/dist/commands/app.js.map +1 -1
  9. package/dist/commands/automation.js +46 -41
  10. package/dist/commands/automation.js.map +1 -1
  11. package/dist/commands/box.d.ts +42 -0
  12. package/dist/commands/box.js +89 -0
  13. package/dist/commands/box.js.map +1 -0
  14. package/dist/commands/bridge-setup.js +3 -1
  15. package/dist/commands/bridge-setup.js.map +1 -1
  16. package/dist/commands/capability.d.ts +87 -1
  17. package/dist/commands/capability.js +174 -64
  18. package/dist/commands/capability.js.map +1 -1
  19. package/dist/commands/close-questions.d.ts +27 -0
  20. package/dist/commands/close-questions.js +49 -0
  21. package/dist/commands/close-questions.js.map +1 -0
  22. package/dist/commands/close-questions.test.d.ts +1 -0
  23. package/dist/commands/close-questions.test.js +52 -0
  24. package/dist/commands/close-questions.test.js.map +1 -0
  25. package/dist/commands/connect.d.ts +1 -3
  26. package/dist/commands/connect.js +2 -2
  27. package/dist/commands/connect.js.map +1 -1
  28. package/dist/commands/context.js +72 -43
  29. package/dist/commands/context.js.map +1 -1
  30. package/dist/commands/data-extra.d.ts +13 -0
  31. package/dist/commands/data-extra.js +35 -6
  32. package/dist/commands/data-extra.js.map +1 -1
  33. package/dist/commands/data.js +2 -1
  34. package/dist/commands/data.js.map +1 -1
  35. package/dist/commands/doctor.d.ts +39 -0
  36. package/dist/commands/doctor.js +136 -14
  37. package/dist/commands/doctor.js.map +1 -1
  38. package/dist/commands/hooks.d.ts +19 -0
  39. package/dist/commands/hooks.js +70 -0
  40. package/dist/commands/hooks.js.map +1 -0
  41. package/dist/commands/import.d.ts +25 -0
  42. package/dist/commands/import.js +98 -12
  43. package/dist/commands/import.js.map +1 -1
  44. package/dist/commands/init.d.ts +14 -0
  45. package/dist/commands/init.js +63 -21
  46. package/dist/commands/init.js.map +1 -1
  47. package/dist/commands/keys-request.d.ts +48 -0
  48. package/dist/commands/keys-request.js +236 -0
  49. package/dist/commands/keys-request.js.map +1 -0
  50. package/dist/commands/keys-request.test.d.ts +1 -0
  51. package/dist/commands/keys-request.test.js +176 -0
  52. package/dist/commands/keys-request.test.js.map +1 -0
  53. package/dist/commands/knowledge.d.ts +6 -0
  54. package/dist/commands/knowledge.js +13 -3
  55. package/dist/commands/knowledge.js.map +1 -1
  56. package/dist/commands/lens.js +2 -1
  57. package/dist/commands/lens.js.map +1 -1
  58. package/dist/commands/market-authoring.js +1 -1
  59. package/dist/commands/market-authoring.js.map +1 -1
  60. package/dist/commands/market.d.ts +61 -4
  61. package/dist/commands/market.js +106 -48
  62. package/dist/commands/market.js.map +1 -1
  63. package/dist/commands/mcp.d.ts +1 -1
  64. package/dist/commands/mcp.js +14 -9
  65. package/dist/commands/mcp.js.map +1 -1
  66. package/dist/commands/observe.category.test.d.ts +1 -0
  67. package/dist/commands/observe.category.test.js +59 -0
  68. package/dist/commands/observe.category.test.js.map +1 -0
  69. package/dist/commands/observe.d.ts +3 -0
  70. package/dist/commands/observe.js +7 -5
  71. package/dist/commands/observe.js.map +1 -1
  72. package/dist/commands/observe.title.test.d.ts +1 -0
  73. package/dist/commands/observe.title.test.js +37 -0
  74. package/dist/commands/observe.title.test.js.map +1 -0
  75. package/dist/commands/playbook.js +9 -0
  76. package/dist/commands/playbook.js.map +1 -1
  77. package/dist/commands/pods.d.ts +6 -1
  78. package/dist/commands/pods.js +38 -17
  79. package/dist/commands/pods.js.map +1 -1
  80. package/dist/commands/proposals-actions.js +7 -1
  81. package/dist/commands/proposals-actions.js.map +1 -1
  82. package/dist/commands/proton-setup.js +3 -1
  83. package/dist/commands/proton-setup.js.map +1 -1
  84. package/dist/commands/providers.d.ts +22 -6
  85. package/dist/commands/providers.js +39 -32
  86. package/dist/commands/providers.js.map +1 -1
  87. package/dist/commands/raycast.d.ts +15 -0
  88. package/dist/commands/raycast.js +36 -10
  89. package/dist/commands/raycast.js.map +1 -1
  90. package/dist/commands/session-feed-wait.d.ts +48 -0
  91. package/dist/commands/session-feed-wait.js +261 -0
  92. package/dist/commands/session-feed-wait.js.map +1 -0
  93. package/dist/commands/session-post.d.ts +40 -0
  94. package/dist/commands/session-post.js +96 -0
  95. package/dist/commands/session-post.js.map +1 -0
  96. package/dist/commands/session-wait.d.ts +9 -4
  97. package/dist/commands/session-wait.js +20 -5
  98. package/dist/commands/session-wait.js.map +1 -1
  99. package/dist/commands/sessions.d.ts +2 -0
  100. package/dist/commands/sessions.js +23 -0
  101. package/dist/commands/sessions.js.map +1 -1
  102. package/dist/commands/skills-commands.d.ts +2 -0
  103. package/dist/commands/skills-commands.js +9 -1
  104. package/dist/commands/skills-commands.js.map +1 -1
  105. package/dist/commands/skills-commands.test.d.ts +1 -0
  106. package/dist/commands/skills-commands.test.js +57 -0
  107. package/dist/commands/skills-commands.test.js.map +1 -0
  108. package/dist/commands/status.js +13 -7
  109. package/dist/commands/status.js.map +1 -1
  110. package/dist/commands/statusline.backoff.test.js +13 -3
  111. package/dist/commands/statusline.backoff.test.js.map +1 -1
  112. package/dist/commands/statusline.d.ts +104 -9
  113. package/dist/commands/statusline.js +392 -148
  114. package/dist/commands/statusline.js.map +1 -1
  115. package/dist/commands/statusline.line2.test.d.ts +15 -0
  116. package/dist/commands/statusline.line2.test.js +226 -0
  117. package/dist/commands/statusline.line2.test.js.map +1 -0
  118. package/dist/commands/statusline.needs-you.test.d.ts +1 -0
  119. package/dist/commands/statusline.needs-you.test.js +17 -0
  120. package/dist/commands/statusline.needs-you.test.js.map +1 -0
  121. package/dist/commands/statusline.now.test.d.ts +10 -0
  122. package/dist/commands/statusline.now.test.js +250 -0
  123. package/dist/commands/statusline.now.test.js.map +1 -0
  124. package/dist/commands/tracks.d.ts +68 -6
  125. package/dist/commands/tracks.js +272 -16
  126. package/dist/commands/tracks.js.map +1 -1
  127. package/dist/commands/upload.js +12 -0
  128. package/dist/commands/upload.js.map +1 -1
  129. package/dist/commands/wait.d.ts +124 -0
  130. package/dist/commands/wait.js +384 -0
  131. package/dist/commands/wait.js.map +1 -0
  132. package/dist/commands/wait.test.d.ts +8 -0
  133. package/dist/commands/wait.test.js +308 -0
  134. package/dist/commands/wait.test.js.map +1 -0
  135. package/dist/commands/whoami.d.ts +7 -0
  136. package/dist/commands/whoami.js +17 -0
  137. package/dist/commands/whoami.js.map +1 -1
  138. package/dist/commands/worker-accounts.d.ts +149 -0
  139. package/dist/commands/worker-accounts.js +339 -0
  140. package/dist/commands/worker-accounts.js.map +1 -0
  141. package/dist/commands/worker-accounts.test.d.ts +1 -0
  142. package/dist/commands/worker-accounts.test.js +224 -0
  143. package/dist/commands/worker-accounts.test.js.map +1 -0
  144. package/dist/commands/worker-github-app.d.ts +63 -0
  145. package/dist/commands/worker-github-app.js +161 -0
  146. package/dist/commands/worker-github-app.js.map +1 -0
  147. package/dist/commands/worker-github-app.test.d.ts +1 -0
  148. package/dist/commands/worker-github-app.test.js +102 -0
  149. package/dist/commands/worker-github-app.test.js.map +1 -0
  150. package/dist/commands/worker-github-broker.d.ts +39 -0
  151. package/dist/commands/worker-github-broker.js +106 -0
  152. package/dist/commands/worker-github-broker.js.map +1 -0
  153. package/dist/commands/worker-github-broker.test.d.ts +1 -0
  154. package/dist/commands/worker-github-broker.test.js +90 -0
  155. package/dist/commands/worker-github-broker.test.js.map +1 -0
  156. package/dist/commands/worker-runs.test.d.ts +1 -0
  157. package/dist/commands/worker-runs.test.js +150 -0
  158. package/dist/commands/worker-runs.test.js.map +1 -0
  159. package/dist/commands/worker-worktrees.d.ts +34 -0
  160. package/dist/commands/worker-worktrees.js +87 -0
  161. package/dist/commands/worker-worktrees.js.map +1 -0
  162. package/dist/commands/worker.d.ts +406 -10
  163. package/dist/commands/worker.js +1180 -126
  164. package/dist/commands/worker.js.map +1 -1
  165. package/dist/commands/worker.test.js +588 -11
  166. package/dist/commands/worker.test.js.map +1 -1
  167. package/dist/index.js +220 -28
  168. package/dist/index.js.map +1 -1
  169. package/dist/lib/agent-door.d.ts +1 -3
  170. package/dist/lib/agent-door.js +10 -7
  171. package/dist/lib/agent-door.js.map +1 -1
  172. package/dist/lib/agents-config.d.ts +9 -0
  173. package/dist/lib/agents-config.js +20 -0
  174. package/dist/lib/agents-config.js.map +1 -1
  175. package/dist/lib/atomic-file.d.ts +20 -1
  176. package/dist/lib/atomic-file.js +100 -2
  177. package/dist/lib/atomic-file.js.map +1 -1
  178. package/dist/lib/auth.d.ts +28 -2
  179. package/dist/lib/auth.js +52 -12
  180. package/dist/lib/auth.js.map +1 -1
  181. package/dist/lib/box/egress.d.ts +82 -0
  182. package/dist/lib/box/egress.js +264 -0
  183. package/dist/lib/box/egress.js.map +1 -0
  184. package/dist/lib/box/evidence.d.ts +86 -0
  185. package/dist/lib/box/evidence.js +121 -0
  186. package/dist/lib/box/evidence.js.map +1 -0
  187. package/dist/lib/box/job-server.d.ts +72 -0
  188. package/dist/lib/box/job-server.js +250 -0
  189. package/dist/lib/box/job-server.js.map +1 -0
  190. package/dist/lib/box/provisioner.d.ts +83 -0
  191. package/dist/lib/box/provisioner.js +312 -0
  192. package/dist/lib/box/provisioner.js.map +1 -0
  193. package/dist/lib/box/proxmox.d.ts +112 -0
  194. package/dist/lib/box/proxmox.js +211 -0
  195. package/dist/lib/box/proxmox.js.map +1 -0
  196. package/dist/lib/box/wire.d.ts +98 -0
  197. package/dist/lib/box/wire.js +70 -0
  198. package/dist/lib/box/wire.js.map +1 -0
  199. package/dist/lib/bridge-invocation.test.d.ts +1 -0
  200. package/dist/lib/bridge-invocation.test.js +75 -0
  201. package/dist/lib/bridge-invocation.test.js.map +1 -0
  202. package/dist/lib/bridge-status.d.ts +144 -0
  203. package/dist/lib/bridge-status.js +227 -0
  204. package/dist/lib/bridge-status.js.map +1 -0
  205. package/dist/lib/bridge-status.test.d.ts +6 -0
  206. package/dist/lib/bridge-status.test.js +197 -0
  207. package/dist/lib/bridge-status.test.js.map +1 -0
  208. package/dist/lib/client-header.test.d.ts +1 -0
  209. package/dist/lib/client-header.test.js +103 -0
  210. package/dist/lib/client-header.test.js.map +1 -0
  211. package/dist/lib/conversation-headers.d.ts +89 -9
  212. package/dist/lib/conversation-headers.js +156 -3
  213. package/dist/lib/conversation-headers.js.map +1 -1
  214. package/dist/lib/conversation-headers.test.d.ts +1 -0
  215. package/dist/lib/conversation-headers.test.js +78 -0
  216. package/dist/lib/conversation-headers.test.js.map +1 -0
  217. package/dist/lib/credential-class.d.ts +40 -2
  218. package/dist/lib/credential-class.js +48 -6
  219. package/dist/lib/credential-class.js.map +1 -1
  220. package/dist/lib/device-connect.d.ts +45 -0
  221. package/dist/lib/device-connect.js +60 -0
  222. package/dist/lib/device-connect.js.map +1 -1
  223. package/dist/lib/exporter-coverage.d.ts +1 -1
  224. package/dist/lib/exporter-coverage.js +1 -1
  225. package/dist/lib/goal-words.d.ts +38 -0
  226. package/dist/lib/goal-words.js +115 -0
  227. package/dist/lib/goal-words.js.map +1 -0
  228. package/dist/lib/hub-client.d.ts +87 -8
  229. package/dist/lib/hub-client.js +205 -70
  230. package/dist/lib/hub-client.js.map +1 -1
  231. package/dist/lib/init-handoff.d.ts +8 -0
  232. package/dist/lib/init-handoff.js +9 -2
  233. package/dist/lib/init-handoff.js.map +1 -1
  234. package/dist/lib/key-locations.d.ts +3 -3
  235. package/dist/lib/key-locations.js +3 -39
  236. package/dist/lib/key-locations.js.map +1 -1
  237. package/dist/lib/key-source.d.ts +3 -1
  238. package/dist/lib/key-source.js +7 -4
  239. package/dist/lib/key-source.js.map +1 -1
  240. package/dist/lib/knowledge-title.d.ts +3 -2
  241. package/dist/lib/knowledge-title.js +3 -2
  242. package/dist/lib/knowledge-title.js.map +1 -1
  243. package/dist/lib/machine-instance.d.ts +7 -5
  244. package/dist/lib/machine-instance.js +7 -5
  245. package/dist/lib/machine-instance.js.map +1 -1
  246. package/dist/lib/mcp-bridge.d.ts +50 -0
  247. package/dist/lib/mcp-bridge.js +232 -11
  248. package/dist/lib/mcp-bridge.js.map +1 -1
  249. package/dist/lib/mcp-bridge.test.js +224 -4
  250. package/dist/lib/mcp-bridge.test.js.map +1 -1
  251. package/dist/lib/mcp-call.d.ts +5 -1
  252. package/dist/lib/mcp-call.js +7 -6
  253. package/dist/lib/mcp-call.js.map +1 -1
  254. package/dist/lib/mcp-connector-url.test.d.ts +1 -0
  255. package/dist/lib/mcp-connector-url.test.js +13 -0
  256. package/dist/lib/mcp-connector-url.test.js.map +1 -0
  257. package/dist/lib/mcp-snippets.d.ts +16 -0
  258. package/dist/lib/mcp-snippets.js +20 -2
  259. package/dist/lib/mcp-snippets.js.map +1 -1
  260. package/dist/lib/now-line.d.ts +66 -0
  261. package/dist/lib/now-line.js +193 -0
  262. package/dist/lib/now-line.js.map +1 -0
  263. package/dist/lib/now-line.test.d.ts +5 -0
  264. package/dist/lib/now-line.test.js +177 -0
  265. package/dist/lib/now-line.test.js.map +1 -0
  266. package/dist/lib/package-updates.d.ts +66 -0
  267. package/dist/lib/package-updates.js +76 -0
  268. package/dist/lib/package-updates.js.map +1 -0
  269. package/dist/lib/pod-moved.d.ts +70 -0
  270. package/dist/lib/pod-moved.js +146 -0
  271. package/dist/lib/pod-moved.js.map +1 -0
  272. package/dist/lib/pod-reach.d.ts +39 -0
  273. package/dist/lib/pod-reach.js +117 -0
  274. package/dist/lib/pod-reach.js.map +1 -0
  275. package/dist/lib/pod-reach.test.d.ts +1 -0
  276. package/dist/lib/pod-reach.test.js +67 -0
  277. package/dist/lib/pod-reach.test.js.map +1 -0
  278. package/dist/lib/pod.d.ts +8 -4
  279. package/dist/lib/pod.js +12 -10
  280. package/dist/lib/pod.js.map +1 -1
  281. package/dist/lib/register-claude-code-mcp.test.d.ts +1 -0
  282. package/dist/lib/register-claude-code-mcp.test.js +97 -0
  283. package/dist/lib/register-claude-code-mcp.test.js.map +1 -0
  284. package/dist/lib/review-credential.js +3 -1
  285. package/dist/lib/review-credential.js.map +1 -1
  286. package/dist/lib/safe-open.d.ts +5 -3
  287. package/dist/lib/safe-open.js +9 -8
  288. package/dist/lib/safe-open.js.map +1 -1
  289. package/dist/lib/self-host.d.ts +17 -0
  290. package/dist/lib/self-host.js +24 -0
  291. package/dist/lib/self-host.js.map +1 -0
  292. package/dist/lib/session-feed.d.ts +185 -0
  293. package/dist/lib/session-feed.js +257 -0
  294. package/dist/lib/session-feed.js.map +1 -0
  295. package/dist/lib/targets.d.ts +52 -3
  296. package/dist/lib/targets.js +150 -209
  297. package/dist/lib/targets.js.map +1 -1
  298. package/dist/lib/term.d.ts +24 -0
  299. package/dist/lib/term.js +52 -0
  300. package/dist/lib/term.js.map +1 -0
  301. package/dist/lib/term.test.d.ts +1 -0
  302. package/dist/lib/term.test.js +29 -0
  303. package/dist/lib/term.test.js.map +1 -0
  304. package/dist/lib/transcript-index.d.ts +94 -0
  305. package/dist/lib/transcript-index.js +321 -0
  306. package/dist/lib/transcript-index.js.map +1 -0
  307. package/dist/lib/transcript-index.test.d.ts +10 -0
  308. package/dist/lib/transcript-index.test.js +200 -0
  309. package/dist/lib/transcript-index.test.js.map +1 -0
  310. package/dist/lib/wait-help.d.ts +11 -0
  311. package/dist/lib/wait-help.js +25 -0
  312. package/dist/lib/wait-help.js.map +1 -0
  313. package/dist/lib/wait-state.d.ts +57 -0
  314. package/dist/lib/wait-state.js +114 -0
  315. package/dist/lib/wait-state.js.map +1 -0
  316. package/dist/lib/wake-hook-settings.d.ts +57 -0
  317. package/dist/lib/wake-hook-settings.js +153 -0
  318. package/dist/lib/wake-hook-settings.js.map +1 -0
  319. package/dist/lib/wake-state.d.ts +65 -0
  320. package/dist/lib/wake-state.js +122 -0
  321. package/dist/lib/wake-state.js.map +1 -0
  322. package/package.json +5 -2
  323. package/skills/README.md +1 -1
  324. package/skills/agent-os/SKILL.md +8 -8
  325. package/skills/synap/SKILL.md +178 -88
  326. package/skills/synap/code-skills.md +84 -0
  327. package/skills/synap/concepts.md +38 -20
  328. package/skills/synap/creative-loop.md +1 -1
  329. package/skills/synap/document-embeds.md +17 -1
  330. package/skills/synap/focus-sessions.md +17 -8
  331. package/skills/synap/from-intent.md +33 -17
  332. package/skills/synap/governance-rules.md +3 -1
  333. package/skills/synap/lenses.md +4 -4
  334. package/skills/synap/linking.md +2 -2
  335. package/skills/synap/mental-model.md +1 -1
  336. package/skills/synap/operating-mode-rules.md +1 -1
  337. package/skills/synap/quick-reference.md +2 -1
  338. package/skills/synap/reflexes.md +8 -8
  339. package/skills/synap/viewframe-cells.md +1 -1
  340. package/skills/synap/work-model.md +24 -10
  341. package/skills/synap/workspace-design.md +5 -5
  342. package/skills/synap/writes.md +16 -9
  343. package/skills/synap/writing-titles.md +7 -0
  344. package/skills/synap-app/SKILL.md +148 -66
  345. package/skills/synap-app/creating-the-base-app.md +12 -7
  346. package/skills/synap-app/governance.md +27 -16
  347. package/skills/synap-app/install-commands.md +18 -9
  348. package/skills/synap-app/overview.md +16 -9
  349. package/skills/synap-app/using-the-sdk.md +75 -25
  350. package/skills/synap-market/SKILL.md +1 -1
  351. package/skills/synap-market/governance-and-catalog.md +1 -1
  352. package/skills/synap-schema/SKILL.md +24 -21
  353. package/skills/synap-schema/intent-vocabulary-ssot.md +24 -21
  354. package/templates/box/runner.py +206 -0
@@ -1,6 +1,9 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
1
4
  import { describe, expect, it } from "vitest";
2
- import { buildWorkerPrompt, findMcpServerName, AgentBoundForDispatchError, AgentPausedError, parseHandedToYou, parseInterval, pickNew, workerEnv, workerVerdict, } from "./worker.js";
3
- import { lastJsonPayload } from "../lib/mcp-call.js";
5
+ import { buildWorkerPrompt, RUN_MCP_SERVER, runMcpConfig, writeRunMcpConfig, AgentBoundForDispatchError, AgentPausedError, parseHandedToYou, parseInterval, pickNew, LAUNCH_RETRY_BASE_MS, MAX_LAUNCH_RETRIES, workerEnv, workerVerdict, parseVerdict, verdictClaim, verdictForExit, quietIsOwnRun, runProcessIdentity, runEndHeaders, foldRunEvent as foldOne, RUN_ENV_BLOCKLIST, } from "./worker.js";
6
+ import { createMcpClient, lastJsonPayload } from "../lib/mcp-call.js";
4
7
  const orient = (handed) => JSON.stringify({ startHere: { pendingReview: { count: 0 }, handedToYou: handed } });
5
8
  describe("parseHandedToYou", () => {
6
9
  it("reads the handed sessions from orient", () => {
@@ -25,6 +28,20 @@ describe("parseHandedToYou", () => {
25
28
  it("non-JSON orient text throws", () => {
26
29
  expect(() => parseHandedToYou("Error: pod down")).toThrow(/did not return JSON/);
27
30
  });
31
+ it("keeps a resume mark and when the session went quiet; anything but `resume: true` is no mark", () => {
32
+ const items = parseHandedToYou(orient({
33
+ items: [
34
+ { id: "a", goal: "g", resume: true, quietSince: "2026-10-29T10:00:00.000Z" },
35
+ { id: "b", goal: "g", resume: "yes", quietSince: "2026-10-29T10:00:00.000Z" },
36
+ { id: "c", goal: "g", resume: true },
37
+ ],
38
+ }));
39
+ expect(items).toEqual([
40
+ { id: "a", goal: "g", resume: true, quietSince: "2026-10-29T10:00:00.000Z" },
41
+ { id: "b", goal: "g" },
42
+ { id: "c", goal: "g", resume: true },
43
+ ]);
44
+ });
28
45
  });
29
46
  describe("pickNew", () => {
30
47
  const items = [{ id: "a", goal: null }, { id: "b", goal: null }, { id: "c", goal: null }];
@@ -35,18 +52,81 @@ describe("pickNew", () => {
35
52
  it("launches nothing when no slot is free", () => {
36
53
  expect(pickNew(items, { runs: {} }, 0)).toEqual([]);
37
54
  });
55
+ it("never a session handed back to resume, even with no record of it (an empty state file relaunches nothing)", () => {
56
+ const handedBack = [{ id: "a", goal: null, resume: true }, { id: "b", goal: null }];
57
+ expect(pickNew(handedBack, { runs: {} }, 5).map((i) => i.id)).toEqual(["b"]);
58
+ });
59
+ // A run that failed BEFORE Claude started (no Claude session: an expired
60
+ // login, exhausted credits) had no way back: the session stayed dead even
61
+ // after the cause was fixed (2026-10-20). It is retried with backoff now.
62
+ describe("a run that failed before Claude started is retried with backoff", () => {
63
+ const T0 = Date.parse("2026-10-20T12:00:00Z");
64
+ const failedAt = (retries, extra = {}) => ({
65
+ runs: {
66
+ a: {
67
+ sessionId: "a",
68
+ status: "failed",
69
+ startedAt: "t",
70
+ finishedAt: new Date(T0).toISOString(),
71
+ launchRetries: retries,
72
+ ...extra,
73
+ },
74
+ },
75
+ });
76
+ const one = [{ id: "a", goal: null }];
77
+ it("not before its backoff", () => {
78
+ expect(pickNew(one, failedAt(0), 1, T0 + LAUNCH_RETRY_BASE_MS - 1)).toEqual([]);
79
+ });
80
+ it("once the backoff has passed", () => {
81
+ expect(pickNew(one, failedAt(0), 1, T0 + LAUNCH_RETRY_BASE_MS).map((i) => i.id)).toEqual(["a"]);
82
+ });
83
+ it("the backoff doubles with each retry", () => {
84
+ expect(pickNew(one, failedAt(2), 1, T0 + 4 * LAUNCH_RETRY_BASE_MS - 1)).toEqual([]);
85
+ expect(pickNew(one, failedAt(2), 1, T0 + 4 * LAUNCH_RETRY_BASE_MS).map((i) => i.id)).toEqual(["a"]);
86
+ });
87
+ it("never after the last retry", () => {
88
+ expect(pickNew(one, failedAt(MAX_LAUNCH_RETRIES), 1, T0 + 1e12)).toEqual([]);
89
+ });
90
+ it("never when Claude did start (a room follow-up resumes that one)", () => {
91
+ expect(pickNew(one, failedAt(0, { claudeSessionId: "c1" }), 1, T0 + 1e12)).toEqual([]);
92
+ });
93
+ it("never a run that finished", () => {
94
+ const state = { runs: { a: { sessionId: "a", status: "done", startedAt: "t", finishedAt: new Date(T0).toISOString() } } };
95
+ expect(pickNew(one, state, 1, T0 + 1e12)).toEqual([]);
96
+ });
97
+ });
38
98
  });
39
- describe("findMcpServerName", () => {
40
- it("finds the user-scope server pointing at the pod", () => {
41
- const cj = { mcpServers: { other: { url: "https://x/mcp" }, "synap-pod": { url: "https://pod.example/mcp/" } } };
42
- expect(findMcpServerName(cj, "https://pod.example")).toBe("synap-pod");
99
+ describe("the run's MCP server is pinned to the worker agent's own key", () => {
100
+ // A `synap login --reconnect` on the box rewrote ~/.claude.json with ANOTHER
101
+ // agent's key; runs that inherited it posted as that agent (2026-10-20).
102
+ it("points one http server at the pod with the worker's key", () => {
103
+ expect(runMcpConfig("https://pod.example/", "synap_hub_k")).toEqual({
104
+ mcpServers: {
105
+ [RUN_MCP_SERVER]: {
106
+ type: "http",
107
+ url: "https://pod.example/mcp",
108
+ headers: { Authorization: "Bearer synap_hub_k", "X-Synap-Model-Provider": "anthropic" },
109
+ },
110
+ },
111
+ });
43
112
  });
44
- it("falls back to a project-scope server", () => {
45
- const cj = { projects: { "/w": { mcpServers: { s: { url: "https://pod.example/mcp" } } } } };
46
- expect(findMcpServerName(cj, "https://pod.example/")).toBe("s");
113
+ it("names the run's Claude session as the conversation, so the pod sees one run", () => {
114
+ const sid = "8c34a72b-b1c1-4c37-9c1f-02920b69ccb2";
115
+ const cfg = runMcpConfig("https://pod.example", "k", sid);
116
+ expect(cfg.mcpServers[RUN_MCP_SERVER].headers).toEqual({
117
+ Authorization: "Bearer k",
118
+ "X-Synap-Conversation": `claude-code:${sid}`,
119
+ "X-Synap-Model-Provider": "anthropic",
120
+ });
47
121
  });
48
- it("returns null when none matches", () => {
49
- expect(findMcpServerName({ mcpServers: { s: { url: "https://other/mcp" } } }, "https://pod.example")).toBeNull();
122
+ it("writes it owner-only, replacing a wider file", () => {
123
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "worker-mcp-"));
124
+ const file = path.join(dir, "mcp.json");
125
+ fs.writeFileSync(file, "{}", { mode: 0o644 });
126
+ const written = writeRunMcpConfig(dir, runMcpConfig("https://pod.example", "k"));
127
+ expect(written).toBe(file);
128
+ expect(fs.statSync(file).mode & 0o777).toBe(0o600);
129
+ expect(JSON.parse(fs.readFileSync(file, "utf8")).mcpServers[RUN_MCP_SERVER].url).toBe("https://pod.example/mcp");
50
130
  });
51
131
  });
52
132
  describe("workerEnv", () => {
@@ -67,6 +147,12 @@ describe("prompt + verdict", () => {
67
147
  expect(p).toContain("devbox/c5a34b78-");
68
148
  expect(p).toContain("WORKER-RESULT:");
69
149
  });
150
+ it("lets the goal name the directory and allows only a preview deploy the goal asks for", () => {
151
+ const p = buildWorkerPrompt({ sessionId: "c5a34b78-0000", goal: "g", agentName: "cc-devbox", cwd: "/w", mcpServer: "synap-pod" });
152
+ expect(p).toContain("Work in /w, or in the directory the session goal names");
153
+ expect(p).toContain("Never deploy to production");
154
+ expect(p).toContain("preview deploy only when the session goal asks for one");
155
+ });
70
156
  it("reads the last WORKER-RESULT line", () => {
71
157
  expect(workerVerdict("work\nWORKER-RESULT: done — posted report")).toBe("done — posted report");
72
158
  expect(workerVerdict("no verdict here")).toBeUndefined();
@@ -113,4 +199,495 @@ describe("followUpsSince", () => {
113
199
  expect(MAX_RESUMES).toBeGreaterThan(0);
114
200
  });
115
201
  });
202
+ // ─── night shift: limits, design runs, the protocol ──────────────────────────
203
+ import { BG_SETTLE_MS, bgPhase, DESIGN_OUTCOME_KIND, endedOnLimit, foldRunEvent, isDesignSession, limitUntil, parseAgentsList, parseBgId, readTranscriptEnd, } from "./worker.js";
204
+ const fold = (lines) => {
205
+ const rejected = {};
206
+ return lines.reduce((end, ev) => foldRunEvent(end, ev, rejected), {});
207
+ };
208
+ // Reduced from a real `claude -p … --output-format stream-json --verbose` run on
209
+ // Claude Code 2.1.309 (2026-10-23): field names verbatim.
210
+ const OK_STREAM = [
211
+ { type: "system", subtype: "init", session_id: "0d6b64a2-9d15-444e-be4e-a8f449b51b7f", claude_code_version: "2.1.309" },
212
+ { type: "assistant", parent_tool_use_id: null, session_id: "0d6b64a2", message: { role: "assistant", content: [{ type: "text", text: "WORKER-RESULT: done — ok" }] } },
213
+ { type: "rate_limit_event", rate_limit_info: { status: "allowed", resetsAt: 1792725000, rateLimitType: "five_hour" }, session_id: "0d6b64a2" },
214
+ { type: "result", subtype: "success", is_error: false, api_error_status: null, result: "WORKER-RESULT: done — ok", session_id: "0d6b64a2" },
215
+ ];
216
+ // A usage limit, in the stream's spelling. The assistant fields are the real
217
+ // transcript entry's (507 of them in this Mac's ~/.claude/projects: error
218
+ // "rate_limit", apiError "usage_limit_reached", apiErrorStatus 429,
219
+ // apiErrorParams.rate_limit_info.resetsAt) renamed the way the 2.1.309 binary
220
+ // maps them onto SDKAssistantMessage (api_error_status, api_error_params).
221
+ const LIMIT_STREAM = [
222
+ { type: "system", subtype: "init", session_id: "s-limit" },
223
+ { type: "assistant", parent_tool_use_id: null, message: { content: [{ type: "text", text: "Working on it." }] } },
224
+ { type: "rate_limit_event", rate_limit_info: { status: "rejected", resetsAt: 1792474200, rateLimitType: "five_hour" } },
225
+ {
226
+ type: "assistant",
227
+ parent_tool_use_id: null,
228
+ error: "rate_limit",
229
+ api_error: "usage_limit_reached",
230
+ api_error_status: 429,
231
+ api_error_params: { rate_limit_info: { status: "rejected", resetsAt: 1792474200, rateLimitType: "five_hour" } },
232
+ message: { model: "<synthetic>", content: [{ type: "text", text: "You've hit your session limit · resets 7:30am (Europe/Paris)" }] },
233
+ },
234
+ { type: "result", subtype: "success", is_error: true, api_error_status: 429, result: "You've hit your session limit · resets 7:30am (Europe/Paris)" },
235
+ ];
236
+ describe("a run that ends on a usage limit", () => {
237
+ it("is told apart from a run that finished", () => {
238
+ const ok = fold(OK_STREAM);
239
+ expect(ok.claudeSessionId).toBe("0d6b64a2-9d15-444e-be4e-a8f449b51b7f");
240
+ expect(endedOnLimit(ok)).toBeUndefined();
241
+ expect(endedOnLimit(fold(LIMIT_STREAM))).toEqual({ error: "rate_limit", resetsAt: 1792474200_000 });
242
+ });
243
+ it("the assistant's limit error alone counts (no result line: the process died after it), with its own reset", () => {
244
+ const own = { ...LIMIT_STREAM[3], api_error_params: { rate_limit_info: { status: "rejected", resetsAt: 1792480000 } } };
245
+ expect(endedOnLimit(fold([LIMIT_STREAM[0], own]))).toEqual({ error: "rate_limit", resetsAt: 1792480000_000 });
246
+ expect(endedOnLimit(fold([LIMIT_STREAM[0], { ...own, error: "billing_error" }]))?.error).toBe("billing_error");
247
+ });
248
+ it("a 429 result alone counts, with the reset from the rejected rate_limit_event", () => {
249
+ const end = fold([LIMIT_STREAM[0], LIMIT_STREAM[2], { type: "result", is_error: true, api_error_status: 429, result: "x" }]);
250
+ expect(endedOnLimit(end)).toEqual({ error: "rate_limit", resetsAt: 1792474200_000 });
251
+ });
252
+ it("a limit a later healthy turn got past is not how the run ended", () => {
253
+ expect(endedOnLimit(fold([LIMIT_STREAM[3], OK_STREAM[1]]))).toBeUndefined();
254
+ });
255
+ it("a subagent's limit does not decide the run", () => {
256
+ expect(endedOnLimit(fold([{ ...LIMIT_STREAM[3], parent_tool_use_id: "toolu_1" }]))).toBeUndefined();
257
+ });
258
+ it("a printed WORKER-RESULT verdict means the protocol finished", () => {
259
+ const end = fold([...LIMIT_STREAM.slice(0, 4), { type: "result", is_error: true, api_error_status: 429, result: "WORKER-RESULT: done — x" }]);
260
+ expect(endedOnLimit(end)).toBeUndefined();
261
+ });
262
+ it("the account is out until the reset, or a quarter hour (an hour for billing) when none was given", () => {
263
+ const now = 1_000_000;
264
+ expect(limitUntil({ error: "rate_limit", resetsAt: now + 5 }, now)).toBe(now + 5);
265
+ expect(limitUntil({ error: "rate_limit" }, now)).toBe(now + 15 * 60_000);
266
+ expect(limitUntil({ error: "billing_error" }, now)).toBe(now + 60 * 60_000);
267
+ });
268
+ });
269
+ describe("design runs: the structured signal", () => {
270
+ const session = (outcomes) => JSON.stringify({ session: { id: "s" }, outcomes });
271
+ it("an outcome of kind 'design' makes it a design run", () => {
272
+ expect(DESIGN_OUTCOME_KIND).toBe("design");
273
+ expect(isDesignSession(session({ status: "ok", outcomes: [{ key: "pr", kind: "code" }, { key: "design", kind: "design" }] }))).toBe(true);
274
+ expect(isDesignSession(session({ status: "ok", outcomes: [{ key: "design-notes", kind: "document" }] }))).toBe(false);
275
+ });
276
+ it("the goal text is never read for it", () => {
277
+ const s = JSON.stringify({ session: { goal: "design the landing page" }, outcomes: { status: "ok", outcomes: [] } });
278
+ expect(isDesignSession(s)).toBe(false);
279
+ });
280
+ it("outcomes that could not be read THROW — never 'not a design run'", () => {
281
+ expect(() => isDesignSession(session({ status: "unavailable", reason: "read_failed" }))).toThrow(/unavailable/);
282
+ expect(() => isDesignSession(JSON.stringify({ session: {} }))).toThrow(/missing/);
283
+ expect(() => isDesignSession(JSON.stringify({ error: "Focus session x not found" }))).toThrow(/not found/);
284
+ });
285
+ });
286
+ describe("background (design) runs: how they are watched", () => {
287
+ it("reads the short id `claude --bg` prints (verbatim 2.1.309 output)", () => {
288
+ const out = "Starting background service…\nbackgrounded · fd009109\n claude agents list sessions\n claude attach fd009109 open in this terminal\n";
289
+ expect(parseBgId(out)).toBe("fd009109");
290
+ expect(parseBgId("Error: not logged in")).toBeUndefined();
291
+ });
292
+ it("maps `claude agents --json --all` states (verbatim 2.1.309 entry first)", () => {
293
+ const list = parseAgentsList(JSON.stringify([
294
+ { pid: 20822, id: "fd009109", cwd: "/w", kind: "background", startedAt: 1792723262938, sessionId: "fd009109-0488-47e7-a314-064ba85d1c5e", name: "bg test", status: "idle", state: "done" },
295
+ { id: "aa", state: "blocked" },
296
+ { id: "bb", state: "working", status: "busy" },
297
+ { id: "cc", state: "failed" },
298
+ ]));
299
+ expect(list.map((e) => bgPhase(e))).toEqual(["finished", "blocked", "running", "finished"]);
300
+ expect(bgPhase(undefined)).toBe("gone");
301
+ expect(() => parseAgentsList("{}")).toThrow();
302
+ });
303
+ it("the transcript gives the final text and a limit, counting only entries since the launch", () => {
304
+ const t0 = Date.parse("2026-10-23T02:41:00Z");
305
+ const lines = [
306
+ { type: "assistant", timestamp: "2026-10-23T02:40:00Z", message: { content: [{ type: "text", text: "WORKER-RESULT: done — old turn" }] } },
307
+ { type: "user", timestamp: "2026-10-23T02:41:01Z", message: { content: "go" } },
308
+ { type: "assistant", timestamp: "2026-10-23T02:41:05Z", message: { content: [{ type: "thinking" }] } },
309
+ { type: "assistant", timestamp: "2026-10-23T02:41:06Z", message: { content: [{ type: "text", text: "WORKER-RESULT: done — new turn" }] } },
310
+ ].map((l) => JSON.stringify(l));
311
+ const end = readTranscriptEnd(lines.join("\n"), t0);
312
+ expect(end.seen).toBe(2);
313
+ expect(end.text).toBe("WORKER-RESULT: done — new turn");
314
+ expect(readTranscriptEnd(lines.slice(0, 1).join("\n"), t0).seen).toBe(0);
315
+ // The real transcript spelling of a usage limit.
316
+ const limit = JSON.stringify({
317
+ type: "assistant",
318
+ timestamp: "2026-10-23T02:42:00Z",
319
+ isSidechain: false,
320
+ isApiErrorMessage: true,
321
+ error: "rate_limit",
322
+ apiError: "usage_limit_reached",
323
+ apiErrorStatus: 429,
324
+ apiErrorParams: { rate_limit_info: { status: "rejected", resetsAt: 1792474200, rateLimitType: "five_hour" } },
325
+ message: { content: [{ type: "text", text: "You've hit your session limit · resets 7:30am (Europe/Paris)" }] },
326
+ });
327
+ expect(endedOnLimit(readTranscriptEnd([lines[1], limit].join("\n"), t0))).toEqual({ error: "rate_limit", resetsAt: 1792474200_000 });
328
+ expect(BG_SETTLE_MS).toBeGreaterThan(0);
329
+ });
330
+ });
331
+ describe("the night protocol in the prompt", () => {
332
+ const p = (design = false) => buildWorkerPrompt({ sessionId: "c5a34b78-0000", goal: "g", agentName: "cc-devbox", cwd: "/w", mcpServer: "synap-pod", design });
333
+ it("every run: worktree branch, a DRAFT PR at most, never merge / push main", () => {
334
+ expect(p()).toContain("git worktree on a branch named devbox/c5a34b78-<slug>");
335
+ expect(p()).toContain("open a DRAFT pull request (gh pr create --draft); never merge one");
336
+ expect(p()).toContain("never push to main");
337
+ });
338
+ it("every run records every deliverable with its ref, and says what to look at first", () => {
339
+ expect(p()).toContain("Record EVERY deliverable as a session output");
340
+ expect(p()).toContain("outcomes:[{key, label, kind, ref:{url}}]");
341
+ expect(p()).toContain("the draft PR (ref = its URL), the branch, the gate summary path");
342
+ expect(p()).toContain('starting "Look at first:"');
343
+ });
344
+ it("a run never completes its own PR, branch or commit: a PR is delivered when it merges", () => {
345
+ expect(p()).toContain("Never completeOutput a pull request, a branch or a commit");
346
+ expect(p()).toContain("the merge sweeper marks it met then");
347
+ expect(p()).not.toMatch(/completeOutput <key> for each —/);
348
+ expect(p(true)).toContain("Never completeOutput a pull request, a branch or a commit");
349
+ });
350
+ it("a design run is told to quickstart the Artifact tool, publish, and store the URL on the session and the subject", () => {
351
+ const d = p(true);
352
+ expect(d).toContain('Artifact tool with action "quickstart" and intent "design" FIRST');
353
+ expect(d).toContain("the outcome of kind \"design\", ref = the artifact's claude.ai URL");
354
+ expect(d).toContain("subject entity");
355
+ expect(p()).not.toContain("quickstart");
356
+ });
357
+ });
358
+ describe("a code run's own tree folder in the prompt", () => {
359
+ const p = (treeDir) => buildWorkerPrompt({ sessionId: "c5a34b78-0000", goal: "g", agentName: "cc", cwd: "/w", mcpServer: "m", treeDir });
360
+ it("names the folder, a --no-track worktree there (concurrent adds race on the repo config), and forbids the shared clone", () => {
361
+ expect(p("/t/c5a34b78")).toContain("git -C /w/<repo> worktree add --no-track /t/c5a34b78/<repo> -b devbox/c5a34b78-<slug> origin/main");
362
+ expect(p("/t/c5a34b78")).toContain("never in /w/<repo> itself");
363
+ });
364
+ it("without a folder the prompt is what it was", () => {
365
+ expect(p()).not.toContain("worktree add");
366
+ });
367
+ });
368
+ describe("workerEnv with a GitHub token", () => {
369
+ const g = { token: "ghs_app", sock: "/s", binDir: "/bin-synap", gitHelper: "/bin-synap/h", ghConfigDir: "/c" };
370
+ it("puts the App token in GH_TOKEN and the helpers first on PATH, over an inherited GH_TOKEN", () => {
371
+ const env = workerEnv({ GH_TOKEN: "founder", PATH: "/usr/bin" }, undefined, undefined, g);
372
+ expect(env.GH_TOKEN).toBe("ghs_app");
373
+ expect(env.GITHUB_TOKEN).toBe("ghs_app");
374
+ expect(env.PATH).toBe("/bin-synap:/usr/bin");
375
+ });
376
+ it("without the App, an inherited GH_TOKEN is NOT passed on", () => {
377
+ expect(workerEnv({ GH_TOKEN: "founder" }).GH_TOKEN).toBeUndefined();
378
+ });
379
+ });
380
+ describe("workerEnv is an allowlist: a secret in the worker's env never reaches a run", () => {
381
+ const worker = {
382
+ PATH: "/usr/bin",
383
+ HOME: "/h",
384
+ LANG: "C",
385
+ TERM: "xterm",
386
+ LC_ALL: "C",
387
+ CLAUDE_CODE_OAUTH_TOKEN: "oauth",
388
+ CLAUDE_CONFIG_DIR: "/cc",
389
+ SYNAP_AGENT: "a",
390
+ SYNAP_POD_URL: "https://pod",
391
+ VERCEL_TOKEN: "v",
392
+ VERCEL_ORG_ID: "o",
393
+ CLOUDFLARE_API_TOKEN: "c",
394
+ GH_TOKEN: "founder",
395
+ GITHUB_TOKEN: "founder",
396
+ NPM_TOKEN: "n",
397
+ AWS_SECRET_ACCESS_KEY: "a",
398
+ SSH_AUTH_SOCK: "/ssh",
399
+ ANTHROPIC_API_KEY: "sk",
400
+ ANTHROPIC_AUTH_TOKEN: "t",
401
+ ANTHROPIC_BASE_URL: "u",
402
+ };
403
+ it("VERCEL_*, CLOUDFLARE_*, GH_TOKEN, ANTHROPIC_* and any other secret are not visible to the run", () => {
404
+ const env = workerEnv(worker);
405
+ for (const k of ["VERCEL_TOKEN", "VERCEL_ORG_ID", "CLOUDFLARE_API_TOKEN", "GH_TOKEN", "GITHUB_TOKEN", "NPM_TOKEN", "AWS_SECRET_ACCESS_KEY", "SSH_AUTH_SOCK", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"]) {
406
+ expect(env[k], k).toBeUndefined();
407
+ }
408
+ });
409
+ it("keeps what the run needs: PATH, HOME, locale, the Claude login and the pod vars", () => {
410
+ expect(workerEnv(worker)).toEqual({
411
+ PATH: "/usr/bin",
412
+ HOME: "/h",
413
+ LANG: "C",
414
+ TERM: "xterm",
415
+ LC_ALL: "C",
416
+ CLAUDE_CODE_OAUTH_TOKEN: "oauth",
417
+ CLAUDE_CONFIG_DIR: "/cc",
418
+ SYNAP_AGENT: "a",
419
+ SYNAP_POD_URL: "https://pod",
420
+ });
421
+ });
422
+ });
423
+ describe("workerEnv with an account", () => {
424
+ it("the chosen token REPLACES an inherited one", () => {
425
+ const env = workerEnv({ CLAUDE_CODE_OAUTH_TOKEN: "inherited", CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "4" }, undefined, "chosen");
426
+ expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe("chosen");
427
+ expect(env.CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR).toBeUndefined();
428
+ expect(workerEnv({ CLAUDE_CODE_OAUTH_TOKEN: "inherited" }).CLAUDE_CODE_OAUTH_TOKEN).toBe("inherited");
429
+ });
430
+ });
431
+ // ─── protocols: code vs work folder ──────────────────────────────────────────
432
+ import { ASK_WAIT_MINUTES, buildWorkFolderPrompt, CODE_OUTPUT_KINDS, REPO_PARAM_KEYS, sessionProtocol, workFolderFor, } from "./worker.js";
433
+ describe("sessionProtocol: which protocol a handed session gets", () => {
434
+ const read = (session) => JSON.stringify({ session: { id: "s", goal: "g", ...session }, outcomes: { status: "ok", outcomes: [] } });
435
+ // The shape of a Produce Content run (content-os.yaml): params post/platform/format, a
436
+ // document at Script, a content-composition at Draft.
437
+ const produceContent = {
438
+ playbookId: "pb-produce-content",
439
+ subjectEntityId: "post-1",
440
+ metadata: { prompt: "Produce content for instagram…", params: { post: "post-1", platform: "instagram", format: "video" } },
441
+ expectedOutputs: [],
442
+ stages: [
443
+ { key: "brief", name: "Brief" },
444
+ { key: "script", expectedOutputs: [{ kind: "document", label: "Script" }] },
445
+ { key: "draft", expectedOutputs: [{ kind: "content-composition", label: "Composition" }] },
446
+ ],
447
+ };
448
+ it("a playbook run over pod data gets the work folder, from its first stage", () => {
449
+ expect(sessionProtocol(read(produceContent))).toBe("work-folder");
450
+ expect(sessionProtocol(read({ playbookId: "pb", metadata: {}, stages: [] }))).toBe("work-folder");
451
+ });
452
+ it("an ad-hoc hand-off (no playbook) keeps the code protocol, whatever it declares", () => {
453
+ expect(sessionProtocol(read({ playbookId: null }))).toBe("code");
454
+ expect(sessionProtocol(read({}))).toBe("code");
455
+ expect(sessionProtocol(read({ playbookId: null, expectedOutputs: [{ kind: "document", label: "Report" }] }))).toBe("code");
456
+ });
457
+ it("a playbook run whose params name a repo or a branch is code (the keys the dispatch path mounts)", () => {
458
+ expect([...REPO_PARAM_KEYS]).toEqual(["repo", "repos", "branch"]);
459
+ for (const params of [{ repo: "Synap-core/backend" }, { repos: ["Synap-core/synap-cli"] }, { branch: "fix/x" }]) {
460
+ expect(sessionProtocol(read({ ...produceContent, metadata: { params } })), JSON.stringify(params)).toBe("code");
461
+ }
462
+ // Blank values name nothing.
463
+ expect(sessionProtocol(read({ ...produceContent, metadata: { params: { repo: " ", repos: [] } } }))).toBe("work-folder");
464
+ });
465
+ it("a playbook run declaring a code deliverable — on the session or on ANY stage — is code", () => {
466
+ for (const kind of CODE_OUTPUT_KINDS) {
467
+ expect(sessionProtocol(read({ ...produceContent, expectedOutputs: [{ kind, label: "x" }] })), kind).toBe("code");
468
+ const stages = [...produceContent.stages, { key: "ship", expectedOutputs: [{ kind, label: "x" }] }];
469
+ expect(sessionProtocol(read({ ...produceContent, stages })), `stage ${kind}`).toBe("code");
470
+ }
471
+ });
472
+ it("the goal and the instruction are never read for it", () => {
473
+ const codey = { ...produceContent, goal: "open a pull request on the repo", metadata: { prompt: "git worktree, branch, PR" } };
474
+ expect(sessionProtocol(read(codey))).toBe("work-folder");
475
+ });
476
+ it("an unreadable session THROWS — never 'a code session'", () => {
477
+ expect(() => sessionProtocol("Error: pod down")).toThrow(/did not return JSON/);
478
+ expect(() => sessionProtocol(JSON.stringify({ error: "Focus session x not found" }))).toThrow(/not found/);
479
+ expect(() => sessionProtocol(JSON.stringify({ session: null, message: "You have no open focus session." }))).toThrow(/no session/);
480
+ });
481
+ });
482
+ describe("the work-folder protocol in the prompt", () => {
483
+ const sid = "8c34a72b-b1c1-4c37-9c1f-02920b69ccb2";
484
+ const p = buildWorkFolderPrompt({ sessionId: sid, goal: "Produce Content for a post", agentName: "mac", workDir: `/Users/x/synap-work/${sid}`, mcpServer: "synap-worker" });
485
+ it("works in the session's folder, with no git", () => {
486
+ expect(workFolderFor("/Users/x/synap-work", sid)).toBe(`/Users/x/synap-work/${sid}`);
487
+ expect(p).toContain(`Work folder: /Users/x/synap-work/${sid}`);
488
+ expect(p).toContain("No git, no branch, no repository, no pull request");
489
+ expect(p).not.toMatch(/worktree|devbox\/|gh pr/);
490
+ });
491
+ it("reads the playbook's instruction, stage and subject", () => {
492
+ expect(p).toContain("session.metadata.prompt");
493
+ expect(p).toContain("session.currentStage");
494
+ expect(p).toContain("synap_get_entity");
495
+ });
496
+ it("names where a run's params are (the pod stores them on metadata.params)", () => {
497
+ expect(p).toContain("The run's params are in session.metadata.params, by param name");
498
+ expect(p).toContain("session.metadata.params.takes");
499
+ });
500
+ it("keeps a take's transcript in ONE shape: the content-asset Content Studio writes, found before it is made", () => {
501
+ expect(p).toContain("A transcript of a recording is never a new document");
502
+ expect(p).toContain("ONE content-asset (Kind script, File ID = the take's file id)");
503
+ expect(p).toContain('the readable text under a "## Transcript" heading');
504
+ expect(p).toContain('then a "## Words" heading followed by exactly one ```json block of word timings [{"text","start","end"}]');
505
+ expect(p).toContain("Find it before transcribing");
506
+ expect(p).toContain("reuse it and only fill the word timings");
507
+ expect(p).toContain("synap_link_entities type references");
508
+ });
509
+ it("asks through a human-owned slot and waits for the answer — never a room QUESTION:", () => {
510
+ expect(p).toContain('owner:"human"');
511
+ expect(p).toContain("ask:{mode:");
512
+ expect(p).toContain("synap_wait_for_answer");
513
+ expect(p).toContain(`up to ${ASK_WAIT_MINUTES} minutes`);
514
+ expect(p).toContain('never a room "QUESTION:" line');
515
+ expect(p).not.toMatch(/post in the room starting with "QUESTION:"/);
516
+ });
517
+ it("files every deliverable to the pod and records it on the declared slot", () => {
518
+ expect(p).toContain("synap upload <path> --json");
519
+ expect(p).toContain("synap_create_document");
520
+ expect(p).toContain("reuse the key of the slot already declared");
521
+ expect(p).toContain("completeOutput <key>");
522
+ });
523
+ it("never publishes outward nor works around a human gate, and reports the same way as a code run", () => {
524
+ expect(p).toContain("Publish nothing outward");
525
+ expect(p).toContain("never approve, skip or work around it");
526
+ expect(p).toContain('starting "Look at first:"');
527
+ expect(p).toContain("WORKER-RESULT: done | blocked | failed");
528
+ });
529
+ });
530
+ describe("a run's process and end, as the pod reads them", () => {
531
+ const POD_PROCESS_RE = /^([A-Za-z0-9._-]{1,255}):(\d{1,10}):(\d{10,14})$/;
532
+ it("the process identity is <host>:<pid>:<launch ms>, in the pod's own shape, whatever the hostname", () => {
533
+ expect(runProcessIdentity(1_792_000_000_123, "devbox", 4242)).toBe("devbox:4242:1792000000123");
534
+ expect(runProcessIdentity(1_792_000_000_123, "MacBook Pro de Antoine.local", 7)).toMatch(POD_PROCESS_RE);
535
+ expect(runProcessIdentity(1_792_000_000_123, "", 7)).toBe("computer:7:1792000000123");
536
+ });
537
+ it("the run's config says busy for that process; nothing is claimed without a conversation", () => {
538
+ const sid = "8c34a72b-b1c1-4c37-9c1f-02920b69ccb2";
539
+ const cfg = runMcpConfig("https://pod.example", "k", sid, "devbox:1:1792000000000");
540
+ expect(cfg.mcpServers[RUN_MCP_SERVER].headers).toMatchObject({
541
+ "X-Synap-Process": "devbox:1:1792000000000",
542
+ "X-Synap-Runner-State": "busy",
543
+ });
544
+ expect(runEndHeaders({ claudeSessionId: sid, process: "devbox:1:1792000000000" })).toEqual({
545
+ "X-Synap-Conversation": `claude-code:${sid}`,
546
+ "X-Synap-Process": "devbox:1:1792000000000",
547
+ "X-Synap-Runner-State": "ended",
548
+ "X-Synap-Model-Provider": "anthropic",
549
+ });
550
+ expect(runEndHeaders({ process: "devbox:1:1792000000000" })).toBeUndefined();
551
+ expect(runEndHeaders({ claudeSessionId: sid })).toBeUndefined();
552
+ });
553
+ });
554
+ describe("the verdict, read for the session", () => {
555
+ it("parses done | blocked | failed and the sentence; anything else is no verdict", () => {
556
+ expect(parseVerdict("done — PR #4 is open")).toEqual({ status: "done", sentence: "PR #4 is open" });
557
+ expect(parseVerdict("Blocked - needs the key")).toEqual({ status: "blocked", sentence: "needs the key" });
558
+ expect(parseVerdict("failed")).toEqual({ status: "failed", sentence: "" });
559
+ // The prompt's template echoed back names no status: no verdict.
560
+ expect(parseVerdict("done | blocked | failed — <one sentence>")).toBeUndefined();
561
+ expect(parseVerdict("done|blocked|failed")).toBeUndefined();
562
+ expect(parseVerdict("maybe — later")).toBeUndefined();
563
+ expect(parseVerdict(undefined)).toBeUndefined();
564
+ });
565
+ const read = (outcomes, status = "ok") => JSON.stringify({ outcomes: { status, outcomes } });
566
+ it("claims only the ONE open deliverable the agent owns; none and several are told apart", () => {
567
+ expect(verdictClaim(read([{ key: "v", kind: "file", owner: "agent", met: false }]))).toEqual({ kind: "one", key: "v" });
568
+ expect(verdictClaim(read([{ key: "v", kind: "file", owner: "agent", met: false }, { key: "w", kind: "file", met: false }]))).toEqual({
569
+ kind: "several",
570
+ keys: ["v", "w"],
571
+ });
572
+ expect(verdictClaim(read([{ key: "q", kind: "decision", owner: "human", met: false }]))).toEqual({ kind: "none" });
573
+ expect(verdictClaim(read([{ key: "f", kind: "fact", owner: "agent", met: false }]))).toEqual({ kind: "none" });
574
+ expect(verdictClaim(read([]))).toEqual({ kind: "none" });
575
+ });
576
+ it("never claims a pull request, a branch or a commit: a PR is delivered only when it merges", () => {
577
+ const pr = { key: "pr-backend-12", kind: "pull_request", owner: "agent", met: false, ref: { url: "https://github.com/o/r/pull/12" } };
578
+ expect(verdictClaim(read([pr]))).toEqual({ kind: "none" });
579
+ expect(verdictClaim(read([{ key: "branch-x", kind: "branch", met: false }, { key: "commit-r-abc", kind: "commit", met: false }]))).toEqual({
580
+ kind: "none",
581
+ });
582
+ // The one claimable deliverable beside an open PR is still the one.
583
+ expect(verdictClaim(read([pr, { key: "gate", kind: "file", owner: "agent", met: false }]))).toEqual({ kind: "one", key: "gate" });
584
+ });
585
+ it("an outcomes list that could not be read is unreadable, never \"nothing open\"", () => {
586
+ expect(verdictClaim(read([{ key: "v", kind: "file", owner: "agent", met: false }], "unavailable")).kind).toBe("unreadable");
587
+ expect(verdictClaim("not json").kind).toBe("unreadable");
588
+ expect(verdictClaim(JSON.stringify({ session: {} })).kind).toBe("unreadable");
589
+ expect(verdictClaim(JSON.stringify({ outcomes: { status: "ok" } })).kind).toBe("unreadable");
590
+ });
591
+ it("done is heard only from a clean exit; blocked and failed stand", () => {
592
+ const done = { status: "done", sentence: "PR #4 is open" };
593
+ expect(verdictForExit(done, true, 0)).toEqual(done);
594
+ expect(verdictForExit(done, false, 1)).toEqual({ status: "failed", sentence: "the run exited 1; it said done — PR #4 is open" });
595
+ expect(verdictForExit({ status: "done", sentence: "" }, false, undefined)).toEqual({ status: "failed", sentence: "the run did not end cleanly" });
596
+ expect(verdictForExit({ status: "blocked", sentence: "b" }, false, 1)).toEqual({ status: "blocked", sentence: "b" });
597
+ });
598
+ it("a quiet period that began after the run launched is the run's own activity", () => {
599
+ const rec = { launchedAt: "2026-10-31T10:00:00.000Z" };
600
+ expect(quietIsOwnRun({ quietSince: "2026-10-31T10:05:00.000Z" }, rec)).toBe(true);
601
+ expect(quietIsOwnRun({ quietSince: "2026-10-31T10:00:00.000Z" }, rec)).toBe(true);
602
+ expect(quietIsOwnRun({ quietSince: "2026-10-31T09:59:59.000Z" }, rec)).toBe(false);
603
+ expect(quietIsOwnRun({}, rec)).toBe(false);
604
+ expect(quietIsOwnRun({ quietSince: "not a date" }, rec)).toBe(false);
605
+ expect(quietIsOwnRun({ quietSince: "2026-10-31T10:05:00.000Z" }, {})).toBe(false);
606
+ });
607
+ });
608
+ describe("an MCP call's own headers", () => {
609
+ it("ride on that call only, never on the handshake or the next call", async () => {
610
+ const sent = [];
611
+ const fetchImpl = (async (_u, init) => {
612
+ const body = JSON.parse(String(init.body));
613
+ sent.push({ method: body.method, headers: init.headers });
614
+ return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: { content: [{ type: "text", text: "{}" }] } }), {
615
+ status: 200,
616
+ headers: { "content-type": "application/json" },
617
+ });
618
+ });
619
+ const mcp = createMcpClient({ url: "https://pod/mcp", apiKey: "k", fetchImpl });
620
+ await mcp.call("synap_orient", {}, { headers: { "X-Synap-Runner": "synap-worker/1", Authorization: "Bearer other" } });
621
+ await mcp.call("synap_get_session", { sessionId: "s" });
622
+ const calls = sent.filter((s) => s.method === "tools/call");
623
+ expect(calls[0].headers["X-Synap-Runner"]).toBe("synap-worker/1");
624
+ // An extra header never replaces the key.
625
+ expect(calls[0].headers.Authorization).toBe("Bearer k");
626
+ expect(calls[1].headers["X-Synap-Runner"]).toBeUndefined();
627
+ for (const s of sent.filter((s) => s.method !== "tools/call"))
628
+ expect(s.headers["X-Synap-Runner"]).toBeUndefined();
629
+ });
630
+ });
631
+ describe("what a run runs on, as the pod reads it (X-Synap-Model / X-Synap-Model-Provider)", () => {
632
+ const sid = "8c34a72b-b1c1-4c37-9c1f-02920b69ccb2";
633
+ const headersOf = (cfg) => cfg.mcpServers[RUN_MCP_SERVER].headers;
634
+ it("the run's config names the subscription's provider always, and the model once known", () => {
635
+ expect(headersOf(runMcpConfig("https://pod.example", "k", sid, "devbox:1:1792000000000"))).not.toHaveProperty("X-Synap-Model");
636
+ expect(headersOf(runMcpConfig("https://pod.example", "k", sid, "devbox:1:1792000000000", "claude-opus-5-5"))).toMatchObject({
637
+ "X-Synap-Model": "claude-opus-5-5",
638
+ "X-Synap-Model-Provider": "anthropic",
639
+ });
640
+ });
641
+ it("the model is read from the stream's init; a non-id (a synthetic marker, a URL) is never reported", () => {
642
+ const init = (model) => foldOne({}, { type: "system", subtype: "init", session_id: sid, model });
643
+ expect(init("claude-opus-5-5").model).toBe("claude-opus-5-5");
644
+ expect(init("claude-opus-4-6[1m]").model).toBe("claude-opus-4-6[1m]");
645
+ expect(init("<synthetic>").model).toBeUndefined();
646
+ expect(init("https://gw.example/v1").model).toBeUndefined();
647
+ expect(init(42).model).toBeUndefined();
648
+ });
649
+ it("the end-of-run calls carry the model the run said, beside the subscription's provider", () => {
650
+ expect(runEndHeaders({ claudeSessionId: sid, process: "devbox:1:1792000000000", model: "claude-opus-5-5" })).toMatchObject({
651
+ "X-Synap-Model": "claude-opus-5-5",
652
+ "X-Synap-Model-Provider": "anthropic",
653
+ });
654
+ });
655
+ });
656
+ describe("workerEnv never passes a provider switch: a run reports anthropic only because it runs on it", () => {
657
+ const worker = {
658
+ PATH: "/usr/bin",
659
+ CLAUDE_CONFIG_DIR: "/cc",
660
+ CLAUDE_CODE_OAUTH_TOKEN: "oauth",
661
+ CLAUDE_CODE_SUBAGENT_MODEL: "claude-haiku-5-5",
662
+ CLAUDE_CODE_USE_POWERSHELL_TOOL: "1",
663
+ CLAUDE_CODE_USE_BEDROCK: "1",
664
+ CLAUDE_CODE_USE_VERTEX: "1",
665
+ CLAUDE_CODE_USE_FOUNDRY: "1",
666
+ CLAUDE_CODE_USE_MANTLE: "1",
667
+ CLAUDE_CODE_USE_ANTHROPIC_AWS: "1",
668
+ CLAUDE_CODE_SKIP_BEDROCK_AUTH: "1",
669
+ CLAUDE_CODE_SKIP_VERTEX_AUTH: "1",
670
+ CLAUDE_CODE_SKIP_FOUNDRY_AUTH: "1",
671
+ CLAUDE_CODE_SKIP_MANTLE_AUTH: "1",
672
+ CLAUDE_CODE_SKIP_ANTHROPIC_AWS_AUTH: "1",
673
+ CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1",
674
+ };
675
+ it("blocks every provider switch, a future CLAUDE_CODE_USE_<provider> too", () => {
676
+ const env = workerEnv({ ...worker, CLAUDE_CODE_USE_SOMECLOUD: "1" });
677
+ for (const k of Object.keys(worker).filter((k) => /USE_(?!POWERSHELL)|SKIP_.*_AUTH|MANAGED_BY_HOST/.test(k))) {
678
+ expect(env[k], k).toBeUndefined();
679
+ }
680
+ expect(env.CLAUDE_CODE_USE_SOMECLOUD).toBeUndefined();
681
+ });
682
+ it("keeps the Claude vars a run needs, the one non-provider USE_ flag included", () => {
683
+ expect(workerEnv(worker)).toEqual({
684
+ PATH: "/usr/bin",
685
+ CLAUDE_CONFIG_DIR: "/cc",
686
+ CLAUDE_CODE_OAUTH_TOKEN: "oauth",
687
+ CLAUDE_CODE_SUBAGENT_MODEL: "claude-haiku-5-5",
688
+ CLAUDE_CODE_USE_POWERSHELL_TOOL: "1",
689
+ });
690
+ expect(RUN_ENV_BLOCKLIST.some((re) => re.test("CLAUDE_CONFIG_DIR"))).toBe(false);
691
+ });
692
+ });
116
693
  //# sourceMappingURL=worker.test.js.map