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