@otto-assistant/otto 0.1.2 → 0.7.16

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 (638) 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-account-identity.js +62 -0
  7. package/dist/anthropic-account-identity.test.js +38 -0
  8. package/dist/anthropic-auth-plugin.js +917 -0
  9. package/dist/anthropic-auth-state.js +303 -0
  10. package/dist/anthropic-auth-state.test.js +150 -0
  11. package/dist/bin.js +152 -0
  12. package/dist/btw-prefix-detection.js +17 -0
  13. package/dist/btw-prefix-detection.test.js +63 -0
  14. package/dist/channel-management.js +259 -0
  15. package/dist/cli-parsing.test.js +142 -0
  16. package/dist/cli-send-thread.e2e.test.js +353 -0
  17. package/dist/cli-telegram-options.test.js +99 -0
  18. package/dist/cli.js +4210 -568
  19. package/dist/commands/abort.js +65 -0
  20. package/dist/commands/action-buttons.js +245 -0
  21. package/dist/commands/add-dir.js +124 -0
  22. package/dist/commands/add-dir.test.js +126 -0
  23. package/dist/commands/add-project.js +113 -0
  24. package/dist/commands/agent.js +355 -0
  25. package/dist/commands/ask-question.js +320 -0
  26. package/dist/commands/ask-question.test.js +92 -0
  27. package/dist/commands/btw.js +121 -0
  28. package/dist/commands/cli-commands-group-a.test.js +728 -0
  29. package/dist/commands/cli-commands-group-b.test.js +695 -0
  30. package/dist/commands/compact.js +120 -0
  31. package/dist/commands/context-usage.js +140 -0
  32. package/dist/commands/create-new-project.js +130 -0
  33. package/dist/commands/diff.js +63 -0
  34. package/dist/commands/discord-commands-group-a.test.js +655 -0
  35. package/dist/commands/discord-commands-group-b.test.js +595 -0
  36. package/dist/commands/discord-commands-group-c.test.js +739 -0
  37. package/dist/commands/file-upload.js +275 -0
  38. package/dist/commands/fork-subagent.js +177 -0
  39. package/dist/commands/fork.js +262 -0
  40. package/dist/commands/gemini-apikey.js +70 -0
  41. package/dist/commands/login.js +893 -0
  42. package/dist/commands/mcp.js +239 -0
  43. package/dist/commands/memory-snapshot.js +24 -0
  44. package/dist/commands/mention-mode.js +44 -0
  45. package/dist/commands/merge-worktree.js +162 -0
  46. package/dist/commands/model-variant.js +369 -0
  47. package/dist/commands/model.js +798 -0
  48. package/dist/commands/new-worktree.js +465 -0
  49. package/dist/commands/paginated-select.js +57 -0
  50. package/dist/commands/permissions.js +274 -0
  51. package/dist/commands/queue.js +223 -0
  52. package/dist/commands/remove-project.js +115 -0
  53. package/dist/commands/restart-opencode-server.js +127 -0
  54. package/dist/commands/resume.js +149 -0
  55. package/dist/commands/run-command.js +79 -0
  56. package/dist/commands/screenshare.js +303 -0
  57. package/dist/commands/screenshare.test.js +20 -0
  58. package/dist/commands/session-id.js +78 -0
  59. package/dist/commands/session.js +176 -0
  60. package/dist/commands/share.js +80 -0
  61. package/dist/commands/tasks.js +205 -0
  62. package/dist/commands/thread-deletion-sync.js +50 -0
  63. package/dist/commands/types.js +2 -0
  64. package/dist/commands/undo-redo.js +305 -0
  65. package/dist/commands/unset-model.js +139 -0
  66. package/dist/commands/upgrade.js +48 -0
  67. package/dist/commands/user-command.js +155 -0
  68. package/dist/commands/verbosity.js +125 -0
  69. package/dist/commands/vscode.js +269 -0
  70. package/dist/commands/worktree-settings.js +43 -0
  71. package/dist/commands/worktrees.js +468 -0
  72. package/dist/condense-memory.js +33 -0
  73. package/dist/config.js +100 -255
  74. package/dist/context-awareness-plugin.js +340 -0
  75. package/dist/context-awareness-plugin.test.js +126 -0
  76. package/dist/critique-utils.js +95 -0
  77. package/dist/database.js +1355 -0
  78. package/dist/db.js +260 -0
  79. package/dist/db.test.js +138 -0
  80. package/dist/debounce-timeout.js +28 -0
  81. package/dist/debounced-process-flush.js +77 -0
  82. package/dist/discord-bot.js +1124 -0
  83. package/dist/discord-command-registration.js +567 -0
  84. package/dist/discord-urls.js +82 -0
  85. package/dist/discord-utils.js +616 -0
  86. package/dist/discord-utils.test.js +134 -0
  87. package/dist/errors.js +179 -0
  88. package/dist/escape-backticks.test.js +429 -0
  89. package/dist/event-stream-real-capture.e2e.test.js +533 -0
  90. package/dist/eventsource-parser.test.js +327 -0
  91. package/dist/exec-async.js +26 -0
  92. package/dist/external-opencode-sync.js +480 -0
  93. package/dist/format-tables.js +491 -0
  94. package/dist/format-tables.test.js +478 -0
  95. package/dist/forum-sync/config.js +79 -0
  96. package/dist/forum-sync/discord-operations.js +154 -0
  97. package/dist/forum-sync/index.js +5 -0
  98. package/dist/forum-sync/markdown.js +113 -0
  99. package/dist/forum-sync/sync-to-discord.js +417 -0
  100. package/dist/forum-sync/sync-to-files.js +190 -0
  101. package/dist/forum-sync/types.js +53 -0
  102. package/dist/forum-sync/watchers.js +307 -0
  103. package/dist/gateway-proxy-reconnect.e2e.test.js +394 -0
  104. package/dist/gateway-proxy.e2e.test.js +485 -0
  105. package/dist/genai-worker-wrapper.js +111 -0
  106. package/dist/genai-worker.js +311 -0
  107. package/dist/genai.js +232 -0
  108. package/dist/generated/browser.js +17 -0
  109. package/dist/generated/client.js +37 -0
  110. package/dist/generated/commonInputTypes.js +10 -0
  111. package/dist/generated/enums.js +58 -0
  112. package/dist/generated/internal/class.js +49 -0
  113. package/dist/generated/internal/prismaNamespace.js +254 -0
  114. package/dist/generated/internal/prismaNamespaceBrowser.js +224 -0
  115. package/dist/generated/models/bot_api_keys.js +1 -0
  116. package/dist/generated/models/bot_tokens.js +1 -0
  117. package/dist/generated/models/channel_agents.js +1 -0
  118. package/dist/generated/models/channel_directories.js +1 -0
  119. package/dist/generated/models/channel_mention_mode.js +1 -0
  120. package/dist/generated/models/channel_models.js +1 -0
  121. package/dist/generated/models/channel_verbosity.js +1 -0
  122. package/dist/generated/models/channel_worktrees.js +1 -0
  123. package/dist/generated/models/forum_sync_configs.js +1 -0
  124. package/dist/generated/models/global_models.js +1 -0
  125. package/dist/generated/models/ipc_requests.js +1 -0
  126. package/dist/generated/models/part_messages.js +1 -0
  127. package/dist/generated/models/scheduled_tasks.js +1 -0
  128. package/dist/generated/models/session_agents.js +1 -0
  129. package/dist/generated/models/session_events.js +1 -0
  130. package/dist/generated/models/session_models.js +1 -0
  131. package/dist/generated/models/session_start_sources.js +1 -0
  132. package/dist/generated/models/thread_sessions.js +1 -0
  133. package/dist/generated/models/thread_worktrees.js +1 -0
  134. package/dist/generated/models.js +1 -0
  135. package/dist/heap-monitor.js +122 -0
  136. package/dist/hrana-server.js +251 -0
  137. package/dist/hrana-server.test.js +370 -0
  138. package/dist/html-actions.js +123 -0
  139. package/dist/html-actions.test.js +70 -0
  140. package/dist/html-components.js +117 -0
  141. package/dist/html-components.test.js +34 -0
  142. package/dist/image-optimizer-plugin.js +153 -0
  143. package/dist/image-utils.js +112 -0
  144. package/dist/interaction-handler.js +420 -0
  145. package/dist/ipc-polling.js +327 -0
  146. package/dist/ipc-tools-plugin.js +193 -0
  147. package/dist/ipc-utils.js +18 -0
  148. package/dist/limit-heading-depth.js +25 -0
  149. package/dist/limit-heading-depth.test.js +105 -0
  150. package/dist/logger.js +171 -0
  151. package/dist/markdown.js +342 -0
  152. package/dist/markdown.test.js +264 -0
  153. package/dist/memory-overview-plugin.js +128 -0
  154. package/dist/message-finish-field.e2e.test.js +168 -0
  155. package/dist/message-formatting.js +415 -0
  156. package/dist/message-formatting.test.js +115 -0
  157. package/dist/message-preprocessing.js +359 -0
  158. package/dist/onboarding-tutorial.js +163 -0
  159. package/dist/onboarding-welcome.js +37 -0
  160. package/dist/openai-realtime.js +224 -0
  161. package/dist/opencode-command-detection.js +65 -0
  162. package/dist/opencode-command-detection.test.js +240 -0
  163. package/dist/opencode-command.js +131 -0
  164. package/dist/opencode-command.test.js +48 -0
  165. package/dist/opencode-interrupt-plugin.js +388 -0
  166. package/dist/opencode-interrupt-plugin.test.js +463 -0
  167. package/dist/opencode.js +1124 -0
  168. package/dist/otto/branding.js +22 -0
  169. package/dist/otto/index.js +21 -0
  170. package/dist/otto-digital-twin.e2e.test.js +161 -0
  171. package/dist/otto-opencode-plugin-loading.e2e.test.js +94 -0
  172. package/dist/otto-opencode-plugin.js +21 -0
  173. package/dist/otto-opencode-plugin.test.js +98 -0
  174. package/dist/parse-permission-rules.test.js +117 -0
  175. package/dist/patch-text-parser.js +97 -0
  176. package/dist/plugin-logger.js +68 -0
  177. package/dist/privacy-sanitizer.js +105 -0
  178. package/dist/queue-advanced-abort.e2e.test.js +293 -0
  179. package/dist/queue-advanced-action-buttons.e2e.test.js +206 -0
  180. package/dist/queue-advanced-e2e-setup.js +790 -0
  181. package/dist/queue-advanced-footer.e2e.test.js +481 -0
  182. package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
  183. package/dist/queue-advanced-permissions-typing.e2e.test.js +179 -0
  184. package/dist/queue-advanced-question.e2e.test.js +261 -0
  185. package/dist/queue-advanced-typing-interrupt.e2e.test.js +114 -0
  186. package/dist/queue-advanced-typing.e2e.test.js +153 -0
  187. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  188. package/dist/queue-interrupt-drain.e2e.test.js +135 -0
  189. package/dist/queue-question-select-drain.e2e.test.js +256 -0
  190. package/dist/runtime-idle-sweeper.js +52 -0
  191. package/dist/runtime-lifecycle.e2e.test.js +514 -0
  192. package/dist/sentry.js +23 -0
  193. package/dist/session-handler/agent-utils.js +67 -0
  194. package/dist/session-handler/event-stream-state.js +475 -0
  195. package/dist/session-handler/event-stream-state.test.js +632 -0
  196. package/dist/session-handler/model-utils.js +147 -0
  197. package/dist/session-handler/opencode-session-event-log.js +94 -0
  198. package/dist/session-handler/thread-runtime-state.js +131 -0
  199. package/dist/session-handler/thread-session-runtime.js +3390 -0
  200. package/dist/session-handler.js +9 -0
  201. package/dist/session-search.js +100 -0
  202. package/dist/session-search.test.js +40 -0
  203. package/dist/session-title-rename.test.js +92 -0
  204. package/dist/skill-filter.js +31 -0
  205. package/dist/skill-filter.test.js +65 -0
  206. package/dist/startup-service.js +153 -0
  207. package/dist/startup-time.e2e.test.js +296 -0
  208. package/dist/store.js +19 -0
  209. package/dist/subagent-rate-limit-plugin.js +175 -0
  210. package/dist/system-message.js +702 -0
  211. package/dist/system-message.test.js +697 -0
  212. package/dist/task-runner.js +530 -0
  213. package/dist/task-schedule.js +213 -0
  214. package/dist/task-schedule.test.js +71 -0
  215. package/dist/test-utils.js +313 -0
  216. package/dist/thinking-utils.js +35 -0
  217. package/dist/thread-message-queue.e2e.test.js +1111 -0
  218. package/dist/tools.js +357 -0
  219. package/dist/undo-redo.e2e.test.js +161 -0
  220. package/dist/unnest-code-blocks.js +146 -0
  221. package/dist/unnest-code-blocks.test.js +673 -0
  222. package/dist/upgrade.js +156 -0
  223. package/dist/utils.js +172 -0
  224. package/dist/utils.test.js +130 -0
  225. package/dist/voice-attachment.js +34 -0
  226. package/dist/voice-handler.js +646 -0
  227. package/dist/voice-message.e2e.test.js +1021 -0
  228. package/dist/voice.js +456 -0
  229. package/dist/voice.test.js +235 -0
  230. package/dist/wait-session.js +171 -0
  231. package/dist/websockify.js +69 -0
  232. package/dist/worker-types.js +4 -0
  233. package/dist/worktree-lifecycle.e2e.test.js +311 -0
  234. package/dist/worktree-utils.js +3 -0
  235. package/dist/worktrees.js +991 -0
  236. package/dist/worktrees.test.js +415 -0
  237. package/dist/xml.js +92 -0
  238. package/dist/xml.test.js +32 -0
  239. package/package.json +90 -38
  240. package/schema.prisma +303 -0
  241. package/skills/batch/SKILL.md +87 -0
  242. package/skills/critique/SKILL.md +112 -0
  243. package/skills/egaki/SKILL.md +100 -0
  244. package/skills/errore/SKILL.md +647 -0
  245. package/skills/event-sourcing-state/SKILL.md +252 -0
  246. package/skills/goke/SKILL.md +38 -0
  247. package/skills/jitter/EDITOR.md +219 -0
  248. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  249. package/skills/jitter/SKILL.md +158 -0
  250. package/skills/jitter/jitter-clipboard.json +1042 -0
  251. package/skills/jitter/package.json +14 -0
  252. package/skills/jitter/tsconfig.json +15 -0
  253. package/skills/jitter/utils/actions.ts +212 -0
  254. package/skills/jitter/utils/export.ts +114 -0
  255. package/skills/jitter/utils/index.ts +141 -0
  256. package/skills/jitter/utils/snapshot.ts +154 -0
  257. package/skills/jitter/utils/traverse.ts +246 -0
  258. package/skills/jitter/utils/types.ts +279 -0
  259. package/skills/jitter/utils/wait.ts +133 -0
  260. package/skills/lintcn/SKILL.md +873 -0
  261. package/skills/manual-kimaki-upstream-adapt/SKILL.md +114 -0
  262. package/skills/new-skill/SKILL.md +237 -0
  263. package/skills/npm-package/SKILL.md +617 -0
  264. package/skills/opensrc/SKILL.md +78 -0
  265. package/skills/otto-publish/SKILL.md +61 -0
  266. package/skills/playwriter/SKILL.md +35 -0
  267. package/skills/profano/SKILL.md +16 -0
  268. package/skills/proxyman/SKILL.md +215 -0
  269. package/skills/security-review/SKILL.md +208 -0
  270. package/skills/sigillo/SKILL.md +101 -0
  271. package/skills/simplify/SKILL.md +58 -0
  272. package/skills/spiceflow/SKILL.md +28 -0
  273. package/skills/termcast/SKILL.md +945 -0
  274. package/skills/tuistory/SKILL.md +98 -0
  275. package/skills/usecomputer/SKILL.md +264 -0
  276. package/skills/x-articles/SKILL.md +554 -0
  277. package/skills/zele/SKILL.md +49 -0
  278. package/skills/zustand-centralized-state/SKILL.md +1004 -0
  279. package/src/agent-model.e2e.test.ts +979 -0
  280. package/src/ai-tool-to-genai.test.ts +296 -0
  281. package/src/ai-tool-to-genai.ts +283 -0
  282. package/src/ai-tool.ts +39 -0
  283. package/src/anthropic-account-identity.test.ts +52 -0
  284. package/src/anthropic-account-identity.ts +77 -0
  285. package/src/anthropic-auth-plugin.ts +1139 -0
  286. package/src/anthropic-auth-state.test.ts +187 -0
  287. package/src/anthropic-auth-state.ts +386 -0
  288. package/src/bin.ts +182 -0
  289. package/src/btw-prefix-detection.test.ts +73 -0
  290. package/src/btw-prefix-detection.ts +23 -0
  291. package/src/channel-management.ts +376 -0
  292. package/src/cli-parsing.test.ts +197 -0
  293. package/src/cli-send-thread.e2e.test.ts +463 -0
  294. package/src/cli-telegram-options.test.ts +114 -0
  295. package/src/cli.ts +5718 -580
  296. package/src/commands/abort.ts +89 -0
  297. package/src/commands/action-buttons.ts +364 -0
  298. package/src/commands/add-dir.test.ts +154 -0
  299. package/src/commands/add-dir.ts +175 -0
  300. package/src/commands/add-project.ts +149 -0
  301. package/src/commands/agent.ts +496 -0
  302. package/src/commands/ask-question.test.ts +111 -0
  303. package/src/commands/ask-question.ts +455 -0
  304. package/src/commands/btw.ts +184 -0
  305. package/src/commands/cli-commands-group-a.test.ts +837 -0
  306. package/src/commands/cli-commands-group-b.test.ts +800 -0
  307. package/src/commands/compact.ts +157 -0
  308. package/src/commands/context-usage.ts +199 -0
  309. package/src/commands/create-new-project.ts +190 -0
  310. package/src/commands/diff.ts +91 -0
  311. package/src/commands/discord-commands-group-a.test.ts +789 -0
  312. package/src/commands/discord-commands-group-b.test.ts +648 -0
  313. package/src/commands/discord-commands-group-c.test.ts +882 -0
  314. package/src/commands/file-upload.ts +389 -0
  315. package/src/commands/fork-subagent.ts +263 -0
  316. package/src/commands/fork.ts +386 -0
  317. package/src/commands/gemini-apikey.ts +104 -0
  318. package/src/commands/login.ts +1181 -0
  319. package/src/commands/mcp.ts +307 -0
  320. package/src/commands/memory-snapshot.ts +30 -0
  321. package/src/commands/mention-mode.ts +68 -0
  322. package/src/commands/merge-worktree.ts +226 -0
  323. package/src/commands/model-variant.ts +488 -0
  324. package/src/commands/model.ts +1082 -0
  325. package/src/commands/new-worktree.ts +645 -0
  326. package/src/commands/paginated-select.ts +81 -0
  327. package/src/commands/permissions.ts +397 -0
  328. package/src/commands/queue.ts +293 -0
  329. package/src/commands/remove-project.ts +155 -0
  330. package/src/commands/restart-opencode-server.ts +162 -0
  331. package/src/commands/resume.ts +230 -0
  332. package/src/commands/run-command.ts +123 -0
  333. package/src/commands/screenshare.test.ts +30 -0
  334. package/src/commands/screenshare.ts +366 -0
  335. package/src/commands/session-id.ts +109 -0
  336. package/src/commands/session.ts +227 -0
  337. package/src/commands/share.ts +106 -0
  338. package/src/commands/tasks.ts +293 -0
  339. package/src/commands/thread-deletion-sync.ts +80 -0
  340. package/src/commands/types.ts +25 -0
  341. package/src/commands/undo-redo.ts +386 -0
  342. package/src/commands/unset-model.ts +174 -0
  343. package/src/commands/upgrade.ts +59 -0
  344. package/src/commands/user-command.ts +198 -0
  345. package/src/commands/verbosity.ts +173 -0
  346. package/src/commands/vscode.ts +342 -0
  347. package/src/commands/worktree-settings.ts +70 -0
  348. package/src/commands/worktrees.ts +645 -0
  349. package/src/condense-memory.ts +36 -0
  350. package/src/config.ts +103 -339
  351. package/src/context-awareness-plugin.test.ts +144 -0
  352. package/src/context-awareness-plugin.ts +469 -0
  353. package/src/critique-utils.ts +139 -0
  354. package/src/database.ts +1949 -0
  355. package/src/db.test.ts +162 -0
  356. package/src/db.ts +295 -0
  357. package/src/debounce-timeout.ts +43 -0
  358. package/src/debounced-process-flush.ts +104 -0
  359. package/src/discord-bot.ts +1507 -0
  360. package/src/discord-command-registration.ts +752 -0
  361. package/src/discord-urls.ts +89 -0
  362. package/src/discord-utils.test.ts +153 -0
  363. package/src/discord-utils.ts +846 -0
  364. package/src/errors.ts +232 -0
  365. package/src/escape-backticks.test.ts +469 -0
  366. package/src/event-stream-real-capture.e2e.test.ts +692 -0
  367. package/src/eventsource-parser.test.ts +351 -0
  368. package/src/exec-async.ts +35 -0
  369. package/src/external-opencode-sync.ts +685 -0
  370. package/src/format-tables.test.ts +515 -0
  371. package/src/format-tables.ts +718 -0
  372. package/src/forum-sync/config.ts +92 -0
  373. package/src/forum-sync/discord-operations.ts +241 -0
  374. package/src/forum-sync/index.ts +9 -0
  375. package/src/forum-sync/markdown.ts +172 -0
  376. package/src/forum-sync/sync-to-discord.ts +595 -0
  377. package/src/forum-sync/sync-to-files.ts +294 -0
  378. package/src/forum-sync/types.ts +175 -0
  379. package/src/forum-sync/watchers.ts +454 -0
  380. package/src/gateway-proxy-reconnect.e2e.test.ts +523 -0
  381. package/src/gateway-proxy.e2e.test.ts +644 -0
  382. package/src/genai-worker-wrapper.ts +164 -0
  383. package/src/genai-worker.ts +386 -0
  384. package/src/genai.ts +321 -0
  385. package/src/generated/browser.ts +114 -0
  386. package/src/generated/client.ts +138 -0
  387. package/src/generated/commonInputTypes.ts +770 -0
  388. package/src/generated/enums.ts +98 -0
  389. package/src/generated/internal/class.ts +384 -0
  390. package/src/generated/internal/prismaNamespace.ts +2394 -0
  391. package/src/generated/internal/prismaNamespaceBrowser.ts +327 -0
  392. package/src/generated/models/bot_api_keys.ts +1288 -0
  393. package/src/generated/models/bot_tokens.ts +1700 -0
  394. package/src/generated/models/channel_agents.ts +1256 -0
  395. package/src/generated/models/channel_directories.ts +1859 -0
  396. package/src/generated/models/channel_mention_mode.ts +1300 -0
  397. package/src/generated/models/channel_models.ts +1288 -0
  398. package/src/generated/models/channel_verbosity.ts +1228 -0
  399. package/src/generated/models/channel_worktrees.ts +1300 -0
  400. package/src/generated/models/forum_sync_configs.ts +1452 -0
  401. package/src/generated/models/global_models.ts +1288 -0
  402. package/src/generated/models/ipc_requests.ts +1485 -0
  403. package/src/generated/models/part_messages.ts +1302 -0
  404. package/src/generated/models/scheduled_tasks.ts +2320 -0
  405. package/src/generated/models/session_agents.ts +1086 -0
  406. package/src/generated/models/session_events.ts +1439 -0
  407. package/src/generated/models/session_models.ts +1114 -0
  408. package/src/generated/models/session_start_sources.ts +1408 -0
  409. package/src/generated/models/thread_sessions.ts +1781 -0
  410. package/src/generated/models/thread_worktrees.ts +1356 -0
  411. package/src/generated/models.ts +30 -0
  412. package/src/heap-monitor.ts +152 -0
  413. package/src/hrana-server.test.ts +434 -0
  414. package/src/hrana-server.ts +299 -0
  415. package/src/html-actions.test.ts +87 -0
  416. package/src/html-actions.ts +174 -0
  417. package/src/html-components.test.ts +38 -0
  418. package/src/html-components.ts +181 -0
  419. package/src/image-optimizer-plugin.ts +194 -0
  420. package/src/image-utils.ts +149 -0
  421. package/src/interaction-handler.ts +610 -0
  422. package/src/ipc-polling.ts +427 -0
  423. package/src/ipc-tools-plugin.ts +236 -0
  424. package/src/ipc-utils.ts +29 -0
  425. package/src/limit-heading-depth.test.ts +116 -0
  426. package/src/limit-heading-depth.ts +26 -0
  427. package/src/logger.ts +215 -0
  428. package/src/markdown.test.ts +315 -0
  429. package/src/markdown.ts +410 -0
  430. package/src/memory-overview-plugin.ts +163 -0
  431. package/src/message-finish-field.e2e.test.ts +195 -0
  432. package/src/message-formatting.test.ts +126 -0
  433. package/src/message-formatting.ts +535 -0
  434. package/src/message-preprocessing.ts +488 -0
  435. package/src/onboarding-tutorial.ts +167 -0
  436. package/src/onboarding-welcome.ts +49 -0
  437. package/src/openai-realtime.ts +358 -0
  438. package/src/opencode-command-detection.test.ts +307 -0
  439. package/src/opencode-command-detection.ts +76 -0
  440. package/src/opencode-command.test.ts +70 -0
  441. package/src/opencode-command.ts +191 -0
  442. package/src/opencode-interrupt-plugin.test.ts +682 -0
  443. package/src/opencode-interrupt-plugin.ts +507 -0
  444. package/src/opencode.ts +1462 -0
  445. package/src/otto/branding.ts +23 -0
  446. package/src/otto/index.ts +22 -0
  447. package/src/otto-digital-twin.e2e.test.ts +199 -0
  448. package/src/otto-opencode-plugin-loading.e2e.test.ts +117 -0
  449. package/src/otto-opencode-plugin.test.ts +108 -0
  450. package/src/otto-opencode-plugin.ts +22 -0
  451. package/src/parse-permission-rules.test.ts +127 -0
  452. package/src/patch-text-parser.ts +107 -0
  453. package/src/plugin-logger.ts +84 -0
  454. package/src/privacy-sanitizer.ts +142 -0
  455. package/src/queue-advanced-abort.e2e.test.ts +382 -0
  456. package/src/queue-advanced-action-buttons.e2e.test.ts +268 -0
  457. package/src/queue-advanced-e2e-setup.ts +877 -0
  458. package/src/queue-advanced-footer.e2e.test.ts +591 -0
  459. package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
  460. package/src/queue-advanced-permissions-typing.e2e.test.ts +246 -0
  461. package/src/queue-advanced-question.e2e.test.ts +316 -0
  462. package/src/queue-advanced-typing-interrupt.e2e.test.ts +146 -0
  463. package/src/queue-advanced-typing.e2e.test.ts +199 -0
  464. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  465. package/src/queue-interrupt-drain.e2e.test.ts +166 -0
  466. package/src/queue-question-select-drain.e2e.test.ts +327 -0
  467. package/src/runtime-idle-sweeper.ts +76 -0
  468. package/src/runtime-lifecycle.e2e.test.ts +651 -0
  469. package/src/schema.sql +174 -0
  470. package/src/sentry.ts +26 -0
  471. package/src/session-handler/agent-utils.ts +99 -0
  472. package/src/session-handler/event-stream-fixtures/real-session-action-buttons.jsonl +45 -0
  473. package/src/session-handler/event-stream-fixtures/real-session-footer-suppressed-on-pre-idle-interrupt.jsonl +40 -0
  474. package/src/session-handler/event-stream-fixtures/real-session-permission-external-file.jsonl +23 -0
  475. package/src/session-handler/event-stream-fixtures/real-session-task-normal.jsonl +22 -0
  476. package/src/session-handler/event-stream-fixtures/real-session-task-three-parallel-sleeps.jsonl +277 -0
  477. package/src/session-handler/event-stream-fixtures/real-session-task-user-interruption.jsonl +46 -0
  478. package/src/session-handler/event-stream-fixtures/session-abort-after-idle-race.jsonl +21 -0
  479. package/src/session-handler/event-stream-fixtures/session-concurrent-messages-serialized.jsonl +56 -0
  480. package/src/session-handler/event-stream-fixtures/session-explicit-abort.jsonl +44 -0
  481. package/src/session-handler/event-stream-fixtures/session-normal-completion.jsonl +29 -0
  482. package/src/session-handler/event-stream-fixtures/session-tool-call-noisy-stream.jsonl +29 -0
  483. package/src/session-handler/event-stream-fixtures/session-two-completions-same-session.jsonl +50 -0
  484. package/src/session-handler/event-stream-fixtures/session-user-interruption.jsonl +59 -0
  485. package/src/session-handler/event-stream-fixtures/session-voice-queued-followup.jsonl +52 -0
  486. package/src/session-handler/event-stream-state.test.ts +717 -0
  487. package/src/session-handler/event-stream-state.ts +706 -0
  488. package/src/session-handler/model-utils.ts +217 -0
  489. package/src/session-handler/opencode-session-event-log.ts +130 -0
  490. package/src/session-handler/thread-runtime-state.ts +247 -0
  491. package/src/session-handler/thread-session-runtime.ts +4440 -0
  492. package/src/session-handler.ts +15 -0
  493. package/src/session-search.test.ts +50 -0
  494. package/src/session-search.ts +148 -0
  495. package/src/session-title-rename.test.ts +130 -0
  496. package/src/skill-filter.test.ts +83 -0
  497. package/src/skill-filter.ts +42 -0
  498. package/src/startup-service.ts +200 -0
  499. package/src/startup-time.e2e.test.ts +373 -0
  500. package/src/store.ts +139 -0
  501. package/src/subagent-rate-limit-plugin.ts +218 -0
  502. package/src/system-message.test.ts +710 -0
  503. package/src/system-message.ts +814 -0
  504. package/src/task-runner.ts +725 -0
  505. package/src/task-schedule.test.ts +84 -0
  506. package/src/task-schedule.ts +317 -0
  507. package/src/test-utils.ts +451 -0
  508. package/src/thinking-utils.ts +61 -0
  509. package/src/thread-message-queue.e2e.test.ts +1350 -0
  510. package/src/tools.ts +430 -0
  511. package/src/undici.d.ts +12 -0
  512. package/src/undo-redo.e2e.test.ts +209 -0
  513. package/src/unnest-code-blocks.test.ts +713 -0
  514. package/src/unnest-code-blocks.ts +185 -0
  515. package/src/upgrade.ts +185 -0
  516. package/src/utils.test.ts +155 -0
  517. package/src/utils.ts +265 -0
  518. package/src/voice-attachment.ts +51 -0
  519. package/src/voice-handler.ts +908 -0
  520. package/src/voice-message.e2e.test.ts +1255 -0
  521. package/src/voice.test.ts +281 -0
  522. package/src/voice.ts +638 -0
  523. package/src/wait-session.ts +273 -0
  524. package/src/websockify.ts +101 -0
  525. package/src/worker-types.ts +64 -0
  526. package/src/worktree-lifecycle.e2e.test.ts +396 -0
  527. package/src/worktree-utils.ts +4 -0
  528. package/src/worktrees.test.ts +489 -0
  529. package/src/worktrees.ts +1370 -0
  530. package/src/xml.test.ts +38 -0
  531. package/src/xml.ts +121 -0
  532. package/README.md +0 -142
  533. package/dist/cli.d.ts +0 -3
  534. package/dist/cli.d.ts.map +0 -1
  535. package/dist/cli.js.map +0 -1
  536. package/dist/config.d.ts +0 -39
  537. package/dist/config.d.ts.map +0 -1
  538. package/dist/config.js.map +0 -1
  539. package/dist/config.test.d.ts +0 -2
  540. package/dist/config.test.d.ts.map +0 -1
  541. package/dist/config.test.js +0 -202
  542. package/dist/config.test.js.map +0 -1
  543. package/dist/detect.d.ts +0 -9
  544. package/dist/detect.d.ts.map +0 -1
  545. package/dist/detect.js +0 -40
  546. package/dist/detect.js.map +0 -1
  547. package/dist/detect.test.d.ts +0 -2
  548. package/dist/detect.test.d.ts.map +0 -1
  549. package/dist/detect.test.js +0 -26
  550. package/dist/detect.test.js.map +0 -1
  551. package/dist/docker.d.ts +0 -7
  552. package/dist/docker.d.ts.map +0 -1
  553. package/dist/docker.js +0 -17
  554. package/dist/docker.js.map +0 -1
  555. package/dist/docker.test.d.ts +0 -2
  556. package/dist/docker.test.d.ts.map +0 -1
  557. package/dist/docker.test.js +0 -12
  558. package/dist/docker.test.js.map +0 -1
  559. package/dist/health.d.ts +0 -31
  560. package/dist/health.d.ts.map +0 -1
  561. package/dist/health.js +0 -117
  562. package/dist/health.js.map +0 -1
  563. package/dist/health.test.d.ts +0 -2
  564. package/dist/health.test.d.ts.map +0 -1
  565. package/dist/health.test.js +0 -52
  566. package/dist/health.test.js.map +0 -1
  567. package/dist/index.d.ts +0 -20
  568. package/dist/index.d.ts.map +0 -1
  569. package/dist/index.js +0 -15
  570. package/dist/index.js.map +0 -1
  571. package/dist/index.test.d.ts +0 -2
  572. package/dist/index.test.d.ts.map +0 -1
  573. package/dist/index.test.js +0 -8
  574. package/dist/index.test.js.map +0 -1
  575. package/dist/installer.d.ts +0 -10
  576. package/dist/installer.d.ts.map +0 -1
  577. package/dist/installer.js +0 -50
  578. package/dist/installer.js.map +0 -1
  579. package/dist/installer.test.d.ts +0 -2
  580. package/dist/installer.test.d.ts.map +0 -1
  581. package/dist/installer.test.js +0 -43
  582. package/dist/installer.test.js.map +0 -1
  583. package/dist/lifecycle.d.ts +0 -10
  584. package/dist/lifecycle.d.ts.map +0 -1
  585. package/dist/lifecycle.js +0 -45
  586. package/dist/lifecycle.js.map +0 -1
  587. package/dist/lifecycle.test.d.ts +0 -2
  588. package/dist/lifecycle.test.d.ts.map +0 -1
  589. package/dist/lifecycle.test.js +0 -20
  590. package/dist/lifecycle.test.js.map +0 -1
  591. package/dist/manifest.d.ts +0 -18
  592. package/dist/manifest.d.ts.map +0 -1
  593. package/dist/manifest.js +0 -30
  594. package/dist/manifest.js.map +0 -1
  595. package/dist/skills-baseline.d.ts +0 -7
  596. package/dist/skills-baseline.d.ts.map +0 -1
  597. package/dist/skills-baseline.js +0 -9
  598. package/dist/skills-baseline.js.map +0 -1
  599. package/dist/skills.d.ts +0 -110
  600. package/dist/skills.d.ts.map +0 -1
  601. package/dist/skills.js +0 -429
  602. package/dist/skills.js.map +0 -1
  603. package/dist/skills.test.d.ts +0 -2
  604. package/dist/skills.test.d.ts.map +0 -1
  605. package/dist/skills.test.js +0 -416
  606. package/dist/skills.test.js.map +0 -1
  607. package/dist/sync.d.ts +0 -10
  608. package/dist/sync.d.ts.map +0 -1
  609. package/dist/sync.js +0 -39
  610. package/dist/sync.js.map +0 -1
  611. package/dist/tenant.d.ts +0 -13
  612. package/dist/tenant.d.ts.map +0 -1
  613. package/dist/tenant.js +0 -105
  614. package/dist/tenant.js.map +0 -1
  615. package/dist/tenant.test.d.ts +0 -2
  616. package/dist/tenant.test.d.ts.map +0 -1
  617. package/dist/tenant.test.js +0 -37
  618. package/dist/tenant.test.js.map +0 -1
  619. package/src/config.test.ts +0 -237
  620. package/src/detect.test.ts +0 -29
  621. package/src/detect.ts +0 -52
  622. package/src/docker.test.ts +0 -12
  623. package/src/docker.ts +0 -23
  624. package/src/health.test.ts +0 -61
  625. package/src/health.ts +0 -158
  626. package/src/index.test.ts +0 -8
  627. package/src/index.ts +0 -62
  628. package/src/installer.test.ts +0 -52
  629. package/src/installer.ts +0 -62
  630. package/src/lifecycle.test.ts +0 -23
  631. package/src/lifecycle.ts +0 -49
  632. package/src/manifest.ts +0 -42
  633. package/src/skills-baseline.ts +0 -14
  634. package/src/skills.test.ts +0 -503
  635. package/src/skills.ts +0 -512
  636. package/src/sync.ts +0 -53
  637. package/src/tenant.test.ts +0 -49
  638. package/src/tenant.ts +0 -120
@@ -0,0 +1,1462 @@
1
+ // OpenCode single-server process manager.
2
+ //
3
+ // Architecture: ONE opencode serve process shared by all project directories.
4
+ // Each SDK client uses the x-opencode-directory header to scope requests to a
5
+ // specific project. The server lazily creates and caches an Instance per unique
6
+ // directory path internally.
7
+ //
8
+ // Per-directory permissions (external_directory rules for worktrees, tmpdir,
9
+ // etc.) are passed via session.create({ permission }) at session creation time,
10
+ // NOT via the server config. The server config has permissive defaults
11
+ // (edit: allow, bash: allow, external_directory: ask) and session-level rules
12
+ // override them via opencode's findLast() evaluation (last matching rule wins).
13
+ //
14
+ // Uses errore for type-safe error handling.
15
+
16
+ import { spawn, execFileSync, type ChildProcess } from 'node:child_process'
17
+ import fs from 'node:fs'
18
+ import http from 'node:http'
19
+ import net from 'node:net'
20
+ import os from 'node:os'
21
+ import path from 'node:path'
22
+ import readline from 'node:readline'
23
+ import { fileURLToPath } from 'node:url'
24
+
25
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
26
+ import {
27
+ createOpencodeClient,
28
+ type OpencodeClient,
29
+ type Config as SdkConfig,
30
+ type PermissionRuleset,
31
+ } from '@opencode-ai/sdk/v2'
32
+
33
+ import {
34
+ getDataDir,
35
+ getLockPort,
36
+ } from './config.js'
37
+ import { store } from './store.js'
38
+ import { getHranaUrl } from './hrana-server.js'
39
+
40
+ // SDK Config type is simplified; opencode accepts nested permission objects with path patterns
41
+ type PermissionAction = 'ask' | 'allow' | 'deny'
42
+ type PermissionRule = PermissionAction | Record<string, PermissionAction>
43
+ type Config = Omit<SdkConfig, 'permission'> & {
44
+ permission?: {
45
+ edit?: PermissionRule
46
+ bash?: PermissionRule
47
+ external_directory?: PermissionRule
48
+ webfetch?: PermissionRule
49
+ [key: string]: PermissionRule | undefined
50
+ }
51
+ }
52
+ import * as errore from 'errore'
53
+ import { createLogger, LogPrefix } from './logger.js'
54
+ import { notifyError } from './sentry.js'
55
+ import {
56
+ DirectoryNotAccessibleError,
57
+ ServerStartError,
58
+ ServerNotReadyError,
59
+ FetchError,
60
+ type OpenCodeErrors,
61
+ } from './errors.js'
62
+ import {
63
+ ensureOttoCommandShim,
64
+ getPathEnvKey,
65
+ getSpawnCommandAndArgs,
66
+ prependPathEntry,
67
+ selectResolvedCommand,
68
+ } from './opencode-command.js'
69
+ import { computeSkillPermission } from './skill-filter.js'
70
+
71
+ const opencodeLogger = createLogger(LogPrefix.OPENCODE)
72
+
73
+ // Tracks directories that have been initialized, to avoid repeated log spam
74
+ // from the external sync polling loop.
75
+ const initializedDirectories = new Set<string>()
76
+
77
+ const STARTUP_STDERR_TAIL_LIMIT = 30
78
+ const STARTUP_STDERR_LINE_MAX_LENGTH = 120
79
+ const STARTUP_ERROR_REASON_MAX_LENGTH = 1500
80
+ const ANSI_ESCAPE_REGEX =
81
+ /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g
82
+
83
+ async function requestHealthcheck({
84
+ url,
85
+ timeoutMs = 2000,
86
+ }: {
87
+ url: string
88
+ timeoutMs?: number
89
+ }): Promise<{ status: number; body: string }> {
90
+ return new Promise((resolve, reject) => {
91
+ const req = http.request(
92
+ url,
93
+ {
94
+ method: 'GET',
95
+ headers: {
96
+ connection: 'close',
97
+ },
98
+ },
99
+ (res) => {
100
+ const chunks: Buffer[] = []
101
+ res.on('data', (chunk) => {
102
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
103
+ })
104
+ res.on('end', () => {
105
+ resolve({
106
+ status: res.statusCode || 0,
107
+ body: Buffer.concat(chunks).toString('utf-8'),
108
+ })
109
+ })
110
+ },
111
+ )
112
+ // Without a timeout, a stalled connection (TCP accepted during opencode
113
+ // startup before the HTTP layer is ready) hangs forever, wedging
114
+ // waitForServer on its first iteration and blocking the entire single
115
+ // server startup promise. Abort and let the poll loop retry instead.
116
+ req.setTimeout(timeoutMs, () => {
117
+ req.destroy(new Error(`healthcheck timed out after ${timeoutMs}ms`))
118
+ })
119
+ req.on('error', reject)
120
+ req.end()
121
+ })
122
+ }
123
+
124
+ function truncateWithEllipsis({
125
+ value,
126
+ maxLength,
127
+ }: {
128
+ value: string
129
+ maxLength: number
130
+ }): string {
131
+ if (maxLength <= 3) {
132
+ return value.slice(0, maxLength)
133
+ }
134
+ if (value.length <= maxLength) {
135
+ return value
136
+ }
137
+ return `${value.slice(0, maxLength - 3)}...`
138
+ }
139
+
140
+ function stripAnsiCodes(value: string): string {
141
+ return value.replaceAll(ANSI_ESCAPE_REGEX, '')
142
+ }
143
+
144
+ function sanitizeOutputLine(line: string): string {
145
+ return stripAnsiCodes(line).trim()
146
+ }
147
+
148
+ function sanitizeForCodeFence(line: string): string {
149
+ return line.replaceAll('```', '`\u200b``')
150
+ }
151
+
152
+ function pushStartupStderrTail({
153
+ stderrTail,
154
+ line,
155
+ }: {
156
+ stderrTail: string[]
157
+ line: string
158
+ }): void {
159
+ const sanitizedLine = sanitizeOutputLine(line)
160
+ if (sanitizedLine.length === 0) {
161
+ return
162
+ }
163
+
164
+ const truncatedLine = truncateWithEllipsis({
165
+ value: sanitizeForCodeFence(sanitizedLine),
166
+ maxLength: STARTUP_STDERR_LINE_MAX_LENGTH,
167
+ })
168
+
169
+ stderrTail.push(truncatedLine)
170
+ if (stderrTail.length > STARTUP_STDERR_TAIL_LIMIT) {
171
+ stderrTail.splice(0, stderrTail.length - STARTUP_STDERR_TAIL_LIMIT)
172
+ }
173
+ }
174
+
175
+ function subscribeToProcessLogStream({
176
+ stream,
177
+ onLine,
178
+ }: {
179
+ stream: NodeJS.ReadableStream | null | undefined
180
+ onLine: (line: string) => void
181
+ }): readline.Interface | null {
182
+ if (!stream) {
183
+ return null
184
+ }
185
+
186
+ const logReader = readline.createInterface({
187
+ input: stream,
188
+ crlfDelay: Infinity,
189
+ })
190
+
191
+ logReader.on('line', (line) => {
192
+ const sanitizedLine = sanitizeOutputLine(line)
193
+ if (sanitizedLine.length === 0) {
194
+ return
195
+ }
196
+ onLine(sanitizedLine)
197
+ })
198
+
199
+ return logReader
200
+ }
201
+
202
+ function buildStartupTimeoutReason({
203
+ maxAttempts,
204
+ stderrTail,
205
+ }: {
206
+ maxAttempts: number
207
+ stderrTail: string[]
208
+ }): string {
209
+ const timeoutSeconds = Math.round((maxAttempts * 100) / 1000)
210
+ const baseReason = `Server did not start after ${timeoutSeconds} seconds`
211
+ if (stderrTail.length === 0) {
212
+ return baseReason
213
+ }
214
+
215
+ const formatReason = ({
216
+ lines,
217
+ omitted,
218
+ }: {
219
+ lines: string[]
220
+ omitted: number
221
+ }): string => {
222
+ const omittedLine =
223
+ omitted > 0
224
+ ? `[... ${omitted} older stderr lines omitted to fit Discord ...]\n`
225
+ : ''
226
+ const stderrCodeBlock = `${omittedLine}${lines.join('\n')}`
227
+ return `${baseReason}\nLast opencode stderr lines:\n\`\`\`text\n${stderrCodeBlock}\n\`\`\``
228
+ }
229
+
230
+ let lines = [...stderrTail]
231
+ let omitted = 0
232
+ let formattedReason = formatReason({ lines, omitted })
233
+
234
+ while (
235
+ formattedReason.length > STARTUP_ERROR_REASON_MAX_LENGTH &&
236
+ lines.length > 0
237
+ ) {
238
+ lines = lines.slice(1)
239
+ omitted += 1
240
+ formattedReason = formatReason({ lines, omitted })
241
+ }
242
+
243
+ return truncateWithEllipsis({
244
+ value: formattedReason,
245
+ maxLength: STARTUP_ERROR_REASON_MAX_LENGTH,
246
+ })
247
+ }
248
+
249
+ // ── Single server state ──────────────────────────────────────────
250
+ // One opencode serve process, shared by all project directories.
251
+ // Clients are created per-directory with the x-opencode-directory header.
252
+
253
+ type SingleServer = {
254
+ process: ChildProcess | null
255
+ pid: number
256
+ port: number
257
+ baseUrl: string
258
+ }
259
+
260
+ type ServerLifecycleEvent =
261
+ | { type: 'started'; port: number }
262
+ | { type: 'stopped' }
263
+
264
+ let singleServer: SingleServer | null = null
265
+ let serverRetryCount = 0
266
+ const serverLifecycleListeners = new Set<(event: ServerLifecycleEvent) => void>()
267
+ let processCleanupHandlersRegistered = false
268
+ let startingServerProcess: ChildProcess | null = null
269
+ const clientCache = new Map<string, OpencodeClient>()
270
+ function getSharedServerStatePath(): string {
271
+ return path.join(getDataDir(), 'opencode-server-state.json')
272
+ }
273
+
274
+ type SharedServerState = {
275
+ pid: number
276
+ port: number
277
+ baseUrl: string
278
+ }
279
+
280
+ function writeSharedServerState({
281
+ state,
282
+ }: {
283
+ state: SharedServerState
284
+ }): void {
285
+ const writeResult = errore.try({
286
+ try: () => {
287
+ fs.writeFileSync(getSharedServerStatePath(), JSON.stringify(state))
288
+ },
289
+ catch: (cause) => {
290
+ return new Error('Failed to write shared opencode server state', { cause })
291
+ },
292
+ })
293
+ if (writeResult instanceof Error) {
294
+ opencodeLogger.warn(writeResult.message)
295
+ }
296
+ }
297
+
298
+ function clearSharedServerState(): void {
299
+ const unlinkResult = errore.try({
300
+ try: () => {
301
+ fs.unlinkSync(getSharedServerStatePath())
302
+ },
303
+ catch: (cause) => {
304
+ const error = cause instanceof Error ? cause : new Error(String(cause))
305
+ if ('code' in error && error.code === 'ENOENT') {
306
+ return
307
+ }
308
+ return new Error('Failed to clear shared opencode server state', { cause })
309
+ },
310
+ })
311
+ if (unlinkResult instanceof Error) {
312
+ opencodeLogger.warn(unlinkResult.message)
313
+ }
314
+ }
315
+
316
+ function readSharedServerState(): SharedServerState | null {
317
+ const parsed = errore.try({
318
+ try: () => {
319
+ const raw = fs.readFileSync(getSharedServerStatePath(), 'utf-8')
320
+ return JSON.parse(raw) as Partial<SharedServerState>
321
+ },
322
+ catch: (cause) => {
323
+ const error = cause instanceof Error ? cause : new Error(String(cause))
324
+ if ('code' in error && error.code === 'ENOENT') {
325
+ return null
326
+ }
327
+ return new Error('Failed to read shared opencode server state', { cause })
328
+ },
329
+ })
330
+ if (parsed instanceof Error) {
331
+ opencodeLogger.warn(parsed.message)
332
+ return null
333
+ }
334
+ if (parsed === null) {
335
+ return null
336
+ }
337
+ if (
338
+ typeof parsed.pid !== 'number' ||
339
+ !Number.isInteger(parsed.pid) ||
340
+ parsed.pid <= 0 ||
341
+ typeof parsed.port !== 'number' ||
342
+ !Number.isInteger(parsed.port) ||
343
+ parsed.port <= 0 ||
344
+ typeof parsed.baseUrl !== 'string' ||
345
+ parsed.baseUrl.length === 0
346
+ ) {
347
+ clearSharedServerState()
348
+ return null
349
+ }
350
+ return {
351
+ pid: parsed.pid,
352
+ port: parsed.port,
353
+ baseUrl: parsed.baseUrl,
354
+ }
355
+ }
356
+
357
+ function isProcessAlive({ pid }: { pid: number }): boolean {
358
+ return errore.try({
359
+ try: () => {
360
+ process.kill(pid, 0)
361
+ return true
362
+ },
363
+ catch: () => {
364
+ return false
365
+ },
366
+ })
367
+ }
368
+
369
+ function notifyServerLifecycle(event: ServerLifecycleEvent): void {
370
+ for (const listener of serverLifecycleListeners) {
371
+ listener(event)
372
+ }
373
+ }
374
+
375
+ export function subscribeOpencodeServerLifecycle(
376
+ listener: (event: ServerLifecycleEvent) => void,
377
+ ): () => void {
378
+ serverLifecycleListeners.add(listener)
379
+ return () => {
380
+ serverLifecycleListeners.delete(listener)
381
+ }
382
+ }
383
+
384
+ function killSingleServerProcessNow({
385
+ reason,
386
+ }: {
387
+ reason: string
388
+ }): void {
389
+ if (!singleServer) {
390
+ return
391
+ }
392
+
393
+ const serverProcess = singleServer.process
394
+ if (!serverProcess) {
395
+ return
396
+ }
397
+ const pid = serverProcess.pid
398
+ if (!pid || serverProcess.killed) {
399
+ return
400
+ }
401
+
402
+ const killResult = errore.try({
403
+ try: () => {
404
+ serverProcess.kill('SIGTERM')
405
+ },
406
+ catch: (error) => {
407
+ return new Error('Failed to send SIGTERM to opencode server', {
408
+ cause: error,
409
+ })
410
+ },
411
+ })
412
+
413
+ if (killResult instanceof Error) {
414
+ opencodeLogger.warn(
415
+ `[cleanup:${reason}] ${killResult.message} (pid: ${pid}, port: ${singleServer.port})`,
416
+ )
417
+ return
418
+ }
419
+
420
+ opencodeLogger.log(
421
+ `[cleanup:${reason}] Sent SIGTERM to opencode server (pid: ${pid}, port: ${singleServer.port})`,
422
+ )
423
+ }
424
+
425
+ function killStartingServerProcessNow({
426
+ reason,
427
+ }: {
428
+ reason: string
429
+ }): void {
430
+ const serverProcess = startingServerProcess
431
+ if (!serverProcess) {
432
+ return
433
+ }
434
+
435
+ const pid = serverProcess.pid
436
+ if (!pid || serverProcess.killed) {
437
+ return
438
+ }
439
+
440
+ const killResult = errore.try({
441
+ try: () => {
442
+ serverProcess.kill('SIGTERM')
443
+ },
444
+ catch: (error) => {
445
+ return new Error('Failed to send SIGTERM to starting opencode server', {
446
+ cause: error,
447
+ })
448
+ },
449
+ })
450
+
451
+ if (killResult instanceof Error) {
452
+ opencodeLogger.warn(
453
+ `[cleanup:${reason}] ${killResult.message} (pid: ${pid})`,
454
+ )
455
+ return
456
+ }
457
+
458
+ opencodeLogger.log(
459
+ `[cleanup:${reason}] Sent SIGTERM to starting opencode server (pid: ${pid})`,
460
+ )
461
+ }
462
+
463
+ function ensureProcessCleanupHandlersRegistered(): void {
464
+ if (processCleanupHandlersRegistered) {
465
+ return
466
+ }
467
+ processCleanupHandlersRegistered = true
468
+
469
+ opencodeLogger.log('Registering process cleanup handlers for opencode server')
470
+
471
+ process.on('exit', () => {
472
+ killSingleServerProcessNow({ reason: 'process-exit' })
473
+ killStartingServerProcessNow({ reason: 'process-exit' })
474
+ })
475
+
476
+ // Fallback for short-lived CLI subcommands that call process.exit without
477
+ // running discord-bot.ts shutdown handlers.
478
+ process.on('SIGINT', () => {
479
+ killSingleServerProcessNow({ reason: 'sigint' })
480
+ killStartingServerProcessNow({ reason: 'sigint' })
481
+ })
482
+ process.on('SIGTERM', () => {
483
+ killSingleServerProcessNow({ reason: 'sigterm' })
484
+ killStartingServerProcessNow({ reason: 'sigterm' })
485
+ })
486
+ }
487
+
488
+ // ── Resolve opencode binary ──────────────────────────────────────
489
+ // Resolve the full path to the opencode binary so we can spawn without
490
+ // shell: true. Using shell: true creates an intermediate sh process — when
491
+ // cleanup sends SIGTERM it only kills the shell, leaving the actual opencode
492
+ // process orphaned (reparented to PID 1). Resolving the path upfront lets
493
+ // us spawn the binary directly and SIGTERM reaches the right process.
494
+ let resolvedOpencodeCommand: string | null = null
495
+
496
+ // Guard against accidentally spawning the otto binary as the opencode server.
497
+ // If OPENCODE_PATH or the resolved `opencode` in PATH actually points to otto
498
+ // itself, that would cause an infinite process loop.
499
+ function looksLikeOttoCommand(commandPath: string): boolean {
500
+ const normalized = path.basename(commandPath).toLowerCase()
501
+ return (
502
+ normalized === 'otto' ||
503
+ normalized === 'otto.cmd' ||
504
+ normalized === 'otto.bat' ||
505
+ normalized === 'otto' ||
506
+ normalized === 'otto.cmd' ||
507
+ normalized === 'otto.bat'
508
+ )
509
+ }
510
+
511
+ export function resolveOpencodeCommand(): string {
512
+ if (resolvedOpencodeCommand) {
513
+ return resolvedOpencodeCommand
514
+ }
515
+
516
+ const envPath = process.env.OPENCODE_PATH
517
+ if (envPath) {
518
+ const resolvedFromEnv = selectResolvedCommand({
519
+ output: envPath,
520
+ isWindows: process.platform === 'win32',
521
+ })
522
+ if (resolvedFromEnv && !looksLikeOttoCommand(resolvedFromEnv)) {
523
+ resolvedOpencodeCommand = resolvedFromEnv
524
+ return resolvedFromEnv
525
+ }
526
+ if (resolvedFromEnv) {
527
+ opencodeLogger.warn(
528
+ `Ignoring OPENCODE_PATH because it points to otto (${resolvedFromEnv})`,
529
+ )
530
+ }
531
+ }
532
+
533
+ const isWindows = process.platform === 'win32'
534
+ const whichCmd = isWindows ? 'where' : 'which'
535
+ const result = errore.try({
536
+ try: () => {
537
+ const commandOutput = execFileSync(whichCmd, ['opencode'], {
538
+ encoding: 'utf8',
539
+ timeout: 5000,
540
+ })
541
+ const resolved = selectResolvedCommand({
542
+ output: commandOutput,
543
+ isWindows,
544
+ })
545
+ if (resolved && !looksLikeOttoCommand(resolved)) {
546
+ return resolved
547
+ }
548
+ if (resolved) {
549
+ throw new Error(`opencode command resolves to otto binary (${resolved})`)
550
+ }
551
+ throw new Error('opencode not found in PATH')
552
+ },
553
+ catch: () => new Error('opencode not found in PATH'),
554
+ })
555
+
556
+ if (result instanceof Error) {
557
+ // Fall back to bare command name — spawn will fail with a clear error
558
+ // if it can't find the binary.
559
+ opencodeLogger.warn('Could not resolve opencode path via which, falling back to "opencode"')
560
+ return 'opencode'
561
+ }
562
+
563
+ resolvedOpencodeCommand = result
564
+ opencodeLogger.log(`Resolved opencode binary: ${result}`)
565
+ return result
566
+ }
567
+ async function getOpenPort(): Promise<number> {
568
+ return new Promise((resolve, reject) => {
569
+ const server = net.createServer()
570
+ server.listen(0, () => {
571
+ const address = server.address()
572
+ if (address && typeof address === 'object') {
573
+ const port = address.port
574
+ server.close(() => {
575
+ resolve(port)
576
+ })
577
+ } else {
578
+ reject(new Error('Failed to get port'))
579
+ }
580
+ })
581
+ server.on('error', reject)
582
+ })
583
+ }
584
+
585
+ async function waitForServer({
586
+ port,
587
+ directory,
588
+ maxAttempts = 300,
589
+ startupStderrTail,
590
+ }: {
591
+ port: number
592
+ directory?: string
593
+ maxAttempts?: number
594
+ startupStderrTail: string[]
595
+ }): Promise<ServerStartError | true> {
596
+ const endpoint = new URL(`http://127.0.0.1:${port}/api/health`)
597
+ if (directory) {
598
+ endpoint.searchParams.set('directory', directory)
599
+ }
600
+ for (let i = 0; i < maxAttempts; i++) {
601
+ const response = await errore.tryAsync({
602
+ try: () => requestHealthcheck({ url: endpoint.toString() }),
603
+ catch: (e) => new FetchError({ url: endpoint.toString(), cause: e }),
604
+ })
605
+ if (response instanceof Error) {
606
+ // Connection refused or other transient errors - continue polling.
607
+ // Use 100ms interval instead of 1s so we detect readiness faster.
608
+ // Critical for scale-to-zero cold starts where every ms matters.
609
+ await new Promise((resolve) => setTimeout(resolve, 100))
610
+ continue
611
+ }
612
+ if (response.status < 500) {
613
+ return true
614
+ }
615
+ const body = response.body
616
+ // Fatal errors that won't resolve with retrying
617
+ if (body.includes('BunInstallFailedError')) {
618
+ return new ServerStartError({ port, reason: body.slice(0, 200) })
619
+ }
620
+ await new Promise((resolve) => setTimeout(resolve, 100))
621
+ }
622
+ return new ServerStartError({
623
+ port,
624
+ reason: buildStartupTimeoutReason({
625
+ maxAttempts,
626
+ stderrTail: startupStderrTail,
627
+ }),
628
+ })
629
+ }
630
+
631
+ // ── Single server lifecycle ──────────────────────────────────────
632
+ // The server is started lazily on first initializeOpencodeForDirectory() call.
633
+ // It uses permissive defaults (edit: allow, bash: allow, external_directory: ask).
634
+ // Per-directory permissions are applied at session creation time instead.
635
+
636
+ // In-flight promise to prevent concurrent startups from racing
637
+ let startingServer: Promise<ServerStartError | SingleServer> | null = null
638
+ let preferredStartupDirectory: string | null = null
639
+
640
+ function ensureOpencodeHomeDirectories({
641
+ directories,
642
+ }: {
643
+ directories: Record<string, string>
644
+ }) {
645
+ Object.values(directories).map((directory) => {
646
+ fs.mkdirSync(directory, { recursive: true })
647
+ })
648
+ }
649
+
650
+ async function ensureSingleServer({
651
+ directory,
652
+ }: {
653
+ directory?: string
654
+ } = {}): Promise<ServerStartError | SingleServer> {
655
+ const startupDirectory = directory || preferredStartupDirectory || undefined
656
+ if (singleServer && singleServer.process && !singleServer.process.killed) {
657
+ return singleServer
658
+ }
659
+ if (singleServer && singleServer.process === null) {
660
+ return singleServer
661
+ }
662
+
663
+ const sharedServer = await tryAdoptSharedServer({ directory: startupDirectory })
664
+ if (sharedServer) {
665
+ singleServer = sharedServer
666
+ notifyServerLifecycle({ type: 'started', port: sharedServer.port })
667
+ return sharedServer
668
+ }
669
+
670
+ // Deduplicate concurrent startup attempts
671
+ if (startingServer) {
672
+ return startingServer
673
+ }
674
+
675
+ startingServer = startSingleServer({ directory: startupDirectory })
676
+ try {
677
+ return await startingServer
678
+ } finally {
679
+ startingServer = null
680
+ }
681
+ }
682
+
683
+ async function startSingleServer({
684
+ directory,
685
+ }: {
686
+ directory?: string
687
+ } = {}): Promise<ServerStartError | SingleServer> {
688
+ ensureProcessCleanupHandlersRegistered()
689
+
690
+ const port = await getOpenPort()
691
+
692
+ const serveArgs = [
693
+ 'serve',
694
+ '--port',
695
+ port.toString(),
696
+ '--print-logs',
697
+ '--log-level',
698
+ 'WARN',
699
+ ]
700
+
701
+ const {
702
+ command: spawnCommand,
703
+ args: spawnArgs,
704
+ windowsVerbatimArguments,
705
+ } = getSpawnCommandAndArgs({
706
+ resolvedCommand: resolveOpencodeCommand(),
707
+ baseArgs: serveArgs,
708
+ })
709
+
710
+ // Server config uses permissive defaults. Per-directory external_directory
711
+ // permissions are set at session creation time via session.create({ permission }).
712
+ // Common directories (tmpdir, ~/.config/opencode, the otto data dir) are
713
+ // pre-allowed at the server level so they never trigger permission prompts
714
+ // regardless of whether session-level rules compose correctly.
715
+ const tmpdir = os.tmpdir().replaceAll('\\', '/')
716
+ const opencodeConfigDir = path
717
+ .join(os.homedir(), '.config', 'opencode')
718
+ .replaceAll('\\', '/')
719
+ const opensrcDir = path
720
+ .join(os.homedir(), '.opensrc')
721
+ .replaceAll('\\', '/')
722
+ // Allow the actual data dir (could be ~/.otto or legacy ~/.otto)
723
+ const ottoDataDir = getDataDir().replaceAll('\\', '/')
724
+ // No catch-all '*': 'ask' here — the user's opencode.json default is respected.
725
+ // Only allowlist specific known-safe directories at the server level.
726
+ const externalDirectoryPermissions: Record<string, 'ask' | 'allow' | 'deny'> = {
727
+ '/tmp': 'allow',
728
+ '/tmp/*': 'allow',
729
+ '/private/tmp': 'allow',
730
+ '/private/tmp/*': 'allow',
731
+ [tmpdir]: 'allow',
732
+ [`${tmpdir}/*`]: 'allow',
733
+ [opencodeConfigDir]: 'allow',
734
+ [`${opencodeConfigDir}/*`]: 'allow',
735
+ [opensrcDir]: 'allow',
736
+ [`${opensrcDir}/*`]: 'allow',
737
+ [ottoDataDir]: 'allow',
738
+ [`${ottoDataDir}/*`]: 'allow',
739
+ }
740
+ const ottoShimDirectory = ensureOttoCommandShim({
741
+ dataDir: getDataDir(),
742
+ execPath: process.execPath,
743
+ execArgv: process.execArgv,
744
+ entryScript: process.argv[1] || fileURLToPath(new URL('../bin.js', import.meta.url)),
745
+ })
746
+ const pathEnvKey = getPathEnvKey(process.env)
747
+ const pathEnv = ottoShimDirectory instanceof Error
748
+ ? process.env[pathEnvKey]
749
+ : prependPathEntry({
750
+ entry: ottoShimDirectory,
751
+ existingPath: process.env[pathEnvKey],
752
+ })
753
+ if (ottoShimDirectory instanceof Error) {
754
+ opencodeLogger.warn(ottoShimDirectory.message)
755
+ }
756
+ const gatewayToken = store.getState().gatewayToken || process.env.OTTO_DB_AUTH_TOKEN
757
+ const opencodeConfigHomeDir = path.join(getDataDir(), 'opencode-home')
758
+ fs.mkdirSync(opencodeConfigHomeDir, { recursive: true })
759
+ const vitestOpencodeEnv = (() => {
760
+ if (process.env.OTTO_VITEST !== '1') {
761
+ return {}
762
+ }
763
+ const root = path.join(getDataDir(), 'opencode-vitest-home')
764
+ const directories = {
765
+ OPENCODE_TEST_HOME: root,
766
+ OPENCODE_CONFIG_DIR: path.join(root, '.opencode-otto'),
767
+ XDG_CONFIG_HOME: path.join(root, '.config'),
768
+ XDG_DATA_HOME: path.join(root, '.local', 'share'),
769
+ XDG_CACHE_HOME: path.join(root, '.cache'),
770
+ XDG_STATE_HOME: path.join(root, '.local', 'state'),
771
+ }
772
+ // OpenCode writes state/config files into these XDG locations during boot.
773
+ // In CI, a fresh temp data dir means the parent folders may not exist yet,
774
+ // and some writes fail closed with NotFound before OpenCode has a chance to
775
+ // create them lazily. Pre-create the directories so startup-time tests do
776
+ // not flap based on process scheduling.
777
+ ensureOpencodeHomeDirectories({ directories })
778
+ return directories
779
+ })()
780
+
781
+ // Write config to a file instead of passing via OPENCODE_CONFIG_CONTENT env var.
782
+ // OPENCODE_CONFIG (file path) is loaded before project config in opencode's
783
+ // priority chain, so project-level opencode.json can override otto defaults.
784
+ // OPENCODE_CONFIG_CONTENT was loaded last and overrode user project configs,
785
+ // causing issue #90 (project permissions not being respected).
786
+ const isDev = import.meta.url.endsWith('.ts') || import.meta.url.endsWith('.tsx')
787
+ // Skill whitelist/blacklist from --enable-skill / --disable-skill CLI flags.
788
+ // Applied as opencode permission.skill rules so every agent inherits the
789
+ // filter via Permission.merge(defaults, agentRules, user).
790
+ const skillPermission = computeSkillPermission({
791
+ enabledSkills: store.getState().enabledSkills,
792
+ disabledSkills: store.getState().disabledSkills,
793
+ })
794
+ const opencodeConfig = {
795
+ $schema: 'https://opencode.ai/config.json',
796
+ lsp: false,
797
+ formatter: false,
798
+ plugin: [
799
+ new URL(
800
+ isDev ? './otto-opencode-plugin.ts' : './otto-opencode-plugin.js',
801
+ import.meta.url,
802
+ ).href,
803
+ ],
804
+ permission: {
805
+ edit: 'allow',
806
+ bash: 'allow',
807
+ external_directory: externalDirectoryPermissions,
808
+ webfetch: 'allow',
809
+ ...(skillPermission && { skill: skillPermission }),
810
+ },
811
+ agent: {
812
+ explore: {
813
+ permission: {
814
+ '*': 'deny',
815
+ grep: 'allow',
816
+ glob: 'allow',
817
+ list: 'allow',
818
+ read: {
819
+ '*': 'allow',
820
+ '*.env': 'deny',
821
+ '*.env.*': 'deny',
822
+ '*.env.example': 'allow',
823
+ },
824
+ webfetch: 'allow',
825
+ websearch: 'allow',
826
+ codesearch: 'allow',
827
+ external_directory: externalDirectoryPermissions,
828
+ },
829
+ },
830
+ },
831
+ skills: {
832
+ paths: [path.resolve(__dirname, '..', 'skills')],
833
+ },
834
+ } satisfies Config
835
+ const opencodeConfigPath = path.join(getDataDir(), 'opencode-config.json')
836
+ const opencodeConfigJson = JSON.stringify(opencodeConfig, null, 2)
837
+ const existingContent = (() => {
838
+ try {
839
+ return fs.readFileSync(opencodeConfigPath, 'utf-8')
840
+ } catch {
841
+ return ''
842
+ }
843
+ })()
844
+ if (existingContent !== opencodeConfigJson) {
845
+ fs.writeFileSync(opencodeConfigPath, opencodeConfigJson)
846
+ }
847
+
848
+ const serverProcess = spawn(
849
+ spawnCommand,
850
+ spawnArgs,
851
+ {
852
+ stdio: 'pipe',
853
+ detached: false,
854
+ windowsVerbatimArguments,
855
+ // No project-specific cwd — the server handles all directories via
856
+ // x-opencode-directory header. Use home dir as a neutral working dir.
857
+ cwd: os.homedir(),
858
+ env: {
859
+ ...process.env,
860
+ OPENCODE_CONFIG: opencodeConfigPath,
861
+ OPENCODE_CONFIG_DIR: opencodeConfigHomeDir,
862
+ OPENCODE_PORT: port.toString(),
863
+ OTTO: '1',
864
+ OTTO_DATA_DIR: getDataDir(),
865
+ // opencode-injection-guard still keys session scan patterns off legacy Kimaki
866
+ // env vars at our pinned submodule revision — mirror Otto dirs so Otto doesn't
867
+ // need unpublishable forks of upstream guard code.
868
+ KIMAKI: '1',
869
+ KIMAKI_DATA_DIR: getDataDir(),
870
+ OTTO_LOCK_PORT: getLockPort().toString(),
871
+ ...(gatewayToken && { OTTO_DB_AUTH_TOKEN: gatewayToken }),
872
+ // Guard: prevents agents from running `otto` root command inside
873
+ // an OpenCode session, which would steal the lock port and break the bot.
874
+ OTTO_OPENCODE_PROCESS: '1',
875
+ ...(getHranaUrl() && { OTTO_DB_URL: getHranaUrl()! }),
876
+ ...(process.env.OTTO_SENTRY_DSN && {
877
+ OTTO_SENTRY_DSN: process.env.OTTO_SENTRY_DSN,
878
+ }),
879
+ ...vitestOpencodeEnv,
880
+ ...(pathEnv && { [pathEnvKey]: pathEnv }),
881
+ },
882
+ },
883
+ )
884
+
885
+ startingServerProcess = serverProcess
886
+
887
+ // Buffer logs until we know if server started successfully.
888
+ const logBuffer: string[] = []
889
+ const startupStderrTail: string[] = []
890
+ let serverReady = false
891
+
892
+ logBuffer.push(
893
+ `Spawned opencode serve --port ${port} (pid: ${serverProcess.pid})`,
894
+ )
895
+
896
+ const stdoutReader = subscribeToProcessLogStream({
897
+ stream: serverProcess.stdout,
898
+ onLine: (line) => {
899
+ if (!serverReady) {
900
+ logBuffer.push(`[stdout] ${line}`)
901
+ return
902
+ }
903
+ opencodeLogger.log(line)
904
+ },
905
+ })
906
+
907
+ const stderrReader = subscribeToProcessLogStream({
908
+ stream: serverProcess.stderr,
909
+ onLine: (line) => {
910
+ if (!serverReady) {
911
+ logBuffer.push(`[stderr] ${line}`)
912
+ pushStartupStderrTail({ stderrTail: startupStderrTail, line })
913
+ return
914
+ }
915
+ opencodeLogger.error(line)
916
+ },
917
+ })
918
+
919
+ serverProcess.on('error', (error) => {
920
+ logBuffer.push(`Failed to start server on port ${port}: ${error}`)
921
+ })
922
+
923
+ serverProcess.on('exit', (code, signal) => {
924
+ stdoutReader?.close()
925
+ stderrReader?.close()
926
+
927
+ if (startingServerProcess === serverProcess) {
928
+ startingServerProcess = null
929
+ }
930
+
931
+ opencodeLogger.log(
932
+ `Opencode server exited with code: ${code}, signal: ${signal}`,
933
+ )
934
+ singleServer = null
935
+ clearSharedServerState()
936
+ clientCache.clear()
937
+ notifyServerLifecycle({ type: 'stopped' })
938
+
939
+ // Intentional kills should not trigger auto-restart:
940
+ // - SIGTERM from our cleanup/restart code
941
+ // - SIGINT propagated from Ctrl+C (parent process group signal)
942
+ // - any exit during bot shutdown (shuttingDown flag)
943
+ // Only unexpected crashes (non-zero exit without signal) get retried.
944
+ if (signal === 'SIGTERM' || signal === 'SIGINT' || (global as any).shuttingDown) {
945
+ serverRetryCount = 0
946
+ return
947
+ }
948
+ if (code !== 0) {
949
+ if (serverRetryCount < 5) {
950
+ serverRetryCount += 1
951
+ opencodeLogger.log(
952
+ `Restarting server (attempt ${serverRetryCount}/5)`,
953
+ )
954
+ ensureSingleServer().then(
955
+ (result) => {
956
+ if (result instanceof Error) {
957
+ opencodeLogger.error(`Failed to restart opencode server:`, result)
958
+ void notifyError(result, `OpenCode server restart failed`)
959
+ }
960
+ },
961
+ )
962
+ } else {
963
+ const crashError = new Error(
964
+ `Server crashed too many times (5), not restarting`,
965
+ )
966
+ opencodeLogger.error(crashError.message)
967
+ void notifyError(crashError, `OpenCode server crash loop exhausted`)
968
+ }
969
+ } else {
970
+ serverRetryCount = 0
971
+ }
972
+ })
973
+
974
+ const waitResult = await waitForServer({
975
+ port,
976
+ directory,
977
+ startupStderrTail,
978
+ })
979
+ if (waitResult instanceof Error) {
980
+ killStartingServerProcessNow({ reason: 'startup-failed' })
981
+ if (startingServerProcess === serverProcess) {
982
+ startingServerProcess = null
983
+ }
984
+
985
+ // Dump buffered logs on failure
986
+ opencodeLogger.error(`Server failed to start:`)
987
+ for (const line of logBuffer) {
988
+ opencodeLogger.error(` ${line}`)
989
+ }
990
+ return waitResult
991
+ }
992
+ serverReady = true
993
+ opencodeLogger.log(`Server ready on port ${port}`)
994
+
995
+ // Always dump startup logs so plugin loading errors and other startup output
996
+ // are visible in otto.log.
997
+ for (const line of logBuffer) {
998
+ opencodeLogger.log(line)
999
+ }
1000
+
1001
+ const server: SingleServer = {
1002
+ process: serverProcess,
1003
+ pid: serverProcess.pid || process.pid,
1004
+ port,
1005
+ baseUrl: `http://127.0.0.1:${port}`,
1006
+ }
1007
+ if (startingServerProcess === serverProcess) {
1008
+ startingServerProcess = null
1009
+ }
1010
+ singleServer = server
1011
+ writeSharedServerState({
1012
+ state: {
1013
+ pid: server.pid,
1014
+ port: server.port,
1015
+ baseUrl: server.baseUrl,
1016
+ },
1017
+ })
1018
+ notifyServerLifecycle({ type: 'started', port })
1019
+ return server
1020
+ }
1021
+
1022
+ async function tryAdoptSharedServer({
1023
+ directory,
1024
+ }: {
1025
+ directory?: string
1026
+ }): Promise<SingleServer | null> {
1027
+ const sharedState = readSharedServerState()
1028
+ if (!sharedState) {
1029
+ return null
1030
+ }
1031
+ if (!isProcessAlive({ pid: sharedState.pid })) {
1032
+ clearSharedServerState()
1033
+ return null
1034
+ }
1035
+ const healthResult = await waitForServer({
1036
+ port: sharedState.port,
1037
+ directory,
1038
+ maxAttempts: 3,
1039
+ startupStderrTail: [],
1040
+ })
1041
+ if (healthResult instanceof Error) {
1042
+ return null
1043
+ }
1044
+ opencodeLogger.log(
1045
+ `Adopted shared opencode server (pid: ${sharedState.pid}, port: ${sharedState.port})`,
1046
+ )
1047
+ return {
1048
+ process: null,
1049
+ pid: sharedState.pid,
1050
+ port: sharedState.port,
1051
+ baseUrl: sharedState.baseUrl,
1052
+ }
1053
+ }
1054
+
1055
+ function getOrCreateClient({
1056
+ baseUrl,
1057
+ directory,
1058
+ }: {
1059
+ baseUrl: string
1060
+ directory: string
1061
+ }): OpencodeClient {
1062
+ const cached = clientCache.get(directory)
1063
+ if (cached) {
1064
+ return cached
1065
+ }
1066
+
1067
+ const fetchWithTimeout = (request: Request) =>
1068
+ fetch(request, {
1069
+ // @ts-ignore
1070
+ timeout: false,
1071
+ })
1072
+
1073
+ const client = createOpencodeClient({
1074
+ baseUrl,
1075
+ directory,
1076
+ fetch: fetchWithTimeout as typeof fetch,
1077
+ })
1078
+ clientCache.set(directory, client)
1079
+ return client
1080
+ }
1081
+
1082
+ // ── Public API ───────────────────────────────────────────────────
1083
+ // Same signatures as before so callers don't need to change.
1084
+
1085
+ /**
1086
+ * Initialize OpenCode server for a directory.
1087
+ * Starts the single shared server if not running, then returns a client
1088
+ * factory scoped to the given directory via x-opencode-directory header.
1089
+ *
1090
+ * @param directory - The project directory to scope requests to
1091
+ * @param options.originalRepoDirectory - For worktrees: the original repo directory
1092
+ * (no longer used for server-level permissions — use buildSessionPermissions
1093
+ * at session.create() time instead)
1094
+ */
1095
+ export async function initializeOpencodeForDirectory(
1096
+ directory: string,
1097
+ _options?: { originalRepoDirectory?: string; channelId?: string },
1098
+ ): Promise<OpenCodeErrors | (() => OpencodeClient)> {
1099
+ // Verify directory exists and is accessible
1100
+ const accessCheck = errore.tryFn({
1101
+ try: () => {
1102
+ fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK)
1103
+ },
1104
+ catch: () => new DirectoryNotAccessibleError({ directory }),
1105
+ })
1106
+ if (accessCheck instanceof Error) {
1107
+ return accessCheck
1108
+ }
1109
+
1110
+ preferredStartupDirectory = directory
1111
+
1112
+ const server = await ensureSingleServer({ directory })
1113
+ if (server instanceof Error) {
1114
+ return server
1115
+ }
1116
+
1117
+ if (!initializedDirectories.has(directory)) {
1118
+ initializedDirectories.add(directory)
1119
+ }
1120
+
1121
+ return () => {
1122
+ if (!singleServer) {
1123
+ throw new ServerNotReadyError({ directory })
1124
+ }
1125
+ return getOrCreateClient({
1126
+ baseUrl: singleServer.baseUrl,
1127
+ directory,
1128
+ })
1129
+ }
1130
+ }
1131
+
1132
+ /**
1133
+ * Build per-session permission rules for external_directory access.
1134
+ * These rules are passed to session.create({ permission }) and override
1135
+ * the server-level defaults via opencode's findLast() evaluation.
1136
+ *
1137
+ * This replaces the old per-server OPENCODE_CONFIG_CONTENT external_directory
1138
+ * permissions — now each session carries its own directory-scoped rules.
1139
+ */
1140
+ export function buildSessionPermissions({
1141
+ directory,
1142
+ originalRepoDirectory,
1143
+ extraAllowedDirectories,
1144
+ }: {
1145
+ directory: string
1146
+ originalRepoDirectory?: string
1147
+ /** Additional directories to allow (e.g. other registered project dirs). */
1148
+ extraAllowedDirectories?: string[]
1149
+ }): PermissionRuleset {
1150
+ // Normalize path separators for cross-platform compatibility (Windows uses backslashes)
1151
+ const tmpdir = os.tmpdir().replaceAll('\\', '/')
1152
+ const normalizedDirectory = directory.replaceAll('\\', '/')
1153
+ const originalRepo = originalRepoDirectory?.replaceAll('\\', '/')
1154
+
1155
+ const rules: PermissionRuleset = [
1156
+ // Allow tmpdir access
1157
+ { permission: 'external_directory', pattern: '/tmp', action: 'allow' },
1158
+ { permission: 'external_directory', pattern: '/tmp/*', action: 'allow' },
1159
+ { permission: 'external_directory', pattern: '/private/tmp', action: 'allow' },
1160
+ { permission: 'external_directory', pattern: '/private/tmp/*', action: 'allow' },
1161
+ { permission: 'external_directory', pattern: tmpdir, action: 'allow' },
1162
+ { permission: 'external_directory', pattern: `${tmpdir}/*`, action: 'allow' },
1163
+ // Allow the project directory itself
1164
+ { permission: 'external_directory', pattern: normalizedDirectory, action: 'allow' },
1165
+ { permission: 'external_directory', pattern: `${normalizedDirectory}/*`, action: 'allow' },
1166
+ ]
1167
+
1168
+ const homeDirectoryRules = ({ relativePath }: { relativePath: string }) => {
1169
+ const normalizedRelativePath = relativePath.replaceAll('\\', '/')
1170
+ const basePattern = path.resolve(os.homedir(), normalizedRelativePath)
1171
+ return [
1172
+ { permission: 'external_directory', pattern: basePattern, action: 'allow' },
1173
+ { permission: 'external_directory', pattern: `${basePattern}/*`, action: 'allow' },
1174
+ ] satisfies PermissionRuleset
1175
+ }
1176
+
1177
+ // Allow ~/.config/opencode so the agent doesn't get permission prompts when
1178
+ // it tries to read the global AGENTS.md or opencode config (the path is
1179
+ // visible in the system prompt, so models sometimes try to read it).
1180
+ rules.push(...homeDirectoryRules({ relativePath: '.config/opencode' }))
1181
+
1182
+ // Allow ~/.config/openc0de too because the Anthropic plugin rewrites the
1183
+ // name in the system prompt and some models may try to inspect that path.
1184
+ rules.push(...homeDirectoryRules({ relativePath: '.config/openc0de' }))
1185
+
1186
+ // Allow ~/.opensrc so agents can inspect cached opensrc checkouts without
1187
+ // permission prompts.
1188
+ rules.push(...homeDirectoryRules({ relativePath: '.opensrc' }))
1189
+
1190
+ // Allow ~/.otto (and legacy ~/.otto) so the agent can access otto data dir (logs, db, etc.)
1191
+ // without permission prompts.
1192
+ rules.push(...homeDirectoryRules({ relativePath: '.otto' }))
1193
+ rules.push(...homeDirectoryRules({ relativePath: '.otto' }))
1194
+
1195
+ // Allow opencode tool output artifacts under XDG data so agents can inspect
1196
+ // prior tool outputs without interactive permission prompts.
1197
+ rules.push(...homeDirectoryRules({ relativePath: '.local/share/opencode/tool-output' }))
1198
+
1199
+ // Allow common language caches under the user's home directory so toolchains
1200
+ // can inspect downloaded modules and artifacts without external_directory prompts.
1201
+ rules.push(
1202
+ ...homeDirectoryRules({ relativePath: '.cache/zig' }),
1203
+ ...homeDirectoryRules({ relativePath: '.cargo' }),
1204
+ ...homeDirectoryRules({ relativePath: '.cache/go-build' }),
1205
+ ...homeDirectoryRules({ relativePath: 'go/pkg' }),
1206
+ )
1207
+
1208
+ // Allow other registered project directories so the model can inspect them
1209
+ // without triggering external_directory permission prompts.
1210
+ if (extraAllowedDirectories?.length) {
1211
+ for (const dir of extraAllowedDirectories) {
1212
+ const normalized = dir.replaceAll('\\', '/')
1213
+ if (normalized !== normalizedDirectory && normalized !== originalRepo) {
1214
+ rules.push(
1215
+ { permission: 'external_directory', pattern: normalized, action: 'allow' },
1216
+ { permission: 'external_directory', pattern: `${normalized}/*`, action: 'allow' },
1217
+ )
1218
+ }
1219
+ }
1220
+ }
1221
+
1222
+ // For worktree sessions: explicitly deny the original checkout so agents do
1223
+ // not keep editing the main repo after the thread has moved to a managed
1224
+ // worktree. Deny rules are appended last so they override earlier allow/
1225
+ // ask defaults via opencode's findLast() evaluation.
1226
+ if (originalRepo && originalRepo !== normalizedDirectory) {
1227
+ rules.push(
1228
+ ...buildExternalDirectoryPermissionRules({
1229
+ resolvedPattern: originalRepo,
1230
+ action: 'deny',
1231
+ }),
1232
+ )
1233
+ }
1234
+
1235
+
1236
+ return rules
1237
+ }
1238
+
1239
+ const ALL_EXTERNAL_DIRECTORIES_PATTERN = '*'
1240
+
1241
+ export function buildExternalDirectoryPermissionRules({
1242
+ resolvedPattern,
1243
+ action,
1244
+ }: {
1245
+ resolvedPattern: string
1246
+ action: 'allow' | 'deny' | 'ask'
1247
+ }): PermissionRuleset {
1248
+ if (resolvedPattern === ALL_EXTERNAL_DIRECTORIES_PATTERN) {
1249
+ return [
1250
+ {
1251
+ permission: 'external_directory',
1252
+ pattern: ALL_EXTERNAL_DIRECTORIES_PATTERN,
1253
+ action,
1254
+ },
1255
+ ]
1256
+ }
1257
+
1258
+ return [
1259
+ {
1260
+ permission: 'external_directory',
1261
+ pattern: resolvedPattern,
1262
+ action,
1263
+ },
1264
+ {
1265
+ permission: 'external_directory',
1266
+ pattern: `${resolvedPattern}/*`,
1267
+ action,
1268
+ },
1269
+ ]
1270
+ }
1271
+
1272
+ /**
1273
+ * Parse raw permission strings into PermissionRuleset entries.
1274
+ *
1275
+ * Accepted formats:
1276
+ * "tool:action" → { permission: tool, pattern: "*", action }
1277
+ * "tool:pattern:action" → { permission: tool, pattern, action }
1278
+ *
1279
+ * The action must be one of "allow", "deny", "ask" (case-insensitive).
1280
+ * Parts are trimmed to tolerate whitespace from YAML deserialization.
1281
+ * Invalid entries are silently skipped (bad user input shouldn't crash the bot).
1282
+ * If `raw` is not an array, returns empty (defensive against malformed YAML markers).
1283
+ */
1284
+ export function parsePermissionRules(raw: unknown): PermissionRuleset {
1285
+ if (!Array.isArray(raw)) {
1286
+ return []
1287
+ }
1288
+ const validActions = new Set(['allow', 'deny', 'ask'])
1289
+ return raw.flatMap((entry) => {
1290
+ if (typeof entry !== 'string') {
1291
+ return []
1292
+ }
1293
+ const parts = entry.split(':').map((s) => {
1294
+ return s.trim()
1295
+ })
1296
+ if (parts.length === 2) {
1297
+ const [permission, rawAction] = parts
1298
+ const action = rawAction!.toLowerCase()
1299
+ if (!permission || !validActions.has(action)) {
1300
+ return []
1301
+ }
1302
+ return [{ permission, pattern: '*', action: action as 'allow' | 'deny' | 'ask' }]
1303
+ }
1304
+ if (parts.length >= 3) {
1305
+ // Last segment is the action, first segment is the permission,
1306
+ // everything in between is the pattern (may contain colons in theory,
1307
+ // but unlikely for tool patterns).
1308
+ const permission = parts[0]!
1309
+ const rawAction = parts[parts.length - 1]!
1310
+ const action = rawAction.toLowerCase()
1311
+ const pattern = parts.slice(1, -1).join(':')
1312
+ if (!permission || !pattern || !validActions.has(action)) {
1313
+ return []
1314
+ }
1315
+ return [{ permission, pattern, action: action as 'allow' | 'deny' | 'ask' }]
1316
+ }
1317
+ return []
1318
+ })
1319
+ }
1320
+
1321
+ // ── Injection guard per-session config ───────────────────────────
1322
+ // Per-session injection guard patterns are written as JSON files to
1323
+ // <dataDir>/injection-guard/<sessionId>.json. The injection guard plugin
1324
+ // (running inside the opencode server process) reads OTTO_DATA_DIR env
1325
+ // var to find these files in tool.execute.after.
1326
+ // This avoids needing env vars (which are per-process, not per-session).
1327
+
1328
+ function getInjectionGuardDir(): string {
1329
+ return path.join(getDataDir(), 'injection-guard')
1330
+ }
1331
+
1332
+ /**
1333
+ * Write per-session injection guard config so the plugin picks it up.
1334
+ * Only call this if injectionGuardPatterns is non-empty.
1335
+ */
1336
+ export function writeInjectionGuardConfig({
1337
+ sessionId,
1338
+ scanPatterns,
1339
+ }: {
1340
+ sessionId: string
1341
+ scanPatterns: string[]
1342
+ }): void {
1343
+ if (scanPatterns.length === 0) {
1344
+ return
1345
+ }
1346
+ try {
1347
+ const dir = getInjectionGuardDir()
1348
+ fs.mkdirSync(dir, { recursive: true })
1349
+ fs.writeFileSync(
1350
+ path.join(dir, `${sessionId}.json`),
1351
+ JSON.stringify({ scanPatterns }),
1352
+ )
1353
+ } catch {
1354
+ // Best effort -- don't crash the bot if data dir write fails
1355
+ }
1356
+ }
1357
+
1358
+ /**
1359
+ * Remove per-session injection guard config file.
1360
+ */
1361
+ export function removeInjectionGuardConfig({ sessionId }: { sessionId: string }): void {
1362
+ try {
1363
+ fs.unlinkSync(path.join(getInjectionGuardDir(), `${sessionId}.json`))
1364
+ } catch {
1365
+ // File may already be gone
1366
+ }
1367
+ }
1368
+
1369
+ /**
1370
+ * Read per-session injection guard config. Used by the otto plugin
1371
+ * inside the opencode server process.
1372
+ */
1373
+ export function readInjectionGuardConfig({ sessionId }: { sessionId: string }): { scanPatterns: string[] } | null {
1374
+ try {
1375
+ const raw = fs.readFileSync(
1376
+ path.join(getInjectionGuardDir(), `${sessionId}.json`),
1377
+ 'utf-8',
1378
+ )
1379
+ return JSON.parse(raw) as { scanPatterns: string[] }
1380
+ } catch {
1381
+ return null
1382
+ }
1383
+ }
1384
+
1385
+ // ── Public helpers ───────────────────────────────────────────────
1386
+ // These helpers expose the single shared server and directory-scoped clients.
1387
+
1388
+ export function getOpencodeServerPort(_directory?: string): number | null {
1389
+ return singleServer?.port ?? null
1390
+ }
1391
+
1392
+ export function getOpencodeClient(directory: string): OpencodeClient | null {
1393
+ if (!singleServer) {
1394
+ return null
1395
+ }
1396
+ return getOrCreateClient({
1397
+ baseUrl: singleServer.baseUrl,
1398
+ directory,
1399
+ })
1400
+ }
1401
+
1402
+ /**
1403
+ * Stop the single opencode server.
1404
+ * Used for process teardown, tests, and explicit restarts.
1405
+ */
1406
+ export async function stopOpencodeServer(): Promise<boolean> {
1407
+ if (!singleServer) {
1408
+ return false
1409
+ }
1410
+
1411
+ const server = singleServer
1412
+ opencodeLogger.log(
1413
+ `Stopping opencode server (pid: ${server.pid}, port: ${server.port})`,
1414
+ )
1415
+ if (server.process && !server.process.killed) {
1416
+ const killResult = errore.try({
1417
+ try: () => {
1418
+ server.process!.kill('SIGTERM')
1419
+ },
1420
+ catch: (error) => {
1421
+ return new Error('Failed to send SIGTERM to opencode server', {
1422
+ cause: error,
1423
+ })
1424
+ },
1425
+ })
1426
+ if (killResult instanceof Error) {
1427
+ opencodeLogger.warn(killResult.message)
1428
+ }
1429
+ }
1430
+
1431
+ killStartingServerProcessNow({ reason: 'stop-opencode-server' })
1432
+ startingServerProcess = null
1433
+
1434
+ singleServer = null
1435
+ clearSharedServerState()
1436
+ clientCache.clear()
1437
+ serverRetryCount = 0
1438
+ await new Promise((resolve) => {
1439
+ setTimeout(resolve, 1000)
1440
+ })
1441
+ return true
1442
+ }
1443
+
1444
+ /**
1445
+ * Restart the single opencode server.
1446
+ * Kills the existing process and starts a new one.
1447
+ * Used for resolving opencode state issues, refreshing auth, plugins, etc.
1448
+ */
1449
+ export async function restartOpencodeServer(): Promise<OpenCodeErrors | true> {
1450
+ if (singleServer) {
1451
+ await stopOpencodeServer()
1452
+ }
1453
+
1454
+ // Reset retry count for the fresh start
1455
+ serverRetryCount = 0
1456
+
1457
+ const result = await ensureSingleServer()
1458
+ if (result instanceof Error) {
1459
+ return result
1460
+ }
1461
+ return true
1462
+ }