@otto-assistant/otto 0.1.2 → 0.7.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (638) hide show
  1. package/bin.js +2 -0
  2. package/dist/agent-model.e2e.test.js +755 -0
  3. package/dist/ai-tool-to-genai.js +233 -0
  4. package/dist/ai-tool-to-genai.test.js +267 -0
  5. package/dist/ai-tool.js +6 -0
  6. package/dist/anthropic-account-identity.js +62 -0
  7. package/dist/anthropic-account-identity.test.js +38 -0
  8. package/dist/anthropic-auth-plugin.js +917 -0
  9. package/dist/anthropic-auth-state.js +303 -0
  10. package/dist/anthropic-auth-state.test.js +150 -0
  11. package/dist/bin.js +152 -0
  12. package/dist/btw-prefix-detection.js +17 -0
  13. package/dist/btw-prefix-detection.test.js +63 -0
  14. package/dist/channel-management.js +259 -0
  15. package/dist/cli-parsing.test.js +142 -0
  16. package/dist/cli-send-thread.e2e.test.js +353 -0
  17. package/dist/cli-telegram-options.test.js +99 -0
  18. package/dist/cli.js +4210 -568
  19. package/dist/commands/abort.js +65 -0
  20. package/dist/commands/action-buttons.js +245 -0
  21. package/dist/commands/add-dir.js +124 -0
  22. package/dist/commands/add-dir.test.js +126 -0
  23. package/dist/commands/add-project.js +113 -0
  24. package/dist/commands/agent.js +355 -0
  25. package/dist/commands/ask-question.js +320 -0
  26. package/dist/commands/ask-question.test.js +92 -0
  27. package/dist/commands/btw.js +121 -0
  28. package/dist/commands/cli-commands-group-a.test.js +728 -0
  29. package/dist/commands/cli-commands-group-b.test.js +695 -0
  30. package/dist/commands/compact.js +120 -0
  31. package/dist/commands/context-usage.js +140 -0
  32. package/dist/commands/create-new-project.js +130 -0
  33. package/dist/commands/diff.js +63 -0
  34. package/dist/commands/discord-commands-group-a.test.js +655 -0
  35. package/dist/commands/discord-commands-group-b.test.js +595 -0
  36. package/dist/commands/discord-commands-group-c.test.js +739 -0
  37. package/dist/commands/file-upload.js +275 -0
  38. package/dist/commands/fork-subagent.js +177 -0
  39. package/dist/commands/fork.js +262 -0
  40. package/dist/commands/gemini-apikey.js +70 -0
  41. package/dist/commands/login.js +893 -0
  42. package/dist/commands/mcp.js +239 -0
  43. package/dist/commands/memory-snapshot.js +24 -0
  44. package/dist/commands/mention-mode.js +44 -0
  45. package/dist/commands/merge-worktree.js +162 -0
  46. package/dist/commands/model-variant.js +369 -0
  47. package/dist/commands/model.js +798 -0
  48. package/dist/commands/new-worktree.js +465 -0
  49. package/dist/commands/paginated-select.js +57 -0
  50. package/dist/commands/permissions.js +274 -0
  51. package/dist/commands/queue.js +223 -0
  52. package/dist/commands/remove-project.js +115 -0
  53. package/dist/commands/restart-opencode-server.js +127 -0
  54. package/dist/commands/resume.js +149 -0
  55. package/dist/commands/run-command.js +79 -0
  56. package/dist/commands/screenshare.js +303 -0
  57. package/dist/commands/screenshare.test.js +20 -0
  58. package/dist/commands/session-id.js +78 -0
  59. package/dist/commands/session.js +176 -0
  60. package/dist/commands/share.js +80 -0
  61. package/dist/commands/tasks.js +205 -0
  62. package/dist/commands/thread-deletion-sync.js +50 -0
  63. package/dist/commands/types.js +2 -0
  64. package/dist/commands/undo-redo.js +305 -0
  65. package/dist/commands/unset-model.js +139 -0
  66. package/dist/commands/upgrade.js +48 -0
  67. package/dist/commands/user-command.js +155 -0
  68. package/dist/commands/verbosity.js +125 -0
  69. package/dist/commands/vscode.js +269 -0
  70. package/dist/commands/worktree-settings.js +43 -0
  71. package/dist/commands/worktrees.js +468 -0
  72. package/dist/condense-memory.js +33 -0
  73. package/dist/config.js +100 -255
  74. package/dist/context-awareness-plugin.js +340 -0
  75. package/dist/context-awareness-plugin.test.js +126 -0
  76. package/dist/critique-utils.js +95 -0
  77. package/dist/database.js +1355 -0
  78. package/dist/db.js +260 -0
  79. package/dist/db.test.js +138 -0
  80. package/dist/debounce-timeout.js +28 -0
  81. package/dist/debounced-process-flush.js +77 -0
  82. package/dist/discord-bot.js +1124 -0
  83. package/dist/discord-command-registration.js +567 -0
  84. package/dist/discord-urls.js +82 -0
  85. package/dist/discord-utils.js +616 -0
  86. package/dist/discord-utils.test.js +134 -0
  87. package/dist/errors.js +179 -0
  88. package/dist/escape-backticks.test.js +429 -0
  89. package/dist/event-stream-real-capture.e2e.test.js +533 -0
  90. package/dist/eventsource-parser.test.js +327 -0
  91. package/dist/exec-async.js +26 -0
  92. package/dist/external-opencode-sync.js +480 -0
  93. package/dist/format-tables.js +491 -0
  94. package/dist/format-tables.test.js +478 -0
  95. package/dist/forum-sync/config.js +79 -0
  96. package/dist/forum-sync/discord-operations.js +154 -0
  97. package/dist/forum-sync/index.js +5 -0
  98. package/dist/forum-sync/markdown.js +113 -0
  99. package/dist/forum-sync/sync-to-discord.js +417 -0
  100. package/dist/forum-sync/sync-to-files.js +190 -0
  101. package/dist/forum-sync/types.js +53 -0
  102. package/dist/forum-sync/watchers.js +307 -0
  103. package/dist/gateway-proxy-reconnect.e2e.test.js +394 -0
  104. package/dist/gateway-proxy.e2e.test.js +485 -0
  105. package/dist/genai-worker-wrapper.js +111 -0
  106. package/dist/genai-worker.js +311 -0
  107. package/dist/genai.js +232 -0
  108. package/dist/generated/browser.js +17 -0
  109. package/dist/generated/client.js +37 -0
  110. package/dist/generated/commonInputTypes.js +10 -0
  111. package/dist/generated/enums.js +58 -0
  112. package/dist/generated/internal/class.js +49 -0
  113. package/dist/generated/internal/prismaNamespace.js +254 -0
  114. package/dist/generated/internal/prismaNamespaceBrowser.js +224 -0
  115. package/dist/generated/models/bot_api_keys.js +1 -0
  116. package/dist/generated/models/bot_tokens.js +1 -0
  117. package/dist/generated/models/channel_agents.js +1 -0
  118. package/dist/generated/models/channel_directories.js +1 -0
  119. package/dist/generated/models/channel_mention_mode.js +1 -0
  120. package/dist/generated/models/channel_models.js +1 -0
  121. package/dist/generated/models/channel_verbosity.js +1 -0
  122. package/dist/generated/models/channel_worktrees.js +1 -0
  123. package/dist/generated/models/forum_sync_configs.js +1 -0
  124. package/dist/generated/models/global_models.js +1 -0
  125. package/dist/generated/models/ipc_requests.js +1 -0
  126. package/dist/generated/models/part_messages.js +1 -0
  127. package/dist/generated/models/scheduled_tasks.js +1 -0
  128. package/dist/generated/models/session_agents.js +1 -0
  129. package/dist/generated/models/session_events.js +1 -0
  130. package/dist/generated/models/session_models.js +1 -0
  131. package/dist/generated/models/session_start_sources.js +1 -0
  132. package/dist/generated/models/thread_sessions.js +1 -0
  133. package/dist/generated/models/thread_worktrees.js +1 -0
  134. package/dist/generated/models.js +1 -0
  135. package/dist/heap-monitor.js +122 -0
  136. package/dist/hrana-server.js +251 -0
  137. package/dist/hrana-server.test.js +370 -0
  138. package/dist/html-actions.js +123 -0
  139. package/dist/html-actions.test.js +70 -0
  140. package/dist/html-components.js +117 -0
  141. package/dist/html-components.test.js +34 -0
  142. package/dist/image-optimizer-plugin.js +153 -0
  143. package/dist/image-utils.js +112 -0
  144. package/dist/interaction-handler.js +420 -0
  145. package/dist/ipc-polling.js +327 -0
  146. package/dist/ipc-tools-plugin.js +193 -0
  147. package/dist/ipc-utils.js +18 -0
  148. package/dist/limit-heading-depth.js +25 -0
  149. package/dist/limit-heading-depth.test.js +105 -0
  150. package/dist/logger.js +171 -0
  151. package/dist/markdown.js +342 -0
  152. package/dist/markdown.test.js +264 -0
  153. package/dist/memory-overview-plugin.js +128 -0
  154. package/dist/message-finish-field.e2e.test.js +168 -0
  155. package/dist/message-formatting.js +415 -0
  156. package/dist/message-formatting.test.js +115 -0
  157. package/dist/message-preprocessing.js +359 -0
  158. package/dist/onboarding-tutorial.js +163 -0
  159. package/dist/onboarding-welcome.js +37 -0
  160. package/dist/openai-realtime.js +224 -0
  161. package/dist/opencode-command-detection.js +65 -0
  162. package/dist/opencode-command-detection.test.js +240 -0
  163. package/dist/opencode-command.js +131 -0
  164. package/dist/opencode-command.test.js +48 -0
  165. package/dist/opencode-interrupt-plugin.js +388 -0
  166. package/dist/opencode-interrupt-plugin.test.js +463 -0
  167. package/dist/opencode.js +1124 -0
  168. package/dist/otto/branding.js +22 -0
  169. package/dist/otto/index.js +21 -0
  170. package/dist/otto-digital-twin.e2e.test.js +161 -0
  171. package/dist/otto-opencode-plugin-loading.e2e.test.js +94 -0
  172. package/dist/otto-opencode-plugin.js +21 -0
  173. package/dist/otto-opencode-plugin.test.js +98 -0
  174. package/dist/parse-permission-rules.test.js +117 -0
  175. package/dist/patch-text-parser.js +97 -0
  176. package/dist/plugin-logger.js +68 -0
  177. package/dist/privacy-sanitizer.js +105 -0
  178. package/dist/queue-advanced-abort.e2e.test.js +293 -0
  179. package/dist/queue-advanced-action-buttons.e2e.test.js +206 -0
  180. package/dist/queue-advanced-e2e-setup.js +790 -0
  181. package/dist/queue-advanced-footer.e2e.test.js +481 -0
  182. package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
  183. package/dist/queue-advanced-permissions-typing.e2e.test.js +179 -0
  184. package/dist/queue-advanced-question.e2e.test.js +261 -0
  185. package/dist/queue-advanced-typing-interrupt.e2e.test.js +114 -0
  186. package/dist/queue-advanced-typing.e2e.test.js +153 -0
  187. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  188. package/dist/queue-interrupt-drain.e2e.test.js +135 -0
  189. package/dist/queue-question-select-drain.e2e.test.js +256 -0
  190. package/dist/runtime-idle-sweeper.js +52 -0
  191. package/dist/runtime-lifecycle.e2e.test.js +514 -0
  192. package/dist/sentry.js +23 -0
  193. package/dist/session-handler/agent-utils.js +67 -0
  194. package/dist/session-handler/event-stream-state.js +475 -0
  195. package/dist/session-handler/event-stream-state.test.js +632 -0
  196. package/dist/session-handler/model-utils.js +147 -0
  197. package/dist/session-handler/opencode-session-event-log.js +94 -0
  198. package/dist/session-handler/thread-runtime-state.js +131 -0
  199. package/dist/session-handler/thread-session-runtime.js +3390 -0
  200. package/dist/session-handler.js +9 -0
  201. package/dist/session-search.js +100 -0
  202. package/dist/session-search.test.js +40 -0
  203. package/dist/session-title-rename.test.js +92 -0
  204. package/dist/skill-filter.js +31 -0
  205. package/dist/skill-filter.test.js +65 -0
  206. package/dist/startup-service.js +153 -0
  207. package/dist/startup-time.e2e.test.js +296 -0
  208. package/dist/store.js +19 -0
  209. package/dist/subagent-rate-limit-plugin.js +175 -0
  210. package/dist/system-message.js +702 -0
  211. package/dist/system-message.test.js +697 -0
  212. package/dist/task-runner.js +530 -0
  213. package/dist/task-schedule.js +213 -0
  214. package/dist/task-schedule.test.js +71 -0
  215. package/dist/test-utils.js +313 -0
  216. package/dist/thinking-utils.js +35 -0
  217. package/dist/thread-message-queue.e2e.test.js +1111 -0
  218. package/dist/tools.js +357 -0
  219. package/dist/undo-redo.e2e.test.js +161 -0
  220. package/dist/unnest-code-blocks.js +146 -0
  221. package/dist/unnest-code-blocks.test.js +673 -0
  222. package/dist/upgrade.js +156 -0
  223. package/dist/utils.js +172 -0
  224. package/dist/utils.test.js +130 -0
  225. package/dist/voice-attachment.js +34 -0
  226. package/dist/voice-handler.js +646 -0
  227. package/dist/voice-message.e2e.test.js +1021 -0
  228. package/dist/voice.js +456 -0
  229. package/dist/voice.test.js +235 -0
  230. package/dist/wait-session.js +171 -0
  231. package/dist/websockify.js +69 -0
  232. package/dist/worker-types.js +4 -0
  233. package/dist/worktree-lifecycle.e2e.test.js +311 -0
  234. package/dist/worktree-utils.js +3 -0
  235. package/dist/worktrees.js +991 -0
  236. package/dist/worktrees.test.js +415 -0
  237. package/dist/xml.js +92 -0
  238. package/dist/xml.test.js +32 -0
  239. package/package.json +90 -38
  240. package/schema.prisma +303 -0
  241. package/skills/batch/SKILL.md +87 -0
  242. package/skills/critique/SKILL.md +112 -0
  243. package/skills/egaki/SKILL.md +100 -0
  244. package/skills/errore/SKILL.md +647 -0
  245. package/skills/event-sourcing-state/SKILL.md +252 -0
  246. package/skills/goke/SKILL.md +38 -0
  247. package/skills/jitter/EDITOR.md +219 -0
  248. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  249. package/skills/jitter/SKILL.md +158 -0
  250. package/skills/jitter/jitter-clipboard.json +1042 -0
  251. package/skills/jitter/package.json +14 -0
  252. package/skills/jitter/tsconfig.json +15 -0
  253. package/skills/jitter/utils/actions.ts +212 -0
  254. package/skills/jitter/utils/export.ts +114 -0
  255. package/skills/jitter/utils/index.ts +141 -0
  256. package/skills/jitter/utils/snapshot.ts +154 -0
  257. package/skills/jitter/utils/traverse.ts +246 -0
  258. package/skills/jitter/utils/types.ts +279 -0
  259. package/skills/jitter/utils/wait.ts +133 -0
  260. package/skills/lintcn/SKILL.md +873 -0
  261. package/skills/manual-kimaki-upstream-adapt/SKILL.md +114 -0
  262. package/skills/new-skill/SKILL.md +237 -0
  263. package/skills/npm-package/SKILL.md +617 -0
  264. package/skills/opensrc/SKILL.md +78 -0
  265. package/skills/otto-publish/SKILL.md +61 -0
  266. package/skills/playwriter/SKILL.md +35 -0
  267. package/skills/profano/SKILL.md +16 -0
  268. package/skills/proxyman/SKILL.md +215 -0
  269. package/skills/security-review/SKILL.md +208 -0
  270. package/skills/sigillo/SKILL.md +101 -0
  271. package/skills/simplify/SKILL.md +58 -0
  272. package/skills/spiceflow/SKILL.md +28 -0
  273. package/skills/termcast/SKILL.md +945 -0
  274. package/skills/tuistory/SKILL.md +98 -0
  275. package/skills/usecomputer/SKILL.md +264 -0
  276. package/skills/x-articles/SKILL.md +554 -0
  277. package/skills/zele/SKILL.md +49 -0
  278. package/skills/zustand-centralized-state/SKILL.md +1004 -0
  279. package/src/agent-model.e2e.test.ts +979 -0
  280. package/src/ai-tool-to-genai.test.ts +296 -0
  281. package/src/ai-tool-to-genai.ts +283 -0
  282. package/src/ai-tool.ts +39 -0
  283. package/src/anthropic-account-identity.test.ts +52 -0
  284. package/src/anthropic-account-identity.ts +77 -0
  285. package/src/anthropic-auth-plugin.ts +1139 -0
  286. package/src/anthropic-auth-state.test.ts +187 -0
  287. package/src/anthropic-auth-state.ts +386 -0
  288. package/src/bin.ts +182 -0
  289. package/src/btw-prefix-detection.test.ts +73 -0
  290. package/src/btw-prefix-detection.ts +23 -0
  291. package/src/channel-management.ts +376 -0
  292. package/src/cli-parsing.test.ts +197 -0
  293. package/src/cli-send-thread.e2e.test.ts +463 -0
  294. package/src/cli-telegram-options.test.ts +114 -0
  295. package/src/cli.ts +5718 -580
  296. package/src/commands/abort.ts +89 -0
  297. package/src/commands/action-buttons.ts +364 -0
  298. package/src/commands/add-dir.test.ts +154 -0
  299. package/src/commands/add-dir.ts +175 -0
  300. package/src/commands/add-project.ts +149 -0
  301. package/src/commands/agent.ts +496 -0
  302. package/src/commands/ask-question.test.ts +111 -0
  303. package/src/commands/ask-question.ts +455 -0
  304. package/src/commands/btw.ts +184 -0
  305. package/src/commands/cli-commands-group-a.test.ts +837 -0
  306. package/src/commands/cli-commands-group-b.test.ts +800 -0
  307. package/src/commands/compact.ts +157 -0
  308. package/src/commands/context-usage.ts +199 -0
  309. package/src/commands/create-new-project.ts +190 -0
  310. package/src/commands/diff.ts +91 -0
  311. package/src/commands/discord-commands-group-a.test.ts +789 -0
  312. package/src/commands/discord-commands-group-b.test.ts +648 -0
  313. package/src/commands/discord-commands-group-c.test.ts +882 -0
  314. package/src/commands/file-upload.ts +389 -0
  315. package/src/commands/fork-subagent.ts +263 -0
  316. package/src/commands/fork.ts +386 -0
  317. package/src/commands/gemini-apikey.ts +104 -0
  318. package/src/commands/login.ts +1181 -0
  319. package/src/commands/mcp.ts +307 -0
  320. package/src/commands/memory-snapshot.ts +30 -0
  321. package/src/commands/mention-mode.ts +68 -0
  322. package/src/commands/merge-worktree.ts +226 -0
  323. package/src/commands/model-variant.ts +488 -0
  324. package/src/commands/model.ts +1082 -0
  325. package/src/commands/new-worktree.ts +645 -0
  326. package/src/commands/paginated-select.ts +81 -0
  327. package/src/commands/permissions.ts +397 -0
  328. package/src/commands/queue.ts +293 -0
  329. package/src/commands/remove-project.ts +155 -0
  330. package/src/commands/restart-opencode-server.ts +162 -0
  331. package/src/commands/resume.ts +230 -0
  332. package/src/commands/run-command.ts +123 -0
  333. package/src/commands/screenshare.test.ts +30 -0
  334. package/src/commands/screenshare.ts +366 -0
  335. package/src/commands/session-id.ts +109 -0
  336. package/src/commands/session.ts +227 -0
  337. package/src/commands/share.ts +106 -0
  338. package/src/commands/tasks.ts +293 -0
  339. package/src/commands/thread-deletion-sync.ts +80 -0
  340. package/src/commands/types.ts +25 -0
  341. package/src/commands/undo-redo.ts +386 -0
  342. package/src/commands/unset-model.ts +174 -0
  343. package/src/commands/upgrade.ts +59 -0
  344. package/src/commands/user-command.ts +198 -0
  345. package/src/commands/verbosity.ts +173 -0
  346. package/src/commands/vscode.ts +342 -0
  347. package/src/commands/worktree-settings.ts +70 -0
  348. package/src/commands/worktrees.ts +645 -0
  349. package/src/condense-memory.ts +36 -0
  350. package/src/config.ts +103 -339
  351. package/src/context-awareness-plugin.test.ts +144 -0
  352. package/src/context-awareness-plugin.ts +469 -0
  353. package/src/critique-utils.ts +139 -0
  354. package/src/database.ts +1949 -0
  355. package/src/db.test.ts +162 -0
  356. package/src/db.ts +295 -0
  357. package/src/debounce-timeout.ts +43 -0
  358. package/src/debounced-process-flush.ts +104 -0
  359. package/src/discord-bot.ts +1507 -0
  360. package/src/discord-command-registration.ts +752 -0
  361. package/src/discord-urls.ts +89 -0
  362. package/src/discord-utils.test.ts +153 -0
  363. package/src/discord-utils.ts +846 -0
  364. package/src/errors.ts +232 -0
  365. package/src/escape-backticks.test.ts +469 -0
  366. package/src/event-stream-real-capture.e2e.test.ts +692 -0
  367. package/src/eventsource-parser.test.ts +351 -0
  368. package/src/exec-async.ts +35 -0
  369. package/src/external-opencode-sync.ts +685 -0
  370. package/src/format-tables.test.ts +515 -0
  371. package/src/format-tables.ts +718 -0
  372. package/src/forum-sync/config.ts +92 -0
  373. package/src/forum-sync/discord-operations.ts +241 -0
  374. package/src/forum-sync/index.ts +9 -0
  375. package/src/forum-sync/markdown.ts +172 -0
  376. package/src/forum-sync/sync-to-discord.ts +595 -0
  377. package/src/forum-sync/sync-to-files.ts +294 -0
  378. package/src/forum-sync/types.ts +175 -0
  379. package/src/forum-sync/watchers.ts +454 -0
  380. package/src/gateway-proxy-reconnect.e2e.test.ts +523 -0
  381. package/src/gateway-proxy.e2e.test.ts +644 -0
  382. package/src/genai-worker-wrapper.ts +164 -0
  383. package/src/genai-worker.ts +386 -0
  384. package/src/genai.ts +321 -0
  385. package/src/generated/browser.ts +114 -0
  386. package/src/generated/client.ts +138 -0
  387. package/src/generated/commonInputTypes.ts +770 -0
  388. package/src/generated/enums.ts +98 -0
  389. package/src/generated/internal/class.ts +384 -0
  390. package/src/generated/internal/prismaNamespace.ts +2394 -0
  391. package/src/generated/internal/prismaNamespaceBrowser.ts +327 -0
  392. package/src/generated/models/bot_api_keys.ts +1288 -0
  393. package/src/generated/models/bot_tokens.ts +1700 -0
  394. package/src/generated/models/channel_agents.ts +1256 -0
  395. package/src/generated/models/channel_directories.ts +1859 -0
  396. package/src/generated/models/channel_mention_mode.ts +1300 -0
  397. package/src/generated/models/channel_models.ts +1288 -0
  398. package/src/generated/models/channel_verbosity.ts +1228 -0
  399. package/src/generated/models/channel_worktrees.ts +1300 -0
  400. package/src/generated/models/forum_sync_configs.ts +1452 -0
  401. package/src/generated/models/global_models.ts +1288 -0
  402. package/src/generated/models/ipc_requests.ts +1485 -0
  403. package/src/generated/models/part_messages.ts +1302 -0
  404. package/src/generated/models/scheduled_tasks.ts +2320 -0
  405. package/src/generated/models/session_agents.ts +1086 -0
  406. package/src/generated/models/session_events.ts +1439 -0
  407. package/src/generated/models/session_models.ts +1114 -0
  408. package/src/generated/models/session_start_sources.ts +1408 -0
  409. package/src/generated/models/thread_sessions.ts +1781 -0
  410. package/src/generated/models/thread_worktrees.ts +1356 -0
  411. package/src/generated/models.ts +30 -0
  412. package/src/heap-monitor.ts +152 -0
  413. package/src/hrana-server.test.ts +434 -0
  414. package/src/hrana-server.ts +299 -0
  415. package/src/html-actions.test.ts +87 -0
  416. package/src/html-actions.ts +174 -0
  417. package/src/html-components.test.ts +38 -0
  418. package/src/html-components.ts +181 -0
  419. package/src/image-optimizer-plugin.ts +194 -0
  420. package/src/image-utils.ts +149 -0
  421. package/src/interaction-handler.ts +610 -0
  422. package/src/ipc-polling.ts +427 -0
  423. package/src/ipc-tools-plugin.ts +236 -0
  424. package/src/ipc-utils.ts +29 -0
  425. package/src/limit-heading-depth.test.ts +116 -0
  426. package/src/limit-heading-depth.ts +26 -0
  427. package/src/logger.ts +215 -0
  428. package/src/markdown.test.ts +315 -0
  429. package/src/markdown.ts +410 -0
  430. package/src/memory-overview-plugin.ts +163 -0
  431. package/src/message-finish-field.e2e.test.ts +195 -0
  432. package/src/message-formatting.test.ts +126 -0
  433. package/src/message-formatting.ts +535 -0
  434. package/src/message-preprocessing.ts +488 -0
  435. package/src/onboarding-tutorial.ts +167 -0
  436. package/src/onboarding-welcome.ts +49 -0
  437. package/src/openai-realtime.ts +358 -0
  438. package/src/opencode-command-detection.test.ts +307 -0
  439. package/src/opencode-command-detection.ts +76 -0
  440. package/src/opencode-command.test.ts +70 -0
  441. package/src/opencode-command.ts +191 -0
  442. package/src/opencode-interrupt-plugin.test.ts +682 -0
  443. package/src/opencode-interrupt-plugin.ts +507 -0
  444. package/src/opencode.ts +1462 -0
  445. package/src/otto/branding.ts +23 -0
  446. package/src/otto/index.ts +22 -0
  447. package/src/otto-digital-twin.e2e.test.ts +199 -0
  448. package/src/otto-opencode-plugin-loading.e2e.test.ts +117 -0
  449. package/src/otto-opencode-plugin.test.ts +108 -0
  450. package/src/otto-opencode-plugin.ts +22 -0
  451. package/src/parse-permission-rules.test.ts +127 -0
  452. package/src/patch-text-parser.ts +107 -0
  453. package/src/plugin-logger.ts +84 -0
  454. package/src/privacy-sanitizer.ts +142 -0
  455. package/src/queue-advanced-abort.e2e.test.ts +382 -0
  456. package/src/queue-advanced-action-buttons.e2e.test.ts +268 -0
  457. package/src/queue-advanced-e2e-setup.ts +877 -0
  458. package/src/queue-advanced-footer.e2e.test.ts +591 -0
  459. package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
  460. package/src/queue-advanced-permissions-typing.e2e.test.ts +246 -0
  461. package/src/queue-advanced-question.e2e.test.ts +316 -0
  462. package/src/queue-advanced-typing-interrupt.e2e.test.ts +146 -0
  463. package/src/queue-advanced-typing.e2e.test.ts +199 -0
  464. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  465. package/src/queue-interrupt-drain.e2e.test.ts +166 -0
  466. package/src/queue-question-select-drain.e2e.test.ts +327 -0
  467. package/src/runtime-idle-sweeper.ts +76 -0
  468. package/src/runtime-lifecycle.e2e.test.ts +651 -0
  469. package/src/schema.sql +174 -0
  470. package/src/sentry.ts +26 -0
  471. package/src/session-handler/agent-utils.ts +99 -0
  472. package/src/session-handler/event-stream-fixtures/real-session-action-buttons.jsonl +45 -0
  473. package/src/session-handler/event-stream-fixtures/real-session-footer-suppressed-on-pre-idle-interrupt.jsonl +40 -0
  474. package/src/session-handler/event-stream-fixtures/real-session-permission-external-file.jsonl +23 -0
  475. package/src/session-handler/event-stream-fixtures/real-session-task-normal.jsonl +22 -0
  476. package/src/session-handler/event-stream-fixtures/real-session-task-three-parallel-sleeps.jsonl +277 -0
  477. package/src/session-handler/event-stream-fixtures/real-session-task-user-interruption.jsonl +46 -0
  478. package/src/session-handler/event-stream-fixtures/session-abort-after-idle-race.jsonl +21 -0
  479. package/src/session-handler/event-stream-fixtures/session-concurrent-messages-serialized.jsonl +56 -0
  480. package/src/session-handler/event-stream-fixtures/session-explicit-abort.jsonl +44 -0
  481. package/src/session-handler/event-stream-fixtures/session-normal-completion.jsonl +29 -0
  482. package/src/session-handler/event-stream-fixtures/session-tool-call-noisy-stream.jsonl +29 -0
  483. package/src/session-handler/event-stream-fixtures/session-two-completions-same-session.jsonl +50 -0
  484. package/src/session-handler/event-stream-fixtures/session-user-interruption.jsonl +59 -0
  485. package/src/session-handler/event-stream-fixtures/session-voice-queued-followup.jsonl +52 -0
  486. package/src/session-handler/event-stream-state.test.ts +717 -0
  487. package/src/session-handler/event-stream-state.ts +706 -0
  488. package/src/session-handler/model-utils.ts +217 -0
  489. package/src/session-handler/opencode-session-event-log.ts +130 -0
  490. package/src/session-handler/thread-runtime-state.ts +247 -0
  491. package/src/session-handler/thread-session-runtime.ts +4440 -0
  492. package/src/session-handler.ts +15 -0
  493. package/src/session-search.test.ts +50 -0
  494. package/src/session-search.ts +148 -0
  495. package/src/session-title-rename.test.ts +130 -0
  496. package/src/skill-filter.test.ts +83 -0
  497. package/src/skill-filter.ts +42 -0
  498. package/src/startup-service.ts +200 -0
  499. package/src/startup-time.e2e.test.ts +373 -0
  500. package/src/store.ts +139 -0
  501. package/src/subagent-rate-limit-plugin.ts +218 -0
  502. package/src/system-message.test.ts +710 -0
  503. package/src/system-message.ts +814 -0
  504. package/src/task-runner.ts +725 -0
  505. package/src/task-schedule.test.ts +84 -0
  506. package/src/task-schedule.ts +317 -0
  507. package/src/test-utils.ts +451 -0
  508. package/src/thinking-utils.ts +61 -0
  509. package/src/thread-message-queue.e2e.test.ts +1350 -0
  510. package/src/tools.ts +430 -0
  511. package/src/undici.d.ts +12 -0
  512. package/src/undo-redo.e2e.test.ts +209 -0
  513. package/src/unnest-code-blocks.test.ts +713 -0
  514. package/src/unnest-code-blocks.ts +185 -0
  515. package/src/upgrade.ts +185 -0
  516. package/src/utils.test.ts +155 -0
  517. package/src/utils.ts +265 -0
  518. package/src/voice-attachment.ts +51 -0
  519. package/src/voice-handler.ts +908 -0
  520. package/src/voice-message.e2e.test.ts +1255 -0
  521. package/src/voice.test.ts +281 -0
  522. package/src/voice.ts +638 -0
  523. package/src/wait-session.ts +273 -0
  524. package/src/websockify.ts +101 -0
  525. package/src/worker-types.ts +64 -0
  526. package/src/worktree-lifecycle.e2e.test.ts +396 -0
  527. package/src/worktree-utils.ts +4 -0
  528. package/src/worktrees.test.ts +489 -0
  529. package/src/worktrees.ts +1370 -0
  530. package/src/xml.test.ts +38 -0
  531. package/src/xml.ts +121 -0
  532. package/README.md +0 -142
  533. package/dist/cli.d.ts +0 -3
  534. package/dist/cli.d.ts.map +0 -1
  535. package/dist/cli.js.map +0 -1
  536. package/dist/config.d.ts +0 -39
  537. package/dist/config.d.ts.map +0 -1
  538. package/dist/config.js.map +0 -1
  539. package/dist/config.test.d.ts +0 -2
  540. package/dist/config.test.d.ts.map +0 -1
  541. package/dist/config.test.js +0 -202
  542. package/dist/config.test.js.map +0 -1
  543. package/dist/detect.d.ts +0 -9
  544. package/dist/detect.d.ts.map +0 -1
  545. package/dist/detect.js +0 -40
  546. package/dist/detect.js.map +0 -1
  547. package/dist/detect.test.d.ts +0 -2
  548. package/dist/detect.test.d.ts.map +0 -1
  549. package/dist/detect.test.js +0 -26
  550. package/dist/detect.test.js.map +0 -1
  551. package/dist/docker.d.ts +0 -7
  552. package/dist/docker.d.ts.map +0 -1
  553. package/dist/docker.js +0 -17
  554. package/dist/docker.js.map +0 -1
  555. package/dist/docker.test.d.ts +0 -2
  556. package/dist/docker.test.d.ts.map +0 -1
  557. package/dist/docker.test.js +0 -12
  558. package/dist/docker.test.js.map +0 -1
  559. package/dist/health.d.ts +0 -31
  560. package/dist/health.d.ts.map +0 -1
  561. package/dist/health.js +0 -117
  562. package/dist/health.js.map +0 -1
  563. package/dist/health.test.d.ts +0 -2
  564. package/dist/health.test.d.ts.map +0 -1
  565. package/dist/health.test.js +0 -52
  566. package/dist/health.test.js.map +0 -1
  567. package/dist/index.d.ts +0 -20
  568. package/dist/index.d.ts.map +0 -1
  569. package/dist/index.js +0 -15
  570. package/dist/index.js.map +0 -1
  571. package/dist/index.test.d.ts +0 -2
  572. package/dist/index.test.d.ts.map +0 -1
  573. package/dist/index.test.js +0 -8
  574. package/dist/index.test.js.map +0 -1
  575. package/dist/installer.d.ts +0 -10
  576. package/dist/installer.d.ts.map +0 -1
  577. package/dist/installer.js +0 -50
  578. package/dist/installer.js.map +0 -1
  579. package/dist/installer.test.d.ts +0 -2
  580. package/dist/installer.test.d.ts.map +0 -1
  581. package/dist/installer.test.js +0 -43
  582. package/dist/installer.test.js.map +0 -1
  583. package/dist/lifecycle.d.ts +0 -10
  584. package/dist/lifecycle.d.ts.map +0 -1
  585. package/dist/lifecycle.js +0 -45
  586. package/dist/lifecycle.js.map +0 -1
  587. package/dist/lifecycle.test.d.ts +0 -2
  588. package/dist/lifecycle.test.d.ts.map +0 -1
  589. package/dist/lifecycle.test.js +0 -20
  590. package/dist/lifecycle.test.js.map +0 -1
  591. package/dist/manifest.d.ts +0 -18
  592. package/dist/manifest.d.ts.map +0 -1
  593. package/dist/manifest.js +0 -30
  594. package/dist/manifest.js.map +0 -1
  595. package/dist/skills-baseline.d.ts +0 -7
  596. package/dist/skills-baseline.d.ts.map +0 -1
  597. package/dist/skills-baseline.js +0 -9
  598. package/dist/skills-baseline.js.map +0 -1
  599. package/dist/skills.d.ts +0 -110
  600. package/dist/skills.d.ts.map +0 -1
  601. package/dist/skills.js +0 -429
  602. package/dist/skills.js.map +0 -1
  603. package/dist/skills.test.d.ts +0 -2
  604. package/dist/skills.test.d.ts.map +0 -1
  605. package/dist/skills.test.js +0 -416
  606. package/dist/skills.test.js.map +0 -1
  607. package/dist/sync.d.ts +0 -10
  608. package/dist/sync.d.ts.map +0 -1
  609. package/dist/sync.js +0 -39
  610. package/dist/sync.js.map +0 -1
  611. package/dist/tenant.d.ts +0 -13
  612. package/dist/tenant.d.ts.map +0 -1
  613. package/dist/tenant.js +0 -105
  614. package/dist/tenant.js.map +0 -1
  615. package/dist/tenant.test.d.ts +0 -2
  616. package/dist/tenant.test.d.ts.map +0 -1
  617. package/dist/tenant.test.js +0 -37
  618. package/dist/tenant.test.js.map +0 -1
  619. package/src/config.test.ts +0 -237
  620. package/src/detect.test.ts +0 -29
  621. package/src/detect.ts +0 -52
  622. package/src/docker.test.ts +0 -12
  623. package/src/docker.ts +0 -23
  624. package/src/health.test.ts +0 -61
  625. package/src/health.ts +0 -158
  626. package/src/index.test.ts +0 -8
  627. package/src/index.ts +0 -62
  628. package/src/installer.test.ts +0 -52
  629. package/src/installer.ts +0 -62
  630. package/src/lifecycle.test.ts +0 -23
  631. package/src/lifecycle.ts +0 -49
  632. package/src/manifest.ts +0 -42
  633. package/src/skills-baseline.ts +0 -14
  634. package/src/skills.test.ts +0 -503
  635. package/src/skills.ts +0 -512
  636. package/src/sync.ts +0 -53
  637. package/src/tenant.test.ts +0 -49
  638. package/src/tenant.ts +0 -120
@@ -0,0 +1,1370 @@
1
+ // Worktree service and git helpers.
2
+ // Provides reusable, Discord-agnostic worktree creation/merge logic,
3
+ // submodule initialization, and git diff transfer utilities.
4
+
5
+ import crypto from 'node:crypto'
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import * as errore from 'errore'
9
+ import { getDataDir } from './config.js'
10
+ import { execAsync } from './exec-async.js'
11
+ import { createLogger, LogPrefix } from './logger.js'
12
+
13
+ export { execAsync } from './exec-async.js'
14
+
15
+ const SUBMODULE_INIT_TIMEOUT_MS = 20 * 60_000
16
+ const INSTALL_TIMEOUT_MS = 60_000
17
+
18
+ const logger = createLogger(LogPrefix.WORKTREE)
19
+
20
+ const LOCKFILE_TO_INSTALL_COMMAND: Array<[string, string]> = [
21
+ ['pnpm-lock.yaml', 'pnpm install'],
22
+ ['bun.lock', 'bun install'],
23
+ ['bun.lockb', 'bun install'],
24
+ ['yarn.lock', 'yarn install'],
25
+ ['package-lock.json', 'npm install'],
26
+ ]
27
+
28
+ function detectInstallCommand(directory: string): string | null {
29
+ for (const [lockfile, command] of LOCKFILE_TO_INSTALL_COMMAND) {
30
+ if (fs.existsSync(path.join(directory, lockfile))) {
31
+ return command
32
+ }
33
+ }
34
+ return null
35
+ }
36
+
37
+ /**
38
+ * Run the detected package manager install in a worktree directory.
39
+ * Non-fatal: returns Error on failure/timeout so callers can log and continue.
40
+ * The 60s timeout kills the process if install hangs.
41
+ */
42
+ export async function runDependencyInstall({
43
+ directory,
44
+ }: {
45
+ directory: string
46
+ }): Promise<void | Error> {
47
+ const installCommand = detectInstallCommand(directory)
48
+ if (!installCommand) {
49
+ return
50
+ }
51
+ logger.log(`Running "${installCommand}" in ${directory} (timeout=${INSTALL_TIMEOUT_MS}ms)`)
52
+ try {
53
+ await execAsync(installCommand, {
54
+ cwd: directory,
55
+ timeout: INSTALL_TIMEOUT_MS,
56
+ })
57
+ logger.log(`Dependencies installed in ${directory}`)
58
+ } catch (e) {
59
+ return new Error(`Install failed: ${formatCommandError(e)}`, { cause: e })
60
+ }
61
+ }
62
+
63
+ type CommandError = Error & {
64
+ cmd?: string
65
+ stderr?: string
66
+ stdout?: string
67
+ signal?: NodeJS.Signals
68
+ killed?: boolean
69
+ }
70
+
71
+ function formatCommandError(error: unknown): string {
72
+ if (!(error instanceof Error)) {
73
+ return String(error)
74
+ }
75
+
76
+ const commandError = error as CommandError
77
+ const details: string[] = [commandError.message]
78
+
79
+ if (commandError.cmd) {
80
+ details.push(`cmd=${commandError.cmd}`)
81
+ }
82
+ if (commandError.signal) {
83
+ details.push(`signal=${commandError.signal}`)
84
+ }
85
+ if (commandError.killed) {
86
+ details.push('process=killed')
87
+ }
88
+ if (commandError.stderr?.trim()) {
89
+ details.push(`stderr=${commandError.stderr.trim()}`)
90
+ }
91
+ if (commandError.stdout?.trim()) {
92
+ details.push(`stdout=${commandError.stdout.trim()}`)
93
+ }
94
+
95
+ return details.join(' | ')
96
+ }
97
+
98
+ type GitSubmoduleConfig = {
99
+ name: string
100
+ path: string
101
+ url: string | null
102
+ }
103
+
104
+ export type SubmoduleReferencePlan = {
105
+ path: string
106
+ referenceDirectory: string | null
107
+ }
108
+
109
+ export function parseGitmodulesFileContent(
110
+ gitmodulesContent: string,
111
+ ): GitSubmoduleConfig[] | Error {
112
+ const lines = gitmodulesContent.split('\n')
113
+ const configs: GitSubmoduleConfig[] = []
114
+ let currentName: string | null = null
115
+ let currentPath: string | null = null
116
+ let currentUrl: string | null = null
117
+
118
+ const flushCurrent = (): void | Error => {
119
+ if (!currentName) {
120
+ return
121
+ }
122
+ if (!currentPath) {
123
+ return new Error(`Submodule ${currentName} is missing path in .gitmodules`)
124
+ }
125
+ configs.push({
126
+ name: currentName,
127
+ path: currentPath,
128
+ url: currentUrl,
129
+ })
130
+ }
131
+
132
+ for (const rawLine of lines) {
133
+ const line = rawLine.trim()
134
+ if (!line || line.startsWith('#') || line.startsWith(';')) {
135
+ continue
136
+ }
137
+
138
+ const sectionMatch = line.match(/^\[submodule\s+"([^"]+)"\]$/)
139
+ if (sectionMatch?.[1]) {
140
+ const flushError = flushCurrent()
141
+ if (flushError instanceof Error) {
142
+ return flushError
143
+ }
144
+
145
+ currentName = sectionMatch[1]
146
+ currentPath = null
147
+ currentUrl = null
148
+ continue
149
+ }
150
+
151
+ if (!currentName) {
152
+ continue
153
+ }
154
+
155
+ const keyValueMatch = line.match(/^([^=\s]+)\s*=\s*(.*)$/)
156
+ const key = keyValueMatch?.[1]
157
+ const value = keyValueMatch?.[2]
158
+ if (!key || value === undefined) {
159
+ continue
160
+ }
161
+
162
+ if (key === 'path') {
163
+ currentPath = value
164
+ continue
165
+ }
166
+
167
+ if (key === 'url') {
168
+ currentUrl = value
169
+ }
170
+ }
171
+
172
+ const flushError = flushCurrent()
173
+ if (flushError instanceof Error) {
174
+ return flushError
175
+ }
176
+
177
+ return configs
178
+ }
179
+
180
+ async function readSubmoduleConfigs(
181
+ directory: string,
182
+ ): Promise<GitSubmoduleConfig[] | Error> {
183
+ const gitmodulesPath = path.join(directory, '.gitmodules')
184
+ const gitmodulesExists = await fs.promises
185
+ .access(gitmodulesPath)
186
+ .then(() => {
187
+ return true
188
+ })
189
+ .catch(() => {
190
+ return false
191
+ })
192
+ if (!gitmodulesExists) {
193
+ return []
194
+ }
195
+
196
+ const gitmodulesContent = await errore.tryAsync({
197
+ try: () => fs.promises.readFile(gitmodulesPath, 'utf-8'),
198
+ catch: (e) =>
199
+ new Error(`Failed to read ${gitmodulesPath}`, {
200
+ cause: e,
201
+ }),
202
+ })
203
+ if (gitmodulesContent instanceof Error) {
204
+ return gitmodulesContent
205
+ }
206
+
207
+ const parsed = parseGitmodulesFileContent(gitmodulesContent)
208
+ if (parsed instanceof Error) {
209
+ return new Error(`Failed to parse ${gitmodulesPath}: ${parsed.message}`, {
210
+ cause: parsed,
211
+ })
212
+ }
213
+
214
+ return parsed
215
+ }
216
+
217
+ export function buildSubmoduleReferencePlan({
218
+ sourceDirectory,
219
+ submodulePaths,
220
+ existingSourceSubmoduleDirectories,
221
+ }: {
222
+ sourceDirectory: string
223
+ submodulePaths: string[]
224
+ existingSourceSubmoduleDirectories: Set<string>
225
+ }): SubmoduleReferencePlan[] {
226
+ return submodulePaths.map((submodulePath) => {
227
+ const sourceSubmoduleDirectory = path.resolve(sourceDirectory, submodulePath)
228
+ if (existingSourceSubmoduleDirectories.has(sourceSubmoduleDirectory)) {
229
+ return {
230
+ path: submodulePath,
231
+ referenceDirectory: sourceSubmoduleDirectory,
232
+ }
233
+ }
234
+
235
+ return {
236
+ path: submodulePath,
237
+ referenceDirectory: null,
238
+ }
239
+ })
240
+ }
241
+
242
+ function buildGitCommand(args: string[]): string {
243
+ const quotedArgs = args.map((arg) => {
244
+ return JSON.stringify(arg)
245
+ })
246
+ return `git ${quotedArgs.join(' ')}`
247
+ }
248
+
249
+ export function buildSubmoduleUpdateCommandArgs({
250
+ path: submodulePath,
251
+ referenceDirectory,
252
+ }: SubmoduleReferencePlan): string[] {
253
+ if (referenceDirectory) {
254
+ return [
255
+ '-c',
256
+ 'protocol.file.allow=always',
257
+ 'submodule',
258
+ 'update',
259
+ '--init',
260
+ '--recursive',
261
+ '--reference',
262
+ referenceDirectory,
263
+ '--',
264
+ submodulePath,
265
+ ]
266
+ }
267
+
268
+ return [
269
+ '-c',
270
+ 'protocol.file.allow=always',
271
+ 'submodule',
272
+ 'update',
273
+ '--init',
274
+ '--recursive',
275
+ '--',
276
+ submodulePath,
277
+ ]
278
+ }
279
+
280
+ async function hasSubmoduleGitMetadata(directory: string): Promise<boolean> {
281
+ const gitPath = path.join(directory, '.git')
282
+ return fs.promises
283
+ .access(gitPath)
284
+ .then(() => {
285
+ return true
286
+ })
287
+ .catch(() => {
288
+ return false
289
+ })
290
+ }
291
+
292
+ async function initializeSubmodulesWithLocalReferences({
293
+ sourceDirectory,
294
+ worktreeDirectory,
295
+ }: {
296
+ sourceDirectory: string
297
+ worktreeDirectory: string
298
+ }): Promise<void | Error> {
299
+ const submoduleConfigs = await readSubmoduleConfigs(worktreeDirectory)
300
+ if (submoduleConfigs instanceof Error) {
301
+ return submoduleConfigs
302
+ }
303
+ if (submoduleConfigs.length === 0) {
304
+ return
305
+ }
306
+
307
+ const sourceDirectories = submoduleConfigs.map(({ path: submodulePath }) => {
308
+ return path.resolve(sourceDirectory, submodulePath)
309
+ })
310
+
311
+ const sourceDirectoryChecks = await Promise.all(
312
+ sourceDirectories.map(async (sourceSubmoduleDirectory) => {
313
+ const exists = await hasSubmoduleGitMetadata(sourceSubmoduleDirectory)
314
+ return { sourceSubmoduleDirectory, exists }
315
+ }),
316
+ )
317
+
318
+ const existingSourceSubmoduleDirectories = new Set(
319
+ sourceDirectoryChecks
320
+ .filter(({ exists }) => {
321
+ return exists
322
+ })
323
+ .map(({ sourceSubmoduleDirectory }) => {
324
+ return sourceSubmoduleDirectory
325
+ }),
326
+ )
327
+
328
+ const submodulePlan = buildSubmoduleReferencePlan({
329
+ sourceDirectory,
330
+ submodulePaths: submoduleConfigs.map(({ path: submodulePath }) => {
331
+ return submodulePath
332
+ }),
333
+ existingSourceSubmoduleDirectories,
334
+ })
335
+
336
+ for (const planItem of submodulePlan) {
337
+ const commandArgs = buildSubmoduleUpdateCommandArgs(planItem)
338
+ const command = buildGitCommand(commandArgs)
339
+ const result = await errore.tryAsync({
340
+ try: () =>
341
+ execAsync(command, {
342
+ cwd: worktreeDirectory,
343
+ timeout: SUBMODULE_INIT_TIMEOUT_MS,
344
+ }),
345
+ catch: (e) =>
346
+ new Error(
347
+ `git ${commandArgs.join(' ')} failed for ${planItem.path}: ${formatCommandError(e)}`,
348
+ { cause: e },
349
+ ),
350
+ })
351
+ if (result instanceof Error) {
352
+ // Non-fatal: broken .gitmodules entries (e.g. path listed but not in tree)
353
+ // should not block worktree creation. Log and continue with remaining submodules.
354
+ logger.warn(
355
+ `Skipping submodule ${planItem.path}: ${result.message}`,
356
+ )
357
+ }
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Get submodule paths from .gitmodules file.
363
+ * Returns empty array if no submodules or on error.
364
+ */
365
+ async function getSubmodulePaths(directory: string): Promise<string[]> {
366
+ const submoduleConfigs = await readSubmoduleConfigs(directory)
367
+ if (submoduleConfigs instanceof Error) {
368
+ logger.warn(`Failed reading submodules from ${directory}: ${submoduleConfigs.message}`)
369
+ return []
370
+ }
371
+
372
+ return submoduleConfigs.map(({ path: submodulePath }) => {
373
+ return submodulePath
374
+ })
375
+ }
376
+
377
+ /**
378
+ * Remove broken submodule stubs created by git worktree.
379
+ * When git worktree add runs on a repo with submodules, it creates submodule
380
+ * directories with .git files pointing to ../.git/worktrees/<name>/modules/<submodule>
381
+ * but that path only has a config file, missing HEAD/objects/refs.
382
+ * This causes git commands to fail with "fatal: not a git repository".
383
+ */
384
+ async function removeBrokenSubmoduleStubs(directory: string): Promise<void> {
385
+ const submodulePaths = await getSubmodulePaths(directory)
386
+
387
+ for (const subPath of submodulePaths) {
388
+ const fullPath = path.join(directory, subPath)
389
+ const gitFile = path.join(fullPath, '.git')
390
+
391
+ try {
392
+ const stat = await fs.promises.stat(gitFile)
393
+ if (!stat.isFile()) {
394
+ continue
395
+ }
396
+
397
+ // Read .git file to get gitdir path
398
+ const content = await fs.promises.readFile(gitFile, 'utf-8')
399
+ const match = content.match(/^gitdir:\s*(.+)$/m)
400
+ if (!match || !match[1]) {
401
+ continue
402
+ }
403
+
404
+ const gitdir = path.resolve(fullPath, match[1].trim())
405
+ const headFile = path.join(gitdir, 'HEAD')
406
+
407
+ // If HEAD doesn't exist, this is a broken stub
408
+ const headExists = await fs.promises
409
+ .access(headFile)
410
+ .then(() => {
411
+ return true
412
+ })
413
+ .catch(() => {
414
+ return false
415
+ })
416
+
417
+ if (!headExists) {
418
+ logger.log(`Removing broken submodule stub: ${subPath}`)
419
+ await fs.promises.rm(fullPath, { recursive: true, force: true })
420
+ }
421
+ } catch {
422
+ // Directory doesn't exist or other error, skip
423
+ }
424
+ }
425
+ }
426
+
427
+ function parseSubmoduleGitdir(gitFileContent: string): string | Error {
428
+ const match = gitFileContent.match(/^gitdir:\s*(.+)$/m)
429
+ const gitdir = match?.[1]?.trim()
430
+ if (!gitdir) {
431
+ return new Error('Missing gitdir pointer')
432
+ }
433
+ return gitdir
434
+ }
435
+
436
+ async function validateSubmodulePointers(
437
+ directory: string,
438
+ ): Promise<void | Error> {
439
+ const submodulePaths = await getSubmodulePaths(directory)
440
+ if (submodulePaths.length === 0) {
441
+ return
442
+ }
443
+
444
+ const validationIssues: string[] = []
445
+
446
+ await Promise.all(
447
+ submodulePaths.map(async (submodulePath) => {
448
+ const submoduleDir = path.join(directory, submodulePath)
449
+ const submoduleGitFile = path.join(submoduleDir, '.git')
450
+
451
+ const gitFileExists = await fs.promises
452
+ .access(submoduleGitFile)
453
+ .then(() => {
454
+ return true
455
+ })
456
+ .catch(() => {
457
+ return false
458
+ })
459
+ if (!gitFileExists) {
460
+ validationIssues.push(`${submodulePath}: missing .git file`)
461
+ return
462
+ }
463
+
464
+ const gitFileContentResult = await errore.tryAsync({
465
+ try: () => fs.promises.readFile(submoduleGitFile, 'utf-8'),
466
+ catch: (e) =>
467
+ new Error(`Failed to read .git for ${submodulePath}`, { cause: e }),
468
+ })
469
+ if (gitFileContentResult instanceof Error) {
470
+ validationIssues.push(
471
+ `${submodulePath}: ${gitFileContentResult.message}`,
472
+ )
473
+ return
474
+ }
475
+
476
+ const parsedGitdir = parseSubmoduleGitdir(gitFileContentResult)
477
+ if (parsedGitdir instanceof Error) {
478
+ validationIssues.push(`${submodulePath}: ${parsedGitdir.message}`)
479
+ return
480
+ }
481
+
482
+ const resolvedGitdir = path.resolve(submoduleDir, parsedGitdir)
483
+ const headPath = path.join(resolvedGitdir, 'HEAD')
484
+ const headExists = await fs.promises
485
+ .access(headPath)
486
+ .then(() => {
487
+ return true
488
+ })
489
+ .catch(() => {
490
+ return false
491
+ })
492
+ if (!headExists) {
493
+ validationIssues.push(
494
+ `${submodulePath}: gitdir missing HEAD (${resolvedGitdir})`,
495
+ )
496
+ }
497
+ }),
498
+ )
499
+
500
+ const submoduleStatusResult = await errore.tryAsync({
501
+ try: () =>
502
+ execAsync('git submodule status --recursive', {
503
+ cwd: directory,
504
+ timeout: SUBMODULE_INIT_TIMEOUT_MS,
505
+ }),
506
+ catch: (e) =>
507
+ new Error('git submodule status --recursive failed', { cause: e }),
508
+ })
509
+ if (submoduleStatusResult instanceof Error) {
510
+ validationIssues.push(submoduleStatusResult.message)
511
+ }
512
+
513
+ if (validationIssues.length === 0) {
514
+ return
515
+ }
516
+
517
+ return new Error(
518
+ `Submodule validation failed: ${validationIssues.join('; ')}`,
519
+ )
520
+ }
521
+
522
+ type WorktreeResult = {
523
+ directory: string
524
+ branch: string
525
+ }
526
+
527
+ async function resolveDefaultWorktreeTarget(
528
+ directory: string,
529
+ ): Promise<string> {
530
+ return 'HEAD'
531
+ }
532
+
533
+ /**
534
+ * Build the on-disk directory for a managed worktree.
535
+ *
536
+ * Layout: `<ottoDataDir>/worktrees/<8charProjectHash>/<basename>`
537
+ *
538
+ * - Lives under the otto data dir instead of the long
539
+ * `~/.local/share/opencode/worktree/<40-char-hash>/<name>` path so folder
540
+ * names stay short and readable (agents tend to give up and reuse the old
541
+ * worktree when paths get absurdly long).
542
+ * - The 8-char project hash keeps worktrees from different projects that
543
+ * happen to share a slug from colliding.
544
+ * - Strips the `opencode/otto-` (or `opencode-otto-`) prefix from the
545
+ * folder name since it's redundant noise on disk. The git branch name
546
+ * itself still uses `opencode/otto-<slug>` so merge/cleanup logic is
547
+ * unchanged.
548
+ */
549
+ export function getManagedWorktreeDirectory({
550
+ directory,
551
+ name,
552
+ }: {
553
+ directory: string
554
+ name: string
555
+ }): string {
556
+ const projectHash = crypto
557
+ .createHash('sha1')
558
+ .update(directory)
559
+ .digest('hex')
560
+ .slice(0, 8)
561
+ const withoutPrefix = name
562
+ .replace(/^opencode\/otto-/, '')
563
+ .replace(/^opencode\/otto-/, '') // backward compat for legacy branch names
564
+ .replaceAll('/', '-')
565
+ return path.join(getDataDir(), 'worktrees', projectHash, withoutPrefix)
566
+ }
567
+
568
+ /**
569
+ * Create a worktree using git and initialize git submodules.
570
+ * This wrapper ensures submodules are properly set up in new worktrees.
571
+ */
572
+ export async function createWorktreeWithSubmodules({
573
+ directory,
574
+ name,
575
+ baseBranch,
576
+ onProgress,
577
+ }: {
578
+ directory: string
579
+ name: string
580
+ /** Override the base branch to create the worktree from. Defaults to HEAD. */
581
+ baseBranch?: string
582
+ /** Called with a short phase label so callers can update UI (e.g. Discord status message). */
583
+ onProgress?: (phase: string) => void
584
+ }): Promise<WorktreeResult | Error> {
585
+ // 1. Create worktree via git (checked out immediately).
586
+ const worktreeDir = getManagedWorktreeDirectory({ directory, name })
587
+ const targetRef = baseBranch || (await resolveDefaultWorktreeTarget(directory))
588
+
589
+ if (fs.existsSync(worktreeDir)) {
590
+ return new Error(`Worktree directory already exists: ${worktreeDir}`)
591
+ }
592
+
593
+ await fs.promises.mkdir(path.dirname(worktreeDir), { recursive: true })
594
+
595
+ const createCommand = `git worktree add ${JSON.stringify(worktreeDir)} -B ${JSON.stringify(name)} ${JSON.stringify(targetRef)}`
596
+ const createResult = await errore.tryAsync({
597
+ try: () =>
598
+ execAsync(createCommand, {
599
+ cwd: directory,
600
+ timeout: SUBMODULE_INIT_TIMEOUT_MS,
601
+ }),
602
+ catch: (e) =>
603
+ new Error(`git worktree add failed: ${formatCommandError(e)}`, {
604
+ cause: e,
605
+ }),
606
+ })
607
+ if (createResult instanceof Error) {
608
+ return createResult
609
+ }
610
+
611
+ // 2. Remove broken submodule stubs before init
612
+ // git worktree creates stub directories with .git files pointing to incomplete gitdirs
613
+ await removeBrokenSubmoduleStubs(worktreeDir)
614
+
615
+ // 4. Init submodules in new worktree.
616
+ // For each submodule we use git's built-in --reference mechanism when the
617
+ // source checkout already has that submodule cloned. This preserves commit
618
+ // pinning while allowing local-only submodule commits to resolve reliably.
619
+ logger.log(
620
+ `Initializing submodules in ${worktreeDir} (timeout=${SUBMODULE_INIT_TIMEOUT_MS}ms)`,
621
+ )
622
+ const submoduleInitResult = await initializeSubmodulesWithLocalReferences({
623
+ sourceDirectory: directory,
624
+ worktreeDirectory: worktreeDir,
625
+ })
626
+ if (submoduleInitResult instanceof Error) {
627
+ // Non-fatal: log and continue. The worktree itself is already created,
628
+ // only submodule init had issues (e.g. stale .gitmodules entries).
629
+ logger.error('Submodule initialization failed (non-fatal)', {
630
+ worktreeDir,
631
+ timeoutMs: SUBMODULE_INIT_TIMEOUT_MS,
632
+ command: 'git submodule update --init --recursive [--reference ...]',
633
+ error: submoduleInitResult.message,
634
+ })
635
+ } else {
636
+ logger.log(`Submodules initialized in ${worktreeDir}`)
637
+ }
638
+
639
+ // 4.5 Validate submodule pointers and git metadata.
640
+ // Non-fatal: stale .gitmodules entries (path listed but removed from tree)
641
+ // should not block worktree creation.
642
+ const submoduleValidationError = await validateSubmodulePointers(worktreeDir)
643
+ if (submoduleValidationError instanceof Error) {
644
+ logger.error('Submodule validation issues (non-fatal)', {
645
+ worktreeDir,
646
+ error: submoduleValidationError.message,
647
+ })
648
+ }
649
+
650
+ // 5. Dependency install (non-fatal, 60s timeout).
651
+ // Runs the detected package manager install so workspace packages with
652
+ // `prepare` scripts get built (e.g. errore → dist/).
653
+ onProgress?.('Installing dependencies...')
654
+ const installResult = await runDependencyInstall({ directory: worktreeDir })
655
+ if (installResult instanceof Error) {
656
+ logger.error('Dependency install failed (non-fatal)', {
657
+ worktreeDir,
658
+ error: installResult.message,
659
+ })
660
+ }
661
+
662
+ return { directory: worktreeDir, branch: name }
663
+ }
664
+
665
+ // ─── Worktree merge ──────────────────────────────────────────────────────────
666
+ // Merge pipeline (preserves all worktree commits, no squash):
667
+ // 1. Reject if uncommitted changes exist
668
+ // 2. Rebase worktree commits onto target (default branch)
669
+ // 3. Fast-forward push to target via local git push
670
+ // 4. Switch to detached HEAD, delete branch
671
+ //
672
+ // Uses `git push <git-common-dir> HEAD:<target>` with
673
+ // `receive.denyCurrentBranch=updateInstead` to fast-forward the target
674
+ // WITHOUT checking it out in the main repo.
675
+ //
676
+ // Returns MergeWorktreeErrors | MergeSuccess. All errors are tagged via errore.
677
+ // - DirtyWorktreeError → git untouched
678
+ // - NothingToMergeError → git untouched
679
+ // - RebaseConflictError → git left mid-rebase for AI/user resolution
680
+ // - RebaseError → rebase not in progress; temp branch cleaned
681
+ // - NotFastForwardError → source intact; no push
682
+ // - ConflictingFilesError → no push; lists overlapping files
683
+ // - PushError → source rebased but target unchanged
684
+ // - GitCommandError → catch-all for unexpected git failures
685
+
686
+ import {
687
+ DirtyWorktreeError,
688
+ NothingToMergeError,
689
+ RebaseConflictError,
690
+ RebaseError,
691
+ NotFastForwardError,
692
+ ConflictingFilesError,
693
+ PushError,
694
+ GitCommandError,
695
+ type MergeWorktreeErrors,
696
+ } from './errors.js'
697
+
698
+ export type MergeSuccess = {
699
+ defaultBranch: string
700
+ branchName: string
701
+ commitCount: number
702
+ shortSha: string
703
+ }
704
+
705
+ export async function git(
706
+ dir: string,
707
+ args: string,
708
+ opts?: { timeout?: number },
709
+ ): Promise<GitCommandError | string> {
710
+ const result = await errore.tryAsync({
711
+ try: () =>
712
+ execAsync(
713
+ `git -C "${dir}" ${args}`,
714
+ opts ? { timeout: opts.timeout } : undefined,
715
+ ),
716
+ catch: (e) => new GitCommandError({ command: args, cause: e }),
717
+ })
718
+ if (result instanceof Error) {
719
+ return result
720
+ }
721
+ return result.stdout.trim()
722
+ }
723
+
724
+ export async function getDefaultBranch(
725
+ repoDir: string,
726
+ opts?: { timeout?: number },
727
+ ): Promise<string> {
728
+ const ref = await git(repoDir, 'symbolic-ref refs/remotes/origin/HEAD', opts)
729
+ if (ref instanceof Error) {
730
+ return 'main'
731
+ }
732
+ return ref.replace(/^refs\/remotes\/origin\//, '') || 'main'
733
+ }
734
+
735
+ export async function deleteWorktree({
736
+ projectDirectory,
737
+ worktreeDirectory,
738
+ worktreeName,
739
+ }: {
740
+ projectDirectory: string
741
+ worktreeDirectory: string
742
+ // Branch name to delete after removing the worktree.
743
+ // Pass empty string for detached HEAD worktrees — branch deletion is skipped.
744
+ worktreeName: string
745
+ }): Promise<void | Error> {
746
+ let removeResult = await git(
747
+ projectDirectory,
748
+ `worktree remove ${JSON.stringify(worktreeDirectory)}`,
749
+ {
750
+ timeout: SUBMODULE_INIT_TIMEOUT_MS,
751
+ },
752
+ )
753
+ // git refuses to remove worktrees with submodule entries:
754
+ // "fatal: working trees containing submodules cannot be moved or removed"
755
+ // Retry with --force which bypasses this guard. This is safe because
756
+ // canDeleteWorktree already verified the worktree is clean and merged.
757
+ if (removeResult instanceof Error) {
758
+ const stderr =
759
+ (removeResult.cause as { stderr?: string } | undefined)?.stderr ?? ''
760
+ if (stderr.includes('containing submodules')) {
761
+ removeResult = await git(
762
+ projectDirectory,
763
+ `worktree remove --force ${JSON.stringify(worktreeDirectory)}`,
764
+ { timeout: SUBMODULE_INIT_TIMEOUT_MS },
765
+ )
766
+ }
767
+ }
768
+ if (removeResult instanceof Error) {
769
+ return new Error(`Failed to remove worktree ${worktreeName || worktreeDirectory}`, {
770
+ cause: removeResult,
771
+ })
772
+ }
773
+
774
+ // Skip branch deletion for detached HEAD worktrees (no branch to delete)
775
+ if (worktreeName) {
776
+ const deleteBranchResult = await git(
777
+ projectDirectory,
778
+ `branch -d ${JSON.stringify(worktreeName)}`,
779
+ )
780
+ if (deleteBranchResult instanceof Error) {
781
+ return new Error(`Failed to delete branch ${worktreeName}`, {
782
+ cause: deleteBranchResult,
783
+ })
784
+ }
785
+ }
786
+
787
+ const pruneResult = await git(projectDirectory, 'worktree prune')
788
+ if (pruneResult instanceof Error) {
789
+ logger.warn(`Failed to prune worktrees after deleting ${worktreeName || worktreeDirectory}`)
790
+ }
791
+ }
792
+
793
+ export async function isDirty(
794
+ dir: string,
795
+ opts?: { timeout?: number },
796
+ ): Promise<boolean> {
797
+ const status = await git(dir, 'status --porcelain', opts)
798
+ if (status instanceof Error) {
799
+ return false
800
+ }
801
+ return status.length > 0
802
+ }
803
+
804
+ async function getGitCommonDir(dir: string): Promise<GitCommandError | string> {
805
+ const commonDir = await git(dir, 'rev-parse --git-common-dir')
806
+ if (commonDir instanceof Error) {
807
+ return commonDir
808
+ }
809
+ if (path.isAbsolute(commonDir)) {
810
+ return commonDir
811
+ }
812
+ return path.resolve(dir, commonDir)
813
+ }
814
+
815
+ async function isAncestor(
816
+ dir: string,
817
+ ref1: string,
818
+ ref2: string,
819
+ ): Promise<boolean> {
820
+ const result = await git(dir, `merge-base --is-ancestor "${ref1}" "${ref2}"`)
821
+ return !(result instanceof Error)
822
+ }
823
+
824
+ async function isRebasedOnto(dir: string, target: string): Promise<boolean> {
825
+ const mergeBase = await git(dir, `merge-base HEAD "${target}"`)
826
+ if (mergeBase instanceof Error) {
827
+ return false
828
+ }
829
+ const targetSha = await git(dir, `rev-parse "${target}"`)
830
+ if (targetSha instanceof Error) {
831
+ return false
832
+ }
833
+ return mergeBase === targetSha
834
+ }
835
+
836
+ async function getChangedFiles(
837
+ dir: string,
838
+ ref1: string,
839
+ ref2: string,
840
+ ): Promise<string[]> {
841
+ const result = await git(dir, `diff --name-only "${ref1}" "${ref2}"`)
842
+ if (result instanceof Error) {
843
+ return []
844
+ }
845
+ return result.split('\n').filter(Boolean)
846
+ }
847
+
848
+ /**
849
+ * Get dirty files using porcelain -z format.
850
+ * Handles rename/copy entries which emit two NUL-separated paths.
851
+ */
852
+ async function getDirtyFiles(dir: string): Promise<string[]> {
853
+ const result = await git(dir, 'status --porcelain -z')
854
+ if (result instanceof Error) {
855
+ return []
856
+ }
857
+ const files: string[] = []
858
+ const parts = result.split('\0')
859
+ let i = 0
860
+ while (i < parts.length) {
861
+ const entry = parts[i]
862
+ if (!entry || entry.length < 3) {
863
+ i++
864
+ continue
865
+ }
866
+ const status = entry.slice(0, 2)
867
+ const filePath = entry.slice(3)
868
+ if (filePath) {
869
+ files.push(filePath)
870
+ }
871
+ if (
872
+ status[0] === 'R' ||
873
+ status[0] === 'C' ||
874
+ status[1] === 'R' ||
875
+ status[1] === 'C'
876
+ ) {
877
+ i++
878
+ const oldPath = parts[i]
879
+ if (oldPath) {
880
+ files.push(oldPath)
881
+ }
882
+ }
883
+ i++
884
+ }
885
+ return files
886
+ }
887
+
888
+ /**
889
+ * Check if target worktree has dirty files overlapping with the push range.
890
+ * updateInstead only modifies the working tree when pushing to the currently
891
+ * checked-out branch. If the main repo is on a different branch, the push
892
+ * won't touch the working tree at all, so there's nothing to conflict with.
893
+ */
894
+ async function checkTargetWorktreeConflicts({
895
+ targetDir,
896
+ sourceDir,
897
+ targetBranch,
898
+ }: {
899
+ targetDir: string
900
+ sourceDir: string
901
+ targetBranch: string
902
+ }): Promise<string[] | null> {
903
+ // Only check for conflicts if the main repo has the target branch checked out.
904
+ // updateInstead only updates the working tree for the currently checked-out
905
+ // branch — if the main repo is on a different branch, the push to targetBranch
906
+ // won't touch the working tree at all.
907
+ const currentBranch = await git(targetDir, 'symbolic-ref --short HEAD')
908
+ if (currentBranch instanceof Error || currentBranch !== targetBranch) {
909
+ return null
910
+ }
911
+ if (!(await isDirty(targetDir))) {
912
+ return null
913
+ }
914
+ const pushFiles = await getChangedFiles(sourceDir, targetBranch, 'HEAD')
915
+ const dirtyFiles = await getDirtyFiles(targetDir)
916
+ const overlapping = pushFiles.filter((f) => {
917
+ return dirtyFiles.includes(f)
918
+ })
919
+ return overlapping.length > 0 ? overlapping : null
920
+ }
921
+
922
+ /**
923
+ * Check if git is mid-rebase by looking for rebase-merge or rebase-apply dirs.
924
+ */
925
+ async function isRebaseInProgress(dir: string): Promise<boolean> {
926
+ for (const rebaseDir of ['rebase-merge', 'rebase-apply']) {
927
+ const gitPath = await git(dir, `rev-parse --git-path ${rebaseDir}`)
928
+ if (gitPath instanceof Error) {
929
+ continue
930
+ }
931
+ const resolvedPath = path.isAbsolute(gitPath)
932
+ ? gitPath
933
+ : path.resolve(dir, gitPath)
934
+ const exists = await fs.promises
935
+ .access(resolvedPath)
936
+ .then(() => {
937
+ return true
938
+ })
939
+ .catch(() => {
940
+ return false
941
+ })
942
+ if (exists) {
943
+ return true
944
+ }
945
+ }
946
+ return false
947
+ }
948
+
949
+ /**
950
+ * Merge a worktree branch into the default branch by rebasing all commits
951
+ * onto target, then fast-forward pushing. Preserves every worktree commit.
952
+ * Returns MergeWorktreeErrors | MergeSuccess.
953
+ */
954
+ export async function mergeWorktree({
955
+ worktreeDir,
956
+ mainRepoDir,
957
+ worktreeName,
958
+ targetBranch,
959
+ onProgress,
960
+ }: {
961
+ worktreeDir: string
962
+ mainRepoDir: string
963
+ worktreeName: string
964
+ /** Override the branch to merge into. Defaults to origin/HEAD (or main). */
965
+ targetBranch?: string
966
+ onProgress?: (message: string) => void
967
+ }): Promise<MergeWorktreeErrors | MergeSuccess> {
968
+ const log = (msg: string) => {
969
+ logger.log(msg)
970
+ onProgress?.(msg)
971
+ }
972
+
973
+ // Resolve current branch. If detached, create a temp branch.
974
+ let branchName: string
975
+ let tempBranch: string | null = null
976
+ const branchResult = await git(worktreeDir, 'symbolic-ref --short HEAD')
977
+ if (branchResult instanceof Error) {
978
+ tempBranch = `otto-merge-${Date.now()}`
979
+ const createResult = await git(worktreeDir, `checkout -b "${tempBranch}"`)
980
+ if (createResult instanceof Error) {
981
+ return createResult
982
+ }
983
+ branchName = tempBranch
984
+ } else {
985
+ branchName = branchResult || worktreeName
986
+ }
987
+
988
+ const defaultBranch = targetBranch || (await getDefaultBranch(mainRepoDir))
989
+ log(`Merging ${branchName} into ${defaultBranch}`)
990
+
991
+ // Best-effort cleanup of temp branch on error paths
992
+ const cleanupTempBranch = async () => {
993
+ if (!tempBranch) {
994
+ return
995
+ }
996
+
997
+ const detachResult = await git(worktreeDir, 'checkout --detach')
998
+ if (detachResult instanceof Error) {
999
+ logger.warn(
1000
+ `[MERGE CLEANUP] Failed to detach HEAD before deleting temp branch: ${detachResult.message}`,
1001
+ )
1002
+ }
1003
+
1004
+ const deleteTempBranchResult = await git(
1005
+ worktreeDir,
1006
+ `branch -D "${tempBranch}"`,
1007
+ )
1008
+ if (deleteTempBranchResult instanceof Error) {
1009
+ logger.warn(
1010
+ `[MERGE CLEANUP] Failed to delete temp branch ${tempBranch}: ${deleteTempBranchResult.message}`,
1011
+ )
1012
+ }
1013
+ }
1014
+
1015
+ // ── Step 1: If a rebase is already paused mid-flight, surface it ──
1016
+ // This happens when the user reruns /merge-worktree while the model is
1017
+ // still resolving conflicts. With multi-commit rebases, each conflict
1018
+ // leaves staged conflict markers (isDirty would say yes) AND merge-base
1019
+ // may already equal target (isRebasedOnto would say yes), so neither
1020
+ // of those checks is safe to run first. We must detect the in-progress
1021
+ // rebase explicitly and route back to the AI-resolve flow.
1022
+ if (await isRebaseInProgress(worktreeDir)) {
1023
+ return new RebaseConflictError({ target: defaultBranch })
1024
+ }
1025
+
1026
+ // ── Step 2: Reject uncommitted changes ──
1027
+ if (await isDirty(worktreeDir)) {
1028
+ await cleanupTempBranch()
1029
+ return new DirtyWorktreeError()
1030
+ }
1031
+
1032
+ // ── Step 3: Rebase worktree commits onto target ──
1033
+ // If already rebased onto target AND no rebase is in progress, skip
1034
+ // rebase entirely. The in-progress check above guarantees the second
1035
+ // half; we keep it implicit here.
1036
+ const alreadyRebased = await isRebasedOnto(worktreeDir, defaultBranch)
1037
+
1038
+ const mergeBaseResult = await git(
1039
+ worktreeDir,
1040
+ `merge-base HEAD "${defaultBranch}"`,
1041
+ )
1042
+ const mergeBase =
1043
+ mergeBaseResult instanceof Error ? defaultBranch : mergeBaseResult
1044
+
1045
+ const commitCountResult = await git(
1046
+ worktreeDir,
1047
+ `rev-list --count "${mergeBase}..HEAD"`,
1048
+ )
1049
+ if (commitCountResult instanceof Error) {
1050
+ await cleanupTempBranch()
1051
+ return commitCountResult
1052
+ }
1053
+ const commitCount = parseInt(commitCountResult, 10)
1054
+
1055
+ if (commitCount === 0) {
1056
+ await cleanupTempBranch()
1057
+ return new NothingToMergeError({ target: defaultBranch })
1058
+ }
1059
+
1060
+ if (!alreadyRebased) {
1061
+ // Rebase all worktree commits onto target, preserving each commit.
1062
+ log(
1063
+ commitCount > 1
1064
+ ? `Rebasing ${commitCount} commits onto ${defaultBranch}...`
1065
+ : `Rebasing onto ${defaultBranch}...`,
1066
+ )
1067
+ const rebaseResult = await git(worktreeDir, `rebase "${defaultBranch}"`, {
1068
+ timeout: 60_000,
1069
+ })
1070
+ if (rebaseResult instanceof Error) {
1071
+ if (await isRebaseInProgress(worktreeDir)) {
1072
+ return new RebaseConflictError({
1073
+ target: defaultBranch,
1074
+ cause: rebaseResult,
1075
+ })
1076
+ }
1077
+ await cleanupTempBranch()
1078
+ return new RebaseError({ target: defaultBranch, cause: rebaseResult })
1079
+ }
1080
+ } else {
1081
+ log('Already rebased onto target')
1082
+ }
1083
+
1084
+ // ── Step 4: Fast-forward push via local git push ──
1085
+ if (!(await isAncestor(worktreeDir, defaultBranch, 'HEAD'))) {
1086
+ await cleanupTempBranch()
1087
+ return new NotFastForwardError({ target: defaultBranch })
1088
+ }
1089
+
1090
+ const overlappingFiles = await checkTargetWorktreeConflicts({
1091
+ targetDir: mainRepoDir,
1092
+ sourceDir: worktreeDir,
1093
+ targetBranch: defaultBranch,
1094
+ })
1095
+ if (overlappingFiles) {
1096
+ await cleanupTempBranch()
1097
+ return new ConflictingFilesError({ target: defaultBranch })
1098
+ }
1099
+
1100
+ const gitCommonDir = await getGitCommonDir(worktreeDir)
1101
+ if (gitCommonDir instanceof Error) {
1102
+ await cleanupTempBranch()
1103
+ return gitCommonDir
1104
+ }
1105
+
1106
+ log(`Pushing to ${defaultBranch}...`)
1107
+ const pushResult = await git(
1108
+ worktreeDir,
1109
+ `push --receive-pack="git -c receive.denyCurrentBranch=updateInstead receive-pack" "${gitCommonDir}" "HEAD:${defaultBranch}"`,
1110
+ { timeout: 30_000 },
1111
+ )
1112
+ if (pushResult instanceof Error) {
1113
+ await cleanupTempBranch()
1114
+ return new PushError({ target: defaultBranch, cause: pushResult })
1115
+ }
1116
+
1117
+ // Get short SHA for display
1118
+ const shortSha = await git(worktreeDir, 'rev-parse --short HEAD')
1119
+ if (shortSha instanceof Error) {
1120
+ // Push succeeded but can't get SHA -- non-fatal, use placeholder
1121
+ logger.warn('Failed to get short SHA after push')
1122
+ }
1123
+
1124
+ // ── Step 5: Clean up -- detach HEAD and delete branch ──
1125
+ log('Cleaning up worktree...')
1126
+ const detachResult = await git(worktreeDir, `checkout --detach "${defaultBranch}"`)
1127
+ if (detachResult instanceof Error) {
1128
+ logger.warn(
1129
+ `[MERGE CLEANUP] Failed to detach worktree HEAD after push: ${detachResult.message}`,
1130
+ )
1131
+ }
1132
+
1133
+ const deleteBranchResult = await git(worktreeDir, `branch -D "${branchName}"`)
1134
+ if (deleteBranchResult instanceof Error) {
1135
+ logger.warn(
1136
+ `[MERGE CLEANUP] Failed to delete branch ${branchName}: ${deleteBranchResult.message}`,
1137
+ )
1138
+ }
1139
+
1140
+ if (branchName !== worktreeName && worktreeName) {
1141
+ const deleteWorktreeBranchResult = await git(
1142
+ worktreeDir,
1143
+ `branch -D "${worktreeName}"`,
1144
+ )
1145
+ if (deleteWorktreeBranchResult instanceof Error) {
1146
+ logger.warn(
1147
+ `[MERGE CLEANUP] Failed to delete worktree branch ${worktreeName}: ${deleteWorktreeBranchResult.message}`,
1148
+ )
1149
+ }
1150
+ }
1151
+
1152
+ return {
1153
+ defaultBranch,
1154
+ branchName: worktreeName || branchName,
1155
+ commitCount,
1156
+ shortSha: shortSha instanceof Error ? 'unknown' : shortSha,
1157
+ }
1158
+ }
1159
+
1160
+ /**
1161
+ * List branches sorted by most recent commit date.
1162
+ * Returns branch short names (e.g. "main", "origin/feature-x").
1163
+ * Filters by optional query string (case-insensitive substring match).
1164
+ * Limited to 25 results for Discord autocomplete.
1165
+ *
1166
+ * @param includeRemote - When true (default), includes remote tracking branches (`-a` flag).
1167
+ * Set to false for merge targets where only local branches make sense.
1168
+ */
1169
+ export async function listBranchesByLastCommit({
1170
+ directory,
1171
+ query,
1172
+ includeRemote = true,
1173
+ }: {
1174
+ directory: string
1175
+ query?: string
1176
+ includeRemote?: boolean
1177
+ }): Promise<string[]> {
1178
+ const branchFlag = includeRemote ? '-a' : ''
1179
+ const result = await git(
1180
+ directory,
1181
+ `branch ${branchFlag} --sort=-committerdate --format=%(refname:short)`,
1182
+ )
1183
+ if (result instanceof Error) {
1184
+ return []
1185
+ }
1186
+
1187
+ const lowerQuery = query?.toLowerCase() || ''
1188
+ return result
1189
+ .split('\n')
1190
+ .map((line) => {
1191
+ return line.trim()
1192
+ })
1193
+ .filter((name) => {
1194
+ if (!name) {
1195
+ return false
1196
+ }
1197
+ // Skip HEAD pointer entries like "origin/HEAD -> origin/main"
1198
+ if (name.includes('->')) {
1199
+ return false
1200
+ }
1201
+ if (!lowerQuery) {
1202
+ return true
1203
+ }
1204
+ return name.toLowerCase().includes(lowerQuery)
1205
+ })
1206
+ .slice(0, 25)
1207
+ }
1208
+
1209
+ /**
1210
+ * Validate that a branch name is safe for use in git commands.
1211
+ * Uses `git check-ref-format --branch` which rejects names with shell metacharacters,
1212
+ * double dots, trailing dots/locks, etc. Returns the normalized name or an Error.
1213
+ */
1214
+ export async function validateBranchRef({
1215
+ directory,
1216
+ ref,
1217
+ }: {
1218
+ directory: string
1219
+ ref: string
1220
+ }): Promise<string | Error> {
1221
+ const result = await git(directory, `check-ref-format --branch ${JSON.stringify(ref)}`)
1222
+ if (result instanceof Error) {
1223
+ return new Error(`Invalid branch name: ${ref}`)
1224
+ }
1225
+ return result
1226
+ }
1227
+
1228
+ /**
1229
+ * Validate that a directory is a git worktree of the given project.
1230
+ * Parses `git worktree list --porcelain` from the project directory and
1231
+ * checks that the candidate path appears as one of the listed worktrees.
1232
+ * Returns the resolved absolute path on success, or an Error on failure.
1233
+ */
1234
+ export async function validateWorktreeDirectory({
1235
+ projectDirectory,
1236
+ candidatePath,
1237
+ }: {
1238
+ projectDirectory: string
1239
+ candidatePath: string
1240
+ }): Promise<string | Error> {
1241
+ const absoluteCandidate = path.resolve(candidatePath)
1242
+
1243
+ if (!fs.existsSync(absoluteCandidate)) {
1244
+ return new Error(`Directory does not exist: ${absoluteCandidate}`)
1245
+ }
1246
+
1247
+ const result = await git(projectDirectory, 'worktree list --porcelain')
1248
+ if (result instanceof Error) {
1249
+ return new Error('Failed to list git worktrees', { cause: result })
1250
+ }
1251
+
1252
+ const worktreePaths = result
1253
+ .split('\n')
1254
+ .filter((line) => {
1255
+ return line.startsWith('worktree ')
1256
+ })
1257
+ .map((line) => {
1258
+ return line.slice('worktree '.length)
1259
+ })
1260
+
1261
+ if (!worktreePaths.includes(absoluteCandidate)) {
1262
+ return new Error(
1263
+ `Directory is not a git worktree of ${projectDirectory}: ${absoluteCandidate}`,
1264
+ )
1265
+ }
1266
+
1267
+ return absoluteCandidate
1268
+ }
1269
+
1270
+ // Parsed entry from `git worktree list --porcelain`.
1271
+ // Represents any worktree (otto, opencode, manual) visible to git.
1272
+ export type GitWorktree = {
1273
+ directory: string
1274
+ branch: string | null // null for detached HEAD
1275
+ head: string
1276
+ detached: boolean
1277
+ locked: boolean
1278
+ prunable: boolean
1279
+ }
1280
+
1281
+ type PartialGitWorktree = {
1282
+ directory?: string
1283
+ branch?: string | null
1284
+ head?: string
1285
+ detached?: boolean
1286
+ locked?: boolean
1287
+ prunable?: boolean
1288
+ }
1289
+
1290
+ function flushGitWorktreeEntry(current: PartialGitWorktree): GitWorktree | null {
1291
+ if (!current.directory) {
1292
+ return null
1293
+ }
1294
+ return {
1295
+ directory: current.directory,
1296
+ branch: current.branch ?? null,
1297
+ head: current.head ?? '',
1298
+ detached: current.detached ?? false,
1299
+ locked: current.locked ?? false,
1300
+ prunable: current.prunable ?? false,
1301
+ }
1302
+ }
1303
+
1304
+ // Parse `git worktree list --porcelain` output into structured entries.
1305
+ // Skips the first entry (the main checkout) since that's the project root.
1306
+ export function parseGitWorktreeListPorcelain(
1307
+ output: string,
1308
+ ): GitWorktree[] {
1309
+ const entries: GitWorktree[] = []
1310
+ let current: PartialGitWorktree = {}
1311
+
1312
+ for (const line of output.split('\n')) {
1313
+ if (line.startsWith('worktree ')) {
1314
+ const flushed = flushGitWorktreeEntry(current)
1315
+ if (flushed) {
1316
+ entries.push(flushed)
1317
+ }
1318
+ current = { directory: line.slice('worktree '.length) }
1319
+ continue
1320
+ }
1321
+ if (line.startsWith('HEAD ')) {
1322
+ current.head = line.slice('HEAD '.length)
1323
+ continue
1324
+ }
1325
+ if (line.startsWith('branch ')) {
1326
+ // "branch refs/heads/opencode/otto-foo" → "opencode/otto-foo" (backward compat prefix)
1327
+ current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
1328
+ continue
1329
+ }
1330
+ if (line === 'detached') {
1331
+ current.detached = true
1332
+ continue
1333
+ }
1334
+ // "locked" or "locked <reason>"
1335
+ if (line === 'locked' || line.startsWith('locked ')) {
1336
+ current.locked = true
1337
+ continue
1338
+ }
1339
+ if (line.startsWith('prunable')) {
1340
+ current.prunable = true
1341
+ continue
1342
+ }
1343
+ }
1344
+ // Flush last entry
1345
+ const flushed = flushGitWorktreeEntry(current)
1346
+ if (flushed) {
1347
+ entries.push(flushed)
1348
+ }
1349
+
1350
+ // Skip the first entry — it's the main checkout (project root)
1351
+ return entries.slice(1)
1352
+ }
1353
+
1354
+ // List all git worktrees for a project directory (excluding the main checkout).
1355
+ // Returns Error on git failure, empty array if no worktrees exist.
1356
+ export async function listGitWorktrees({
1357
+ projectDirectory,
1358
+ timeout,
1359
+ }: {
1360
+ projectDirectory: string
1361
+ timeout?: number
1362
+ }): Promise<GitWorktree[] | Error> {
1363
+ const result = await git(projectDirectory, 'worktree list --porcelain', {
1364
+ timeout,
1365
+ })
1366
+ if (result instanceof Error) {
1367
+ return result
1368
+ }
1369
+ return parseGitWorktreeListPorcelain(result)
1370
+ }