@polderlabs/bizar 6.2.4 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (332) hide show
  1. package/.claude/CLAUDE.md +15 -0
  2. package/.claude/agents/_shared/AGENT_BASELINE.md +168 -0
  3. package/.claude/agents/_shared/CLAUDE_TOOLS.md +412 -0
  4. package/.claude/agents/_shared/SKILLS.md +109 -0
  5. package/.claude/agents/agent-browser.md +80 -0
  6. package/.claude/agents/baldr.md +49 -0
  7. package/.claude/agents/forseti.md +51 -0
  8. package/.claude/agents/frigg.md +42 -0
  9. package/.claude/agents/heimdall.md +33 -0
  10. package/.claude/agents/hermod.md +53 -0
  11. package/.claude/agents/mimir.md +49 -0
  12. package/.claude/agents/odin.md +287 -0
  13. package/.claude/agents/quick.md +34 -0
  14. package/.claude/agents/semble-search.md +50 -0
  15. package/.claude/agents/thor.md +53 -0
  16. package/.claude/agents/tyr.md +56 -0
  17. package/.claude/agents/vidarr.md +54 -0
  18. package/.claude/agents/vor.md +53 -0
  19. package/.claude/commands/audit.md +25 -0
  20. package/.claude/commands/bizar.md +22 -0
  21. package/.claude/commands/explain.md +17 -0
  22. package/.claude/commands/init.md +29 -0
  23. package/.claude/commands/learn.md +44 -0
  24. package/.claude/commands/plan.md +35 -0
  25. package/.claude/commands/plow-through.md +50 -0
  26. package/.claude/commands/pr-review.md +49 -0
  27. package/.claude/commands/setup-provider.md +96 -0
  28. package/.claude/commands/tailscale-serve.md +100 -0
  29. package/.claude/commands/team.md +132 -0
  30. package/.claude/commands/test.md +62 -0
  31. package/.claude/commands/validate.md +68 -0
  32. package/.claude/commands/visual-plan.md +24 -0
  33. package/.claude/hooks/README.md +92 -0
  34. package/.claude/hooks/posttooluse-editwrite.mjs +91 -0
  35. package/.claude/hooks/pretooluse-bash.mjs +81 -0
  36. package/.claude/hooks/pretooluse-editwrite.mjs +139 -0
  37. package/.claude/hooks/sessionend-recall.mjs +74 -0
  38. package/.claude/hooks/sessionstart-prime.mjs +80 -0
  39. package/.claude/hooks/userpromptsubmit-tag.mjs +80 -0
  40. package/.claude/settings.json +116 -0
  41. package/.claude/skills/9router/SKILL.md +80 -0
  42. package/.claude/skills/9router-chat/SKILL.md +73 -0
  43. package/.claude/skills/9router-embeddings/SKILL.md +69 -0
  44. package/.claude/skills/9router-image/SKILL.md +86 -0
  45. package/.claude/skills/9router-stt/SKILL.md +79 -0
  46. package/.claude/skills/9router-tts/SKILL.md +80 -0
  47. package/.claude/skills/9router-web-fetch/SKILL.md +99 -0
  48. package/.claude/skills/9router-web-search/SKILL.md +91 -0
  49. package/.claude/skills/bizar/README.md +9 -0
  50. package/.claude/skills/bizar/SKILL.md +450 -0
  51. package/.claude/skills/cpp-coding-standards/README.md +28 -0
  52. package/.claude/skills/cpp-coding-standards/SKILL.md +634 -0
  53. package/.claude/skills/cpp-coding-standards/references/concurrency.md +320 -0
  54. package/.claude/skills/cpp-coding-standards/references/error-handling.md +229 -0
  55. package/.claude/skills/cpp-coding-standards/references/memory-safety.md +216 -0
  56. package/.claude/skills/cpp-coding-standards/references/modern-idioms.md +282 -0
  57. package/.claude/skills/cpp-coding-standards/references/review-checklist.md +96 -0
  58. package/.claude/skills/cpp-testing/README.md +28 -0
  59. package/.claude/skills/cpp-testing/SKILL.md +304 -0
  60. package/.claude/skills/cpp-testing/references/coverage.md +370 -0
  61. package/.claude/skills/cpp-testing/references/framework-compare.md +175 -0
  62. package/.claude/skills/cpp-testing/references/host-test-for-embedded.md +499 -0
  63. package/.claude/skills/cpp-testing/references/mocking.md +364 -0
  64. package/.claude/skills/cpp-testing/references/tdd-workflow.md +308 -0
  65. package/.claude/skills/cubesandbox/SKILL.md +148 -0
  66. package/.claude/skills/embedded-esp-idf/README.md +41 -0
  67. package/.claude/skills/embedded-esp-idf/SKILL.md +439 -0
  68. package/.claude/skills/embedded-esp-idf/references/freertos-patterns.md +214 -0
  69. package/.claude/skills/embedded-esp-idf/references/host-tests.md +164 -0
  70. package/.claude/skills/embedded-esp-idf/references/idf-py-commands.md +157 -0
  71. package/.claude/skills/embedded-esp-idf/references/kconfig.md +159 -0
  72. package/.claude/skills/embedded-esp-idf/references/logging-discipline.md +118 -0
  73. package/.claude/skills/embedded-esp-idf/references/memory-and-iram.md +137 -0
  74. package/.claude/skills/embedded-esp-idf/references/nvs.md +121 -0
  75. package/.claude/skills/embedded-esp-idf/references/packed-structs.md +192 -0
  76. package/.claude/skills/embedded-esp-idf/scripts/idf_env.sh +47 -0
  77. package/.claude/skills/embedded-esp-idf/scripts/size_check.sh +77 -0
  78. package/.claude/skills/glyph/SKILL.md +163 -0
  79. package/.claude/skills/harness-engineering/SKILL.md +143 -0
  80. package/.claude/skills/lightrag/SKILL.md +81 -0
  81. package/.claude/skills/memory-protocol/SKILL.md +105 -0
  82. package/.claude/skills/obsidian/SKILL.md +306 -0
  83. package/.claude/skills/read-the-damn-docs/SKILL.md +113 -0
  84. package/.claude/skills/self-improvement/SKILL.md +64 -0
  85. package/README.md +87 -59
  86. package/bizar-dash/dist/assets/{EnvVarsSection-DXM8gRm_.js → EnvVarsSection-B58aiJiE.js} +1 -1
  87. package/bizar-dash/dist/assets/{EnvVarsSection-DXM8gRm_.js.map → EnvVarsSection-B58aiJiE.js.map} +1 -1
  88. package/bizar-dash/dist/assets/{MobileChat-BnKN_Ks_.js → MobileChat-BJrqwVDd.js} +1 -1
  89. package/bizar-dash/dist/assets/{MobileChat-BnKN_Ks_.js.map → MobileChat-BJrqwVDd.js.map} +1 -1
  90. package/bizar-dash/dist/assets/{MobileSettings-DjCPxC-Q.js → MobileSettings-CEQNJNLJ.js} +1 -1
  91. package/bizar-dash/dist/assets/{MobileSettings-DjCPxC-Q.js.map → MobileSettings-CEQNJNLJ.js.map} +1 -1
  92. package/bizar-dash/dist/assets/{main-DYiZqMrn.js → main-IvfQAOfy.js} +1 -1
  93. package/bizar-dash/dist/assets/{main-DYiZqMrn.js.map → main-IvfQAOfy.js.map} +1 -1
  94. package/bizar-dash/dist/assets/{markdown-C6mXtQxD.js → markdown-tOLaD6nm.js} +1 -1
  95. package/bizar-dash/dist/assets/{markdown-C6mXtQxD.js.map → markdown-tOLaD6nm.js.map} +1 -1
  96. package/bizar-dash/dist/assets/{mobile-CMHqtLV2.js → mobile-DYCHcUpq.js} +1 -1
  97. package/bizar-dash/dist/assets/{mobile-CMHqtLV2.js.map → mobile-DYCHcUpq.js.map} +1 -1
  98. package/bizar-dash/dist/assets/{mobile-layout-3jIhHX_p.js → mobile-layout-CBHjpwsb.js} +2 -2
  99. package/bizar-dash/dist/assets/{mobile-layout-3jIhHX_p.js.map → mobile-layout-CBHjpwsb.js.map} +1 -1
  100. package/bizar-dash/dist/assets/{useSlashCommands-DtITw8Xv.js → useSlashCommands-Bd7_FA6U.js} +2 -2
  101. package/bizar-dash/dist/assets/{useSlashCommands-DtITw8Xv.js.map → useSlashCommands-Bd7_FA6U.js.map} +1 -1
  102. package/bizar-dash/dist/assets/{vendor-CeHGtduv.js → vendor-C843201K.js} +12 -12
  103. package/bizar-dash/dist/assets/vendor-C843201K.js.map +1 -0
  104. package/bizar-dash/dist/index.html +6 -6
  105. package/bizar-dash/dist/mobile.html +2 -2
  106. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  107. package/bizar-dash/src/server/api.mjs +4 -4
  108. package/bizar-dash/src/server/background-store.mjs +15 -16
  109. package/bizar-dash/src/server/bg-poller.mjs +25 -35
  110. package/bizar-dash/src/server/bg-retry.mjs +66 -194
  111. package/bizar-dash/src/server/claude-bg-spawner.mjs +390 -0
  112. package/bizar-dash/src/server/claude-info.mjs +412 -0
  113. package/bizar-dash/src/server/{cline-runner.mjs → claude-runner.mjs} +53 -48
  114. package/bizar-dash/src/server/claude-sdk.mjs +191 -0
  115. package/bizar-dash/src/server/providers-store.mjs +22 -49
  116. package/bizar-dash/src/server/routes/background.mjs +24 -20
  117. package/bizar-dash/src/server/routes/chat.mjs +223 -326
  118. package/bizar-dash/src/server/routes/claude-session-detail.mjs +329 -0
  119. package/bizar-dash/src/server/routes/claude-sessions.mjs +259 -0
  120. package/bizar-dash/src/server/routes/tasks.mjs +20 -28
  121. package/bizar-dash/src/server/task-delegator.mjs +76 -77
  122. package/bizar-dash/src/web/components/chat/useChat.ts +23 -27
  123. package/bizar-dash/src/web/views/Chat.tsx +8 -8
  124. package/cli/bin.mjs +31 -18
  125. package/cli/commands/claude-cmd.mjs +348 -0
  126. package/cli/commands/install.mjs +12 -11
  127. package/cli/commands/sandbox.mjs +220 -0
  128. package/cli/commands/validate.mjs +252 -288
  129. package/cli/dev-link.test.mjs +11 -11
  130. package/cli/doctor.mjs +131 -213
  131. package/cli/install.mjs +33 -37
  132. package/cli/install.test.mjs +9 -9
  133. package/cli/provision-claude.mjs +893 -0
  134. package/cli/provision.mjs +330 -226
  135. package/cli/provision.test.mjs +102 -0
  136. package/cli/utils.mjs +72 -58
  137. package/config/AGENTS.md +28 -28
  138. package/config/agents/_shared/AGENT_BASELINE.md +104 -684
  139. package/config/skills/bizar/SKILL.md +197 -0
  140. package/config/skills/cubesandbox/SKILL.md +148 -0
  141. package/config/skills/harness-engineering/SKILL.md +142 -0
  142. package/install.sh +66 -44
  143. package/package.json +19 -11
  144. package/packages/sdk/ARCHITECTURE.md +40 -36
  145. package/packages/sdk/package-lock.json +32 -0
  146. package/packages/sdk/package.json +15 -5
  147. package/{plugins/bizar → packages/sdk}/src/dangerous-patterns.ts +6 -28
  148. package/packages/sdk/src/fingerprint.ts +0 -0
  149. package/packages/sdk/src/index.ts +38 -73
  150. package/packages/sdk/src/mcp/bin.ts +51 -0
  151. package/packages/sdk/src/mcp/server.ts +498 -0
  152. package/{plugins/bizar/src/memory-vault.ts → packages/sdk/src/memory/index.ts} +18 -21
  153. package/packages/sdk/tests/sdk.test.mjs +148 -0
  154. package/packages/sdk/vitest.config.ts +1 -1
  155. package/scripts/bh-full-e2e.mjs +166 -363
  156. package/scripts/check-deps.mjs +28 -46
  157. package/scripts/mirror-claude-md.sh +78 -0
  158. package/scripts/test-in-container.sh +157 -0
  159. package/templates/clean-state-checklist.md +3 -3
  160. package/templates/sprint-contract.md +2 -2
  161. package/bizar-dash/.bizar/activity.log +0 -3
  162. package/bizar-dash/.omx/logs/omx-2026-07-07.jsonl +0 -1
  163. package/bizar-dash/.omx/state/session.json +0 -10
  164. package/bizar-dash/bizar-design/EXPLANATION.md +0 -307
  165. package/bizar-dash/bizar-design/INSTRUCTIONS.md +0 -149
  166. package/bizar-dash/bizar-design/canvas.html +0 -281
  167. package/bizar-dash/bizar-design/canvas.html.artifact.json +0 -19
  168. package/bizar-dash/bizar-design/components.css +0 -1665
  169. package/bizar-dash/bizar-design/components.html +0 -557
  170. package/bizar-dash/bizar-design/components.html.artifact.json +0 -19
  171. package/bizar-dash/bizar-design/desktop/agents.html +0 -74
  172. package/bizar-dash/bizar-design/desktop/agents.html.artifact.json +0 -19
  173. package/bizar-dash/bizar-design/desktop/memory.html +0 -75
  174. package/bizar-dash/bizar-design/desktop/memory.html.artifact.json +0 -19
  175. package/bizar-dash/bizar-design/desktop/metrics.html +0 -63
  176. package/bizar-dash/bizar-design/desktop/metrics.html.artifact.json +0 -19
  177. package/bizar-dash/bizar-design/desktop/overview.html +0 -95
  178. package/bizar-dash/bizar-design/desktop/overview.html.artifact.json +0 -19
  179. package/bizar-dash/bizar-design/desktop/settings.html +0 -70
  180. package/bizar-dash/bizar-design/desktop/settings.html.artifact.json +0 -19
  181. package/bizar-dash/bizar-design/icons.svg +0 -44
  182. package/bizar-dash/bizar-design/index.html +0 -192
  183. package/bizar-dash/bizar-design/index.html.artifact.json +0 -19
  184. package/bizar-dash/bizar-design/mobile/agents.html +0 -29
  185. package/bizar-dash/bizar-design/mobile/agents.html.artifact.json +0 -19
  186. package/bizar-dash/bizar-design/mobile/memory.html +0 -28
  187. package/bizar-dash/bizar-design/mobile/memory.html.artifact.json +0 -19
  188. package/bizar-dash/bizar-design/mobile/metrics.html +0 -29
  189. package/bizar-dash/bizar-design/mobile/metrics.html.artifact.json +0 -19
  190. package/bizar-dash/bizar-design/mobile/overview.html +0 -33
  191. package/bizar-dash/bizar-design/mobile/overview.html.artifact.json +0 -19
  192. package/bizar-dash/bizar-design/mobile/settings.html +0 -28
  193. package/bizar-dash/bizar-design/mobile/settings.html.artifact.json +0 -19
  194. package/bizar-dash/bizar-design/prototype.js +0 -114
  195. package/bizar-dash/bizar-design/tokens.css +0 -106
  196. package/bizar-dash/dist/assets/vendor-CeHGtduv.js.map +0 -1
  197. package/bizar-dash/node_modules/.package-lock.json +0 -6
  198. package/bizar-dash/package-lock.json +0 -6
  199. package/bizar-dash/src/server/cline-sdk.mjs +0 -132
  200. package/bizar-dash/src/server/routes/cline-session-detail.mjs +0 -559
  201. package/bizar-dash/src/server/routes/cline-sessions.mjs +0 -291
  202. package/cli/commands/cline-cmd.mjs +0 -289
  203. package/cli/commands/validate.test.mjs +0 -344
  204. package/cli/doctor.test.mjs +0 -350
  205. package/config/cline.json.template +0 -342
  206. package/packages/sdk/src/client.ts +0 -188
  207. package/packages/sdk/src/cline-events.ts +0 -134
  208. package/packages/sdk/src/cline-types.ts +0 -66
  209. package/packages/sdk/src/cline.ts +0 -339
  210. package/packages/sdk/src/errors.ts +0 -129
  211. package/packages/sdk/src/events.ts +0 -153
  212. package/packages/sdk/src/types.ts +0 -176
  213. package/packages/sdk/tests/client.test.ts +0 -217
  214. package/packages/sdk/tests/errors.test.ts +0 -108
  215. package/packages/sdk/tests/events.test.ts +0 -139
  216. package/packages/sdk/tests/fixtures/fetch-mock.ts +0 -152
  217. package/packages/sdk/tests/fixtures/sse-mock.ts +0 -30
  218. package/plugins/bizar/ARCHITECTURE.md +0 -142
  219. package/plugins/bizar/CONSTRAINTS.md +0 -67
  220. package/plugins/bizar/LICENSE +0 -21
  221. package/plugins/bizar/README.md +0 -448
  222. package/plugins/bizar/index.ts +0 -872
  223. package/plugins/bizar/package.json +0 -39
  224. package/plugins/bizar/scripts/check-forbidden-imports.sh +0 -33
  225. package/plugins/bizar/src/background-state.ts +0 -641
  226. package/plugins/bizar/src/background.ts +0 -1806
  227. package/plugins/bizar/src/cline-runner.ts +0 -203
  228. package/plugins/bizar/src/clineruntime.ts +0 -227
  229. package/plugins/bizar/src/commands-impl.ts +0 -151
  230. package/plugins/bizar/src/commands.ts +0 -1799
  231. package/plugins/bizar/src/compaction.d.mts +0 -48
  232. package/plugins/bizar/src/compaction.mjs +0 -192
  233. package/plugins/bizar/src/dashboard-client.ts +0 -233
  234. package/plugins/bizar/src/event-stream.ts +0 -606
  235. package/plugins/bizar/src/fingerprint.ts +0 -120
  236. package/plugins/bizar/src/handoff.ts +0 -79
  237. package/plugins/bizar/src/hooks/memory-flush-on-compact.ts +0 -123
  238. package/plugins/bizar/src/hooks/memory-inject.ts +0 -247
  239. package/plugins/bizar/src/hooks/memory-write-on-end.ts +0 -188
  240. package/plugins/bizar/src/hooks/skill-curator.ts +0 -180
  241. package/plugins/bizar/src/http-client.ts +0 -467
  242. package/plugins/bizar/src/key-rotation.ts +0 -218
  243. package/plugins/bizar/src/logger.ts +0 -144
  244. package/plugins/bizar/src/loop-engineering.ts +0 -241
  245. package/plugins/bizar/src/loop.ts +0 -176
  246. package/plugins/bizar/src/mistake-recovery.ts +0 -98
  247. package/plugins/bizar/src/odin.ts +0 -227
  248. package/plugins/bizar/src/options.ts +0 -470
  249. package/plugins/bizar/src/plan-fs.ts +0 -323
  250. package/plugins/bizar/src/reasoning-clean.ts +0 -454
  251. package/plugins/bizar/src/report.ts +0 -178
  252. package/plugins/bizar/src/research-prompt.ts +0 -35
  253. package/plugins/bizar/src/serve-info.ts +0 -228
  254. package/plugins/bizar/src/serve.ts +0 -496
  255. package/plugins/bizar/src/settings.ts +0 -349
  256. package/plugins/bizar/src/state.ts +0 -298
  257. package/plugins/bizar/src/tool-discipline.ts +0 -105
  258. package/plugins/bizar/src/tools/agent-browser.ts +0 -315
  259. package/plugins/bizar/src/tools/bg-collect.ts +0 -131
  260. package/plugins/bizar/src/tools/bg-get-comments.ts +0 -266
  261. package/plugins/bizar/src/tools/bg-kill.ts +0 -116
  262. package/plugins/bizar/src/tools/bg-pause.ts +0 -99
  263. package/plugins/bizar/src/tools/bg-report-progress.ts +0 -115
  264. package/plugins/bizar/src/tools/bg-resume.ts +0 -94
  265. package/plugins/bizar/src/tools/bg-send-message.ts +0 -223
  266. package/plugins/bizar/src/tools/bg-spawn.ts +0 -502
  267. package/plugins/bizar/src/tools/bg-status.ts +0 -130
  268. package/plugins/bizar/src/tools/graph-query.ts +0 -278
  269. package/plugins/bizar/src/tools/loop-engineering.ts +0 -193
  270. package/plugins/bizar/src/tools/memory-list.ts +0 -43
  271. package/plugins/bizar/src/tools/memory-read.ts +0 -69
  272. package/plugins/bizar/src/tools/memory-search.ts +0 -47
  273. package/plugins/bizar/src/tools/memory-write.ts +0 -54
  274. package/plugins/bizar/src/tools/open-kb.ts +0 -198
  275. package/plugins/bizar/src/tools/plan-action.ts +0 -785
  276. package/plugins/bizar/src/tools/read-glyph-feedback.ts +0 -191
  277. package/plugins/bizar/src/tools/team-spawn.ts +0 -73
  278. package/plugins/bizar/src/tools/team-status.ts +0 -76
  279. package/plugins/bizar/src/tools/wait-for-feedback.ts +0 -415
  280. package/plugins/bizar/src/trajectory.ts +0 -104
  281. package/plugins/bizar/tests/README.md +0 -99
  282. package/plugins/bizar/tests/attach-handler-bug.test.ts +0 -169
  283. package/plugins/bizar/tests/background-state.test.ts +0 -277
  284. package/plugins/bizar/tests/background.test.ts +0 -402
  285. package/plugins/bizar/tests/block.test.ts +0 -195
  286. package/plugins/bizar/tests/canonical-key-order.test.ts +0 -75
  287. package/plugins/bizar/tests/clineruntime-config.test.ts +0 -283
  288. package/plugins/bizar/tests/commands-impl.test.ts +0 -316
  289. package/plugins/bizar/tests/commands.test.ts +0 -584
  290. package/plugins/bizar/tests/compaction.test.ts +0 -264
  291. package/plugins/bizar/tests/config.test.ts +0 -128
  292. package/plugins/bizar/tests/dashboard-client.test.ts +0 -159
  293. package/plugins/bizar/tests/dispose.test.ts +0 -336
  294. package/plugins/bizar/tests/event-stream.test.ts +0 -409
  295. package/plugins/bizar/tests/event.test.ts +0 -262
  296. package/plugins/bizar/tests/fingerprint.test.ts +0 -162
  297. package/plugins/bizar/tests/http-client.test.ts +0 -404
  298. package/plugins/bizar/tests/init-helpers.test.ts +0 -203
  299. package/plugins/bizar/tests/integration/slash-command.test.ts +0 -349
  300. package/plugins/bizar/tests/integration/tool-routing.test.ts +0 -98
  301. package/plugins/bizar/tests/key-rotation.test.ts +0 -396
  302. package/plugins/bizar/tests/loop-engineering.test.ts +0 -168
  303. package/plugins/bizar/tests/loop.test.ts +0 -397
  304. package/plugins/bizar/tests/memory-write-on-end.test.ts +0 -92
  305. package/plugins/bizar/tests/mistake-recovery.test.ts +0 -116
  306. package/plugins/bizar/tests/odin.test.ts +0 -125
  307. package/plugins/bizar/tests/options.test.ts +0 -329
  308. package/plugins/bizar/tests/reasoning-clean.test.ts +0 -422
  309. package/plugins/bizar/tests/safety.test.ts +0 -256
  310. package/plugins/bizar/tests/serve.test.ts +0 -339
  311. package/plugins/bizar/tests/settings.test.ts +0 -351
  312. package/plugins/bizar/tests/stall-think.test.ts +0 -750
  313. package/plugins/bizar/tests/state.test.ts +0 -276
  314. package/plugins/bizar/tests/tool-discipline.test.ts +0 -77
  315. package/plugins/bizar/tests/tools/agent-browser.test.ts +0 -98
  316. package/plugins/bizar/tests/tools/bg-collect.test.ts +0 -337
  317. package/plugins/bizar/tests/tools/bg-get-comments.test.ts +0 -485
  318. package/plugins/bizar/tests/tools/bg-kill.test.ts +0 -235
  319. package/plugins/bizar/tests/tools/bg-pause.test.ts +0 -61
  320. package/plugins/bizar/tests/tools/bg-report-progress.test.ts +0 -79
  321. package/plugins/bizar/tests/tools/bg-resume.test.ts +0 -40
  322. package/plugins/bizar/tests/tools/bg-send-message.test.ts +0 -116
  323. package/plugins/bizar/tests/tools/bg-spawn-delegation.test.ts +0 -147
  324. package/plugins/bizar/tests/tools/bg-spawn-http.test.ts +0 -233
  325. package/plugins/bizar/tests/tools/bg-spawn.test.ts +0 -311
  326. package/plugins/bizar/tests/tools/bg-status.test.ts +0 -217
  327. package/plugins/bizar/tests/tools/cline-runner.test.ts +0 -115
  328. package/plugins/bizar/tests/tools/plan-action.test.ts +0 -599
  329. package/plugins/bizar/tests/tools/read-glyph-feedback.test.ts +0 -253
  330. package/plugins/bizar/tests/tools/wait-for-feedback.test.ts +0 -390
  331. package/plugins/bizar/tests/update-deadlock.test.ts +0 -151
  332. package/plugins/bizar/tsconfig.json +0 -29
@@ -1,1806 +0,0 @@
1
- /**
2
- * background.ts
3
- *
4
- * InstanceManager — owns the in-memory map of background instances and
5
- * orchestrates the per-instance event handlers (v0.4.2 spec §2.2, §4, §5.4, §6.2).
6
- *
7
- * Responsibilities:
8
- * - `add()` is the single entry point for inserting a new instance. The
9
- * cap check and the map insertion happen inside one async mutex, so
10
- * concurrent `add()` calls can never exceed the cap (HIGH-10 / HIGH-12 /
11
- * HIGH-21 / HIGH-38).
12
- * - `update()` patches the in-memory state and persists to disk. The
13
- * per-instance mutex from {@link BackgroundStateStore} serializes
14
- * concurrent updates to the same instance.
15
- * - `kill()` and `collect()` operate on the in-memory state; the HTTP
16
- * calls go through {@link HttpClient}.
17
- * - `rebuildInMemoryMap()` is called on init (spec §5.4). Any in-flight
18
- * `running` or `pending` instance is marked `failed` because the
19
- * serve child is new and the cline sessions are gone.
20
- * - `shutdownAll()` is called on `dispose` / SIGTERM. Marks all in-memory
21
- * instances `failed` with `error: "plugin shutting down"`, aborts
22
- * each via `POST /session/{id}/abort` (best-effort, 5s timeout per
23
- * call), then waits for the serve child to exit.
24
- *
25
- * Per-instance event handler (spec §4.1, §4.3, §6.2):
26
- * - For every `EventMessagePartUpdated` of `type: "tool"`, increment
27
- * `toolCallCount`. If the count reaches the per-instance cap, abort
28
- * the session and mark the instance `failed` with
29
- * `error: "Tool-call cap reached (N). Aborted to prevent cost runaway."`.
30
- * - If the tool part's error matches the loop-guard regex
31
- * `Loop protection: 12 identical calls to (\S+)`, capture the tool
32
- * name into `loopGuardTool`, set `error` to the canonical string,
33
- * and mark the instance `failed`.
34
- * - For every `EventMessagePartUpdated` of `type: "text"` on an
35
- * assistant message, refresh `resultPreview` (last 200 chars).
36
- * - On `EventSessionIdle`, mark the instance `done`.
37
- * - On `EventSessionError`, mark the instance `failed` with the error.
38
- *
39
- * v0.3.0 — stall and thinking-loop protection:
40
- * - Every event handler updates `lastEventAt` (the "heartbeat"). The
41
- * stall checker fires every `STALL_CHECK_INTERVAL_MS`; if a non-terminal
42
- * instance has `now - lastEventAt > backgroundStallTimeoutMs`, the
43
- * session is aborted and the instance marked `failed`.
44
- * - `tool` and `text` parts advance `lastToolOrTextAt`. `thinking`
45
- * parts do NOT advance it; that is the loop indicator.
46
- * - The thinking-loop checker fires every `STALL_CHECK_INTERVAL_MS`. For
47
- * a `running` instance with `now - lastToolOrTextAt >
48
- * backgroundThinkingLoopTimeoutMs`:
49
- * - If `interventionCount < backgroundMaxInterventions`: send a
50
- * research-intervention prompt (fire-and-forget) and increment the
51
- * counter.
52
- * - Otherwise: abort the session and mark `failed`.
53
- * - When a `tool` or `text` part arrives after one or more interventions,
54
- * the counter is reset to 0 (sign of progress). The intervention
55
- * metadata is cleared so a later status check does not show stale
56
- * intervention info.
57
- *
58
- * "Track BEFORE HTTP" invariant (spec §2.2 / HIGH-21):
59
- * - The instance is added to the map (status `pending`) BEFORE any HTTP
60
- * call. If the HTTP call fails, the instance is marked `failed`. The
61
- * map is never left in a half-state.
62
- */
63
-
64
- import type {
65
- BackgroundState,
66
- BackgroundStateStore,
67
- Logger,
68
- ToolCallEntry,
69
- } from "./background-state.js";
70
- import {
71
- MAX_TOOL_CALL_HISTORY,
72
- MAX_TOOL_ARG_CHARS,
73
- TERMINAL_STATUSES,
74
- } from "./background-state.js";
75
- import type { HttpClient } from "./http-client.js";
76
- import type { EventStream, StreamEvent, SessionEventHandler } from "./event-stream.js";
77
- import type { ServeLifecycle } from "./serve.js";
78
- import { researchInterventionPrompt } from "./research-prompt.js";
79
-
80
- import * as clineRunner from "./cline-runner.js";
81
-
82
- // --- v5.5.1 — dashboard HTTP helpers -------------------------------------
83
- //
84
- // Background agents run as cline serve SDK sessions managed by the
85
- // dashboard. The plugin's `pause`/`resume` (and the tool layer's
86
- // `kill`/`send-message`) therefore talk to the dashboard over HTTP
87
- // instead of touching the OS or the cline SDK directly.
88
- //
89
- // These helpers are intentionally tiny — they only need to do POSTs
90
- // for `pause`/`resume`. The token + URL resolution matches what
91
- // `bg-spawn.ts` and `bg-send-message.ts` do.
92
-
93
- import { readFileSync, existsSync } from "node:fs";
94
- import { join } from "node:path";
95
- import { homedir } from "node:os";
96
-
97
- function resolveDashboardUrl(): string {
98
- const fromEnv = process.env.BIZAR_DASHBOARD_URL;
99
- if (fromEnv && fromEnv.trim()) return fromEnv.trim().replace(/\/+$/, "");
100
- const port = process.env.BIZAR_DASHBOARD_PORT;
101
- if (port && /^\d+$/.test(port)) return `http://127.0.0.1:${port}`;
102
- return "http://127.0.0.1:4098";
103
- }
104
-
105
- const DEFAULT_DASHBOARD_AUTH_PATHS = [
106
- join(homedir(), ".config", "bizar", "dashboard-secret"),
107
- join(homedir(), ".cache", "bizarharness", "dash-auth.json"),
108
- join(homedir(), ".cache", "bizar", "dash-auth.json"),
109
- ];
110
-
111
- function readDashboardToken(): string {
112
- for (const candidate of DEFAULT_DASHBOARD_AUTH_PATHS) {
113
- try {
114
- if (!existsSync(candidate)) continue;
115
- const text = readFileSync(candidate, "utf-8").trim();
116
- if (text && text.length >= 16) return text;
117
- } catch {
118
- /* ignore */
119
- }
120
- }
121
- for (const candidate of DEFAULT_DASHBOARD_AUTH_PATHS) {
122
- try {
123
- if (!existsSync(candidate)) continue;
124
- const parsed = JSON.parse(readFileSync(candidate, "utf-8")) as { password?: unknown };
125
- if (typeof parsed.password === "string" && parsed.password.length >= 16) {
126
- return parsed.password;
127
- }
128
- } catch {
129
- /* ignore */
130
- }
131
- }
132
- return "";
133
- }
134
-
135
- /**
136
- * POST to the dashboard with a JSON `{}` body. Used for pause/resume —
137
- * no payload, just the URL. Returns `{ ok: true }` on 2xx, `{ ok: false,
138
- * error }` on transport failure or non-2xx (so the caller can surface
139
- * a structured error without catching exceptions).
140
- */
141
- async function postDashboardControl(
142
- path: string,
143
- logger: Logger,
144
- ): Promise<{ ok: boolean; error?: string }> {
145
- const url = `${resolveDashboardUrl()}${path}`;
146
- const token = readDashboardToken();
147
- const headers: Record<string, string> = {
148
- "content-type": "application/json",
149
- accept: "application/json",
150
- };
151
- if (token) headers.authorization = `Bearer ${token}`;
152
- const ac = new AbortController();
153
- const timer = setTimeout(() => ac.abort(), 5_000);
154
- try {
155
- const res = await fetch(url, {
156
- method: "POST",
157
- headers,
158
- body: JSON.stringify({}),
159
- signal: ac.signal,
160
- });
161
- if (!res.ok) {
162
- const text = await res.text().catch(() => "");
163
- logger.warn(`bizar: dashboard ${path} returned ${res.status}: ${text.slice(0, 200)}`);
164
- return { ok: false, error: `dashboard_http_${res.status}` };
165
- }
166
- return { ok: true };
167
- } catch (err) {
168
- const msg = err instanceof Error ? err.message : String(err);
169
- logger.warn(`bizar: dashboard ${path} failed: ${msg}`);
170
- return { ok: false, error: msg };
171
- } finally {
172
- clearTimeout(timer);
173
- }
174
- }
175
-
176
- // --- Public surface -------------------------------------------------------
177
-
178
- /** A snapshot of an instance for the `bizar_status` tool. */
179
- export interface InstanceView {
180
- instanceId: string;
181
- agent: string;
182
- status: BackgroundState["status"];
183
- startedAt: number;
184
- completedAt?: number;
185
- toolCallCount: number;
186
- promptPreview: string;
187
- resultPreview?: string;
188
- error?: string;
189
- parentAgent: string;
190
- parentInstanceId?: string;
191
- sessionId: string;
192
- // v0.3.0 — stall and thinking-loop protection
193
- lastEventAt?: number;
194
- interventionCount?: number;
195
- interventionAt?: number;
196
- interventionReason?: string;
197
- // v5.x — extended surface for dashboard integration
198
- /** Tool call history (cap 100). Each entry has name, args, status, timing. */
199
- toolCalls?: ToolCallEntry[];
200
- /** Progress percentage (0..100; -1 = indeterminate). */
201
- progress?: number;
202
- /** Progress message (free-form). */
203
- progressMessage?: string;
204
- /** PID of the cline run subprocess, if known. */
205
- processId?: number;
206
- /** Current runner state (one of "starting"|"running"|"done"|"failed"|"killed"). */
207
- runnerState?: string;
208
- /** Tags from spawn (free-form). */
209
- tags?: string[];
210
- /** When the instance was paused (epoch ms), if paused. */
211
- pausedAt?: number;
212
- }
213
-
214
- /** The return shape of `bizar_collect`. */
215
- export interface CollectResult {
216
- status: BackgroundState["status"];
217
- result: string;
218
- toolCallCount: number;
219
- durationMs: number;
220
- error?: string;
221
- }
222
-
223
- /** Filter shape for `list()`. */
224
- export interface InstanceListFilter {
225
- agent?: string;
226
- status?: BackgroundState["status"];
227
- }
228
-
229
- /** Shape passed to `add()`. The status is forced to `pending` and the
230
- * startedAt is stamped by the manager. */
231
- export type AddDraft = Omit<BackgroundState, "status" | "startedAt">;
232
-
233
- /** Return type of `add()`. `"cap_reached"` is a sentinel for the
234
- * overshoot path; the populated state is the success path. */
235
- export type AddResult = BackgroundState | "cap_reached";
236
-
237
- // --- Constants ------------------------------------------------------------
238
-
239
- /** Maximum length of `resultPreview` per spec §3.2. */
240
- const RESULT_PREVIEW_MAX = 200;
241
- /** Maximum length of `promptPreview` stored in the JSON. */
242
- const PROMPT_PREVIEW_MAX = 200;
243
- /** Tool-call cap regex (spec §4.1, NEW-H8 pin). */
244
- const LOOP_GUARD_RE = /Loop protection: 12 identical calls to (\S+)/;
245
-
246
- /**
247
- * How often the stall + thinking-loop checker fires. 15 seconds is short
248
- * enough to detect stalls within one tick of the default 3-min stall
249
- * timeout, and long enough that the per-instance mutex is not constantly
250
- * contested. Spec §v0.3.0.
251
- */
252
- const STALL_CHECK_INTERVAL_MS = 15_000;
253
-
254
- // --- Class ---------------------------------------------------------------
255
-
256
- /**
257
- * Manages the in-memory map of background instances. Created once at
258
- * plugin init; lives for the life of the plugin process.
259
- */
260
- export class InstanceManager {
261
- private instances = new Map<string, BackgroundState>();
262
- private eventUnsubscribers = new Map<string, () => void>();
263
- private addLock: Promise<unknown> = Promise.resolve();
264
- private stateStore: BackgroundStateStore;
265
- private maxConcurrent: number;
266
- private toolCallCap: number;
267
- private logger: Logger;
268
- // v0.8.0 — `serve`, `http`, `stream` are nullable to support the
269
- // bg-only mode used when the cline serve child is unavailable
270
- // (BIZAR_SERVE_DISABLE=1, startup failure) or when this process IS a
271
- // bg-spawned `cline run` subprocess. In bg-only mode, every method
272
- // that would otherwise call `this.http.X` or `this.stream.X` is a
273
- // no-op; state transitions still happen via the runner's `onExit`
274
- // callback (see src/tools/bg-spawn.ts).
275
- private serve: ServeLifecycle | null;
276
- private http: HttpClient | null;
277
- private stream: EventStream | null;
278
- private worktree: string;
279
- // v0.3.0 — stall and thinking-loop protection
280
- private stallTimeoutMs: number;
281
- private thinkingLoopTimeoutMs: number;
282
- private maxInterventions: number;
283
- /** Interval handle for the periodic stall + thinking-loop checker. */
284
- private stallCheckerTimer: ReturnType<typeof setInterval> | null = null;
285
- /** Guard so tests can disable the interval without monkey-patching. */
286
- private stallCheckerDisabled = false;
287
-
288
- constructor(opts: {
289
- stateStore: BackgroundStateStore;
290
- maxConcurrent: number;
291
- toolCallCap: number;
292
- logger: Logger;
293
- // v0.8.0 — `worktree` is now an explicit param (was previously
294
- // derived from `opts.serve.worktree`, which is impossible when
295
- // `serve` is null in bg-only mode).
296
- worktree: string;
297
- serve: ServeLifecycle | null;
298
- http: HttpClient | null;
299
- stream: EventStream | null;
300
- // v0.3.0
301
- stallTimeoutMs?: number;
302
- thinkingLoopTimeoutMs?: number;
303
- maxInterventions?: number;
304
- }) {
305
- this.stateStore = opts.stateStore;
306
- this.maxConcurrent = Math.max(1, Math.floor(opts.maxConcurrent));
307
- this.toolCallCap = Math.max(1, Math.floor(opts.toolCallCap));
308
- this.logger = opts.logger;
309
- this.serve = opts.serve;
310
- this.http = opts.http;
311
- this.stream = opts.stream;
312
- this.worktree = opts.worktree;
313
- this.stallTimeoutMs = Math.max(
314
- 1_000,
315
- Math.floor(opts.stallTimeoutMs ?? 180_000),
316
- );
317
- this.thinkingLoopTimeoutMs = Math.max(
318
- 1_000,
319
- Math.floor(opts.thinkingLoopTimeoutMs ?? 300_000),
320
- );
321
- this.maxInterventions = Math.max(1, Math.floor(opts.maxInterventions ?? 1));
322
- // Schedule the periodic stall + thinking-loop checker ONLY when we
323
- // have a working HTTP client. In bg-only mode the runner owns the
324
- // subprocess lifecycle, so the checker has nothing to do.
325
- if (this.http !== null) {
326
- this.stallCheckerTimer = setInterval(
327
- () => void this.runStallAndLoopChecks(),
328
- STALL_CHECK_INTERVAL_MS,
329
- );
330
- this.stallCheckerTimer.unref?.();
331
- }
332
- }
333
-
334
- /** True iff the manager was constructed without an HTTP client (no
335
- * cline serve child reachable). HTTP-dependent operations are
336
- * no-ops in this mode. */
337
- get isBgOnly(): boolean {
338
- return this.http === null;
339
- }
340
-
341
- // --- Getters ------------------------------------------------------------
342
-
343
- get size(): number {
344
- return this.instances.size;
345
- }
346
-
347
- /** Current stall timeout (ms). Exposed for tests. */
348
- get stallTimeoutMsValue(): number {
349
- return this.stallTimeoutMs;
350
- }
351
-
352
- /** Current thinking-loop timeout (ms). Exposed for tests. */
353
- get thinkingLoopTimeoutMsValue(): number {
354
- return this.thinkingLoopTimeoutMs;
355
- }
356
-
357
- /** Current max interventions. Exposed for tests. */
358
- get maxInterventionsValue(): number {
359
- return this.maxInterventions;
360
- }
361
-
362
- /**
363
- * Disable the periodic stall + thinking-loop checker. Used by tests
364
- * that want to call `runStallAndLoopChecks()` directly without racing
365
- * the interval. Idempotent.
366
- */
367
- disablePeriodicChecks(): void {
368
- this.stallCheckerDisabled = true;
369
- if (this.stallCheckerTimer !== null) {
370
- clearInterval(this.stallCheckerTimer);
371
- this.stallCheckerTimer = null;
372
- }
373
- }
374
-
375
- /**
376
- * Run one iteration of the stall + thinking-loop checker. Public so
377
- * tests can invoke it deterministically. Production code drives this
378
- * via the `setInterval` registered in the constructor.
379
- */
380
- async runStallAndLoopChecks(): Promise<void> {
381
- if (this.stallCheckerDisabled) return;
382
- // v0.8.0 — bg-only mode has no HTTP client, so stall and
383
- // intervention are no-ops. The constructor also skips registering
384
- // the interval in this mode, but a stray call from a test or future
385
- // caller must still be safe.
386
- if (this.http === null) return;
387
- // Snapshot the instance ids so we do not iterate while the map mutates.
388
- const ids: string[] = [];
389
- for (const inst of this.instances.values()) {
390
- if (TERMINAL_STATUSES.has(inst.status)) continue;
391
- ids.push(inst.instanceId);
392
- }
393
- for (const id of ids) {
394
- const inst = this.instances.get(id);
395
- if (!inst || TERMINAL_STATUSES.has(inst.status)) continue;
396
- const now = Date.now();
397
- // `lastEventAt` / `lastToolOrTextAt` are seeded by `add()` and
398
- // backfilled in `readState`, so they are guaranteed to be set on
399
- // any instance that ever reached this method. We coalesce with
400
- // `?? 0` because TS strict mode treats the schema field as
401
- // optional — the value is informational in the rare case where
402
- // it is missing (an old or corrupt state file).
403
- const lastEventAt = inst.lastEventAt ?? 0;
404
- const lastToolOrTextAt = inst.lastToolOrTextAt ?? 0;
405
- // Stall check fires first; it is the more severe failure.
406
- if (now - lastEventAt > this.stallTimeoutMs) {
407
- await this._abortAsStalled(inst);
408
- continue;
409
- }
410
- // Thinking-loop check applies to `running` instances only. A
411
- // `pending` instance has not yet started generating, so it is not
412
- // a candidate for the loop detector.
413
- if (inst.status === "running") {
414
- const since = now - lastToolOrTextAt;
415
- if (since > this.thinkingLoopTimeoutMs) {
416
- const currentCount = inst.interventionCount ?? 0;
417
- if (currentCount < this.maxInterventions) {
418
- await this._sendIntervention(inst, since);
419
- } else {
420
- await this._abortAsThinkingLoop(inst, since);
421
- }
422
- }
423
- }
424
- }
425
- }
426
-
427
- // --- Internal dispatch (shared by add() and restart()) ------------------
428
-
429
- /**
430
- * Insert a fully-constructed BackgroundState into the in-memory map
431
- * and persist to disk asynchronously. Does NOT check the concurrency
432
- * cap — the caller handles that.
433
- *
434
- * This is extracted from `add()` so `restart()` can reuse the same
435
- * insert-and-persist path without duplicating the logic.
436
- */
437
- private dispatchInternal(full: BackgroundState): BackgroundState {
438
- this.instances.set(full.instanceId, full);
439
- // Persist asynchronously; failure is logged but does not roll back
440
- // the in-memory insert (the instance is "tracked" either way).
441
- this.stateStore.save(full).catch((err: unknown) => {
442
- this.logger.warn(
443
- `bizar: failed to persist new instance ${full.instanceId}: ${
444
- err instanceof Error ? err.message : String(err)
445
- }`,
446
- );
447
- });
448
- return full;
449
- }
450
-
451
- // --- Atomic add (spec §2.2) ---------------------------------------------
452
-
453
- /**
454
- * Add a new instance. The cap check and the map insertion are inside
455
- * one async mutex — no half-state on overshoot. Returns `"cap_reached"`
456
- * on overshoot; the full `BackgroundState` on success.
457
- */
458
- async add(draft: AddDraft): Promise<AddResult> {
459
- return (await (this.addLock = this.addLock.then(async () => {
460
- // Count "live" instances: anything not yet terminal.
461
- let live = 0;
462
- for (const inst of this.instances.values()) {
463
- if (!TERMINAL_STATUSES.has(inst.status)) live += 1;
464
- }
465
- if (live >= this.maxConcurrent) {
466
- this.logger.warn(
467
- `bizar: max concurrent instances reached (${this.maxConcurrent}); rejecting add`,
468
- );
469
- return "cap_reached" as const;
470
- }
471
- const now = Date.now();
472
- const full: BackgroundState = {
473
- ...draft,
474
- status: "pending",
475
- startedAt: now,
476
- toolCallCount: draft.toolCallCount ?? 0,
477
- // Trim the prompt preview so the JSON stays small.
478
- promptPreview: (draft.promptPreview ?? "").slice(0, PROMPT_PREVIEW_MAX),
479
- // v0.3.0 — seed the liveness timestamps so the stall and
480
- // thinking-loop checkers have a baseline. We seed BOTH from
481
- // `startedAt` so a freshly-spawned instance is not immediately
482
- // flagged as stalled while the session is still being created
483
- // (the first event typically arrives within seconds).
484
- lastEventAt: now,
485
- lastToolOrTextAt: now,
486
- interventionCount: 0,
487
- };
488
- this.dispatchInternal(full);
489
- // BUGFIX (v0.5.1): Do NOT call attachEventHandler() here. The
490
- // instance was just added with sessionId="" (filled in later by
491
- // POST /session). EventStream.onSessionEvent rejects empty strings,
492
- // so attaching here threw and the spawn failed before the HTTP
493
- // call could run. Callers must call attachEventHandler() explicitly
494
- // after the real sessionId is known. See test in
495
- // tests/background.test.ts "add() does not attach event handler
496
- // (empty sessionId)".
497
- return full;
498
- }))) as AddResult;
499
- }
500
-
501
- // --- Read access --------------------------------------------------------
502
-
503
- /**
504
- * Look up an instance by id. Returns null if not found.
505
- */
506
- async get(instanceId: string): Promise<BackgroundState | null> {
507
- return this.instances.get(instanceId) ?? null;
508
- }
509
-
510
- /**
511
- * Snapshot of in-memory instances, filtered. Used by `bizar_status`.
512
- */
513
- async list(filter?: InstanceListFilter): Promise<InstanceView[]> {
514
- const out: InstanceView[] = [];
515
- for (const inst of this.instances.values()) {
516
- if (filter?.agent && inst.agent !== filter.agent) continue;
517
- if (filter?.status && inst.status !== filter.status) continue;
518
- out.push(toView(inst));
519
- }
520
- // Sort by startedAt ascending so callers see the oldest first.
521
- out.sort((a, b) => a.startedAt - b.startedAt);
522
- return out;
523
- }
524
-
525
- // --- Update -------------------------------------------------------------
526
-
527
- /**
528
- * Patch an instance in-memory and persist. Returns silently if the
529
- * instance is not found. Mutations that would set a terminal state
530
- * stamp `completedAt` automatically.
531
- */
532
- async update(instanceId: string, patch: Partial<BackgroundState>): Promise<void> {
533
- const inst = this.instances.get(instanceId);
534
- if (!inst) return;
535
- await this.stateStore.withLock(instanceId, async () => {
536
- const current = this.instances.get(instanceId);
537
- if (!current) return;
538
- Object.assign(current, patch);
539
- if (TERMINAL_STATUSES.has(patch.status ?? current.status) && !current.completedAt) {
540
- current.completedAt = Date.now();
541
- }
542
- if (TERMINAL_STATUSES.has(current.status)) {
543
- this.detachEventHandler(instanceId);
544
- }
545
- try {
546
- await this.stateStore.saveUnlocked(current);
547
- } catch (err: unknown) {
548
- this.logger.warn(
549
- `bizar: failed to persist update for ${instanceId}: ${
550
- err instanceof Error ? err.message : String(err)
551
- }`,
552
- );
553
- }
554
- });
555
- }
556
-
557
- // --- Kill ---------------------------------------------------------------
558
-
559
- /**
560
- * Abort the cline session and mark the instance `killed`. If the
561
- * instance is already in a terminal state, this is a no-op (spec §1.5,
562
- * MEDIUM-40).
563
- *
564
- * v5.5.1 — SDK-backed instances are killed via `DELETE /api/background/:id`
565
- * on the dashboard (which calls `sdk.sessions.abort`). Pre-v5.5.1
566
- * fallback: bg-only mode marks the instance killed in-memory and lets
567
- * the runner notice; full mode (with HTTP client) calls
568
- * `http.abortSession` directly. The two paths are now functionally
569
- * equivalent — the dashboard IS the SDK client in v5.5.1.
570
- */
571
- async kill(instanceId: string): Promise<void> {
572
- const inst = this.instances.get(instanceId);
573
- if (!inst) return;
574
- if (TERMINAL_STATUSES.has(inst.status)) {
575
- this.logger.debug(
576
- `bizar: kill(${instanceId}) is a no-op (status=${inst.status})`,
577
- );
578
- return;
579
- }
580
- // v5.5.1 — SDK-backed: ask the dashboard to abort.
581
- if (inst.liveSession) {
582
- const url = `${resolveDashboardUrl()}/api/background/${encodeURIComponent(instanceId)}`;
583
- const token = readDashboardToken();
584
- const headers: Record<string, string> = { accept: "application/json" };
585
- if (token) headers.authorization = `Bearer ${token}`;
586
- const ac = new AbortController();
587
- const timer = setTimeout(() => ac.abort(), 5_000);
588
- try {
589
- await fetch(url, { method: "DELETE", headers, signal: ac.signal });
590
- } catch (err) {
591
- this.logger.warn(
592
- `bizar: kill(${instanceId}) dashboard DELETE failed: ${
593
- err instanceof Error ? err.message : String(err)
594
- }`,
595
- );
596
- } finally {
597
- clearTimeout(timer);
598
- }
599
- await this.update(instanceId, {
600
- status: "killed",
601
- completedAt: Date.now(),
602
- });
603
- this.logger.info(
604
- `bizar: killed background instance ${instanceId} (sdk-backed, dashboard DELETE)`,
605
- );
606
- return;
607
- }
608
- // v0.8.0 — bg-only mode has no HTTP client. The subprocess is
609
- // already owned by cline-runner.ts (see src/tools/bg-spawn.ts);
610
- // mark the instance killed in-memory and let the runner notice the
611
- // status change when the process eventually exits. We do NOT try
612
- // to kill the OS process from here — that's the runner's job and
613
- // we don't have a clean processId reference in bg-only mode
614
- // (the runner does, but it lives in a separate module).
615
- if (this.http === null) {
616
- this.logger.warn(
617
- `bizar: kill(${instanceId}) in bg-only mode: marking killed; subprocess will be reaped by cline-runner.ts on exit`,
618
- );
619
- await this.update(instanceId, {
620
- status: "killed",
621
- completedAt: Date.now(),
622
- });
623
- return;
624
- }
625
- // Pre-v5.5.1 fallback — direct abort on the legacy serve child.
626
- const abort = await this.http.abortSession(inst.sessionId, this.worktree);
627
- if (!abort.ok) {
628
- this.logger.warn(
629
- `bizar: kill(${instanceId}): abort failed: ${abort.error}`,
630
- );
631
- }
632
- await this.update(instanceId, {
633
- status: "killed",
634
- completedAt: Date.now(),
635
- });
636
- this.logger.info(`bizar: killed background instance ${instanceId}`);
637
- }
638
-
639
- // --- Restart (v0.5.5 — persistent auto-restart) ------------------------
640
-
641
- /**
642
- * Re-spawn a failed persistent instance with the same prompt, agent,
643
- * and model config. The new instance gets a fresh `instanceId` and is
644
- * linked to the original via `parentInstanceId`.
645
- *
646
- * Returns `{ ok: true, newInstanceId }` on success, or
647
- * `{ ok: false, error: "..." }` on failure. Does NOT check the
648
- * concurrency cap — the original instance is terminal, so its slot
649
- * is already freed.
650
- */
651
- async restart(instanceId: string): Promise<{
652
- ok: boolean;
653
- newInstanceId?: string;
654
- error?: string;
655
- }> {
656
- const existing = this.instances.get(instanceId);
657
- if (!existing) return { ok: false, error: "instance_not_found" };
658
- const totalRestarts = this.getTotalRestartCount(instanceId);
659
- if (totalRestarts >= (existing.maxRestarts ?? 3)) {
660
- return { ok: false, error: "max_restarts_reached" };
661
- }
662
-
663
- const now = Date.now();
664
- const newInstanceId = generateInstanceId();
665
- const full: BackgroundState = {
666
- instanceId: newInstanceId,
667
- sessionId: "",
668
- agent: existing.agent,
669
- model: existing.model,
670
- promptPreview: (existing.prompt ?? existing.promptPreview ?? "").slice(0, PROMPT_PREVIEW_MAX),
671
- prompt: existing.prompt,
672
- parentAgent: existing.parentAgent,
673
- parentInstanceId: instanceId,
674
- logPath: `${this.worktree}/.cline/log/${newInstanceId}.log`,
675
- timeoutMs: existing.timeoutMs,
676
- toolCallCount: 0,
677
- // v0.5.5 — persist the auto-restart fields
678
- persistent: existing.persistent,
679
- maxRestarts: existing.maxRestarts,
680
- restartCount: (existing.restartCount ?? 0) + 1,
681
- status: "pending",
682
- startedAt: now,
683
- lastEventAt: now,
684
- lastToolOrTextAt: now,
685
- interventionCount: 0,
686
- };
687
-
688
- this.dispatchInternal(full);
689
- this.logger.info(
690
- `bizar: restarted instance ${instanceId} as ${newInstanceId} (restart #${full.restartCount})`,
691
- );
692
- return { ok: true, newInstanceId };
693
- }
694
-
695
- /**
696
- * Walk the parent chain to compute the true restart count for this
697
- * instance (Forseti C4). `restartCount` alone only reflects the child's
698
- * own counter, so a chain of restarts can exceed `maxRestarts`. This
699
- * helper sums the counters across the entire chain (parent + all
700
- * descendants) and returns the total.
701
- */
702
- private getTotalRestartCount(instanceId: string): number {
703
- let current = this.instances.get(instanceId);
704
- if (!current) return 0;
705
- let count = current.restartCount ?? 0;
706
- let parentId = current.parentInstanceId;
707
- const visited = new Set<string>([instanceId]);
708
- while (parentId && !visited.has(parentId)) {
709
- visited.add(parentId);
710
- const parent = this.instances.get(parentId);
711
- if (!parent) break;
712
- count += parent.restartCount ?? 0;
713
- parentId = parent.parentInstanceId;
714
- }
715
- return count;
716
- }
717
-
718
- // --- Collect ------------------------------------------------------------
719
-
720
- /**
721
- * Wait for an instance to reach a terminal status, or until `deadline`
722
- * (ms epoch) is reached. Returns `true` on terminal, `false` on
723
- * timeout.
724
- *
725
- * Two implementations:
726
- * - **With HTTP+stream** (full mode): subscribe to the SSE session
727
- * event stream AND poll the in-memory map. SSE gives sub-second
728
- * resolution; the in-memory check covers terminal states we set
729
- * ourselves (tool-call cap, loop guard, intervention abort).
730
- * - **Bg-only mode** (no HTTP, no SSE): poll the in-memory map
731
- * every 500 ms. Terminal transitions come from the runner's
732
- * `onExit` callback (see src/tools/bg-spawn.ts) which updates
733
- * the instance state directly.
734
- */
735
- private async waitForTerminal(
736
- instanceId: string,
737
- deadline: number,
738
- ): Promise<boolean> {
739
- // Already terminal?
740
- const initial = this.instances.get(instanceId);
741
- if (initial && TERMINAL_STATUSES.has(initial.status)) return true;
742
-
743
- if (this.stream === null || initial === undefined) {
744
- // Bg-only path: poll the in-memory map.
745
- const POLL_MS = 500;
746
- while (Date.now() < deadline) {
747
- await new Promise<void>((resolve) => setTimeout(resolve, POLL_MS));
748
- const cur = this.instances.get(instanceId);
749
- if (!cur) return false;
750
- if (TERMINAL_STATUSES.has(cur.status)) return true;
751
- }
752
- return false;
753
- }
754
-
755
- // Full path: subscribe to the session event stream AND observe our
756
- // own in-memory state changes for terminal transitions we set
757
- // ourselves (tool-cap, loop guard, intervention abort).
758
- return await new Promise<boolean>((resolve) => {
759
- const remaining = Math.max(0, deadline - Date.now());
760
- if (remaining === 0) {
761
- resolve(false);
762
- return;
763
- }
764
- const timer = setTimeout(() => {
765
- unsubscribe();
766
- resolve(false);
767
- }, remaining);
768
- const unsubscribe = this.stream!.onSessionEvent(
769
- initial.sessionId,
770
- (ev) => {
771
- if (ev.type === "session.idle" || ev.type === "session.error") {
772
- clearTimeout(timer);
773
- unsubscribe();
774
- resolve(true);
775
- return;
776
- }
777
- const cur = this.instances.get(instanceId);
778
- if (cur && TERMINAL_STATUSES.has(cur.status)) {
779
- clearTimeout(timer);
780
- unsubscribe();
781
- resolve(true);
782
- }
783
- },
784
- );
785
- const cur = this.instances.get(instanceId);
786
- if (cur && TERMINAL_STATUSES.has(cur.status)) {
787
- clearTimeout(timer);
788
- unsubscribe();
789
- resolve(true);
790
- }
791
- });
792
- }
793
-
794
- /**
795
- * Wait for the instance to reach a terminal state (or until
796
- * `timeoutMs` elapses), then build the result string per spec §4.4.
797
- *
798
- * If the instance is already terminal on entry, we skip the wait and
799
- * go straight to result construction.
800
- */
801
- async collect(instanceId: string, timeoutMs: number): Promise<CollectResult> {
802
- const inst = this.instances.get(instanceId);
803
- if (!inst) {
804
- throw new Error(`collect: instance ${instanceId} not found`);
805
- }
806
- const startedAt = inst.startedAt;
807
- const deadline = Date.now() + Math.max(0, timeoutMs);
808
-
809
- // 1. Wait for terminal state.
810
- if (!TERMINAL_STATUSES.has(inst.status)) {
811
- const reachedTerminal = await this.waitForTerminal(instanceId, deadline);
812
- if (!reachedTerminal) {
813
- // Timed out. Return what we have.
814
- const final = this.instances.get(instanceId);
815
- if (final && !TERMINAL_STATUSES.has(final.status)) {
816
- await this.update(instanceId, {
817
- status: "timed_out",
818
- completedAt: Date.now(),
819
- });
820
- }
821
- const dur = Date.now() - startedAt;
822
- const final2 = this.instances.get(instanceId);
823
- const out: CollectResult = {
824
- status: final2?.status ?? "timed_out",
825
- result: final2?.resultPreview ?? "",
826
- toolCallCount: final2?.toolCallCount ?? 0,
827
- durationMs: dur,
828
- error: `collect timed out after ${timeoutMs}ms`,
829
- };
830
- return out;
831
- }
832
- }
833
-
834
- // 2. Build the result. Fetch messages from the cline server and
835
- // concatenate the assistant text parts. In bg-only mode there is
836
- // no HTTP client to ask, so we fall back to whatever
837
- // `resultPreview` was captured during the run (often empty
838
- // because there is no SSE stream in bg-only mode — the runner
839
- // writes the raw output to the log file instead).
840
- const final = this.instances.get(instanceId);
841
- if (!final) {
842
- throw new Error(`collect: instance ${instanceId} disappeared`);
843
- }
844
- const resultText = await this.buildResultText(final);
845
- const dur = (final.completedAt ?? Date.now()) - startedAt;
846
- const out: CollectResult = {
847
- status: final.status,
848
- result: resultText,
849
- toolCallCount: final.toolCallCount,
850
- durationMs: dur,
851
- };
852
- if (final.error !== undefined) out.error = final.error;
853
- return out;
854
- }
855
-
856
- // --- Rebuild on init (spec §5.4) ----------------------------------------
857
-
858
- /**
859
- * Scan the bg directory, load every instance, and rebuild the in-memory
860
- * map. Any `running` or `pending` instance is checked for liveness:
861
- *
862
- * - v5.5.1: bg instances no longer have OS subprocesses — they live
863
- * as cline serve SDK sessions on the dashboard. `clineRunner.
864
- * isAlive(pid)` always returns `false`, so the adoption branch is
865
- * effectively dead code; we keep it for the rare case a state file
866
- * from v5.5.0 (with a real pid) survives an upgrade. We mark the
867
- * instance `failed` with reason `"v5.5.1 session reconciliation
868
- * pending"` so the operator knows to look at the dashboard; the
869
- * dashboard's own SSE bridge will reconcile.
870
- * - v5.5.0 and earlier: a still-alive PID re-adopts as before.
871
- *
872
- * Historical terminal records (done, failed, killed, timed_out, steered)
873
- * are preserved as-is.
874
- */
875
- async rebuildInMemoryMap(): Promise<void> {
876
- let all: BackgroundState[];
877
- try {
878
- all = await this.stateStore.list();
879
- } catch (err: unknown) {
880
- this.logger.warn(
881
- `bizar: rebuildInMemoryMap: list() failed: ${
882
- err instanceof Error ? err.message : String(err)
883
- }`,
884
- );
885
- return;
886
- }
887
- let rebuilt = 0;
888
- let failed = 0;
889
- let adopted = 0;
890
- for (const inst of all) {
891
- this.instances.set(inst.instanceId, inst);
892
- rebuilt += 1;
893
- if (inst.status === "running" || inst.status === "pending") {
894
- // v5.5.1 — prefer the liveSession flag (the SDK-session marker)
895
- // when present. If the dashboard still tracks the session, we
896
- // re-adopt; otherwise we mark failed with a reconcilable message.
897
- if (inst.liveSession) {
898
- // SDK-backed: we trust the state file. The dashboard's SSE
899
- // bridge will deliver the next event. Mark `running` and
900
- // `runnerState: "adopted"` so the operator sees the plugin
901
- // has caught up.
902
- await this.update(inst.instanceId, {
903
- status: "running",
904
- completedAt: undefined,
905
- runnerState: "adopted",
906
- });
907
- adopted += 1;
908
- this.logger.info(
909
- `bizar: re-adopted SDK-backed bg instance ${inst.instanceId}`,
910
- );
911
- continue;
912
- }
913
- // Pre-v5.5.1 subprocess fallback (rare; the runner is stubbed).
914
- const pid = inst.processId;
915
- const alive = typeof pid === "number" && clineRunner.isAlive(pid);
916
- if (alive) {
917
- await this.update(inst.instanceId, {
918
- status: "running",
919
- completedAt: undefined,
920
- runnerState: "adopted",
921
- });
922
- adopted += 1;
923
- this.logger.info(
924
- `bizar: adopted still-alive subprocess for instance ${inst.instanceId} (pid=${pid})`,
925
- );
926
- continue;
927
- }
928
- const message = "subprocess died during restart";
929
- await this.update(inst.instanceId, {
930
- status: "failed",
931
- error: message,
932
- completedAt: Date.now(),
933
- });
934
- failed += 1;
935
- }
936
- }
937
- if (rebuilt > 0) {
938
- this.logger.info(
939
- `bizar: rebuilt in-memory map (${rebuilt} instances, ${adopted} adopted, ${failed} marked failed)`,
940
- );
941
- }
942
- }
943
-
944
- // --- Shutdown (spec §5.3) ----------------------------------------------
945
-
946
- /**
947
- * Mark all in-memory instances as failed with `error: "plugin shutting down"`,
948
- * abort all running sessions best-effort (5s timeout per call, in
949
- * parallel), then return. The serve child termination is the
950
- * caller's responsibility.
951
- *
952
- * Also clears the v0.3.0 stall-checker interval. After `shutdownAll`,
953
- * the manager is effectively inert — no more periodic checks will
954
- * fire even though the InstanceManager object itself is still alive.
955
- */
956
- async shutdownAll(): Promise<void> {
957
- // v0.3.0 — clear the periodic checker first so it does not race
958
- // the in-flight updates below.
959
- if (this.stallCheckerTimer !== null) {
960
- clearInterval(this.stallCheckerTimer);
961
- this.stallCheckerTimer = null;
962
- }
963
- this.stallCheckerDisabled = true;
964
- const live: BackgroundState[] = [];
965
- for (const inst of this.instances.values()) {
966
- if (!TERMINAL_STATUSES.has(inst.status)) {
967
- live.push(inst);
968
- }
969
- }
970
- // Phase 1: mark failed first (spec §5.3 step 1).
971
- for (const inst of live) {
972
- await this.update(inst.instanceId, {
973
- status: "failed",
974
- error: "plugin shutting down",
975
- completedAt: Date.now(),
976
- });
977
- }
978
- // Phase 2: best-effort aborts in parallel, 5s per call. In bg-only
979
- // mode there is no HTTP client to ask — the runner owns the
980
- // subprocess lifecycle, so the in-memory status flip is the only
981
- // signal we can emit. Skipped cleanly when http is null.
982
- if (this.http !== null) {
983
- const abortPromises = live.map((inst) =>
984
- withTimeout(
985
- this.http!.abortSession(inst.sessionId, this.worktree),
986
- 5_000,
987
- ).catch(() => undefined),
988
- );
989
- await Promise.allSettled(abortPromises);
990
- }
991
- this.logger.info(`bizar: shutdownAll complete (${live.length} instances aborted)`);
992
- }
993
-
994
- // --- v0.3.0 stall and thinking-loop helpers ----------------------------
995
-
996
- /**
997
- * Mark an instance `failed` with the canonical stall message and
998
- * fire-and-forget the cline abort call. The stall timeout is
999
- * intentionally short enough that an abort that fails gracefully
1000
- * (the serve child is dead, etc.) does not leave the user waiting.
1001
- */
1002
- private async _abortAsStalled(inst: BackgroundState): Promise<void> {
1003
- const lastEventAt = inst.lastEventAt ?? 0;
1004
- const sinceMs = Date.now() - lastEventAt;
1005
- this.logger.warn(
1006
- `bizar: instance ${inst.instanceId} stalled (no event for ${sinceMs}ms); aborting`,
1007
- );
1008
- // Fire-and-forget. If the serve child is dead, this returns a
1009
- // failure result but we still mark the instance failed in-memory.
1010
- // v0.8.0 — bg-only mode has no HTTP client; the abort is a no-op
1011
- // there (the runner owns the subprocess).
1012
- if (this.http !== null) {
1013
- this.http
1014
- .abortSession(inst.sessionId, this.worktree)
1015
- .catch(() => undefined);
1016
- }
1017
- await this.update(inst.instanceId, {
1018
- status: "failed",
1019
- error: `No activity for ${this.stallTimeoutMs}ms — LLM appears stalled`,
1020
- completedAt: Date.now(),
1021
- });
1022
- // v0.5.5 — persistent auto-restart on stall
1023
- await this._maybeAutoRestart(inst.instanceId);
1024
- }
1025
-
1026
- /**
1027
- * Send the research-intervention prompt to the running session. The
1028
- * message interrupts the current generation and starts a new turn
1029
- * with the prompt as the next user message. This is fire-and-forget:
1030
- * we do not wait for the prompt to complete, only for the HTTP call
1031
- * to return.
1032
- */
1033
- private async _sendIntervention(
1034
- inst: BackgroundState,
1035
- sinceMs: number,
1036
- ): Promise<void> {
1037
- const messageID = generateMessageId();
1038
- const prompt = researchInterventionPrompt(sinceMs);
1039
- const currentCount = inst.interventionCount ?? 0;
1040
- this.logger.warn(
1041
- `bizar: instance ${inst.instanceId} thinking loop (${sinceMs}ms without tool/text); sending intervention #${currentCount + 1}/${this.maxInterventions}`,
1042
- );
1043
- try {
1044
- // v0.8.0 — bg-only mode has no HTTP client; interventions are a
1045
- // no-op there (the runner doesn't expose a "send a user message
1046
- // mid-run" hook). Logged as a debug so the operator can see why
1047
- // no intervention went out.
1048
- if (this.http === null) {
1049
- this.logger.debug(
1050
- `bizar: skipping intervention for ${inst.sessionId} (bg-only mode; no HTTP client)`,
1051
- );
1052
- } else {
1053
- await this.http.sendPrompt(
1054
- {
1055
- sessionId: inst.sessionId,
1056
- messageID,
1057
- agent: inst.agent,
1058
- parts: [{ type: "text", text: prompt }],
1059
- },
1060
- this.worktree,
1061
- );
1062
- }
1063
- } catch (err: unknown) {
1064
- // We swallow the error: the periodic checker will try again next
1065
- // tick. The intervention counter is still incremented below so
1066
- // we eventually escalate to an abort if the prompt keeps failing.
1067
- this.logger.warn(
1068
- `bizar: intervention prompt send failed for ${inst.instanceId}: ${
1069
- err instanceof Error ? err.message : String(err)
1070
- }`,
1071
- );
1072
- }
1073
- const reason = `thinking loop (${formatDuration(sinceMs)} without tool/text)`;
1074
- await this.update(inst.instanceId, {
1075
- interventionCount: currentCount + 1,
1076
- interventionAt: Date.now(),
1077
- interventionReason: reason,
1078
- // Bumping lastEventAt here is intentional: the intervention call
1079
- // counted as an HTTP-driven activity, so the stall checker does
1080
- // not fire immediately after.
1081
- lastEventAt: Date.now(),
1082
- });
1083
- }
1084
-
1085
- /**
1086
- * Mark an instance `failed` with the canonical thinking-loop message
1087
- * and fire-and-forget the abort call.
1088
- */
1089
- private async _abortAsThinkingLoop(
1090
- inst: BackgroundState,
1091
- sinceMs: number,
1092
- ): Promise<void> {
1093
- this.logger.warn(
1094
- `bizar: instance ${inst.instanceId} thinking loop exhausted ${this.maxInterventions} intervention(s) over ${sinceMs}ms; aborting`,
1095
- );
1096
- // v0.8.0 — bg-only mode has no HTTP client; the abort is a no-op
1097
- // there (the runner owns the subprocess).
1098
- if (this.http !== null) {
1099
- this.http
1100
- .abortSession(inst.sessionId, this.worktree)
1101
- .catch(() => undefined);
1102
- }
1103
- await this.update(inst.instanceId, {
1104
- status: "failed",
1105
- error: `Thinking loop detected: ${formatDuration(sinceMs)} of thinking without tool calls or output. Spawn a Mimir agent for research.`,
1106
- completedAt: Date.now(),
1107
- });
1108
- // v0.5.5 — persistent auto-restart on thinking-loop exhaustion
1109
- await this._maybeAutoRestart(inst.instanceId);
1110
- }
1111
-
1112
- // --- Internal: per-session event handler -------------------------------
1113
-
1114
- public attachEventHandler(inst: BackgroundState): () => void {
1115
- this.detachEventHandler(inst.instanceId);
1116
- // v0.8.0 — bg-only mode has no EventStream; return a no-op unsubscriber.
1117
- if (!this.stream) return () => {};
1118
- const handler: SessionEventHandler = (ev: StreamEvent) => {
1119
- void this.handleInstanceEvent(inst.instanceId, ev);
1120
- };
1121
- const unsubscribe = this.stream.onSessionEvent(inst.sessionId, handler);
1122
- this.eventUnsubscribers.set(inst.instanceId, unsubscribe);
1123
- return unsubscribe;
1124
- }
1125
-
1126
- private detachEventHandler(instanceId: string): void {
1127
- const unsubscribe = this.eventUnsubscribers.get(instanceId);
1128
- if (!unsubscribe) return;
1129
- try {
1130
- unsubscribe();
1131
- } catch {
1132
- // ignore
1133
- }
1134
- this.eventUnsubscribers.delete(instanceId);
1135
- }
1136
-
1137
- private async handleInstanceEvent(
1138
- instanceId: string,
1139
- ev: StreamEvent,
1140
- ): Promise<void> {
1141
- const inst = this.instances.get(instanceId);
1142
- if (!inst) return;
1143
- // Already terminal — ignore further events (e.g., after kill, an
1144
- // EventSessionError may still arrive).
1145
- if (TERMINAL_STATUSES.has(inst.status)) return;
1146
-
1147
- // v0.3.0 — every event advances the heartbeat. We do this BEFORE
1148
- // any further work (or inside the per-instance mutex in update())
1149
- // so the stall checker sees the freshest timestamp regardless of
1150
- // how the rest of the handler proceeds.
1151
- inst.lastEventAt = Date.now();
1152
-
1153
- if (ev.type === "message.part.updated") {
1154
- await this.onPartUpdated(instanceId, ev);
1155
- } else if (ev.type === "session.idle") {
1156
- await this.update(instanceId, {
1157
- status: "done",
1158
- completedAt: Date.now(),
1159
- });
1160
- } else if (ev.type === "session.error") {
1161
- const errMsg = ev.error ?? "session error";
1162
- await this.update(instanceId, {
1163
- status: "failed",
1164
- error: errMsg,
1165
- completedAt: Date.now(),
1166
- });
1167
- // v0.5.5 — persistent auto-restart. If the instance is persistent
1168
- // and was not explicitly killed, try to restart.
1169
- if (inst.persistent && inst.status !== "killed") {
1170
- await this._maybeAutoRestart(instanceId);
1171
- }
1172
- }
1173
- }
1174
-
1175
- /**
1176
- * v0.8.0 — Public version of `_maybeAutoRestart`. The cline-runner
1177
- * (see src/cline-runner.ts) calls this from its onExit callback
1178
- * when a `bizar_spawn_background` subprocess exits. Without this,
1179
- * persistent instances would never auto-restart under the new
1180
- * subprocess-based path (the SSE event handler that previously
1181
- * triggered auto-restart is no longer wired up because we don't
1182
- * subscribe to per-session events anymore).
1183
- */
1184
- async maybeAutoRestart(instanceId: string): Promise<void> {
1185
- await this._maybeAutoRestart(instanceId);
1186
- }
1187
-
1188
- // --- v5.x — pause / resume / progress / tool-call instrumentation -------
1189
-
1190
- /**
1191
- * Pause a running instance. In v5.5.1 (SDK-backed) this delegates to
1192
- * the dashboard's `POST /api/background/:id/pause`, which performs an
1193
- * OUTPUT pause (stops forwarding events to the WS bus; the underlying
1194
- * cline serve session keeps running). For pre-v5.5.1 instances
1195
- * that still carry a `processId`, fall back to the old SIGSTOP path
1196
- * via the (now stubbed) runner — best-effort, returns ok:false if the
1197
- * runner reports the OS no longer supports it.
1198
- *
1199
- * On success, the in-memory status flips to `"paused"` and `pausedAt`
1200
- * is stamped.
1201
- *
1202
- * Already-terminal instances return `{ ok: false, error: "already_terminal" }`.
1203
- * Already-paused instances return `{ ok: true }` (no-op).
1204
- */
1205
- async pause(instanceId: string): Promise<{ ok: boolean; error?: string }> {
1206
- const inst = this.instances.get(instanceId);
1207
- if (!inst) return { ok: false, error: "instance_not_found" };
1208
- if (TERMINAL_STATUSES.has(inst.status)) {
1209
- return { ok: false, error: "already_terminal" };
1210
- }
1211
- if (inst.status === "paused") return { ok: true };
1212
-
1213
- // v5.5.1 — SDK-backed: delegate to the dashboard.
1214
- if (inst.liveSession) {
1215
- const res = await postDashboardControl(`/api/background/${encodeURIComponent(instanceId)}/pause`, this.logger);
1216
- if (!res.ok) {
1217
- return { ok: false, error: res.error || "pause_failed" };
1218
- }
1219
- await this.update(instanceId, {
1220
- status: "paused",
1221
- pausedAt: Date.now(),
1222
- });
1223
- this.logger.info(`bizar: paused background instance ${instanceId} (sdk-backed)`);
1224
- return { ok: true };
1225
- }
1226
-
1227
- // Pre-v5.5.1 fallback: subprocess SIGSTOP. The runner is a stub now,
1228
- // but if a state file from v5.5.0 is still around we try the old
1229
- // path for compatibility.
1230
- if (inst.processId === undefined) {
1231
- return { ok: false, error: "no_subprocess" };
1232
- }
1233
- const pid = inst.processId;
1234
- const res = clineRunner.pauseAgent(pid);
1235
- if (!res.ok) return { ok: false, error: res.error || "pause_failed" };
1236
- await this.update(instanceId, {
1237
- status: "paused",
1238
- pausedAt: Date.now(),
1239
- });
1240
- this.logger.info(
1241
- `bizar: paused background instance ${instanceId} (pid=${pid}, legacy)`,
1242
- );
1243
- return { ok: true };
1244
- }
1245
-
1246
- /**
1247
- * Resume a paused instance. In v5.5.1 (SDK-backed) delegates to the
1248
- * dashboard's `POST /api/background/:id/resume`, which drains the
1249
- * buffered events back to the WS bus. For pre-v5.5.1 instances with
1250
- * a `processId`, falls back to the old SIGCONT path via the runner.
1251
- *
1252
- * Idempotent: resuming a non-paused instance is a no-op that returns
1253
- * `{ ok: true }`.
1254
- */
1255
- async resume(instanceId: string): Promise<{ ok: boolean; error?: string }> {
1256
- const inst = this.instances.get(instanceId);
1257
- if (!inst) return { ok: false, error: "instance_not_found" };
1258
- if (inst.status !== "paused") return { ok: true };
1259
-
1260
- // v5.5.1 — SDK-backed: delegate to the dashboard.
1261
- if (inst.liveSession) {
1262
- const res = await postDashboardControl(`/api/background/${encodeURIComponent(instanceId)}/resume`, this.logger);
1263
- if (!res.ok) {
1264
- return { ok: false, error: res.error || "resume_failed" };
1265
- }
1266
- await this.update(instanceId, {
1267
- status: "running",
1268
- pausedAt: undefined,
1269
- });
1270
- this.logger.info(`bizar: resumed background instance ${instanceId} (sdk-backed)`);
1271
- return { ok: true };
1272
- }
1273
-
1274
- // Pre-v5.5.1 fallback: subprocess SIGCONT.
1275
- if (inst.processId === undefined) {
1276
- return { ok: false, error: "no_subprocess" };
1277
- }
1278
- const res = clineRunner.resumeAgent(inst.processId);
1279
- if (!res.ok) return { ok: false, error: res.error || "resume_failed" };
1280
- await this.update(instanceId, {
1281
- status: "running",
1282
- pausedAt: undefined,
1283
- });
1284
- this.logger.info(
1285
- `bizar: resumed background instance ${instanceId} (pid=${inst.processId}, legacy)`,
1286
- );
1287
- return { ok: true };
1288
- }
1289
-
1290
- /**
1291
- * Update progress (and optional message) for an instance. Called by the
1292
- * `bizar_report_progress` tool. Pinned to [0, 100] unless `indeterminate`
1293
- * is true, in which case we set progress to -1.
1294
- */
1295
- async updateProgress(
1296
- instanceId: string,
1297
- step: number,
1298
- total: number,
1299
- message?: string,
1300
- indeterminate = false,
1301
- ): Promise<{ ok: boolean; progress?: number; error?: string }> {
1302
- const inst = this.instances.get(instanceId);
1303
- if (!inst) return { ok: false, error: "instance_not_found" };
1304
- if (TERMINAL_STATUSES.has(inst.status)) {
1305
- return { ok: false, error: "already_terminal" };
1306
- }
1307
- let pct: number;
1308
- if (indeterminate) {
1309
- pct = -1;
1310
- } else {
1311
- const safeTotal = Math.max(1, Math.floor(total));
1312
- const safeStep = Math.max(0, Math.floor(step));
1313
- pct = Math.max(0, Math.min(100, Math.round((safeStep / safeTotal) * 100)));
1314
- }
1315
- const patch: Partial<BackgroundState> = {
1316
- progress: pct,
1317
- progressAt: Date.now(),
1318
- };
1319
- if (message !== undefined) patch.progressMessage = message.slice(0, 500);
1320
- await this.update(instanceId, patch);
1321
- return { ok: true, progress: pct };
1322
- }
1323
-
1324
- /**
1325
- * Record a tool-call start. Returns the generated id so callers can
1326
- * pass it to `completeToolCall()` later. Caps the history at
1327
- * `MAX_TOOL_CALL_HISTORY` entries.
1328
- */
1329
- async recordToolCallStart(
1330
- instanceId: string,
1331
- name: string,
1332
- args: unknown,
1333
- ): Promise<string> {
1334
- const id = `tc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
1335
- const inst = this.instances.get(instanceId);
1336
- if (!inst) return id;
1337
- const entry: ToolCallEntry = {
1338
- id,
1339
- name: String(name ?? "").slice(0, 200),
1340
- args: truncateForToolHistory(args),
1341
- status: "running",
1342
- startedAt: Date.now(),
1343
- };
1344
- const existing = (inst.toolCalls ?? []).slice(-MAX_TOOL_CALL_HISTORY + 1);
1345
- const next = [...existing, entry].slice(-MAX_TOOL_CALL_HISTORY);
1346
- await this.update(instanceId, { toolCalls: next });
1347
- return id;
1348
- }
1349
-
1350
- /**
1351
- * Mark a tool-call complete. `result` and `error` are both truncated.
1352
- * A no-op if no matching entry is found (the cap may have rolled it off).
1353
- */
1354
- async completeToolCall(
1355
- instanceId: string,
1356
- id: string,
1357
- result?: string,
1358
- error?: string,
1359
- ): Promise<void> {
1360
- const inst = this.instances.get(instanceId);
1361
- if (!inst || !Array.isArray(inst.toolCalls)) return;
1362
- const idx = inst.toolCalls.findIndex((t) => t.id === id);
1363
- if (idx < 0) return;
1364
- const cur = inst.toolCalls[idx];
1365
- if (!cur) return;
1366
- const next = inst.toolCalls.slice();
1367
- const updated: ToolCallEntry = {
1368
- ...cur,
1369
- status: error ? "error" : "ok",
1370
- endedAt: Date.now(),
1371
- };
1372
- if (error !== undefined) {
1373
- updated.error = String(error).slice(0, MAX_TOOL_ARG_CHARS);
1374
- } else if (result !== undefined) {
1375
- updated.result = String(result).slice(0, MAX_TOOL_ARG_CHARS);
1376
- }
1377
- next[idx] = updated;
1378
- await this.update(instanceId, { toolCalls: next });
1379
- }
1380
-
1381
- /**
1382
- * Public accessor used by the cline-runner when it wants to surface
1383
- * the recorded tool-call list (read-only). Returns a copy.
1384
- */
1385
- listToolCalls(instanceId: string): ToolCallEntry[] {
1386
- const inst = this.instances.get(instanceId);
1387
- if (!inst || !Array.isArray(inst.toolCalls)) return [];
1388
- return inst.toolCalls.slice();
1389
- }
1390
-
1391
- /**
1392
- * Public: mark an instance as "adopted" — used by `rebuildInMemoryMap`
1393
- * when a previously running instance's subprocess is still alive. The
1394
- * status flips back to "running" and the runner is resumed.
1395
- */
1396
- async adoptInstance(instanceId: string): Promise<void> {
1397
- const inst = this.instances.get(instanceId);
1398
- if (!inst) return;
1399
- if (!TERMINAL_STATUSES.has(inst.status)) {
1400
- // Already alive in our map; nothing to adopt.
1401
- return;
1402
- }
1403
- await this.update(instanceId, {
1404
- status: "running",
1405
- completedAt: undefined,
1406
- runnerState: "adopted",
1407
- });
1408
- }
1409
-
1410
- /** v0.5.5 — Attempt an auto-restart for a persistent failed instance. */
1411
- private async _maybeAutoRestart(instanceId: string): Promise<void> {
1412
- const inst = this.instances.get(instanceId);
1413
- if (!inst || !inst.persistent) return;
1414
- if (inst.status === "killed") return; // user killed it — do not restart
1415
- this.logger.info(
1416
- `bizar: persistent instance ${instanceId} failed; auto-restarting`,
1417
- );
1418
- const result = await this.restart(instanceId);
1419
- if (result.ok) {
1420
- // Clear any previous restartError on the parent so the operator
1421
- // sees a clean state.
1422
- await this.update(instanceId, { restartError: undefined });
1423
- this.logger.info(
1424
- `bizar: auto-restart complete: ${instanceId} -> ${result.newInstanceId}`,
1425
- );
1426
- } else {
1427
- // Persist a human-readable error so the operator can see why the
1428
- // restart was rejected (e.g. max_restarts_reached).
1429
- await this.update(instanceId, { restartError: result.error || "unknown" });
1430
- this.logger.warn(
1431
- `bizar: auto-restart failed for ${instanceId}: ${result.error}`,
1432
- );
1433
- }
1434
- }
1435
-
1436
- private async onPartUpdated(
1437
- instanceId: string,
1438
- ev: Extract<StreamEvent, { type: "message.part.updated" }>,
1439
- ): Promise<void> {
1440
- const inst = this.instances.get(instanceId);
1441
- if (!inst) return;
1442
- const part = ev.part;
1443
-
1444
- // v0.3.0 — tool and text parts advance the "progress" timestamp.
1445
- // `thinking` parts do NOT, because that is the loop indicator.
1446
- if (part.type === "tool" || part.type === "text") {
1447
- inst.lastToolOrTextAt = Date.now();
1448
- // The agent has shown concrete progress after one or more
1449
- // interventions — reset the intervention counter so the next
1450
- // thinking loop has a fresh budget. Clear the intervention
1451
- // metadata so a later status check does not show stale info.
1452
- if ((inst.interventionCount ?? 0) > 0) {
1453
- inst.interventionCount = 0;
1454
- delete inst.interventionAt;
1455
- delete inst.interventionReason;
1456
- }
1457
- }
1458
-
1459
- // --- Tool-call cap (spec §6.2) ---
1460
- if (part.type === "tool") {
1461
- const nextCount = inst.toolCallCount + 1;
1462
- const patch: Partial<BackgroundState> = { toolCallCount: nextCount };
1463
- if (nextCount >= this.toolCallCap) {
1464
- // Abort and mark failed. Use a fire-and-forget abort because we
1465
- // do not want to block the handler on a network call.
1466
- // v0.8.0 — bg-only mode has no HTTP client; the abort is a
1467
- // no-op there (the runner owns the subprocess).
1468
- if (this.http !== null) {
1469
- this.http
1470
- .abortSession(inst.sessionId, this.worktree)
1471
- .catch(() => undefined);
1472
- }
1473
- patch.status = "failed";
1474
- patch.error = `Tool-call cap reached (${nextCount}). Aborted to prevent cost runaway.`;
1475
- patch.completedAt = Date.now();
1476
- }
1477
- await this.update(instanceId, patch);
1478
- // v0.5.5 — persistent auto-restart on tool-call cap
1479
- if (patch.status === "failed") {
1480
- await this._maybeAutoRestart(instanceId);
1481
- return;
1482
- }
1483
- }
1484
-
1485
- // --- v5.x — Tool call history recording -----------------------------
1486
- // The tool part re-fires on every state transition (pending ->
1487
- // running -> completed). We key the history entry by `partID` so
1488
- // the same call's "running" and "completed" messages converge to
1489
- // one row. When a part has no entry yet, we record a fresh "running"
1490
- // entry; when it already exists, we update status/result/error.
1491
- if (part.type === "tool") {
1492
- const toolName = readToolName(part);
1493
- const existing = Array.isArray(inst.toolCalls)
1494
- ? inst.toolCalls.find((t) => t.id === ev.partID)
1495
- : undefined;
1496
- if (existing === undefined) {
1497
- // Fresh tool call — record start.
1498
- const tcId = ev.partID;
1499
- const entry: ToolCallEntry = {
1500
- id: tcId,
1501
- name: toolName,
1502
- args: readToolArgs(part),
1503
- status: readToolStatus(part),
1504
- startedAt: Date.now(),
1505
- };
1506
- const merged = [...(inst.toolCalls ?? []), entry].slice(
1507
- -MAX_TOOL_CALL_HISTORY,
1508
- );
1509
- await this.update(instanceId, { toolCalls: merged });
1510
- } else {
1511
- // Existing entry — update status/result/error.
1512
- const next = (inst.toolCalls ?? []).slice();
1513
- const idx = next.findIndex((t) => t.id === ev.partID);
1514
- if (idx >= 0) {
1515
- const cur = next[idx];
1516
- if (cur) {
1517
- const status = readToolStatus(part);
1518
- const updated: ToolCallEntry = {
1519
- ...cur,
1520
- status,
1521
- endedAt: status === "ok" || status === "error" ? Date.now() : cur.endedAt,
1522
- };
1523
- const errText = readToolError(part);
1524
- if (errText) updated.error = errText.slice(0, MAX_TOOL_ARG_CHARS);
1525
- // If the part carries a result string in `state.output` or
1526
- // a result-shaped field, surface it. The cline schema
1527
- // is loose here — we accept either `state.output` or raw
1528
- // `output`.
1529
- const output = readToolOutput(part);
1530
- if (output) updated.result = output.slice(0, MAX_TOOL_ARG_CHARS);
1531
- next[idx] = updated;
1532
- await this.update(instanceId, { toolCalls: next });
1533
- }
1534
- }
1535
- }
1536
- }
1537
-
1538
- // --- Loop-guard threshold-12 detection (spec §4.1) ---
1539
- if (part.type === "tool" && !inst.loopGuardTool) {
1540
- const errorText = readToolError(part);
1541
- if (errorText) {
1542
- const m = errorText.match(LOOP_GUARD_RE);
1543
- if (m && m[1]) {
1544
- const tool = m[1];
1545
- await this.update(instanceId, {
1546
- status: "failed",
1547
- error: `Loop protection: 12 identical calls to ${tool}`,
1548
- loopGuardTool: tool,
1549
- completedAt: Date.now(),
1550
- });
1551
- // v0.5.5 — persistent auto-restart on loop-guard
1552
- await this._maybeAutoRestart(instanceId);
1553
- return;
1554
- }
1555
- }
1556
- }
1557
-
1558
- // --- Text-part result preview refresh (spec §3.2) ---
1559
- if (part.type === "text" && typeof part.text === "string") {
1560
- const preview = part.text.slice(-RESULT_PREVIEW_MAX);
1561
- const newIds = [...(inst.resultMessageIds ?? []), ev.messageID];
1562
- // Deduplicate messageIDs.
1563
- const seen = new Set<string>();
1564
- const uniq = newIds.filter((id) => {
1565
- if (seen.has(id)) return false;
1566
- seen.add(id);
1567
- return true;
1568
- });
1569
- await this.update(instanceId, {
1570
- resultPreview: preview,
1571
- resultMessageIds: uniq,
1572
- });
1573
- }
1574
- }
1575
-
1576
- /**
1577
- * Build the result text for `collect` per spec §4.4:
1578
- * - Fetch assistant messages via `GET /session/{id}/message`.
1579
- * - Concatenate `TextPart.text` in order; skip everything else.
1580
- * - If `loopGuardTool` is set, prepend the marker.
1581
- */
1582
- private async buildResultText(inst: BackgroundState): Promise<string> {
1583
- // v0.8.0 — bg-only mode has no HTTP client; fall back to whatever
1584
- // text-part preview we've already accumulated.
1585
- if (!this.http) return inst.resultPreview ?? "";
1586
- const res = await this.http.listMessages(inst.sessionId, this.worktree);
1587
- if (!res.ok) {
1588
- this.logger.warn(`bizar: collect: listMessages failed: ${res.error}`);
1589
- return inst.resultPreview ?? "";
1590
- }
1591
- const textParts: string[] = [];
1592
- for (const msg of res.value) {
1593
- if (msg.role !== "assistant") continue;
1594
- for (const p of msg.parts) {
1595
- if (p.type !== "text") continue;
1596
- if (typeof p.text === "string" && p.text.length > 0) {
1597
- textParts.push(p.text);
1598
- }
1599
- }
1600
- }
1601
- const body = textParts.join("");
1602
- if (inst.loopGuardTool) {
1603
- return `[loop guard: 12 identical calls to ${inst.loopGuardTool}]\n${body}`;
1604
- }
1605
- return body;
1606
- }
1607
- }
1608
-
1609
- // --- Helpers --------------------------------------------------------------
1610
-
1611
- /**
1612
- * Format a millisecond duration as `Xm Ys` (or just `Ys` if under a minute).
1613
- * Used in stall and thinking-loop error messages.
1614
- */
1615
- function formatDuration(ms: number): string {
1616
- const safeMs = Math.max(0, Math.floor(ms));
1617
- const minutes = Math.floor(safeMs / 60_000);
1618
- const seconds = Math.floor((safeMs % 60_000) / 1000);
1619
- return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
1620
- }
1621
-
1622
- /**
1623
- * Generate a unique instance id: `bgr_<22-char base32>` (ULID-like).
1624
- * We use 16 random bytes encoded as 22 base32 characters. The prefix
1625
- * `bgr_` makes the file naming scheme obvious.
1626
- */
1627
- export function generateInstanceId(): string {
1628
- const bytes = new Uint8Array(16);
1629
- globalThis.crypto.getRandomValues(bytes);
1630
- return `bgr_${base32(bytes)}`;
1631
- }
1632
-
1633
- /**
1634
- * Crockford base32 (no I, L, O, U) encoder for 16 bytes → 26 chars.
1635
- * We use 16 bytes (128 bits) to give plenty of entropy; only the first
1636
- * 22 chars are used for the actual id and the last 4 are dropped.
1637
- */
1638
- function base32(bytes: Uint8Array): string {
1639
- const ALPH = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1640
- // Encode 5 bytes → 8 chars; pad the last group with zeros.
1641
- let bits = 0;
1642
- let value = 0;
1643
- let out = "";
1644
- for (let i = 0; i < bytes.length; i++) {
1645
- value = (value << 8) | (bytes[i] ?? 0);
1646
- bits += 8;
1647
- while (bits >= 5) {
1648
- out += ALPH[(value >>> (bits - 5)) & 0x1f];
1649
- bits -= 5;
1650
- }
1651
- }
1652
- if (bits > 0) out += ALPH[(value << (5 - bits)) & 0x1f];
1653
- return out.slice(0, 22);
1654
- }
1655
-
1656
- /**
1657
- * Generate a unique message id: `msg_<22-char base32>`. Same encoding
1658
- * as `generateInstanceId`. Used for `POST /session/{id}/prompt_async`.
1659
- */
1660
- export function generateMessageId(): string {
1661
- const bytes = new Uint8Array(16);
1662
- globalThis.crypto.getRandomValues(bytes);
1663
- return `msg_${base32(bytes)}`;
1664
- }
1665
-
1666
- function toView(inst: BackgroundState): InstanceView {
1667
- const v: InstanceView = {
1668
- instanceId: inst.instanceId,
1669
- agent: inst.agent,
1670
- status: inst.status,
1671
- startedAt: inst.startedAt,
1672
- toolCallCount: inst.toolCallCount,
1673
- promptPreview: inst.promptPreview,
1674
- parentAgent: inst.parentAgent,
1675
- sessionId: inst.sessionId,
1676
- // v0.3.0 — stall and thinking-loop protection. Always include
1677
- // lastEventAt so a status caller can see how fresh the activity is.
1678
- lastEventAt: inst.lastEventAt,
1679
- };
1680
- if (inst.completedAt !== undefined) v.completedAt = inst.completedAt;
1681
- if (inst.resultPreview !== undefined) v.resultPreview = inst.resultPreview;
1682
- if (inst.error !== undefined) v.error = inst.error;
1683
- if (inst.parentInstanceId !== undefined) v.parentInstanceId = inst.parentInstanceId;
1684
- // Only surface intervention metadata when we have actually intervened.
1685
- // `interventionCount > 0` is the canonical signal; absent fields mean
1686
- // "no intervention has been sent yet", which is the common case.
1687
- const interventionCount = inst.interventionCount ?? 0;
1688
- if (interventionCount > 0) {
1689
- v.interventionCount = interventionCount;
1690
- if (inst.interventionAt !== undefined) v.interventionAt = inst.interventionAt;
1691
- if (inst.interventionReason !== undefined) v.interventionReason = inst.interventionReason;
1692
- }
1693
- // v5.x — extended dashboard surface.
1694
- if (Array.isArray(inst.toolCalls) && inst.toolCalls.length > 0) {
1695
- v.toolCalls = inst.toolCalls.slice();
1696
- }
1697
- if (typeof inst.progress === "number") v.progress = inst.progress;
1698
- if (inst.progressMessage !== undefined) v.progressMessage = inst.progressMessage;
1699
- if (inst.processId !== undefined) v.processId = inst.processId;
1700
- if (inst.runnerState !== undefined) v.runnerState = inst.runnerState;
1701
- if (Array.isArray(inst.tags) && inst.tags.length > 0) v.tags = inst.tags.slice();
1702
- if (inst.pausedAt !== undefined) v.pausedAt = inst.pausedAt;
1703
- return v;
1704
- }
1705
-
1706
- /**
1707
- * Extract the canonical loop-guard error string from a tool part. The
1708
- * part may carry the error either on `part.error` or on
1709
- * `part.state.error` (per spec §4.1).
1710
- */
1711
- function readToolError(part: { error?: string; state?: { error?: string } }): string | null {
1712
- if (typeof part.error === "string" && part.error.length > 0) return part.error;
1713
- if (part.state && typeof part.state.error === "string" && part.state.error.length > 0) {
1714
- return part.state.error;
1715
- }
1716
- return null;
1717
- }
1718
-
1719
- /**
1720
- * v5.x — Read the tool name from a tool part. cline's wire format
1721
- * is loose: the tool name may live on `part.tool`, `part.name`, or as
1722
- * the value of the first entries. Returns "unknown" when nothing
1723
- * matches.
1724
- */
1725
- function readToolName(part: { tool?: unknown; name?: unknown; [k: string]: unknown }): string {
1726
- const candidates = [part.tool, part.name];
1727
- for (const c of candidates) {
1728
- if (typeof c === "string" && c.length > 0) return c.slice(0, 200);
1729
- }
1730
- return "unknown";
1731
- }
1732
-
1733
- /**
1734
- * v5.x — Read the tool args from a tool part. cline may carry
1735
- * `args`, `input`, or a JSON-stringified `arguments`. Returns "" when
1736
- * nothing useful is found.
1737
- */
1738
- function readToolArgs(part: { args?: unknown; input?: unknown; arguments?: unknown; [k: string]: unknown }): string {
1739
- const candidates = [part.args, part.input, part.arguments];
1740
- for (const c of candidates) {
1741
- if (c === undefined || c === null) continue;
1742
- if (typeof c === "string") return c.slice(0, MAX_TOOL_ARG_CHARS);
1743
- try {
1744
- return JSON.stringify(c).slice(0, MAX_TOOL_ARG_CHARS);
1745
- } catch {
1746
- // fall through
1747
- }
1748
- }
1749
- return "";
1750
- }
1751
-
1752
- /**
1753
- * v5.x — Map the part's state.status (or top-level status) to our
1754
- * ToolCallEntry status enum.
1755
- */
1756
- function readToolStatus(part: { state?: { status?: unknown }; status?: unknown }): "running" | "ok" | "error" {
1757
- const s = part.state?.status ?? part.status;
1758
- if (typeof s !== "string") return "running";
1759
- const lower = s.toLowerCase();
1760
- if (lower === "ok" || lower === "success" || lower === "completed" || lower === "done") return "ok";
1761
- if (lower === "error" || lower === "failed") return "error";
1762
- return "running";
1763
- }
1764
-
1765
- function readToolOutput(part: { state?: { output?: unknown; result?: unknown; status?: unknown; error?: unknown }; output?: unknown; result?: unknown }): string {
1766
- const candidates = [part.state?.output, part.state?.result, part.output, part.result];
1767
- for (const c of candidates) {
1768
- if (c === undefined || c === null) continue;
1769
- if (typeof c === "string") return c;
1770
- try {
1771
- return JSON.stringify(c);
1772
- } catch {
1773
- // fall through
1774
- }
1775
- }
1776
- return "";
1777
- }
1778
-
1779
- async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
1780
- let timer: ReturnType<typeof setTimeout> | null = null;
1781
- const timeout = new Promise<never>((_, reject) => {
1782
- timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
1783
- });
1784
- try {
1785
- return await Promise.race([promise, timeout]);
1786
- } finally {
1787
- if (timer !== null) clearTimeout(timer);
1788
- }
1789
- }
1790
-
1791
- /**
1792
- * Serialize an unknown tool-args value into a string suitable for the
1793
- * `toolCalls` history. JSON-serializes objects, truncates to
1794
- * {@link MAX_TOOL_ARG_CHARS}. Returns `""` for empty/undefined.
1795
- */
1796
- function truncateForToolHistory(value: unknown): string {
1797
- if (value === undefined || value === null) return "";
1798
- let s: string;
1799
- try {
1800
- s = typeof value === "string" ? value : JSON.stringify(value);
1801
- } catch {
1802
- s = String(value);
1803
- }
1804
- if (s.length > MAX_TOOL_ARG_CHARS) s = s.slice(0, MAX_TOOL_ARG_CHARS) + "…";
1805
- return s;
1806
- }