@kortix/sandbox 0.4.1

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 (246) hide show
  1. package/config/customize.sh +143 -0
  2. package/config/kortix-env-setup.sh +25 -0
  3. package/kortix-master/package.json +22 -0
  4. package/kortix-master/src/config.ts +22 -0
  5. package/kortix-master/src/index.ts +44 -0
  6. package/kortix-master/src/routes/env.ts +65 -0
  7. package/kortix-master/src/routes/proxy.ts +108 -0
  8. package/kortix-master/src/routes/update.ts +185 -0
  9. package/kortix-master/src/services/proxy.ts +43 -0
  10. package/kortix-master/src/services/secret-store.ts +156 -0
  11. package/kortix-master/tsconfig.json +14 -0
  12. package/opencode/agents/kortix-browser.md +142 -0
  13. package/opencode/agents/kortix-build.md +62 -0
  14. package/opencode/agents/kortix-explore.md +66 -0
  15. package/opencode/agents/kortix-image-gen.md +33 -0
  16. package/opencode/agents/kortix-main.md +450 -0
  17. package/opencode/agents/kortix-plan.md +100 -0
  18. package/opencode/agents/kortix-research.md +84 -0
  19. package/opencode/agents/kortix-sheets.md +61 -0
  20. package/opencode/agents/kortix-slides.md +64 -0
  21. package/opencode/agents/kortix-web-dev.md +572 -0
  22. package/opencode/commands/email.md +36 -0
  23. package/opencode/commands/init.md +43 -0
  24. package/opencode/commands/journal.md +44 -0
  25. package/opencode/commands/memory-init.md +81 -0
  26. package/opencode/commands/memory-search.md +50 -0
  27. package/opencode/commands/memory-status.md +56 -0
  28. package/opencode/commands/research.md +36 -0
  29. package/opencode/commands/search.md +38 -0
  30. package/opencode/commands/slides.md +32 -0
  31. package/opencode/commands/spreadsheet.md +30 -0
  32. package/opencode/memory.json +37 -0
  33. package/opencode/ocx.jsonc +10 -0
  34. package/opencode/opencode.jsonc +103 -0
  35. package/opencode/package.json +25 -0
  36. package/opencode/patches/apply.sh +19 -0
  37. package/opencode/patches/opencode-pty-spawn.txt +49 -0
  38. package/opencode/plugin/background-agents.ts.disabled +483 -0
  39. package/opencode/plugin/kdco-primitives/get-project-id.ts +172 -0
  40. package/opencode/plugin/kdco-primitives/index.ts +26 -0
  41. package/opencode/plugin/kdco-primitives/log-warn.ts +51 -0
  42. package/opencode/plugin/kdco-primitives/mutex.ts +122 -0
  43. package/opencode/plugin/kdco-primitives/shell.ts +138 -0
  44. package/opencode/plugin/kdco-primitives/temp.ts +36 -0
  45. package/opencode/plugin/kdco-primitives/terminal-detect.ts +34 -0
  46. package/opencode/plugin/kdco-primitives/types.ts +13 -0
  47. package/opencode/plugin/kdco-primitives/with-timeout.ts +84 -0
  48. package/opencode/plugin/memory.ts +306 -0
  49. package/opencode/plugin/worktree/state.ts +412 -0
  50. package/opencode/plugin/worktree/terminal.ts +1002 -0
  51. package/opencode/plugin/worktree.ts +861 -0
  52. package/opencode/skills/KORTIX-browser/SKILL.md +478 -0
  53. package/opencode/skills/KORTIX-cron-triggers/SKILL.md +173 -0
  54. package/opencode/skills/KORTIX-deep-research/SKILL.md +278 -0
  55. package/opencode/skills/KORTIX-docx/SKILL.md +398 -0
  56. package/opencode/skills/KORTIX-docx/scripts/__init__.py +1 -0
  57. package/opencode/skills/KORTIX-docx/scripts/accept_changes.py +104 -0
  58. package/opencode/skills/KORTIX-docx/scripts/comment.py +244 -0
  59. package/opencode/skills/KORTIX-docx/scripts/office/helpers/__init__.py +0 -0
  60. package/opencode/skills/KORTIX-docx/scripts/office/helpers/merge_runs.py +199 -0
  61. package/opencode/skills/KORTIX-docx/scripts/office/helpers/simplify_redlines.py +197 -0
  62. package/opencode/skills/KORTIX-docx/scripts/office/pack.py +159 -0
  63. package/opencode/skills/KORTIX-docx/scripts/office/soffice.py +183 -0
  64. package/opencode/skills/KORTIX-docx/scripts/office/unpack.py +132 -0
  65. package/opencode/skills/KORTIX-docx/scripts/office/validate.py +111 -0
  66. package/opencode/skills/KORTIX-docx/scripts/office/validators/__init__.py +15 -0
  67. package/opencode/skills/KORTIX-docx/scripts/office/validators/base.py +847 -0
  68. package/opencode/skills/KORTIX-docx/scripts/office/validators/docx.py +446 -0
  69. package/opencode/skills/KORTIX-docx/scripts/office/validators/pptx.py +275 -0
  70. package/opencode/skills/KORTIX-docx/scripts/office/validators/redlining.py +247 -0
  71. package/opencode/skills/KORTIX-docx/scripts/render_docx.py +179 -0
  72. package/opencode/skills/KORTIX-docx/scripts/templates/comments.xml +3 -0
  73. package/opencode/skills/KORTIX-docx/scripts/templates/commentsExtended.xml +3 -0
  74. package/opencode/skills/KORTIX-docx/scripts/templates/commentsExtensible.xml +3 -0
  75. package/opencode/skills/KORTIX-docx/scripts/templates/commentsIds.xml +3 -0
  76. package/opencode/skills/KORTIX-docx/scripts/templates/people.xml +3 -0
  77. package/opencode/skills/KORTIX-domain-research/SKILL.md +96 -0
  78. package/opencode/skills/KORTIX-domain-research/scripts/domain-lookup.py +810 -0
  79. package/opencode/skills/KORTIX-elevenlabs/SKILL.md +230 -0
  80. package/opencode/skills/KORTIX-elevenlabs/scripts/tts.py +389 -0
  81. package/opencode/skills/KORTIX-email/SKILL.md +145 -0
  82. package/opencode/skills/KORTIX-legal-writer/SKILL.md +409 -0
  83. package/opencode/skills/KORTIX-legal-writer/references/bluebook.md +152 -0
  84. package/opencode/skills/KORTIX-legal-writer/references/document-types.md +416 -0
  85. package/opencode/skills/KORTIX-legal-writer/scripts/courtlistener.py +291 -0
  86. package/opencode/skills/KORTIX-legal-writer/scripts/ecfr_lookup.py +299 -0
  87. package/opencode/skills/KORTIX-legal-writer/scripts/verify-legal.py +507 -0
  88. package/opencode/skills/KORTIX-logo-creator/SKILL.md +293 -0
  89. package/opencode/skills/KORTIX-logo-creator/references/prompt-patterns.md +134 -0
  90. package/opencode/skills/KORTIX-logo-creator/scripts/compose_logo.py +406 -0
  91. package/opencode/skills/KORTIX-logo-creator/scripts/create_logo_sheet.py +258 -0
  92. package/opencode/skills/KORTIX-logo-creator/scripts/remove_bg.py +96 -0
  93. package/opencode/skills/KORTIX-memory/SKILL.md +261 -0
  94. package/opencode/skills/KORTIX-memory/scripts/export-sessions.py +409 -0
  95. package/opencode/skills/KORTIX-paper-creator/SKILL.md +549 -0
  96. package/opencode/skills/KORTIX-paper-creator/assets/template.tex +101 -0
  97. package/opencode/skills/KORTIX-paper-creator/scripts/compile.sh +177 -0
  98. package/opencode/skills/KORTIX-paper-creator/scripts/openalex_to_bibtex.py +220 -0
  99. package/opencode/skills/KORTIX-paper-creator/scripts/verify.sh +354 -0
  100. package/opencode/skills/KORTIX-paper-search/SKILL.md +418 -0
  101. package/opencode/skills/KORTIX-pdf/SKILL.md +232 -0
  102. package/opencode/skills/KORTIX-pdf/forms.md +36 -0
  103. package/opencode/skills/KORTIX-pdf/reference.md +105 -0
  104. package/opencode/skills/KORTIX-pdf/scripts/check_bounding_boxes.py +65 -0
  105. package/opencode/skills/KORTIX-pdf/scripts/check_fillable_fields.py +11 -0
  106. package/opencode/skills/KORTIX-pdf/scripts/convert_pdf_to_images.py +33 -0
  107. package/opencode/skills/KORTIX-pdf/scripts/create_validation_image.py +37 -0
  108. package/opencode/skills/KORTIX-pdf/scripts/extract_form_field_info.py +122 -0
  109. package/opencode/skills/KORTIX-pdf/scripts/extract_form_structure.py +115 -0
  110. package/opencode/skills/KORTIX-pdf/scripts/fill_fillable_fields.py +98 -0
  111. package/opencode/skills/KORTIX-pdf/scripts/fill_pdf_form_with_annotations.py +107 -0
  112. package/opencode/skills/KORTIX-plan/SKILL.md +228 -0
  113. package/opencode/skills/KORTIX-presentation-viewer/SKILL.md +87 -0
  114. package/opencode/skills/KORTIX-presentation-viewer/serve.ts +136 -0
  115. package/opencode/skills/KORTIX-presentation-viewer/viewer.html +559 -0
  116. package/opencode/skills/KORTIX-presentations/SKILL.md +344 -0
  117. package/opencode/skills/KORTIX-remotion/SKILL.md +56 -0
  118. package/opencode/skills/KORTIX-remotion/rules/3d.md +86 -0
  119. package/opencode/skills/KORTIX-remotion/rules/animations.md +29 -0
  120. package/opencode/skills/KORTIX-remotion/rules/assets.md +78 -0
  121. package/opencode/skills/KORTIX-remotion/rules/audio-visualization.md +198 -0
  122. package/opencode/skills/KORTIX-remotion/rules/audio.md +169 -0
  123. package/opencode/skills/KORTIX-remotion/rules/calculate-metadata.md +104 -0
  124. package/opencode/skills/KORTIX-remotion/rules/can-decode.md +75 -0
  125. package/opencode/skills/KORTIX-remotion/rules/charts.md +120 -0
  126. package/opencode/skills/KORTIX-remotion/rules/compositions.md +141 -0
  127. package/opencode/skills/KORTIX-remotion/rules/display-captions.md +184 -0
  128. package/opencode/skills/KORTIX-remotion/rules/extract-frames.md +229 -0
  129. package/opencode/skills/KORTIX-remotion/rules/ffmpeg.md +38 -0
  130. package/opencode/skills/KORTIX-remotion/rules/fonts.md +152 -0
  131. package/opencode/skills/KORTIX-remotion/rules/get-audio-duration.md +58 -0
  132. package/opencode/skills/KORTIX-remotion/rules/get-video-dimensions.md +68 -0
  133. package/opencode/skills/KORTIX-remotion/rules/get-video-duration.md +58 -0
  134. package/opencode/skills/KORTIX-remotion/rules/gifs.md +141 -0
  135. package/opencode/skills/KORTIX-remotion/rules/images.md +130 -0
  136. package/opencode/skills/KORTIX-remotion/rules/import-srt-captions.md +69 -0
  137. package/opencode/skills/KORTIX-remotion/rules/light-leaks.md +73 -0
  138. package/opencode/skills/KORTIX-remotion/rules/lottie.md +68 -0
  139. package/opencode/skills/KORTIX-remotion/rules/maps.md +401 -0
  140. package/opencode/skills/KORTIX-remotion/rules/measuring-dom-nodes.md +35 -0
  141. package/opencode/skills/KORTIX-remotion/rules/measuring-text.md +143 -0
  142. package/opencode/skills/KORTIX-remotion/rules/parameters.md +98 -0
  143. package/opencode/skills/KORTIX-remotion/rules/sequencing.md +118 -0
  144. package/opencode/skills/KORTIX-remotion/rules/subtitles.md +36 -0
  145. package/opencode/skills/KORTIX-remotion/rules/tailwind.md +11 -0
  146. package/opencode/skills/KORTIX-remotion/rules/text-animations.md +20 -0
  147. package/opencode/skills/KORTIX-remotion/rules/timing.md +179 -0
  148. package/opencode/skills/KORTIX-remotion/rules/transcribe-captions.md +70 -0
  149. package/opencode/skills/KORTIX-remotion/rules/transitions.md +197 -0
  150. package/opencode/skills/KORTIX-remotion/rules/transparent-videos.md +106 -0
  151. package/opencode/skills/KORTIX-remotion/rules/trimming.md +53 -0
  152. package/opencode/skills/KORTIX-remotion/rules/videos.md +171 -0
  153. package/opencode/skills/KORTIX-secrets/SKILL.md +280 -0
  154. package/opencode/skills/KORTIX-semantic-search/SKILL.md +213 -0
  155. package/opencode/skills/KORTIX-session-search/SKILL.md +807 -0
  156. package/opencode/skills/KORTIX-session-search/Untitled +1 -0
  157. package/opencode/skills/KORTIX-skill-creator/SKILL.md +163 -0
  158. package/opencode/skills/KORTIX-web-research/SKILL.md +69 -0
  159. package/opencode/skills/KORTIX-xlsx/LICENSE.txt +30 -0
  160. package/opencode/skills/KORTIX-xlsx/SKILL.md +549 -0
  161. package/opencode/skills/KORTIX-xlsx/scripts/office/helpers/__init__.py +0 -0
  162. package/opencode/skills/KORTIX-xlsx/scripts/office/helpers/merge_runs.py +199 -0
  163. package/opencode/skills/KORTIX-xlsx/scripts/office/helpers/simplify_redlines.py +197 -0
  164. package/opencode/skills/KORTIX-xlsx/scripts/office/pack.py +159 -0
  165. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  166. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  167. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  168. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  169. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  170. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  171. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  172. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  173. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  174. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  175. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  176. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  177. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  178. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  179. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  180. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  181. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  182. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  183. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  184. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  185. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  186. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  187. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  188. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  189. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  190. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  191. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  192. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
  193. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
  194. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
  195. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
  196. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/mce/mc.xsd +75 -0
  197. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  198. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  199. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  200. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  201. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  202. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  203. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  204. package/opencode/skills/KORTIX-xlsx/scripts/office/soffice.py +183 -0
  205. package/opencode/skills/KORTIX-xlsx/scripts/office/unpack.py +132 -0
  206. package/opencode/skills/KORTIX-xlsx/scripts/office/validate.py +111 -0
  207. package/opencode/skills/KORTIX-xlsx/scripts/office/validators/__init__.py +15 -0
  208. package/opencode/skills/KORTIX-xlsx/scripts/office/validators/base.py +847 -0
  209. package/opencode/skills/KORTIX-xlsx/scripts/office/validators/docx.py +446 -0
  210. package/opencode/skills/KORTIX-xlsx/scripts/office/validators/pptx.py +275 -0
  211. package/opencode/skills/KORTIX-xlsx/scripts/office/validators/redlining.py +247 -0
  212. package/opencode/skills/KORTIX-xlsx/scripts/recalc.py +184 -0
  213. package/opencode/tools/image-gen.ts +342 -0
  214. package/opencode/tools/image-search.ts +190 -0
  215. package/opencode/tools/memory-get.ts +168 -0
  216. package/opencode/tools/memory-search.ts +247 -0
  217. package/opencode/tools/presentation-gen.ts +723 -0
  218. package/opencode/tools/scrape-webpage.ts +115 -0
  219. package/opencode/tools/scripts/.python-version +1 -0
  220. package/opencode/tools/scripts/convert_pdf.py +184 -0
  221. package/opencode/tools/scripts/convert_pptx.py +562 -0
  222. package/opencode/tools/scripts/pyproject.toml +11 -0
  223. package/opencode/tools/scripts/uv.lock +287 -0
  224. package/opencode/tools/scripts/validate_slide.py +74 -0
  225. package/opencode/tools/show-user.ts +217 -0
  226. package/opencode/tools/tests/e2e-presentation-fix.ts +277 -0
  227. package/opencode/tools/tests/image-gen.test.ts +215 -0
  228. package/opencode/tools/tests/image-search.test.ts +125 -0
  229. package/opencode/tools/tests/memory-system-benchmark.ts +1076 -0
  230. package/opencode/tools/tests/presentation-gen.test.ts +389 -0
  231. package/opencode/tools/tests/scrape-webpage.test.ts +74 -0
  232. package/opencode/tools/tests/show-user.test.ts +241 -0
  233. package/opencode/tools/tests/video-gen.test.ts +110 -0
  234. package/opencode/tools/tests/web-search.test.ts +106 -0
  235. package/opencode/tools/video-gen.ts +200 -0
  236. package/opencode/tools/web-search.ts +153 -0
  237. package/opencode/tsconfig.json +29 -0
  238. package/package.json +36 -0
  239. package/patch-agent-browser.js +100 -0
  240. package/postinstall.sh +88 -0
  241. package/services/KORTIX-presentation-viewer/run +37 -0
  242. package/services/agent-browser-viewer/run +48 -0
  243. package/services/kortix-master/run +16 -0
  244. package/services/lss-sync/run +22 -0
  245. package/services/opencode-serve/run +25 -0
  246. package/services/opencode-web/run +21 -0
@@ -0,0 +1,861 @@
1
+ /**
2
+ * OCX Worktree Plugin
3
+ *
4
+ * Creates isolated git worktrees for AI development sessions with
5
+ * seamless terminal spawning across macOS, Windows, and Linux.
6
+ *
7
+ * Inspired by opencode-worktree-session by Felix Anhalt
8
+ * https://github.com/felixAnhalt/opencode-worktree-session
9
+ * License: MIT
10
+ *
11
+ * Rewritten for OCX with production-proven patterns.
12
+ */
13
+
14
+ import type { Database } from "bun:sqlite"
15
+ import { access, copyFile, cp, mkdir, rm, stat, symlink } from "node:fs/promises"
16
+ import * as os from "node:os"
17
+ import * as path from "node:path"
18
+ import { type Plugin, tool } from "@opencode-ai/plugin"
19
+ import type { Event } from "@kortix/opencode-sdk"
20
+ import type { OpencodeClient } from "./kdco-primitives/types"
21
+
22
+ /** Logger interface for structured logging */
23
+ interface Logger {
24
+ debug: (msg: string) => void
25
+ info: (msg: string) => void
26
+ warn: (msg: string) => void
27
+ error: (msg: string) => void
28
+ }
29
+
30
+ import { parse as parseJsonc } from "jsonc-parser"
31
+ import { z } from "zod"
32
+
33
+ import { getProjectId } from "./kdco-primitives/get-project-id"
34
+ import {
35
+ addSession,
36
+ clearPendingDelete,
37
+ getPendingDelete,
38
+ getSession,
39
+ getWorktreePath,
40
+ initStateDb,
41
+ removeSession,
42
+ setPendingDelete,
43
+ } from "./worktree/state"
44
+ import { openTerminal } from "./worktree/terminal"
45
+
46
+ /** Maximum retries for database initialization */
47
+ const DB_MAX_RETRIES = 3
48
+
49
+ /** Delay between retry attempts in milliseconds */
50
+ const DB_RETRY_DELAY_MS = 100
51
+
52
+ /** Maximum depth to traverse session parent chain */
53
+ const MAX_SESSION_CHAIN_DEPTH = 10
54
+
55
+ // =============================================================================
56
+ // TYPES & SCHEMAS
57
+ // =============================================================================
58
+
59
+ /** Result type for fallible operations */
60
+ interface OkResult<T> {
61
+ readonly ok: true
62
+ readonly value: T
63
+ }
64
+ interface ErrResult<E> {
65
+ readonly ok: false
66
+ readonly error: E
67
+ }
68
+ type Result<T, E> = OkResult<T> | ErrResult<E>
69
+
70
+ const Result = {
71
+ ok: <T>(value: T): OkResult<T> => ({ ok: true, value }),
72
+ err: <E>(error: E): ErrResult<E> => ({ ok: false, error }),
73
+ }
74
+
75
+ /**
76
+ * Git branch name validation - blocks invalid refs and shell metacharacters
77
+ * Characters blocked: control chars (0x00-0x1f, 0x7f), ~^:?*[]\\, and shell metacharacters
78
+ */
79
+ function isValidBranchName(name: string): boolean {
80
+ // Check for control characters
81
+ for (let i = 0; i < name.length; i++) {
82
+ const code = name.charCodeAt(i)
83
+ if (code <= 0x1f || code === 0x7f) return false
84
+ }
85
+ // Check for invalid git ref characters and shell metacharacters
86
+ if (/[~^:?*[\]\\;&|`$()]/.test(name)) return false
87
+ return true
88
+ }
89
+
90
+ const branchNameSchema = z
91
+ .string()
92
+ .min(1, "Branch name cannot be empty")
93
+ .max(255, "Branch name too long")
94
+ .refine((name) => !name.startsWith("-"), {
95
+ message: "Branch name cannot start with '-' (prevents option injection)",
96
+ })
97
+ .refine((name) => !name.startsWith("/") && !name.endsWith("/"), {
98
+ message: "Branch name cannot start or end with '/'",
99
+ })
100
+ .refine((name) => !name.includes("//"), {
101
+ message: "Branch name cannot contain '//'",
102
+ })
103
+ .refine((name) => !name.includes("@{"), {
104
+ message: "Branch name cannot contain '@{' (git reflog syntax)",
105
+ })
106
+ .refine((name) => !name.includes(".."), {
107
+ message: "Branch name cannot contain '..'",
108
+ })
109
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Control character detection is intentional for security
110
+ .refine((name) => !/[\x00-\x1f\x7f ~^:?*[\]\\]/.test(name), {
111
+ message: "Branch name contains invalid characters",
112
+ })
113
+ .refine((name) => isValidBranchName(name), "Contains invalid git ref characters")
114
+ .refine((name) => !name.startsWith(".") && !name.endsWith("."), "Cannot start or end with dot")
115
+ .refine((name) => !name.endsWith(".lock"), "Cannot end with .lock")
116
+
117
+ /**
118
+ * Worktree plugin configuration schema.
119
+ * Config file: .opencode/worktree.jsonc
120
+ */
121
+ const worktreeConfigSchema = z.object({
122
+ sync: z
123
+ .object({
124
+ /** Files to copy from main worktree (relative paths only) */
125
+ copyFiles: z.array(z.string()).default([]),
126
+ /** Directories to symlink from main worktree (saves disk space) */
127
+ symlinkDirs: z.array(z.string()).default([]),
128
+ /** Patterns to exclude from copying (reserved for future use) */
129
+ exclude: z.array(z.string()).default([]),
130
+ })
131
+ .default(() => ({ copyFiles: [], symlinkDirs: [], exclude: [] })),
132
+ hooks: z
133
+ .object({
134
+ /** Commands to run after worktree creation */
135
+ postCreate: z.array(z.string()).default([]),
136
+ /** Commands to run before worktree deletion */
137
+ preDelete: z.array(z.string()).default([]),
138
+ })
139
+ .default(() => ({ postCreate: [], preDelete: [] })),
140
+ })
141
+
142
+ type WorktreeConfig = z.infer<typeof worktreeConfigSchema>
143
+
144
+ // =============================================================================
145
+ // ERROR TYPES
146
+ // =============================================================================
147
+
148
+ class WorktreeError extends Error {
149
+ constructor(
150
+ message: string,
151
+ public readonly operation: string,
152
+ public readonly cause?: unknown,
153
+ ) {
154
+ super(`${operation}: ${message}`)
155
+ this.name = "WorktreeError"
156
+ }
157
+ }
158
+
159
+ // =============================================================================
160
+ // SESSION FORKING HELPERS
161
+ // =============================================================================
162
+
163
+ /**
164
+ * Check if a path exists, distinguishing ENOENT from other errors (Law 4)
165
+ */
166
+ async function pathExists(filePath: string): Promise<boolean> {
167
+ try {
168
+ await access(filePath)
169
+ return true
170
+ } catch (e: unknown) {
171
+ if (e && typeof e === "object" && "code" in e && e.code === "ENOENT") {
172
+ return false
173
+ }
174
+ throw e // Re-throw permission errors, etc.
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Copy file if source exists. Returns true if copied, false if source doesn't exist.
180
+ * Throws on copy failure (Law 4: Fail Loud)
181
+ */
182
+ async function copyIfExists(src: string, dest: string): Promise<boolean> {
183
+ if (!(await pathExists(src))) return false
184
+ await copyFile(src, dest)
185
+ return true
186
+ }
187
+
188
+ /**
189
+ * Copy directory contents if source exists.
190
+ * @param src - Source directory path
191
+ * @param dest - Destination directory path
192
+ * @returns true if copy was performed, false if source doesn't exist
193
+ */
194
+ async function copyDirIfExists(src: string, dest: string): Promise<boolean> {
195
+ if (!(await pathExists(src))) return false
196
+ await cp(src, dest, { recursive: true })
197
+ return true
198
+ }
199
+
200
+ interface ForkResult {
201
+ forkedSession: { id: string }
202
+ rootSessionId: string
203
+ planCopied: boolean
204
+ delegationsCopied: boolean
205
+ }
206
+
207
+ /**
208
+ * Fork a session and copy associated plans/delegations.
209
+ * Cleans up forked session on failure (atomic operation).
210
+ */
211
+ async function forkWithContext(
212
+ client: OpencodeClient,
213
+ sessionId: string,
214
+ projectId: string,
215
+ getRootSessionIdFn: (sessionId: string) => Promise<string>,
216
+ ): Promise<ForkResult> {
217
+ // Guard clauses (Law 1)
218
+ if (!client) throw new WorktreeError("client is required", "forkWithContext")
219
+ if (!sessionId) throw new WorktreeError("sessionId is required", "forkWithContext")
220
+ if (!projectId) throw new WorktreeError("projectId is required", "forkWithContext")
221
+
222
+ // Get root session ID with error wrapping
223
+ let rootSessionId: string
224
+ try {
225
+ rootSessionId = await getRootSessionIdFn(sessionId)
226
+ } catch (e) {
227
+ throw new WorktreeError("Failed to get root session ID", "forkWithContext", e)
228
+ }
229
+
230
+ // Fork session
231
+ const forkedSessionResponse = await client.session.fork({
232
+ path: { id: sessionId },
233
+ body: {},
234
+ })
235
+ const forkedSession = forkedSessionResponse.data
236
+ if (!forkedSession?.id) {
237
+ throw new WorktreeError("Failed to fork session: no session data returned", "forkWithContext")
238
+ }
239
+
240
+ // Copy data with cleanup on failure
241
+ let planCopied = false
242
+ let delegationsCopied = false
243
+
244
+ try {
245
+ const workspaceBase = path.join(os.homedir(), ".local", "share", "opencode", "workspace")
246
+ const delegationsBase = path.join(os.homedir(), ".local", "share", "opencode", "delegations")
247
+
248
+ const destWorkspaceDir = path.join(workspaceBase, projectId, forkedSession.id)
249
+ const destDelegationsDir = path.join(delegationsBase, projectId, forkedSession.id)
250
+
251
+ await mkdir(destWorkspaceDir, { recursive: true })
252
+ await mkdir(destDelegationsDir, { recursive: true })
253
+
254
+ // Copy plan
255
+ const srcPlan = path.join(workspaceBase, projectId, rootSessionId, "plan.md")
256
+ const destPlan = path.join(destWorkspaceDir, "plan.md")
257
+ planCopied = await copyIfExists(srcPlan, destPlan)
258
+
259
+ // Copy delegations
260
+ const srcDelegations = path.join(delegationsBase, projectId, rootSessionId)
261
+ delegationsCopied = await copyDirIfExists(srcDelegations, destDelegationsDir)
262
+ } catch (error) {
263
+ client.app
264
+ .log({
265
+ body: {
266
+ service: "worktree",
267
+ level: "error",
268
+ message: `forkWithContext: Copy failed, cleaning up forked session: ${error}`,
269
+ },
270
+ })
271
+ .catch(() => {})
272
+ // Clean up orphaned directories
273
+ const workspaceBase = path.join(os.homedir(), ".local", "share", "opencode", "workspace")
274
+ const delegationsBase = path.join(os.homedir(), ".local", "share", "opencode", "delegations")
275
+ const destWorkspaceDir = path.join(workspaceBase, projectId, forkedSession.id)
276
+ const destDelegationsDir = path.join(delegationsBase, projectId, forkedSession.id)
277
+ await rm(destWorkspaceDir, { recursive: true, force: true }).catch((e) => {
278
+ client.app
279
+ .log({
280
+ body: {
281
+ service: "worktree",
282
+ level: "error",
283
+ message: `forkWithContext: Failed to clean up workspace dir ${destWorkspaceDir}: ${e}`,
284
+ },
285
+ })
286
+ .catch(() => {})
287
+ })
288
+ await rm(destDelegationsDir, { recursive: true, force: true }).catch((e) => {
289
+ client.app
290
+ .log({
291
+ body: {
292
+ service: "worktree",
293
+ level: "error",
294
+ message: `forkWithContext: Failed to clean up delegations dir ${destDelegationsDir}: ${e}`,
295
+ },
296
+ })
297
+ .catch(() => {})
298
+ })
299
+ await client.session.delete({ path: { id: forkedSession.id } }).catch((e) => {
300
+ client.app
301
+ .log({
302
+ body: {
303
+ service: "worktree",
304
+ level: "error",
305
+ message: `forkWithContext: Failed to clean up forked session ${forkedSession.id}: ${e}`,
306
+ },
307
+ })
308
+ .catch(() => {})
309
+ })
310
+ throw new WorktreeError(
311
+ `Failed to copy session data: ${error instanceof Error ? error.message : String(error)}`,
312
+ "forkWithContext",
313
+ error,
314
+ )
315
+ }
316
+
317
+ return { forkedSession, rootSessionId, planCopied, delegationsCopied }
318
+ }
319
+
320
+ // =============================================================================
321
+ // MODULE-LEVEL STATE
322
+ // =============================================================================
323
+
324
+ /** Database instance - initialized once per plugin lifecycle */
325
+ let db: Database | null = null
326
+
327
+ /** Project root path - stored on first initialization */
328
+ let projectRoot: string | null = null
329
+
330
+ /** Flag to prevent duplicate cleanup handler registration */
331
+ let cleanupRegistered = false
332
+
333
+ /**
334
+ * Register process cleanup handlers for graceful database shutdown.
335
+ * Ensures WAL checkpoint and proper close on process termination.
336
+ *
337
+ * NOTE: process.once() is an EventEmitter method that never throws.
338
+ * The boolean guard is defense-in-depth for idempotency, not error recovery.
339
+ *
340
+ * @param database - The database instance to clean up
341
+ */
342
+ function registerCleanupHandlers(database: Database): void {
343
+ if (cleanupRegistered) return // Early exit guard
344
+ cleanupRegistered = true
345
+
346
+ const cleanup = () => {
347
+ try {
348
+ database.exec("PRAGMA wal_checkpoint(TRUNCATE)")
349
+ database.close()
350
+ } catch {
351
+ // Best effort cleanup - process is exiting anyway
352
+ }
353
+ }
354
+
355
+ process.once("SIGTERM", cleanup)
356
+ process.once("SIGINT", cleanup)
357
+ process.once("beforeExit", cleanup)
358
+ }
359
+
360
+ /**
361
+ * Get the database instance, initializing if needed.
362
+ * Includes retry logic for transient initialization failures.
363
+ *
364
+ * @returns Database instance
365
+ * @throws {Error} if initialization fails after all retries
366
+ */
367
+ async function getDb(log: Logger): Promise<Database> {
368
+ if (db) return db
369
+
370
+ if (!projectRoot) {
371
+ throw new Error("Database not initialized: projectRoot not set. Call initDb() first.")
372
+ }
373
+
374
+ let lastError: Error | null = null
375
+
376
+ for (let attempt = 1; attempt <= DB_MAX_RETRIES; attempt++) {
377
+ try {
378
+ db = await initStateDb(projectRoot)
379
+ registerCleanupHandlers(db)
380
+ return db
381
+ } catch (error) {
382
+ lastError = error instanceof Error ? error : new Error(String(error))
383
+ log.warn(`Database init attempt ${attempt}/${DB_MAX_RETRIES} failed: ${lastError.message}`)
384
+
385
+ if (attempt < DB_MAX_RETRIES) {
386
+ Bun.sleepSync(DB_RETRY_DELAY_MS)
387
+ }
388
+ }
389
+ }
390
+
391
+ throw new Error(
392
+ `Failed to initialize database after ${DB_MAX_RETRIES} attempts: ${lastError?.message}`,
393
+ )
394
+ }
395
+
396
+ /**
397
+ * Initialize the database with the project root path.
398
+ * Must be called once before any getDb() calls.
399
+ */
400
+ async function initDb(root: string, log: Logger): Promise<Database> {
401
+ projectRoot = root
402
+ return getDb(log)
403
+ }
404
+
405
+ // =============================================================================
406
+ // GIT MODULE
407
+ // =============================================================================
408
+
409
+ /**
410
+ * Execute a git command safely using Bun.spawn with explicit array.
411
+ * Avoids shell interpolation entirely by passing args as array.
412
+ */
413
+ async function git(args: string[], cwd: string): Promise<Result<string, string>> {
414
+ try {
415
+ const proc = Bun.spawn(["git", ...args], {
416
+ cwd,
417
+ stdout: "pipe",
418
+ stderr: "pipe",
419
+ })
420
+ const [stdout, stderr, exitCode] = await Promise.all([
421
+ new Response(proc.stdout).text(),
422
+ new Response(proc.stderr).text(),
423
+ proc.exited,
424
+ ])
425
+ if (exitCode !== 0) {
426
+ return Result.err(stderr.trim() || `git ${args[0]} failed`)
427
+ }
428
+ return Result.ok(stdout.trim())
429
+ } catch (error) {
430
+ return Result.err(error instanceof Error ? error.message : String(error))
431
+ }
432
+ }
433
+
434
+ async function branchExists(cwd: string, branch: string): Promise<boolean> {
435
+ const result = await git(["rev-parse", "--verify", branch], cwd)
436
+ return result.ok
437
+ }
438
+
439
+ async function createWorktree(
440
+ repoRoot: string,
441
+ branch: string,
442
+ baseBranch?: string,
443
+ ): Promise<Result<string, string>> {
444
+ const worktreePath = await getWorktreePath(repoRoot, branch)
445
+
446
+ // Ensure parent directory exists
447
+ await mkdir(path.dirname(worktreePath), { recursive: true })
448
+
449
+ const exists = await branchExists(repoRoot, branch)
450
+
451
+ if (exists) {
452
+ // Checkout existing branch into worktree
453
+ const result = await git(["worktree", "add", worktreePath, branch], repoRoot)
454
+ return result.ok ? Result.ok(worktreePath) : result
455
+ } else {
456
+ // Create new branch from base
457
+ const base = baseBranch ?? "HEAD"
458
+ const result = await git(["worktree", "add", "-b", branch, worktreePath, base], repoRoot)
459
+ return result.ok ? Result.ok(worktreePath) : result
460
+ }
461
+ }
462
+
463
+ async function removeWorktree(
464
+ repoRoot: string,
465
+ worktreePath: string,
466
+ ): Promise<Result<void, string>> {
467
+ const result = await git(["worktree", "remove", "--force", worktreePath], repoRoot)
468
+ return result.ok ? Result.ok(undefined) : Result.err(result.error)
469
+ }
470
+
471
+ // =============================================================================
472
+ // FILE SYNC MODULE
473
+ // =============================================================================
474
+
475
+ /**
476
+ * Validate that a path is safe (no escape from base directory)
477
+ */
478
+ function isPathSafe(filePath: string, baseDir: string, log: Logger): boolean {
479
+ // Reject absolute paths
480
+ if (path.isAbsolute(filePath)) {
481
+ log.warn(`[worktree] Rejected absolute path: ${filePath}`)
482
+ return false
483
+ }
484
+ // Reject obvious path traversal
485
+ if (filePath.includes("..")) {
486
+ log.warn(`[worktree] Rejected path traversal: ${filePath}`)
487
+ return false
488
+ }
489
+ // Verify resolved path stays within base directory
490
+ const resolved = path.resolve(baseDir, filePath)
491
+ if (!resolved.startsWith(baseDir + path.sep) && resolved !== baseDir) {
492
+ log.warn(`[worktree] Path escapes base directory: ${filePath}`)
493
+ return false
494
+ }
495
+ return true
496
+ }
497
+
498
+ /**
499
+ * Copy files from source directory to target directory.
500
+ * Skips missing files silently (production pattern).
501
+ */
502
+ async function copyFiles(
503
+ sourceDir: string,
504
+ targetDir: string,
505
+ files: string[],
506
+ log: Logger,
507
+ ): Promise<void> {
508
+ for (const file of files) {
509
+ if (!isPathSafe(file, sourceDir, log)) continue
510
+
511
+ const sourcePath = path.join(sourceDir, file)
512
+ const targetPath = path.join(targetDir, file)
513
+
514
+ try {
515
+ const sourceFile = Bun.file(sourcePath)
516
+ if (!(await sourceFile.exists())) {
517
+ log.debug(`[worktree] Skipping missing file: ${file}`)
518
+ continue
519
+ }
520
+
521
+ // Ensure target directory exists
522
+ const targetFileDir = path.dirname(targetPath)
523
+ await mkdir(targetFileDir, { recursive: true })
524
+
525
+ // Copy file
526
+ await Bun.write(targetPath, sourceFile)
527
+ log.info(`[worktree] Copied: ${file}`)
528
+ } catch (error) {
529
+ const isNotFound =
530
+ error instanceof Error &&
531
+ (error.message.includes("ENOENT") || error.message.includes("no such file"))
532
+ if (isNotFound) {
533
+ log.debug(`[worktree] Skipping missing: ${file}`)
534
+ } else {
535
+ log.warn(`[worktree] Failed to copy ${file}: ${error}`)
536
+ }
537
+ }
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Create symlinks for directories from source to target.
543
+ * Uses absolute paths for symlink targets.
544
+ */
545
+ async function symlinkDirs(
546
+ sourceDir: string,
547
+ targetDir: string,
548
+ dirs: string[],
549
+ log: Logger,
550
+ ): Promise<void> {
551
+ for (const dir of dirs) {
552
+ if (!isPathSafe(dir, sourceDir, log)) continue
553
+
554
+ const sourcePath = path.join(sourceDir, dir)
555
+ const targetPath = path.join(targetDir, dir)
556
+
557
+ try {
558
+ // Check if source directory exists
559
+ const fileStat = await stat(sourcePath).catch(() => null)
560
+ if (!fileStat || !fileStat.isDirectory()) {
561
+ log.debug(`[worktree] Skipping missing directory: ${dir}`)
562
+ continue
563
+ }
564
+
565
+ // Ensure parent directory exists
566
+ const targetParentDir = path.dirname(targetPath)
567
+ await mkdir(targetParentDir, { recursive: true })
568
+
569
+ // Remove existing target if it exists (might be empty dir from git)
570
+ await rm(targetPath, { recursive: true, force: true })
571
+
572
+ // Create symlink (use absolute path for source)
573
+ await symlink(sourcePath, targetPath, "dir")
574
+ log.info(`[worktree] Symlinked: ${dir}`)
575
+ } catch (error) {
576
+ log.warn(`[worktree] Failed to symlink ${dir}: ${error}`)
577
+ }
578
+ }
579
+ }
580
+
581
+ /**
582
+ * Run hook commands in the worktree directory.
583
+ */
584
+ async function runHooks(cwd: string, commands: string[], log: Logger): Promise<void> {
585
+ for (const command of commands) {
586
+ log.info(`[worktree] Running hook: ${command}`)
587
+ try {
588
+ // Use shell to properly handle quoted arguments and complex commands
589
+ const result = Bun.spawnSync(["bash", "-c", command], {
590
+ cwd,
591
+ stdout: "inherit",
592
+ stderr: "pipe",
593
+ })
594
+ if (result.exitCode !== 0) {
595
+ const stderr = result.stderr?.toString() || ""
596
+ log.warn(
597
+ `[worktree] Hook failed (exit ${result.exitCode}): ${command}${stderr ? `\n${stderr}` : ""}`,
598
+ )
599
+ }
600
+ } catch (error) {
601
+ log.warn(`[worktree] Hook error: ${error}`)
602
+ }
603
+ }
604
+ }
605
+
606
+ /**
607
+ * Load worktree-specific configuration from .opencode/worktree.jsonc
608
+ * Auto-creates config file with helpful defaults if it doesn't exist.
609
+ */
610
+ async function loadWorktreeConfig(directory: string, log: Logger): Promise<WorktreeConfig> {
611
+ const configPath = path.join(directory, ".opencode", "worktree.jsonc")
612
+
613
+ try {
614
+ const file = Bun.file(configPath)
615
+ if (!(await file.exists())) {
616
+ // Auto-create config with helpful defaults and comments
617
+ const defaultConfig = `{
618
+ "$schema": "https://registry.kdco.dev/schemas/worktree.json",
619
+
620
+ // Worktree plugin configuration
621
+ // Documentation: https://github.com/kdcokenny/ocx
622
+
623
+ "sync": {
624
+ // Files to copy from main worktree to new worktrees
625
+ // Example: [".env", ".env.local", "dev.sqlite"]
626
+ "copyFiles": [],
627
+
628
+ // Directories to symlink (saves disk space)
629
+ // Example: ["node_modules"]
630
+ "symlinkDirs": [],
631
+
632
+ // Patterns to exclude from copying
633
+ "exclude": []
634
+ },
635
+
636
+ "hooks": {
637
+ // Commands to run after worktree creation
638
+ // Example: ["pnpm install", "docker compose up -d"]
639
+ "postCreate": [],
640
+
641
+ // Commands to run before worktree deletion
642
+ // Example: ["docker compose down"]
643
+ "preDelete": []
644
+ }
645
+ }
646
+ `
647
+ // Ensure .opencode directory exists
648
+ await mkdir(path.join(directory, ".opencode"), { recursive: true })
649
+ await Bun.write(configPath, defaultConfig)
650
+ log.info(`[worktree] Created default config: ${configPath}`)
651
+ return worktreeConfigSchema.parse({})
652
+ }
653
+
654
+ const content = await file.text()
655
+ // Use proper JSONC parser (handles comments in strings correctly)
656
+ const parsed = parseJsonc(content)
657
+ if (parsed === undefined) {
658
+ log.error(`[worktree] Invalid worktree.jsonc syntax`)
659
+ return worktreeConfigSchema.parse({})
660
+ }
661
+ return worktreeConfigSchema.parse(parsed)
662
+ } catch (error) {
663
+ log.warn(`[worktree] Failed to load config: ${error}`)
664
+ return worktreeConfigSchema.parse({})
665
+ }
666
+ }
667
+
668
+ // =============================================================================
669
+ // PLUGIN ENTRY
670
+ // =============================================================================
671
+
672
+ export const WorktreePlugin: Plugin = async (ctx) => {
673
+ const { directory, client } = ctx
674
+
675
+ const log = {
676
+ debug: (msg: string) =>
677
+ client.app
678
+ .log({ body: { service: "worktree", level: "debug", message: msg } })
679
+ .catch(() => {}),
680
+ info: (msg: string) =>
681
+ client.app
682
+ .log({ body: { service: "worktree", level: "info", message: msg } })
683
+ .catch(() => {}),
684
+ warn: (msg: string) =>
685
+ client.app
686
+ .log({ body: { service: "worktree", level: "warn", message: msg } })
687
+ .catch(() => {}),
688
+ error: (msg: string) =>
689
+ client.app
690
+ .log({ body: { service: "worktree", level: "error", message: msg } })
691
+ .catch(() => {}),
692
+ }
693
+
694
+ // Initialize SQLite database
695
+ const database = await initDb(directory, log)
696
+
697
+ return {
698
+ tool: {
699
+ worktree_create: tool({
700
+ description:
701
+ "Create a new git worktree for isolated development. A new terminal will open with OpenCode in the worktree.",
702
+ args: {
703
+ branch: tool.schema
704
+ .string()
705
+ .describe("Branch name for the worktree (e.g., 'feature/dark-mode')"),
706
+ baseBranch: tool.schema
707
+ .string()
708
+ .optional()
709
+ .describe("Base branch to create from (defaults to HEAD)"),
710
+ },
711
+ async execute(args, toolCtx) {
712
+ // Validate branch name at boundary
713
+ const branchResult = branchNameSchema.safeParse(args.branch)
714
+ if (!branchResult.success) {
715
+ return `❌ Invalid branch name: ${branchResult.error.issues[0]?.message}`
716
+ }
717
+
718
+ // Validate base branch name at boundary
719
+ if (args.baseBranch) {
720
+ const baseResult = branchNameSchema.safeParse(args.baseBranch)
721
+ if (!baseResult.success) {
722
+ return `❌ Invalid base branch name: ${baseResult.error.issues[0]?.message}`
723
+ }
724
+ }
725
+
726
+ // Create worktree
727
+ const result = await createWorktree(directory, args.branch, args.baseBranch)
728
+ if (!result.ok) {
729
+ return `Failed to create worktree: ${result.error}`
730
+ }
731
+
732
+ const worktreePath = result.value
733
+
734
+ // Sync files from main worktree
735
+ const worktreeConfig = await loadWorktreeConfig(directory, log)
736
+ const mainWorktreePath = directory // The repo root is the main worktree
737
+
738
+ // Copy files
739
+ if (worktreeConfig.sync.copyFiles.length > 0) {
740
+ await copyFiles(mainWorktreePath, worktreePath, worktreeConfig.sync.copyFiles, log)
741
+ }
742
+
743
+ // Symlink directories
744
+ if (worktreeConfig.sync.symlinkDirs.length > 0) {
745
+ await symlinkDirs(mainWorktreePath, worktreePath, worktreeConfig.sync.symlinkDirs, log)
746
+ }
747
+
748
+ // Run postCreate hooks
749
+ if (worktreeConfig.hooks.postCreate.length > 0) {
750
+ await runHooks(worktreePath, worktreeConfig.hooks.postCreate, log)
751
+ }
752
+
753
+ // Fork session with context (replaces --session resume)
754
+ const projectId = await getProjectId(worktreePath, client)
755
+ const { forkedSession, planCopied, delegationsCopied } = await forkWithContext(
756
+ client,
757
+ toolCtx.sessionID,
758
+ projectId,
759
+ async (sid) => {
760
+ // Walk up parentID chain to find root session
761
+ let currentId = sid
762
+ for (let depth = 0; depth < MAX_SESSION_CHAIN_DEPTH; depth++) {
763
+ const session = await client.session.get({ path: { id: currentId } })
764
+ if (!session.data?.parentID) return currentId
765
+ currentId = session.data.parentID
766
+ }
767
+ return currentId
768
+ },
769
+ )
770
+
771
+ log.debug(
772
+ `Forked session ${forkedSession.id}, plan: ${planCopied}, delegations: ${delegationsCopied}`,
773
+ )
774
+
775
+ // Spawn worktree with forked session
776
+ const terminalResult = await openTerminal(
777
+ worktreePath,
778
+ `opencode --session ${forkedSession.id}`,
779
+ args.branch,
780
+ )
781
+
782
+ if (!terminalResult.success) {
783
+ log.warn(`[worktree] Failed to open terminal: ${terminalResult.error}`)
784
+ }
785
+
786
+ // Record session for tracking (used by delete flow)
787
+ addSession(database, {
788
+ id: forkedSession.id,
789
+ branch: args.branch,
790
+ path: worktreePath,
791
+ createdAt: new Date().toISOString(),
792
+ })
793
+
794
+ return `Worktree created at ${worktreePath}\n\nA new terminal has been opened with OpenCode.`
795
+ },
796
+ }),
797
+
798
+ worktree_delete: tool({
799
+ description:
800
+ "Delete the current worktree and clean up. Changes will be committed before removal.",
801
+ args: {
802
+ reason: tool.schema
803
+ .string()
804
+ .describe("Brief explanation of why you are calling this tool"),
805
+ },
806
+ async execute(_args, toolCtx) {
807
+ // Find current session's worktree
808
+ const session = getSession(database, toolCtx?.sessionID ?? "")
809
+ if (!session) {
810
+ return `No worktree associated with this session`
811
+ }
812
+
813
+ // Set pending delete for session.idle (atomic operation)
814
+ setPendingDelete(database, { branch: session.branch, path: session.path }, client)
815
+
816
+ return `Worktree marked for cleanup. It will be removed when this session ends.`
817
+ },
818
+ }),
819
+ },
820
+
821
+ event: async ({ event }: { event: Event }): Promise<void> => {
822
+ if (event.type !== "session.idle") return
823
+
824
+ // Handle pending delete
825
+ const pendingDelete = getPendingDelete(database)
826
+ if (pendingDelete) {
827
+ const { path: worktreePath, branch } = pendingDelete
828
+
829
+ // Run preDelete hooks before cleanup
830
+ const config = await loadWorktreeConfig(directory, log)
831
+ if (config.hooks.preDelete.length > 0) {
832
+ await runHooks(worktreePath, config.hooks.preDelete, log)
833
+ }
834
+
835
+ // Commit any uncommitted changes
836
+ const addResult = await git(["add", "-A"], worktreePath)
837
+ if (!addResult.ok) log.warn(`[worktree] git add failed: ${addResult.error}`)
838
+
839
+ const commitResult = await git(
840
+ ["commit", "-m", "chore(worktree): session snapshot", "--allow-empty"],
841
+ worktreePath,
842
+ )
843
+ if (!commitResult.ok) log.warn(`[worktree] git commit failed: ${commitResult.error}`)
844
+
845
+ // Remove worktree
846
+ const removeResult = await removeWorktree(directory, worktreePath)
847
+ if (!removeResult.ok) {
848
+ log.warn(`[worktree] Failed to remove worktree: ${removeResult.error}`)
849
+ }
850
+
851
+ // Clear pending delete atomically
852
+ clearPendingDelete(database)
853
+
854
+ // Remove session from database
855
+ removeSession(database, branch)
856
+ }
857
+ },
858
+ }
859
+ }
860
+
861
+ export default WorktreePlugin