@otto-assistant/bridge 0.4.92

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 (483) hide show
  1. package/bin.js +2 -0
  2. package/dist/agent-model.e2e.test.js +755 -0
  3. package/dist/ai-tool-to-genai.js +233 -0
  4. package/dist/ai-tool-to-genai.test.js +267 -0
  5. package/dist/ai-tool.js +6 -0
  6. package/dist/anthropic-auth-plugin.js +728 -0
  7. package/dist/anthropic-auth-plugin.test.js +125 -0
  8. package/dist/anthropic-auth-state.js +231 -0
  9. package/dist/bin.js +90 -0
  10. package/dist/channel-management.js +227 -0
  11. package/dist/cli-parsing.test.js +137 -0
  12. package/dist/cli-send-thread.e2e.test.js +356 -0
  13. package/dist/cli.js +3276 -0
  14. package/dist/commands/abort.js +65 -0
  15. package/dist/commands/action-buttons.js +245 -0
  16. package/dist/commands/add-project.js +113 -0
  17. package/dist/commands/agent.js +335 -0
  18. package/dist/commands/ask-question.js +274 -0
  19. package/dist/commands/btw.js +116 -0
  20. package/dist/commands/compact.js +120 -0
  21. package/dist/commands/context-usage.js +140 -0
  22. package/dist/commands/create-new-project.js +130 -0
  23. package/dist/commands/diff.js +63 -0
  24. package/dist/commands/file-upload.js +275 -0
  25. package/dist/commands/fork.js +220 -0
  26. package/dist/commands/gemini-apikey.js +70 -0
  27. package/dist/commands/login.js +885 -0
  28. package/dist/commands/mcp.js +239 -0
  29. package/dist/commands/memory-snapshot.js +24 -0
  30. package/dist/commands/mention-mode.js +44 -0
  31. package/dist/commands/merge-worktree.js +159 -0
  32. package/dist/commands/model-variant.js +364 -0
  33. package/dist/commands/model.js +776 -0
  34. package/dist/commands/new-worktree.js +366 -0
  35. package/dist/commands/paginated-select.js +57 -0
  36. package/dist/commands/permissions.js +274 -0
  37. package/dist/commands/queue.js +206 -0
  38. package/dist/commands/remove-project.js +115 -0
  39. package/dist/commands/restart-opencode-server.js +127 -0
  40. package/dist/commands/resume.js +149 -0
  41. package/dist/commands/run-command.js +79 -0
  42. package/dist/commands/screenshare.js +303 -0
  43. package/dist/commands/screenshare.test.js +20 -0
  44. package/dist/commands/session-id.js +78 -0
  45. package/dist/commands/session.js +176 -0
  46. package/dist/commands/share.js +80 -0
  47. package/dist/commands/tasks.js +205 -0
  48. package/dist/commands/types.js +2 -0
  49. package/dist/commands/undo-redo.js +305 -0
  50. package/dist/commands/unset-model.js +138 -0
  51. package/dist/commands/upgrade.js +42 -0
  52. package/dist/commands/user-command.js +155 -0
  53. package/dist/commands/verbosity.js +125 -0
  54. package/dist/commands/worktree-settings.js +43 -0
  55. package/dist/commands/worktrees.js +410 -0
  56. package/dist/condense-memory.js +33 -0
  57. package/dist/config.js +94 -0
  58. package/dist/context-awareness-plugin.js +363 -0
  59. package/dist/context-awareness-plugin.test.js +124 -0
  60. package/dist/critique-utils.js +95 -0
  61. package/dist/database.js +1310 -0
  62. package/dist/db.js +251 -0
  63. package/dist/db.test.js +138 -0
  64. package/dist/debounce-timeout.js +28 -0
  65. package/dist/debounced-process-flush.js +77 -0
  66. package/dist/discord-bot.js +1008 -0
  67. package/dist/discord-command-registration.js +524 -0
  68. package/dist/discord-urls.js +81 -0
  69. package/dist/discord-utils.js +591 -0
  70. package/dist/discord-utils.test.js +134 -0
  71. package/dist/errors.js +157 -0
  72. package/dist/escape-backticks.test.js +429 -0
  73. package/dist/event-stream-real-capture.e2e.test.js +533 -0
  74. package/dist/eventsource-parser.test.js +327 -0
  75. package/dist/exec-async.js +26 -0
  76. package/dist/external-opencode-sync.js +480 -0
  77. package/dist/format-tables.js +302 -0
  78. package/dist/format-tables.test.js +308 -0
  79. package/dist/forum-sync/config.js +79 -0
  80. package/dist/forum-sync/discord-operations.js +154 -0
  81. package/dist/forum-sync/index.js +5 -0
  82. package/dist/forum-sync/markdown.js +113 -0
  83. package/dist/forum-sync/sync-to-discord.js +417 -0
  84. package/dist/forum-sync/sync-to-files.js +190 -0
  85. package/dist/forum-sync/types.js +53 -0
  86. package/dist/forum-sync/watchers.js +307 -0
  87. package/dist/gateway-proxy-reconnect.e2e.test.js +394 -0
  88. package/dist/gateway-proxy.e2e.test.js +483 -0
  89. package/dist/genai-worker-wrapper.js +111 -0
  90. package/dist/genai-worker.js +311 -0
  91. package/dist/genai.js +232 -0
  92. package/dist/generated/browser.js +17 -0
  93. package/dist/generated/client.js +37 -0
  94. package/dist/generated/commonInputTypes.js +10 -0
  95. package/dist/generated/enums.js +52 -0
  96. package/dist/generated/internal/class.js +49 -0
  97. package/dist/generated/internal/prismaNamespace.js +253 -0
  98. package/dist/generated/internal/prismaNamespaceBrowser.js +223 -0
  99. package/dist/generated/models/bot_api_keys.js +1 -0
  100. package/dist/generated/models/bot_tokens.js +1 -0
  101. package/dist/generated/models/channel_agents.js +1 -0
  102. package/dist/generated/models/channel_directories.js +1 -0
  103. package/dist/generated/models/channel_mention_mode.js +1 -0
  104. package/dist/generated/models/channel_models.js +1 -0
  105. package/dist/generated/models/channel_verbosity.js +1 -0
  106. package/dist/generated/models/channel_worktrees.js +1 -0
  107. package/dist/generated/models/forum_sync_configs.js +1 -0
  108. package/dist/generated/models/global_models.js +1 -0
  109. package/dist/generated/models/ipc_requests.js +1 -0
  110. package/dist/generated/models/part_messages.js +1 -0
  111. package/dist/generated/models/scheduled_tasks.js +1 -0
  112. package/dist/generated/models/session_agents.js +1 -0
  113. package/dist/generated/models/session_events.js +1 -0
  114. package/dist/generated/models/session_models.js +1 -0
  115. package/dist/generated/models/session_start_sources.js +1 -0
  116. package/dist/generated/models/thread_sessions.js +1 -0
  117. package/dist/generated/models/thread_worktrees.js +1 -0
  118. package/dist/generated/models.js +1 -0
  119. package/dist/heap-monitor.js +122 -0
  120. package/dist/hrana-server.js +263 -0
  121. package/dist/hrana-server.test.js +370 -0
  122. package/dist/html-actions.js +123 -0
  123. package/dist/html-actions.test.js +70 -0
  124. package/dist/html-components.js +117 -0
  125. package/dist/html-components.test.js +34 -0
  126. package/dist/image-optimizer-plugin.js +153 -0
  127. package/dist/image-utils.js +112 -0
  128. package/dist/interaction-handler.js +397 -0
  129. package/dist/ipc-polling.js +252 -0
  130. package/dist/ipc-tools-plugin.js +193 -0
  131. package/dist/kimaki-digital-twin.e2e.test.js +161 -0
  132. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +87 -0
  133. package/dist/kimaki-opencode-plugin.js +17 -0
  134. package/dist/kimaki-opencode-plugin.test.js +98 -0
  135. package/dist/limit-heading-depth.js +25 -0
  136. package/dist/limit-heading-depth.test.js +105 -0
  137. package/dist/logger.js +165 -0
  138. package/dist/markdown.js +342 -0
  139. package/dist/markdown.test.js +257 -0
  140. package/dist/message-finish-field.e2e.test.js +165 -0
  141. package/dist/message-formatting.js +413 -0
  142. package/dist/message-formatting.test.js +73 -0
  143. package/dist/message-preprocessing.js +330 -0
  144. package/dist/onboarding-tutorial.js +172 -0
  145. package/dist/onboarding-welcome.js +37 -0
  146. package/dist/openai-realtime.js +224 -0
  147. package/dist/opencode-command-detection.js +65 -0
  148. package/dist/opencode-command-detection.test.js +240 -0
  149. package/dist/opencode-command.js +129 -0
  150. package/dist/opencode-command.test.js +48 -0
  151. package/dist/opencode-interrupt-plugin.js +361 -0
  152. package/dist/opencode-interrupt-plugin.test.js +458 -0
  153. package/dist/opencode.js +861 -0
  154. package/dist/otto/branding.js +22 -0
  155. package/dist/otto/index.js +21 -0
  156. package/dist/parse-permission-rules.test.js +117 -0
  157. package/dist/patch-text-parser.js +97 -0
  158. package/dist/plugin-logger.js +59 -0
  159. package/dist/privacy-sanitizer.js +105 -0
  160. package/dist/queue-advanced-abort.e2e.test.js +293 -0
  161. package/dist/queue-advanced-action-buttons.e2e.test.js +206 -0
  162. package/dist/queue-advanced-e2e-setup.js +786 -0
  163. package/dist/queue-advanced-footer.e2e.test.js +472 -0
  164. package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
  165. package/dist/queue-advanced-permissions-typing.e2e.test.js +180 -0
  166. package/dist/queue-advanced-question.e2e.test.js +261 -0
  167. package/dist/queue-advanced-typing-interrupt.e2e.test.js +114 -0
  168. package/dist/queue-advanced-typing.e2e.test.js +153 -0
  169. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  170. package/dist/queue-interrupt-drain.e2e.test.js +135 -0
  171. package/dist/queue-question-select-drain.e2e.test.js +120 -0
  172. package/dist/runtime-idle-sweeper.js +52 -0
  173. package/dist/runtime-lifecycle.e2e.test.js +508 -0
  174. package/dist/sentry.js +23 -0
  175. package/dist/session-handler/agent-utils.js +67 -0
  176. package/dist/session-handler/event-stream-state.js +420 -0
  177. package/dist/session-handler/event-stream-state.test.js +563 -0
  178. package/dist/session-handler/model-utils.js +124 -0
  179. package/dist/session-handler/opencode-session-event-log.js +94 -0
  180. package/dist/session-handler/thread-runtime-state.js +104 -0
  181. package/dist/session-handler/thread-session-runtime.js +3258 -0
  182. package/dist/session-handler.js +9 -0
  183. package/dist/session-search.js +100 -0
  184. package/dist/session-search.test.js +40 -0
  185. package/dist/session-title-rename.test.js +80 -0
  186. package/dist/startup-service.js +153 -0
  187. package/dist/startup-time.e2e.test.js +296 -0
  188. package/dist/store.js +17 -0
  189. package/dist/system-message.js +613 -0
  190. package/dist/system-message.test.js +602 -0
  191. package/dist/task-runner.js +295 -0
  192. package/dist/task-schedule.js +209 -0
  193. package/dist/task-schedule.test.js +71 -0
  194. package/dist/test-utils.js +299 -0
  195. package/dist/thinking-utils.js +35 -0
  196. package/dist/thread-message-queue.e2e.test.js +999 -0
  197. package/dist/tools.js +357 -0
  198. package/dist/undo-redo.e2e.test.js +161 -0
  199. package/dist/unnest-code-blocks.js +146 -0
  200. package/dist/unnest-code-blocks.test.js +673 -0
  201. package/dist/upgrade.js +114 -0
  202. package/dist/utils.js +144 -0
  203. package/dist/voice-attachment.js +34 -0
  204. package/dist/voice-handler.js +646 -0
  205. package/dist/voice-message.e2e.test.js +1021 -0
  206. package/dist/voice.js +447 -0
  207. package/dist/voice.test.js +235 -0
  208. package/dist/wait-session.js +94 -0
  209. package/dist/websockify.js +69 -0
  210. package/dist/worker-types.js +4 -0
  211. package/dist/worktree-lifecycle.e2e.test.js +308 -0
  212. package/dist/worktree-utils.js +3 -0
  213. package/dist/worktrees.js +929 -0
  214. package/dist/worktrees.test.js +189 -0
  215. package/dist/xml.js +92 -0
  216. package/dist/xml.test.js +32 -0
  217. package/package.json +98 -0
  218. package/schema.prisma +295 -0
  219. package/skills/batch/SKILL.md +87 -0
  220. package/skills/critique/SKILL.md +112 -0
  221. package/skills/egaki/SKILL.md +100 -0
  222. package/skills/errore/SKILL.md +647 -0
  223. package/skills/event-sourcing-state/SKILL.md +252 -0
  224. package/skills/gitchamber/SKILL.md +93 -0
  225. package/skills/goke/SKILL.md +644 -0
  226. package/skills/jitter/EDITOR.md +219 -0
  227. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  228. package/skills/jitter/SKILL.md +158 -0
  229. package/skills/jitter/jitter-clipboard.json +1042 -0
  230. package/skills/jitter/package.json +14 -0
  231. package/skills/jitter/tsconfig.json +15 -0
  232. package/skills/jitter/utils/actions.ts +212 -0
  233. package/skills/jitter/utils/export.ts +114 -0
  234. package/skills/jitter/utils/index.ts +141 -0
  235. package/skills/jitter/utils/snapshot.ts +154 -0
  236. package/skills/jitter/utils/traverse.ts +246 -0
  237. package/skills/jitter/utils/types.ts +279 -0
  238. package/skills/jitter/utils/wait.ts +133 -0
  239. package/skills/lintcn/SKILL.md +873 -0
  240. package/skills/new-skill/SKILL.md +211 -0
  241. package/skills/npm-package/SKILL.md +239 -0
  242. package/skills/playwriter/SKILL.md +35 -0
  243. package/skills/proxyman/SKILL.md +215 -0
  244. package/skills/security-review/SKILL.md +208 -0
  245. package/skills/simplify/SKILL.md +58 -0
  246. package/skills/spiceflow/SKILL.md +14 -0
  247. package/skills/termcast/SKILL.md +945 -0
  248. package/skills/tuistory/SKILL.md +250 -0
  249. package/skills/usecomputer/SKILL.md +264 -0
  250. package/skills/x-articles/SKILL.md +554 -0
  251. package/skills/zele/SKILL.md +112 -0
  252. package/skills/zustand-centralized-state/SKILL.md +1004 -0
  253. package/src/agent-model.e2e.test.ts +976 -0
  254. package/src/ai-tool-to-genai.test.ts +296 -0
  255. package/src/ai-tool-to-genai.ts +283 -0
  256. package/src/ai-tool.ts +39 -0
  257. package/src/anthropic-auth-plugin.test.ts +159 -0
  258. package/src/anthropic-auth-plugin.ts +861 -0
  259. package/src/anthropic-auth-state.ts +282 -0
  260. package/src/bin.ts +111 -0
  261. package/src/channel-management.ts +334 -0
  262. package/src/cli-parsing.test.ts +195 -0
  263. package/src/cli-send-thread.e2e.test.ts +464 -0
  264. package/src/cli.ts +4581 -0
  265. package/src/commands/abort.ts +89 -0
  266. package/src/commands/action-buttons.ts +364 -0
  267. package/src/commands/add-project.ts +149 -0
  268. package/src/commands/agent.ts +473 -0
  269. package/src/commands/ask-question.ts +390 -0
  270. package/src/commands/btw.ts +164 -0
  271. package/src/commands/compact.ts +157 -0
  272. package/src/commands/context-usage.ts +199 -0
  273. package/src/commands/create-new-project.ts +190 -0
  274. package/src/commands/diff.ts +91 -0
  275. package/src/commands/file-upload.ts +389 -0
  276. package/src/commands/fork.ts +321 -0
  277. package/src/commands/gemini-apikey.ts +104 -0
  278. package/src/commands/login.ts +1173 -0
  279. package/src/commands/mcp.ts +307 -0
  280. package/src/commands/memory-snapshot.ts +30 -0
  281. package/src/commands/mention-mode.ts +68 -0
  282. package/src/commands/merge-worktree.ts +223 -0
  283. package/src/commands/model-variant.ts +483 -0
  284. package/src/commands/model.ts +1053 -0
  285. package/src/commands/new-worktree.ts +510 -0
  286. package/src/commands/paginated-select.ts +81 -0
  287. package/src/commands/permissions.ts +397 -0
  288. package/src/commands/queue.ts +271 -0
  289. package/src/commands/remove-project.ts +155 -0
  290. package/src/commands/restart-opencode-server.ts +162 -0
  291. package/src/commands/resume.ts +230 -0
  292. package/src/commands/run-command.ts +123 -0
  293. package/src/commands/screenshare.test.ts +30 -0
  294. package/src/commands/screenshare.ts +366 -0
  295. package/src/commands/session-id.ts +109 -0
  296. package/src/commands/session.ts +227 -0
  297. package/src/commands/share.ts +106 -0
  298. package/src/commands/tasks.ts +293 -0
  299. package/src/commands/types.ts +25 -0
  300. package/src/commands/undo-redo.ts +386 -0
  301. package/src/commands/unset-model.ts +173 -0
  302. package/src/commands/upgrade.ts +52 -0
  303. package/src/commands/user-command.ts +198 -0
  304. package/src/commands/verbosity.ts +173 -0
  305. package/src/commands/worktree-settings.ts +70 -0
  306. package/src/commands/worktrees.ts +552 -0
  307. package/src/condense-memory.ts +36 -0
  308. package/src/config.ts +111 -0
  309. package/src/context-awareness-plugin.test.ts +142 -0
  310. package/src/context-awareness-plugin.ts +510 -0
  311. package/src/critique-utils.ts +139 -0
  312. package/src/database.ts +1876 -0
  313. package/src/db.test.ts +162 -0
  314. package/src/db.ts +286 -0
  315. package/src/debounce-timeout.ts +43 -0
  316. package/src/debounced-process-flush.ts +104 -0
  317. package/src/discord-bot.ts +1330 -0
  318. package/src/discord-command-registration.ts +693 -0
  319. package/src/discord-urls.ts +88 -0
  320. package/src/discord-utils.test.ts +153 -0
  321. package/src/discord-utils.ts +800 -0
  322. package/src/errors.ts +201 -0
  323. package/src/escape-backticks.test.ts +469 -0
  324. package/src/event-stream-real-capture.e2e.test.ts +692 -0
  325. package/src/eventsource-parser.test.ts +351 -0
  326. package/src/exec-async.ts +35 -0
  327. package/src/external-opencode-sync.ts +685 -0
  328. package/src/format-tables.test.ts +335 -0
  329. package/src/format-tables.ts +445 -0
  330. package/src/forum-sync/config.ts +92 -0
  331. package/src/forum-sync/discord-operations.ts +241 -0
  332. package/src/forum-sync/index.ts +9 -0
  333. package/src/forum-sync/markdown.ts +172 -0
  334. package/src/forum-sync/sync-to-discord.ts +595 -0
  335. package/src/forum-sync/sync-to-files.ts +294 -0
  336. package/src/forum-sync/types.ts +175 -0
  337. package/src/forum-sync/watchers.ts +454 -0
  338. package/src/gateway-proxy-reconnect.e2e.test.ts +523 -0
  339. package/src/gateway-proxy.e2e.test.ts +640 -0
  340. package/src/genai-worker-wrapper.ts +164 -0
  341. package/src/genai-worker.ts +386 -0
  342. package/src/genai.ts +321 -0
  343. package/src/generated/browser.ts +114 -0
  344. package/src/generated/client.ts +138 -0
  345. package/src/generated/commonInputTypes.ts +736 -0
  346. package/src/generated/enums.ts +88 -0
  347. package/src/generated/internal/class.ts +384 -0
  348. package/src/generated/internal/prismaNamespace.ts +2386 -0
  349. package/src/generated/internal/prismaNamespaceBrowser.ts +326 -0
  350. package/src/generated/models/bot_api_keys.ts +1288 -0
  351. package/src/generated/models/bot_tokens.ts +1656 -0
  352. package/src/generated/models/channel_agents.ts +1256 -0
  353. package/src/generated/models/channel_directories.ts +1859 -0
  354. package/src/generated/models/channel_mention_mode.ts +1300 -0
  355. package/src/generated/models/channel_models.ts +1288 -0
  356. package/src/generated/models/channel_verbosity.ts +1228 -0
  357. package/src/generated/models/channel_worktrees.ts +1300 -0
  358. package/src/generated/models/forum_sync_configs.ts +1452 -0
  359. package/src/generated/models/global_models.ts +1288 -0
  360. package/src/generated/models/ipc_requests.ts +1485 -0
  361. package/src/generated/models/part_messages.ts +1302 -0
  362. package/src/generated/models/scheduled_tasks.ts +2320 -0
  363. package/src/generated/models/session_agents.ts +1086 -0
  364. package/src/generated/models/session_events.ts +1439 -0
  365. package/src/generated/models/session_models.ts +1114 -0
  366. package/src/generated/models/session_start_sources.ts +1408 -0
  367. package/src/generated/models/thread_sessions.ts +1781 -0
  368. package/src/generated/models/thread_worktrees.ts +1356 -0
  369. package/src/generated/models.ts +30 -0
  370. package/src/heap-monitor.ts +152 -0
  371. package/src/hrana-server.test.ts +434 -0
  372. package/src/hrana-server.ts +314 -0
  373. package/src/html-actions.test.ts +87 -0
  374. package/src/html-actions.ts +174 -0
  375. package/src/html-components.test.ts +38 -0
  376. package/src/html-components.ts +181 -0
  377. package/src/image-optimizer-plugin.ts +194 -0
  378. package/src/image-utils.ts +149 -0
  379. package/src/interaction-handler.ts +576 -0
  380. package/src/ipc-polling.ts +326 -0
  381. package/src/ipc-tools-plugin.ts +236 -0
  382. package/src/kimaki-digital-twin.e2e.test.ts +199 -0
  383. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +109 -0
  384. package/src/kimaki-opencode-plugin.test.ts +108 -0
  385. package/src/kimaki-opencode-plugin.ts +18 -0
  386. package/src/limit-heading-depth.test.ts +116 -0
  387. package/src/limit-heading-depth.ts +26 -0
  388. package/src/logger.ts +208 -0
  389. package/src/markdown.test.ts +308 -0
  390. package/src/markdown.ts +410 -0
  391. package/src/message-finish-field.e2e.test.ts +192 -0
  392. package/src/message-formatting.test.ts +81 -0
  393. package/src/message-formatting.ts +533 -0
  394. package/src/message-preprocessing.ts +455 -0
  395. package/src/onboarding-tutorial.ts +176 -0
  396. package/src/onboarding-welcome.ts +49 -0
  397. package/src/openai-realtime.ts +358 -0
  398. package/src/opencode-command-detection.test.ts +307 -0
  399. package/src/opencode-command-detection.ts +76 -0
  400. package/src/opencode-command.test.ts +70 -0
  401. package/src/opencode-command.ts +188 -0
  402. package/src/opencode-interrupt-plugin.test.ts +677 -0
  403. package/src/opencode-interrupt-plugin.ts +477 -0
  404. package/src/opencode.ts +1110 -0
  405. package/src/otto/branding.ts +23 -0
  406. package/src/otto/index.ts +22 -0
  407. package/src/parse-permission-rules.test.ts +127 -0
  408. package/src/patch-text-parser.ts +107 -0
  409. package/src/plugin-logger.ts +68 -0
  410. package/src/privacy-sanitizer.ts +142 -0
  411. package/src/queue-advanced-abort.e2e.test.ts +382 -0
  412. package/src/queue-advanced-action-buttons.e2e.test.ts +268 -0
  413. package/src/queue-advanced-e2e-setup.ts +873 -0
  414. package/src/queue-advanced-footer.e2e.test.ts +576 -0
  415. package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
  416. package/src/queue-advanced-permissions-typing.e2e.test.ts +245 -0
  417. package/src/queue-advanced-question.e2e.test.ts +316 -0
  418. package/src/queue-advanced-typing-interrupt.e2e.test.ts +146 -0
  419. package/src/queue-advanced-typing.e2e.test.ts +199 -0
  420. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  421. package/src/queue-interrupt-drain.e2e.test.ts +166 -0
  422. package/src/queue-question-select-drain.e2e.test.ts +152 -0
  423. package/src/runtime-idle-sweeper.ts +76 -0
  424. package/src/runtime-lifecycle.e2e.test.ts +641 -0
  425. package/src/schema.sql +173 -0
  426. package/src/sentry.ts +26 -0
  427. package/src/session-handler/agent-utils.ts +97 -0
  428. package/src/session-handler/event-stream-fixtures/real-session-action-buttons.jsonl +45 -0
  429. package/src/session-handler/event-stream-fixtures/real-session-footer-suppressed-on-pre-idle-interrupt.jsonl +40 -0
  430. package/src/session-handler/event-stream-fixtures/real-session-permission-external-file.jsonl +23 -0
  431. package/src/session-handler/event-stream-fixtures/real-session-task-normal.jsonl +22 -0
  432. package/src/session-handler/event-stream-fixtures/real-session-task-three-parallel-sleeps.jsonl +277 -0
  433. package/src/session-handler/event-stream-fixtures/real-session-task-user-interruption.jsonl +46 -0
  434. package/src/session-handler/event-stream-fixtures/session-abort-after-idle-race.jsonl +21 -0
  435. package/src/session-handler/event-stream-fixtures/session-concurrent-messages-serialized.jsonl +56 -0
  436. package/src/session-handler/event-stream-fixtures/session-explicit-abort.jsonl +44 -0
  437. package/src/session-handler/event-stream-fixtures/session-normal-completion.jsonl +29 -0
  438. package/src/session-handler/event-stream-fixtures/session-tool-call-noisy-stream.jsonl +29 -0
  439. package/src/session-handler/event-stream-fixtures/session-two-completions-same-session.jsonl +50 -0
  440. package/src/session-handler/event-stream-fixtures/session-user-interruption.jsonl +59 -0
  441. package/src/session-handler/event-stream-fixtures/session-voice-queued-followup.jsonl +52 -0
  442. package/src/session-handler/event-stream-state.test.ts +645 -0
  443. package/src/session-handler/event-stream-state.ts +608 -0
  444. package/src/session-handler/model-utils.ts +183 -0
  445. package/src/session-handler/opencode-session-event-log.ts +130 -0
  446. package/src/session-handler/thread-runtime-state.ts +212 -0
  447. package/src/session-handler/thread-session-runtime.ts +4281 -0
  448. package/src/session-handler.ts +15 -0
  449. package/src/session-search.test.ts +50 -0
  450. package/src/session-search.ts +148 -0
  451. package/src/session-title-rename.test.ts +112 -0
  452. package/src/startup-service.ts +200 -0
  453. package/src/startup-time.e2e.test.ts +373 -0
  454. package/src/store.ts +122 -0
  455. package/src/system-message.test.ts +612 -0
  456. package/src/system-message.ts +723 -0
  457. package/src/task-runner.ts +421 -0
  458. package/src/task-schedule.test.ts +84 -0
  459. package/src/task-schedule.ts +311 -0
  460. package/src/test-utils.ts +435 -0
  461. package/src/thinking-utils.ts +61 -0
  462. package/src/thread-message-queue.e2e.test.ts +1219 -0
  463. package/src/tools.ts +430 -0
  464. package/src/undici.d.ts +12 -0
  465. package/src/undo-redo.e2e.test.ts +209 -0
  466. package/src/unnest-code-blocks.test.ts +713 -0
  467. package/src/unnest-code-blocks.ts +185 -0
  468. package/src/upgrade.ts +127 -0
  469. package/src/utils.ts +212 -0
  470. package/src/voice-attachment.ts +51 -0
  471. package/src/voice-handler.ts +908 -0
  472. package/src/voice-message.e2e.test.ts +1255 -0
  473. package/src/voice.test.ts +281 -0
  474. package/src/voice.ts +627 -0
  475. package/src/wait-session.ts +147 -0
  476. package/src/websockify.ts +101 -0
  477. package/src/worker-types.ts +64 -0
  478. package/src/worktree-lifecycle.e2e.test.ts +391 -0
  479. package/src/worktree-utils.ts +4 -0
  480. package/src/worktrees.test.ts +223 -0
  481. package/src/worktrees.ts +1294 -0
  482. package/src/xml.test.ts +38 -0
  483. package/src/xml.ts +121 -0
@@ -0,0 +1,1004 @@
1
+ ---
2
+ name: zustand-centralized-state
3
+ description: >
4
+ Centralized state management pattern using Zustand vanilla stores. One immutable
5
+ state atom, functional transitions via setState(), and a single subscribe() for
6
+ all reactive side effects. Based on Rich Hickey's "Simple Made Easy" principles:
7
+ prefer values over mutable state, derive instead of cache, centralize transitions,
8
+ and push side effects to the edges. Resource co-location in the same store is
9
+ also valid when lifecycle management is safer that way. Also covers state
10
+ encapsulation: keeping state local to its owner (closures, plugins, factory
11
+ functions) so it doesn't leak across the app, reducing the blast radius of
12
+ mutations. Also covers event sourcing: keeping a bounded event buffer and
13
+ deriving state with pure functions instead of mutable flags, making event
14
+ handlers easy to test and reason about. Use this skill when building any
15
+ stateful TypeScript application (servers, extensions, CLIs, relays) to keep
16
+ state simple, testable, and easy to reason about. ALWAYS read this skill
17
+ when a project uses zustand/vanilla for state management outside of React.
18
+ version: 0.3.0
19
+ ---
20
+
21
+ # Centralized State Management
22
+
23
+ A pattern for managing application state that keeps programs simple, testable, and
24
+ easy to reason about. Uses Zustand vanilla stores as the mechanism, but the
25
+ principles apply to any state management approach.
26
+
27
+ ## Background
28
+
29
+ Rich Hickey's talk **"Simple Made Easy"** (2011) argues that most program complexity
30
+ comes from **complecting** (interleaving) things that should be independent. Mutable
31
+ state is one of the worst offenders: it interleaves *identity* (what thing are we
32
+ talking about), *state* (what is its current value), and *time* (when did it change).
33
+
34
+ When you mutate a Map in place, you lose the previous value, every reader is coupled
35
+ to every writer, and you can't reason about what the state was at any point in time.
36
+ State scattered across multiple mutable variables in different scopes makes it
37
+ impossible to answer "what does the program look like right now?"
38
+
39
+ The solution is not "never have state" -- that's impossible for real programs. The
40
+ solution is to **manage state explicitly**: one place it lives, controlled transitions,
41
+ immutable values, and side effects derived from state rather than scattered across
42
+ handlers.
43
+
44
+ This makes programs:
45
+ - **Simpler to reason about** -- one place to look for all state
46
+ - **Easier to test** -- pure state transitions, no I/O needed
47
+ - **Less buggy** -- impossible to have half-updated inconsistent state
48
+ - **Easier to debug** -- you can log/snapshot state at any transition
49
+
50
+ ## Core Principles
51
+
52
+ ### 1. Prefer values over mutable state
53
+
54
+ Use immutable data. When state changes, produce a new value instead of mutating in
55
+ place. In TypeScript with Zustand, this means `setState()` with functional updates
56
+ that return new objects/Maps rather than mutating existing ones.
57
+
58
+ ```ts
59
+ // BAD: mutation scattered in handler
60
+ connectedTabs.set(tabId, { ...info, state: 'connected' })
61
+ connectionState = 'connected'
62
+
63
+ // GOOD: single atomic transition producing new values
64
+ store.setState((state) => {
65
+ const newTabs = new Map(state.tabs)
66
+ newTabs.set(tabId, { ...info, state: 'connected' })
67
+ return { tabs: newTabs, connectionState: 'connected' }
68
+ })
69
+ ```
70
+
71
+ The second version is atomic -- both `tabs` and `connectionState` update together
72
+ or not at all. There's no intermediate state where tabs shows connected but
73
+ connectionState is still idle.
74
+
75
+ ### 2. Derive instead of cache
76
+
77
+ If a value can be computed from existing state, compute it on demand instead of
78
+ maintaining a separate cache that must stay in sync.
79
+
80
+ ```ts
81
+ // BAD: separate index that can get out of sync
82
+ const extensionKeyIndex = new Map<string, string>() // stableKey -> connectionId
83
+
84
+ // must remember to update on every add/remove:
85
+ extensionKeyIndex.set(ext.stableKey, ext.id)
86
+ // forgot to delete on disconnect? now you have a stale entry
87
+
88
+ // GOOD: derive it when needed
89
+ function findExtensionByKey(state: RelayState, key: string) {
90
+ for (const ext of state.extensions.values()) {
91
+ if (ext.stableKey === key) return ext
92
+ }
93
+ }
94
+ ```
95
+
96
+ At small scales (dozens of entries, not millions), the linear scan is free and you've
97
+ eliminated an entire class of consistency bugs.
98
+
99
+ **Anti-pattern: parallel maps for the same entity.** A common mistake is splitting
100
+ one entity across two maps to "separate state from I/O" — e.g. a `clients` map for
101
+ domain fields and a `clientIO` map for WebSocket handles, keyed by the same ID.
102
+ This forces every add/remove to touch both maps and inevitably one gets forgotten
103
+ (leaking stale handles or leaving orphaned state). Instead, co-locate I/O handles
104
+ on the entity type itself:
105
+
106
+ ```ts
107
+ // BAD: two maps that must stay in sync
108
+ type ClientState = { id: string; extensionId: string }
109
+ type ClientIO = { id: string; ws: WSContext }
110
+ type State = {
111
+ clients: Map<string, ClientState>
112
+ clientIO: Map<string, ClientIO> // same keys, always
113
+ }
114
+
115
+ // GOOD: one map, one entity, one add/remove
116
+ type Client = { id: string; extensionId: string; ws: WSContext }
117
+ type State = {
118
+ clients: Map<string, Client>
119
+ }
120
+ ```
121
+
122
+ "Separate state from I/O" means keep `setState()` callbacks pure (no side effects) —
123
+ it does NOT mean store I/O handles in a separate map. Co-locating handles with their
124
+ entity prevents consistency bugs and makes cleanup trivial.
125
+
126
+ ### 3. Centralize all state in one store
127
+
128
+ All application state lives in a single Zustand store. There should be one place to
129
+ look to understand the full state of the program.
130
+
131
+ ```ts
132
+ import { createStore } from 'zustand/vanilla'
133
+
134
+ type AppState = {
135
+ connections: Map<string, Connection>
136
+ clients: Map<string, Client>
137
+ connectionState: 'idle' | 'connected' | 'error'
138
+ errorText: string | undefined
139
+ }
140
+
141
+ const store = createStore<AppState>(() => ({
142
+ connections: new Map(),
143
+ clients: new Map(),
144
+ connectionState: 'idle',
145
+ errorText: undefined,
146
+ }))
147
+ ```
148
+
149
+ This is the single source of truth. No separate variables, no state scattered across
150
+ closures, no Maps defined in different scopes.
151
+
152
+ **One store, not many.** A common temptation is to create separate stores for each
153
+ domain (one for connections, one for clients, one for config). This splits state
154
+ across multiple sources of truth, makes cross-domain transitions non-atomic, and
155
+ forces you to coordinate subscribes across stores. A single store avoids all of
156
+ this. If you worry about subscribe callbacks firing too often when unrelated state
157
+ changes, use `subscribeWithSelector` to watch only the slice you care about (see
158
+ "Subscribing to nested state with selectors" below). This gives you the performance
159
+ of multiple stores with the simplicity of one.
160
+
161
+ ### 4. State transitions use only current state and event data
162
+
163
+ Every `setState()` call should be a pure function of the current state and the
164
+ incoming event data. No reading from external variables, no side effects inside
165
+ `setState()`.
166
+
167
+ ```ts
168
+ // the transition only uses `state` (current) and `event` (incoming data)
169
+ store.setState((state) => {
170
+ const newTabs = new Map(state.tabs)
171
+ newTabs.set(event.tabId, {
172
+ sessionId: event.sessionId,
173
+ state: 'connected',
174
+ })
175
+ return { tabs: newTabs }
176
+ })
177
+ ```
178
+
179
+ This makes every transition testable: given this state and this event, the new state
180
+ should be X. No mocks needed, no I/O setup, just data in and data out.
181
+
182
+ ### 5. Resource co-location is allowed when it improves lifecycle safety
183
+
184
+ Putting runtime resources in Zustand is valid when keeping them outside the store
185
+ would create split-brain lifecycle management (state in one place, resources in
186
+ another) and increase leak risk.
187
+
188
+ Examples of colocated resources:
189
+ - WebSocket handles
190
+ - timers/interval handles
191
+ - pending request callback maps
192
+ - abort controllers
193
+
194
+ If resources live in the store:
195
+ - transitions still must be deterministic and side-effect free
196
+ - store references, don't execute effects inside transitions
197
+ - cleanup effects (close sockets, clear intervals) still run in handlers/subscribe
198
+ based on state transitions
199
+
200
+ Rule of thumb:
201
+ - Prefer plain-data state for maximal testability
202
+ - Co-locate resources when one centralized store materially improves cleanup and
203
+ ownership tracking
204
+
205
+ ### 6. Mutable resources are state too
206
+
207
+ If a runtime resource has mutable lifecycle state, treat it as state and keep it in
208
+ the centralized store alongside the data it controls.
209
+
210
+ `AbortController` is the clearest example:
211
+ - it has mutable lifecycle (`signal.aborted` flips from `false` to `true`)
212
+ - that lifecycle controls behavior (whether work should continue)
213
+ - ownership and cleanup matter (who creates, replaces, aborts, and clears it)
214
+
215
+ In practice, an abort controller is often equivalent to a state bit with a handle.
216
+ Keeping it in a local variable while related domain state lives in Zustand creates
217
+ split-brain state and leak risk.
218
+
219
+ ```ts
220
+ // BAD: split state (store + local mutable resource)
221
+ let requestController: AbortController | undefined
222
+
223
+ requestController = new AbortController()
224
+
225
+ // GOOD: one source of truth
226
+ type State = {
227
+ requestController: AbortController | undefined
228
+ }
229
+
230
+ store.setState((state) => {
231
+ return {
232
+ ...state,
233
+ requestController: new AbortController(),
234
+ }
235
+ })
236
+ ```
237
+
238
+ This keeps lifecycle ownership explicit: transitions decide when controller
239
+ references appear/disappear; handlers/subscribe perform side effects like
240
+ `controller.abort()` based on state transitions.
241
+
242
+ ### 7. Centralize side effects in subscribe
243
+
244
+ Side effects (I/O, UI updates, cleanup, logging) go in a single `subscribe()`
245
+ callback that reacts to state changes. Side effects are **derived from state**, not
246
+ scattered across handlers.
247
+
248
+ ```ts
249
+ store.subscribe((state, prevState) => {
250
+ // logging
251
+ logger.log('state changed:', state)
252
+
253
+ // UI update derived purely from current state
254
+ updateIcon(state.connectionState, state.tabs)
255
+
256
+ // cleanup: if a connection was removed, close its resources
257
+ for (const [id, conn] of prevState.connections) {
258
+ if (!state.connections.has(id)) {
259
+ conn.socket.close()
260
+ }
261
+ }
262
+ })
263
+ ```
264
+
265
+ ## The Pattern
266
+
267
+ The architecture has three layers:
268
+
269
+ ```
270
+ Event handlers State store Subscribe
271
+ (imperative shell) (centralized atom) (reactive side effects)
272
+ ~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~
273
+
274
+ onMessage(data) ------> store.setState( store.subscribe(
275
+ onConnect(ws) (state) => { (state, prev) => {
276
+ onDisconnect(id) // pure // side effects
277
+ onTimer() // transition // derived from
278
+ // no I/O // state shape
279
+ } }
280
+ ) )
281
+ ```
282
+
283
+ **Event handlers** parse incoming events and call `setState()`.
284
+ They may also do direct I/O that needs event data (like forwarding a message).
285
+
286
+ **State store** holds the single immutable state atom. Transitions are pure functions.
287
+
288
+ **Subscribe** reacts to state changes and performs side effects that are purely
289
+ derived from the current state shape (not from specific events).
290
+
291
+ ## Rules
292
+
293
+ 1. Use `zustand/vanilla` for non-React applications (servers, extensions, CLIs) --
294
+ it has no React dependency and works in any JS runtime
295
+ 2. Define all state in a single `createStore()` call with a typed state interface
296
+ 3. Never mutate state directly -- always use `store.setState()` with functional
297
+ updates that return new objects
298
+ 4. Keep `setState()` callbacks deterministic -- no external effects, only compute
299
+ new state from current state + event data
300
+ 5. Use a single `subscribe()` for all reactive side effects -- not multiple
301
+ subscribes scattered across the codebase
302
+ 6. Side effects in subscribe should be derived from state shape, not from specific
303
+ events -- ask "given this state, what should the world look like?" not "what
304
+ event just happened?"
305
+ 7. Derive computed values instead of caching them in separate state -- if it can be
306
+ computed from existing state, compute it
307
+ 8. Use `(state, prevState)` diffing in subscribe when you need to react to specific
308
+ changes (e.g. "a connection was removed")
309
+ 9. Keep the state interface minimal -- only store what you can't derive
310
+ 10. For state transitions that are complex or reused, extract them as pure
311
+ functions that take state + event data and return new state
312
+ 11. Resource co-location is acceptable: storing sockets/timers/callback maps in
313
+ Zustand is fine when it prevents lifecycle drift. Keep side effects out of
314
+ transitions.
315
+ 12. Treat mutable runtime resources as state (e.g. `AbortController`) -- if a
316
+ resource has lifecycle state that drives behavior, keep its reference in the
317
+ same centralized store as related domain state.
318
+
319
+ ## When subscribe does NOT fit
320
+
321
+ Not all side effects belong in subscribe. The subscribe callback gets
322
+ `(newState, prevState)` but doesn't know **what event caused the change**. This
323
+ matters for message routing:
324
+
325
+ ```ts
326
+ // this does NOT fit subscribe -- you need the actual message, not just state diff
327
+ function onCdpEvent(extensionId: string, message: CdpMessage) {
328
+ // 1. state transition -> subscribe
329
+ store.setState((s) => addTarget(s, extensionId, message.params))
330
+ // 2. forward the exact message -> stays in handler (needs event data)
331
+ forwardToPlaywright(extensionId, message)
332
+ }
333
+ ```
334
+
335
+ Rule of thumb:
336
+ - **Subscribe**: side effects derived from state shape ("icon should show green
337
+ because connectionState is 'connected'")
338
+ - **Handler**: side effects that need event data ("forward this specific CDP
339
+ message to the playwright client")
340
+
341
+ ## Real-World Example: Chrome Extension State
342
+
343
+ A Chrome extension that manages browser tab connections. Before: mutable variables
344
+ scattered across the background script. After: one Zustand store, one subscribe.
345
+
346
+ ### State definition
347
+
348
+ ```ts
349
+ import { createStore } from 'zustand/vanilla'
350
+
351
+ type ConnectionState = 'idle' | 'connected' | 'extension-replaced'
352
+ type TabState = 'connecting' | 'connected' | 'error'
353
+
354
+ interface TabInfo {
355
+ sessionId?: string
356
+ targetId?: string
357
+ state: TabState
358
+ errorText?: string
359
+ pinnedCount?: number
360
+ attachOrder?: number
361
+ isRecording?: boolean
362
+ }
363
+
364
+ interface ExtensionState {
365
+ tabs: Map<number, TabInfo>
366
+ connectionState: ConnectionState
367
+ currentTabId: number | undefined
368
+ errorText: string | undefined
369
+ }
370
+
371
+ const store = createStore<ExtensionState>(() => ({
372
+ tabs: new Map(),
373
+ connectionState: 'idle',
374
+ currentTabId: undefined,
375
+ errorText: undefined,
376
+ }))
377
+ ```
378
+
379
+ ### State transitions in event handlers
380
+
381
+ ```ts
382
+ // tab successfully attached
383
+ store.setState((state) => {
384
+ const newTabs = new Map(state.tabs)
385
+ newTabs.set(tabId, {
386
+ sessionId,
387
+ targetId,
388
+ state: 'connected',
389
+ attachOrder: newTabs.size,
390
+ })
391
+ return { tabs: newTabs, connectionState: 'connected' }
392
+ })
393
+
394
+ // tab detached
395
+ store.setState((state) => {
396
+ const newTabs = new Map(state.tabs)
397
+ newTabs.delete(tabId)
398
+ return { tabs: newTabs }
399
+ })
400
+
401
+ // WebSocket disconnected
402
+ store.setState((state) => {
403
+ const newTabs = new Map(state.tabs)
404
+ for (const [id, tab] of newTabs) {
405
+ newTabs.set(id, { ...tab, state: 'connecting' })
406
+ }
407
+ return { tabs: newTabs, connectionState: 'idle' }
408
+ })
409
+
410
+ // extension replaced (kicked by another instance)
411
+ store.setState({
412
+ tabs: new Map(),
413
+ connectionState: 'extension-replaced',
414
+ errorText: 'Another instance took over this connection',
415
+ })
416
+ ```
417
+
418
+ ### All side effects in one subscribe
419
+
420
+ ```ts
421
+ store.subscribe((state, prevState) => {
422
+ // 1. log every state change
423
+ logger.log(state)
424
+
425
+ // 2. update extension icon based on current state
426
+ // purely derived from state -- doesn't care what event caused the change
427
+ void updateIcons(state)
428
+
429
+ // 3. show/hide context menu based on whether current tab is connected
430
+ updateContextMenuVisibility(state)
431
+
432
+ // 4. sync Chrome tab groups when tab list changes
433
+ if (serializeTabs(state.tabs) !== serializeTabs(prevState.tabs)) {
434
+ syncTabGroup(state.tabs)
435
+ }
436
+ })
437
+ ```
438
+
439
+ The `updateIcons` function reads `connectionState`, `tabs`, and `errorText` to decide
440
+ which icon to show. It doesn't know or care whether the state changed because a tab
441
+ was attached, a WebSocket reconnected, or an error happened. It just asks: **given
442
+ this state, what should the icon look like?**
443
+
444
+ This is the key insight: side effects are a **projection of current state**, not a
445
+ reaction to specific events.
446
+
447
+ ### Why this is better
448
+
449
+ **Before** (scattered side effects):
450
+ ```
451
+ onTabAttached() -> update tabs Map, update icon, update badge, update tab group
452
+ onTabDetached() -> update tabs Map, update icon, update badge, update tab group
453
+ onWsConnected() -> update connectionState, update icon
454
+ onWsDisconnected() -> update tabs Map, update connectionState, update icon, clear badge
455
+ onError() -> update errorText, update icon, update badge
456
+ ```
457
+
458
+ Every handler has to remember to update every side effect. Add a new side effect
459
+ (e.g. "update status bar")? You must find and update every handler.
460
+
461
+ **After** (centralized):
462
+ ```
463
+ onTabAttached() -> store.setState(...)
464
+ onTabDetached() -> store.setState(...)
465
+ onWsConnected() -> store.setState(...)
466
+ onWsDisconnected() -> store.setState(...)
467
+ onError() -> store.setState(...)
468
+
469
+ subscribe() -> update icon, update badge, update tab group, update status bar
470
+ ```
471
+
472
+ Handlers only update state. Subscribe handles all side effects. Add a new side
473
+ effect? Add one line in subscribe. Impossible to forget a handler.
474
+
475
+ ## Testing
476
+
477
+ State transitions are pure functions, so testing requires no mocks, no WebSockets,
478
+ no I/O setup:
479
+
480
+ ```ts
481
+ import { test, expect } from 'vitest'
482
+
483
+ test('attaching a tab updates state correctly', () => {
484
+ const before: ExtensionState = {
485
+ tabs: new Map(),
486
+ connectionState: 'idle',
487
+ currentTabId: undefined,
488
+ errorText: undefined,
489
+ }
490
+
491
+ const after = attachTab(before, {
492
+ tabId: 42,
493
+ sessionId: 'session-1',
494
+ targetId: 'target-1',
495
+ })
496
+
497
+ expect(after.tabs.size).toBe(1)
498
+ expect(after.tabs.get(42)?.state).toBe('connected')
499
+ expect(after.connectionState).toBe('connected')
500
+ // previous state is unchanged (immutable)
501
+ expect(before.tabs.size).toBe(0)
502
+ expect(before.connectionState).toBe('idle')
503
+ })
504
+
505
+ test('disconnecting resets all tabs to connecting', () => {
506
+ const before: ExtensionState = {
507
+ tabs: new Map([
508
+ [1, { state: 'connected', sessionId: 's1' }],
509
+ [2, { state: 'connected', sessionId: 's2' }],
510
+ ]),
511
+ connectionState: 'connected',
512
+ currentTabId: 1,
513
+ errorText: undefined,
514
+ }
515
+
516
+ const after = onDisconnect(before)
517
+
518
+ expect(after.connectionState).toBe('idle')
519
+ for (const tab of after.tabs.values()) {
520
+ expect(tab.state).toBe('connecting')
521
+ }
522
+ // original unchanged
523
+ for (const tab of before.tabs.values()) {
524
+ expect(tab.state).toBe('connected')
525
+ }
526
+ })
527
+ ```
528
+
529
+ No WebSocket mocks. No Chrome API stubs. No timers. Just data in, data out.
530
+
531
+ ## Extracting reusable transition functions
532
+
533
+ When transitions are complex or reused across handlers, extract them as pure
534
+ functions:
535
+
536
+ ```ts
537
+ // pure transition function -- takes state + event, returns new state
538
+ function attachTab(state: ExtensionState, event: {
539
+ tabId: number
540
+ sessionId: string
541
+ targetId: string
542
+ }): ExtensionState {
543
+ const newTabs = new Map(state.tabs)
544
+ newTabs.set(event.tabId, {
545
+ sessionId: event.sessionId,
546
+ targetId: event.targetId,
547
+ state: 'connected',
548
+ attachOrder: newTabs.size,
549
+ })
550
+ return { ...state, tabs: newTabs, connectionState: 'connected' }
551
+ }
552
+
553
+ // used in handler
554
+ store.setState((state) => attachTab(state, { tabId, sessionId, targetId }))
555
+ ```
556
+
557
+ This keeps handlers minimal and transitions testable.
558
+
559
+ ## Zustand vanilla API reference
560
+
561
+ ```ts
562
+ import { createStore } from 'zustand/vanilla'
563
+
564
+ // create store with initial state
565
+ const store = createStore<MyState>(() => initialState)
566
+
567
+ // read current state (snapshot, safe to hold)
568
+ const snapshot = store.getState()
569
+
570
+ // functional update (preferred -- derives from current state)
571
+ store.setState((state) => ({ ...state, count: state.count + 1 }))
572
+
573
+ // direct merge (for simple top-level updates)
574
+ store.setState({ connectionState: 'connected' })
575
+
576
+ // subscribe to all changes (returns unsubscribe function)
577
+ const unsub = store.subscribe((state, prevState) => { ... })
578
+
579
+ // subscribe with selector (fires only when selected value changes)
580
+ // requires subscribeWithSelector middleware -- see section below
581
+ const unsub = store.subscribe(
582
+ (state) => state.connectionState,
583
+ (connectionState, prevConnectionState) => { ... },
584
+ )
585
+ ```
586
+
587
+ ## Subscribing to nested state with selectors
588
+
589
+ By default, `store.subscribe()` fires on **every** state change with no selector
590
+ support. When your state contains Maps or nested objects and you only care about a
591
+ specific part, use the `subscribeWithSelector` middleware from `zustand/middleware`.
592
+ This adds a selector overload to `subscribe` so the callback only fires when the
593
+ selected value changes.
594
+
595
+ ```ts
596
+ import { createStore } from 'zustand/vanilla'
597
+ import { subscribeWithSelector } from 'zustand/middleware'
598
+
599
+ interface Session {
600
+ userId: string
601
+ status: 'active' | 'idle' | 'expired'
602
+ }
603
+
604
+ interface AppState {
605
+ sessions: Map<string, Session>
606
+ serverStatus: 'starting' | 'running' | 'stopping'
607
+ }
608
+
609
+ const store = createStore<AppState>()(
610
+ subscribeWithSelector(() => ({
611
+ sessions: new Map(),
612
+ serverStatus: 'starting' as const,
613
+ }))
614
+ )
615
+
616
+ // only fires when the sessions Map reference changes,
617
+ // NOT when serverStatus or other fields change
618
+ store.subscribe(
619
+ (state) => state.sessions,
620
+ (sessions, prevSessions) => {
621
+ for (const [id] of sessions) {
622
+ if (!prevSessions.has(id)) {
623
+ logger.log(`new session: ${id}`)
624
+ }
625
+ }
626
+ for (const [id] of prevSessions) {
627
+ if (!sessions.has(id)) {
628
+ logger.log(`session removed: ${id}`)
629
+ }
630
+ }
631
+ },
632
+ )
633
+ ```
634
+
635
+ The selector subscribe signature is:
636
+
637
+ ```ts
638
+ store.subscribe(selector, listener, options?)
639
+ // options: { equalityFn?, fireImmediately? }
640
+ ```
641
+
642
+ When the selector returns a new object each time (e.g. picking multiple fields),
643
+ use `shallow` from `zustand/shallow` as `equalityFn`. Without it, the default
644
+ `Object.is` compares by reference and would fire on every state change since the
645
+ selector always creates a fresh object:
646
+
647
+ ```ts
648
+ import { shallow } from 'zustand/shallow'
649
+
650
+ store.subscribe(
651
+ (state) => ({
652
+ serverStatus: state.serverStatus,
653
+ sessionCount: state.sessions.size,
654
+ }),
655
+ (picked, prevPicked) => {
656
+ updateDashboard(picked)
657
+ },
658
+ { equalityFn: shallow },
659
+ )
660
+ ```
661
+
662
+ ## Encapsulate state to limit blast radius
663
+
664
+ Centralizing global state in one store is good, but the best state is state that
665
+ **doesn't leak outside its owner**. When state is read and mutated from many
666
+ places, it becomes hard to reason about: N state fields that interact create an
667
+ explosion of possible combinations. The fewer places that can see or touch a piece
668
+ of state, the easier the program is to understand.
669
+
670
+ The goal: keep state **small** and **local** to the code that owns it. Don't
671
+ expose it to the rest of the application. This is the same principle behind
672
+ React's `useState` -- a component's state is private, and no other component can
673
+ reach in and mutate it. The component renders based on its own state, and the
674
+ only way to change that state is through the component's own event handlers.
675
+
676
+ This principle applies everywhere, not just React:
677
+
678
+ ### Closures and plugins
679
+
680
+ A closure (or plugin factory) can hold state in local variables that are invisible
681
+ to the outside world. The returned interface exposes only **behavior** (event
682
+ handlers, methods), never the raw state.
683
+
684
+ ```ts
685
+ // Real example: opencode-plugin.ts interruptOpencodeSessionOnUserMessage
686
+ const interruptOnMessage: Plugin = async (ctx) => {
687
+ // All state is closure-local — invisible to anything outside this plugin
688
+ let seq = 0
689
+ const busy = new Set<string>()
690
+ const timers = new Map<string, ReturnType<typeof setTimeout>>()
691
+ const events: StoredEvent[] = []
692
+
693
+ return {
694
+ async event({ event }) {
695
+ // Only this handler mutates busy/timers/events
696
+ events.push({ event, index: ++seq })
697
+ if (events.length > 100) events.shift()
698
+
699
+ if (event.type === 'session.status') {
700
+ const { sessionID, status } = event.properties
701
+ if (status.type === 'busy') {
702
+ busy.add(sessionID)
703
+ } else {
704
+ busy.delete(sessionID)
705
+ const timer = timers.get(sessionID)
706
+ if (timer) {
707
+ clearTimeout(timer)
708
+ timers.delete(sessionID)
709
+ }
710
+ }
711
+ }
712
+ },
713
+
714
+ async 'chat.message'(input) {
715
+ // Reads busy set, manages timers — all closure-scoped
716
+ const { sessionID } = input
717
+ if (!sessionID) return
718
+ if (!busy.has(sessionID)) return
719
+ // ... abort and resume logic
720
+ },
721
+ }
722
+ }
723
+ ```
724
+
725
+ This plugin is easy to reason about because:
726
+ - **4 state variables**, all in one place (the closure)
727
+ - **2 handlers** that read/write them (`event` and `chat.message`)
728
+ - **Nothing outside** can see or mutate `busy`, `timers`, `events`, or `seq`
729
+ - You can understand the full state machine by reading ~80 lines
730
+
731
+ Compare this to the alternative where `busy`, `timers`, etc. are module-level
732
+ variables or fields on a shared object that any handler in the codebase can
733
+ reach into. Now every handler is a potential writer, and you have to grep the
734
+ entire codebase to understand the state lifecycle.
735
+
736
+ ### Closure-based modules
737
+
738
+ The same pattern works for any feature that needs internal state. A factory
739
+ function returns an interface of operations, while the state stays trapped
740
+ inside the closure. Nothing outside can read or mutate it directly.
741
+
742
+ ```ts
743
+ // BAD: module-level state that any file can import and mutate
744
+ export const rateLimitState = {
745
+ tokens: new Map<string, number>(), // anyone can .set(), .clear()
746
+ lastRefill: new Map<string, number>(), // anyone can .delete()
747
+ }
748
+
749
+ // some random file reaches in:
750
+ rateLimitState.tokens.set('user-1', 9999) // bypasses all logic
751
+ ```
752
+
753
+ ```ts
754
+ // GOOD: state is closure-local, only operations are exposed
755
+ function createRateLimiter({ maxTokens, refillMs }: {
756
+ maxTokens: number
757
+ refillMs: number
758
+ }) {
759
+ const tokens = new Map<string, number>()
760
+ const lastRefill = new Map<string, number>()
761
+
762
+ function refill(key: string) {
763
+ const now = Date.now()
764
+ const last = lastRefill.get(key) ?? 0
765
+ const elapsed = now - last
766
+ const newTokens = Math.floor(elapsed / refillMs) * maxTokens
767
+ if (newTokens > 0) {
768
+ tokens.set(key, Math.min(maxTokens, (tokens.get(key) ?? maxTokens) + newTokens))
769
+ lastRefill.set(key, now)
770
+ }
771
+ }
772
+
773
+ return {
774
+ tryConsume(key: string): boolean {
775
+ refill(key)
776
+ const current = tokens.get(key) ?? maxTokens
777
+ if (current <= 0) return false
778
+ tokens.set(key, current - 1)
779
+ return true
780
+ },
781
+ remaining(key: string): number {
782
+ refill(key)
783
+ return tokens.get(key) ?? maxTokens
784
+ },
785
+ }
786
+ }
787
+
788
+ const limiter = createRateLimiter({ maxTokens: 10, refillMs: 1000 })
789
+ limiter.tryConsume('user-1') // the only way to change state
790
+ // limiter.tokens — doesn't exist, no way to reach in
791
+ ```
792
+
793
+ The returned object exposes **behavior** (`tryConsume`, `remaining`), never the
794
+ raw Maps. Just like a React component -- you can't set another component's state
795
+ from outside, you can only interact through its public interface.
796
+
797
+ ### When to centralize vs encapsulate
798
+
799
+ | Situation | Approach |
800
+ |---|---|
801
+ | State shared across many modules (app config, connection status) | Centralize in one zustand store |
802
+ | State used by one module or feature (rate limiting, retry tracking) | Encapsulate in a closure |
803
+ | State used by 2-3 closely related handlers | Encapsulate in a shared closure (plugin pattern) |
804
+ | State that drives UI across the whole app | Centralize in store + subscribe |
805
+
806
+ The rule of thumb: **start encapsulated, promote to centralized only when
807
+ multiple unrelated parts of the app need the same state.** Most state should be
808
+ local. Global state should be the exception, not the default.
809
+
810
+ **Important:** encapsulation only applies to local, feature-scoped state. If state
811
+ is truly global (shared across many unrelated modules), it should live in a
812
+ centralized zustand store as described in the earlier sections. Encapsulation is
813
+ not a replacement for centralized state -- it's for the cases where state doesn't
814
+ need to be global in the first place.
815
+
816
+ ## Derive state from events instead of tracking it
817
+
818
+ The best state is **no state at all**. When you have an event stream (SSE events,
819
+ WebSocket messages, webhook callbacks), the most common mistake is to maintain
820
+ internal mutable state that gets updated on each event and then read elsewhere in
821
+ the handler. This creates the usual problems: the state can get out of sync, it's
822
+ mutated from multiple places, and the interaction between state fields creates
823
+ a combinatorial explosion of possible program states.
824
+
825
+ A better approach is **event sourcing**: keep a bounded buffer of recent events
826
+ and derive any "state" you need on demand by scanning the buffer with a pure
827
+ function. The event stream is the single source of truth -- there is no separate
828
+ mutable state to keep in sync.
829
+
830
+ ### The pattern
831
+
832
+ ```ts
833
+ type StoredEvent = { event: Event; index: number }
834
+
835
+ // The only mutable state: an append-only bounded buffer
836
+ let seq = 0
837
+ const events: StoredEvent[] = []
838
+
839
+ function onEvent(event: Event) {
840
+ events.push({ event, index: ++seq })
841
+ if (events.length > 100) events.shift()
842
+ }
843
+
844
+ // Derive "state" from the event buffer with a pure function.
845
+ // No mutable boolean, no flag to keep in sync.
846
+ function wasSessionAborted(
847
+ events: StoredEvent[],
848
+ sessionId: string,
849
+ afterIndex: number,
850
+ ): boolean {
851
+ return events.some((e) => {
852
+ return (
853
+ e.index > afterIndex &&
854
+ e.event.type === 'session.error' &&
855
+ e.event.properties.sessionID === sessionId &&
856
+ e.event.properties.error?.name === 'MessageAbortedError'
857
+ )
858
+ })
859
+ }
860
+ ```
861
+
862
+ ### Why mutable state is worse
863
+
864
+ Consider an OpenCode session event handler that needs to distinguish between a
865
+ session going idle because it **completed normally** vs because it was **aborted**.
866
+ The idle event itself doesn't carry this information -- you need to know whether
867
+ an abort error arrived just before the idle.
868
+
869
+ **BAD: mutable flag that must stay in sync**
870
+
871
+ ```ts
872
+ // BAD: mutable state scattered across event handlers
873
+ let wasAborted = false
874
+
875
+ function onEvent(event: Event) {
876
+ if (event.type === 'session.error') {
877
+ if (event.properties.error?.name === 'MessageAbortedError') {
878
+ wasAborted = true // set in one handler...
879
+ }
880
+ }
881
+
882
+ if (event.type === 'session.idle') {
883
+ if (wasAborted) {
884
+ // ...read in another handler
885
+ handleAbortedIdle()
886
+ } else {
887
+ handleNormalCompletion()
888
+ }
889
+ wasAborted = false // must remember to reset, or next idle is wrong
890
+ }
891
+ }
892
+ ```
893
+
894
+ Problems with this:
895
+ - `wasAborted` is written in one place, read in another, reset in a third
896
+ - If you forget the reset, every subsequent idle looks like an abort
897
+ - If events arrive out of order or a new feature adds another path that
898
+ sets the flag, the state machine breaks silently
899
+ - Testing requires setting up the mutable flag in the right state first
900
+
901
+ **GOOD: derive from the event buffer**
902
+
903
+ ```ts
904
+ // GOOD: event buffer is the sole source of truth, derive everything from it
905
+ type StoredEvent = { event: Event; index: number }
906
+ let seq = 0
907
+ const events: StoredEvent[] = []
908
+
909
+ function onEvent(event: Event) {
910
+ events.push({ event, index: ++seq })
911
+ if (events.length > 100) events.shift()
912
+
913
+ if (event.type === 'session.idle') {
914
+ const sessionId = event.properties.sessionID
915
+ // Pure function: was there an abort error for this session
916
+ // in the recent event history?
917
+ const aborted = wasSessionAborted(events, sessionId)
918
+ if (aborted) {
919
+ handleAbortedIdle(sessionId)
920
+ } else {
921
+ handleNormalCompletion(sessionId)
922
+ }
923
+ }
924
+ }
925
+
926
+ // Pure function — easy to test, no mutable state dependency
927
+ function wasSessionAborted(
928
+ events: StoredEvent[],
929
+ sessionId: string,
930
+ ): boolean {
931
+ // Scan backward for the most recent status event for this session
932
+ for (let i = events.length - 1; i >= 0; i--) {
933
+ const e = events[i]!.event
934
+ if (e.properties?.sessionID !== sessionId) continue
935
+ if (
936
+ e.type === 'session.error' &&
937
+ e.properties.error?.name === 'MessageAbortedError'
938
+ ) {
939
+ return true
940
+ }
941
+ // Found a non-error event for this session before any abort — not aborted
942
+ if (e.type === 'session.status') return false
943
+ }
944
+ return false
945
+ }
946
+ ```
947
+
948
+ This is better because:
949
+ - **No mutable boolean** -- there's nothing to reset or keep in sync
950
+ - **Pure derivation** -- `wasSessionAborted` takes data in, returns data out
951
+ - **Easy to test** -- construct an array of events, call the function, assert
952
+ - **Easy to extend** -- need to know if idle was from a timeout? Add another
953
+ pure function that scans the same buffer, no new state variable needed
954
+
955
+ ### Testing event-sourced state
956
+
957
+ The pure derivation functions are trivial to test -- no mocks, no setup, just
958
+ events in and booleans out:
959
+
960
+ ```ts
961
+ test('detects abort from event stream', () => {
962
+ const events: StoredEvent[] = [
963
+ { event: { type: 'session.status', properties: { sessionID: 's1', status: { type: 'busy' } } }, index: 1 },
964
+ { event: { type: 'session.error', properties: { sessionID: 's1', error: { name: 'MessageAbortedError' } } }, index: 2 },
965
+ { event: { type: 'session.idle', properties: { sessionID: 's1' } }, index: 3 },
966
+ ]
967
+ expect(wasSessionAborted(events, 's1')).toBe(true)
968
+ })
969
+
970
+ test('normal completion has no abort error', () => {
971
+ const events: StoredEvent[] = [
972
+ { event: { type: 'session.status', properties: { sessionID: 's1', status: { type: 'busy' } } }, index: 1 },
973
+ { event: { type: 'session.idle', properties: { sessionID: 's1' } }, index: 2 },
974
+ ]
975
+ expect(wasSessionAborted(events, 's1')).toBe(false)
976
+ })
977
+ ```
978
+
979
+ ### When to use event sourcing vs mutable state
980
+
981
+ | Situation | Approach |
982
+ |---|---|
983
+ | Need to classify events based on recent history (abort vs complete, retry vs first attempt) | Derive from event buffer |
984
+ | Tracking a long-lived resource lifecycle (connection open/close) | Mutable state or zustand store |
985
+ | Flag that's set and read in the same handler | Local variable (no state needed) |
986
+ | Need to answer "what happened before X?" | Event buffer scan |
987
+
988
+ The key insight: if you're adding a boolean flag just to communicate information
989
+ between two event handlers, you probably don't need that flag. Keep the events
990
+ around and derive the answer when you need it.
991
+
992
+ ## Summary
993
+
994
+ | Principle | Practice |
995
+ |---|---|
996
+ | Values over state | `setState()` returns new objects, never mutate in place |
997
+ | Derive over cache | Compute indexes and aggregates on demand |
998
+ | Centralize state | One `createStore()`, one state type, one source of truth |
999
+ | Pure transitions | `setState((state) => newState)` with no side effects |
1000
+ | Centralize side effects | One `subscribe()` for all reactive effects |
1001
+ | State vs I/O boundary | Prefer separation, but co-location is valid for safer cleanup |
1002
+ | Test with data | State in -> state out, no mocks needed |
1003
+ | Encapsulate state | Keep state local to its owner (closure, component), promote to global only when needed |
1004
+ | Derive from events | Keep a bounded event buffer, derive "state" with pure functions instead of mutable flags |