@otto-assistant/otto 0.1.2 → 0.7.15

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