@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,49 @@
1
+ Spawns a new interactive PTY (pseudo-terminal) session that runs in the background.
2
+
3
+ Unlike the built-in bash tool which runs commands synchronously and waits for completion, PTY sessions persist and allow you to:
4
+ - Run long-running processes (dev servers, watch modes, etc.)
5
+ - Send interactive input (including Ctrl+C, arrow keys, etc.)
6
+ - Read output at any time
7
+ - Manage multiple concurrent terminal sessions
8
+
9
+ Usage:
10
+ - The `command` parameter is required (e.g., "npm", "python", "bash")
11
+ - Use `args` to pass arguments to the command (e.g., ["run", "dev"])
12
+ - Use `workdir` to set the working directory (defaults to project root)
13
+ - Use `env` to set additional environment variables
14
+ - Use `title` to give the session a human-readable name
15
+ - The `description` parameter is required: a clear, concise 5-10 word description
16
+ - Use `notifyOnExit` to receive a notification when the process exits (default: false)
17
+
18
+ Returns the session info including:
19
+ - `id`: Unique identifier (pty_XXXXXXXX) for use with other pty_* tools
20
+ - `pid`: Process ID
21
+ - `status`: Current status ("running")
22
+
23
+ After spawning, use:
24
+ - `pty_write` to send input to the PTY
25
+ - `pty_read` to read output from the PTY
26
+ - `pty_list` to see all active PTY sessions
27
+ - `pty_kill` to terminate the PTY
28
+
29
+ Exit Notifications:
30
+ When `notifyOnExit` is true, you will receive a message when the process exits containing:
31
+ - Session ID and title
32
+ - Exit code
33
+ - Total output lines
34
+ - Last line of output (truncated to 250 chars)
35
+
36
+ This is useful for long-running processes where you want to be notified when they complete
37
+ instead of polling with `pty_read`. If the process fails (non-zero exit code), the notification
38
+ will suggest using `pty_read` with the `pattern` parameter to search for errors.
39
+
40
+ Examples:
41
+ - Start a dev server: command="npm", args=["run", "dev"], title="Dev Server"
42
+ - Start a Python REPL: command="python3", title="Python REPL"
43
+ - Run tests in watch mode: command="npm", args=["test", "--", "--watch"]
44
+ - Run build with notification: command="npm", args=["run", "build"], notifyOnExit=true
45
+
46
+ IMPORTANT — Anti-patterns to avoid:
47
+ - NEVER prepend `sleep N &&` to commands in PTY. PTY sessions are already async — sleep just adds dead time before the command even starts. Run the command directly.
48
+ - NEVER use `sleep` for synchronization. Use notifyOnExit=true and wait for the <pty_exited> notification, or chain commands with && in the synchronous bash tool.
49
+ - NEVER wrap simple one-shot commands in PTY. Use the bash tool for commands that complete quickly (<2 min). PTY is for long-running/interactive processes only.
@@ -0,0 +1,483 @@
1
+ /**
2
+ * background-agents — Async delegation plugin for OpenCode
3
+ *
4
+ * Provides fire-and-forget background delegation to any agent.
5
+ * Results persist in memory, survive context compaction, and
6
+ * automatically notify the parent session on completion.
7
+ *
8
+ * Tools:
9
+ * delegate(prompt, agent, title?) — Launch a background task
10
+ * delegation_read(id) — Retrieve full result
11
+ * delegation_list() — List all delegations
12
+ *
13
+ * Hooks:
14
+ * event(session.idle) — Detect child completion, notify parent
15
+ * experimental.session.compacting — Inject delegation state for compaction survival
16
+ */
17
+
18
+ import { type Plugin, tool } from "@opencode-ai/plugin";
19
+ import type { Event } from "@opencode-ai/sdk";
20
+
21
+ // ── Types ────────────────────────────────────────────────────────────────────
22
+
23
+ interface Delegation {
24
+ id: string;
25
+ sessionID: string;
26
+ parentSessionID: string;
27
+ parentAgent: string;
28
+ prompt: string;
29
+ agent: string;
30
+ title: string;
31
+ status: "running" | "complete" | "error" | "timeout";
32
+ startedAt: number;
33
+ completedAt?: number;
34
+ result?: string;
35
+ error?: string;
36
+ }
37
+
38
+ interface SessionMessageItem {
39
+ info: { id: string; role: string; sessionID: string };
40
+ parts: Array<{ type: string; text?: string; tool?: string }>;
41
+ }
42
+
43
+ // ── State ────────────────────────────────────────────────────────────────────
44
+
45
+ const delegations = new Map<string, Delegation>();
46
+ let counter = 0;
47
+
48
+ function nextId(): string {
49
+ return `d-${++counter}`;
50
+ }
51
+
52
+ // ── Helpers ──────────────────────────────────────────────────────────────────
53
+
54
+ function formatDuration(ms: number): string {
55
+ const seconds = Math.floor(ms / 1000);
56
+ if (seconds < 60) return `${seconds}s`;
57
+ const minutes = Math.floor(seconds / 60);
58
+ const remaining = seconds % 60;
59
+ return `${minutes}m ${remaining}s`;
60
+ }
61
+
62
+ /**
63
+ * Extract the last assistant text from a session's messages.
64
+ */
65
+ function extractLastAssistantText(
66
+ messages: SessionMessageItem[],
67
+ ): string {
68
+ for (let i = messages.length - 1; i >= 0; i--) {
69
+ const msg = messages[i]!;
70
+ if (msg.info.role !== "assistant") continue;
71
+
72
+ const textParts = msg.parts
73
+ .filter((p) => p.type === "text" && p.text)
74
+ .map((p) => p.text!)
75
+ .join("\n");
76
+
77
+ if (textParts.length > 0) return textParts;
78
+ }
79
+ return "";
80
+ }
81
+
82
+ /**
83
+ * Find a delegation by its child session ID.
84
+ */
85
+ function findBySessionID(sessionID: string): Delegation | undefined {
86
+ for (const d of delegations.values()) {
87
+ if (d.sessionID === sessionID) return d;
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ /**
93
+ * Find all delegations belonging to a parent session (or its root).
94
+ */
95
+ function findByParent(parentSessionID: string): Delegation[] {
96
+ return Array.from(delegations.values()).filter(
97
+ (d) => d.parentSessionID === parentSessionID,
98
+ );
99
+ }
100
+
101
+ // ── Plugin ───────────────────────────────────────────────────────────────────
102
+
103
+ const MAX_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
104
+
105
+ export const BackgroundAgentsPlugin: Plugin = async ({ client }) => {
106
+ // Track sessions we've already notified about to avoid duplicates
107
+ const notified = new Set<string>();
108
+
109
+ // ── Completion handler ─────────────────────────────────────────────────
110
+
111
+ async function handleCompletion(delegation: Delegation): Promise<void> {
112
+ if (notified.has(delegation.id)) return;
113
+ notified.add(delegation.id);
114
+
115
+ try {
116
+ // Read messages from child session
117
+ const messagesResult = await client.session.messages({
118
+ path: { id: delegation.sessionID },
119
+ });
120
+
121
+ const messages = (messagesResult.data ?? []) as SessionMessageItem[];
122
+ const lastText = extractLastAssistantText(messages);
123
+
124
+ delegation.status = "complete";
125
+ delegation.completedAt = Date.now();
126
+ delegation.result = lastText || "(No output produced)";
127
+
128
+ // Build notification for parent
129
+ const duration = formatDuration(
130
+ delegation.completedAt - delegation.startedAt,
131
+ );
132
+ const resultPreview =
133
+ delegation.result.length > 1000
134
+ ? delegation.result.slice(0, 1000) + "\n\n[...truncated — use delegation_read for full output]"
135
+ : delegation.result;
136
+
137
+ const notification = [
138
+ "<delegation_completed>",
139
+ `Delegation: ${delegation.id}`,
140
+ `Title: ${delegation.title}`,
141
+ `Agent: ${delegation.agent}`,
142
+ `Duration: ${duration}`,
143
+ "",
144
+ `Result:`,
145
+ resultPreview,
146
+ "</delegation_completed>",
147
+ "",
148
+ `A background delegation just completed. Review the result above and inform the user. Use delegation_read("${delegation.id}") if you need the full untruncated output.`,
149
+ ].join("\n");
150
+
151
+ // Send notification into the parent session
152
+ await client.session.promptAsync({
153
+ path: { id: delegation.parentSessionID },
154
+ body: {
155
+ agent: delegation.parentAgent,
156
+ parts: [{ type: "text", text: notification }],
157
+ },
158
+ });
159
+ } catch (e) {
160
+ delegation.status = "error";
161
+ delegation.completedAt = Date.now();
162
+ delegation.error = e instanceof Error ? e.message : String(e);
163
+ }
164
+ }
165
+
166
+ // ── Tools ──────────────────────────────────────────────────────────────
167
+
168
+ const delegateTool = tool({
169
+ description:
170
+ "Delegate a task to a background agent. Returns immediately — the agent " +
171
+ "runs asynchronously. You will be automatically notified via a " +
172
+ "<delegation_completed> message when it finishes. Use delegation_read(id) " +
173
+ "to retrieve the full result if it was truncated or lost during compaction.\n\n" +
174
+ "Available agents: kortix-main, kortix-research, kortix-web-dev, " +
175
+ "kortix-browser, kortix-slides, kortix-image-gen, kortix-sheets.\n\n" +
176
+ "Write a clear, self-contained prompt — the agent starts with zero context.",
177
+ args: {
178
+ prompt: tool.schema
179
+ .string()
180
+ .describe(
181
+ "The full prompt for the background agent. Must be self-contained — " +
182
+ "include all context, constraints, and desired output location.",
183
+ ),
184
+ agent: tool.schema
185
+ .string()
186
+ .describe(
187
+ "Agent to delegate to. Options: 'kortix-main' (general), " +
188
+ "'kortix-research' (deep research), 'kortix-web-dev' (web apps), " +
189
+ "'kortix-browser' (browser automation), 'kortix-slides' (presentations), " +
190
+ "'kortix-image-gen' (images), 'kortix-sheets' (spreadsheets).",
191
+ ),
192
+ title: tool.schema
193
+ .string()
194
+ .optional()
195
+ .describe(
196
+ "Short title for the delegation (e.g. 'OAuth2 research', 'Landing page build'). " +
197
+ "Defaults to the agent name.",
198
+ ),
199
+ },
200
+ async execute(args, context) {
201
+ const id = nextId();
202
+ const title = args.title || `${args.agent} task`;
203
+
204
+ try {
205
+ // Create child session linked to parent via parentID
206
+ const sessionResult = await client.session.create({
207
+ body: {
208
+ parentID: context.sessionID,
209
+ title: `[${id}] ${title}`,
210
+ },
211
+ });
212
+
213
+ const childSessionID = (sessionResult.data as { id: string })?.id;
214
+ if (!childSessionID) {
215
+ return JSON.stringify({
216
+ error: "Failed to create child session",
217
+ });
218
+ }
219
+
220
+ // Store delegation
221
+ const delegation: Delegation = {
222
+ id,
223
+ sessionID: childSessionID,
224
+ parentSessionID: context.sessionID,
225
+ parentAgent: context.agent,
226
+ prompt: args.prompt,
227
+ agent: args.agent,
228
+ title,
229
+ status: "running",
230
+ startedAt: Date.now(),
231
+ };
232
+ delegations.set(id, delegation);
233
+
234
+ // Fire the prompt asynchronously
235
+ await client.session.promptAsync({
236
+ path: { id: childSessionID },
237
+ body: {
238
+ agent: args.agent,
239
+ parts: [{ type: "text", text: args.prompt }],
240
+ },
241
+ });
242
+
243
+ // Set timeout safety net
244
+ setTimeout(async () => {
245
+ if (delegation.status === "running") {
246
+ delegation.status = "timeout";
247
+ delegation.completedAt = Date.now();
248
+ delegation.error = `Timed out after ${MAX_TIMEOUT_MS / 1000}s`;
249
+
250
+ // Try to abort
251
+ try {
252
+ await client.session.abort({
253
+ path: { id: childSessionID },
254
+ });
255
+ } catch {}
256
+
257
+ // Notify parent about timeout
258
+ await client.session.promptAsync({
259
+ path: { id: delegation.parentSessionID },
260
+ body: {
261
+ agent: delegation.parentAgent,
262
+ parts: [
263
+ {
264
+ type: "text",
265
+ text: `<delegation_timeout>\nDelegation ${id} ("${title}") timed out after ${MAX_TIMEOUT_MS / 1000}s.\nAgent: ${args.agent}\n</delegation_timeout>`,
266
+ },
267
+ ],
268
+ },
269
+ });
270
+ }
271
+ }, MAX_TIMEOUT_MS);
272
+
273
+ context.metadata({ title: `Delegated: ${title}` });
274
+
275
+ return [
276
+ `Delegation ${id} started.`,
277
+ `Title: ${title}`,
278
+ `Agent: ${args.agent}`,
279
+ `Session: ${childSessionID}`,
280
+ ``,
281
+ `You will be automatically notified when it completes.`,
282
+ `Do not poll — continue with other work.`,
283
+ ].join("\n");
284
+ } catch (e) {
285
+ return JSON.stringify({
286
+ error: `Failed to delegate: ${e instanceof Error ? e.message : String(e)}`,
287
+ });
288
+ }
289
+ },
290
+ });
291
+
292
+ const delegationReadTool = tool({
293
+ description:
294
+ "Read the full output of a completed delegation. Use this when " +
295
+ "the <delegation_completed> notification was truncated or lost " +
296
+ "during context compaction.",
297
+ args: {
298
+ id: tool.schema
299
+ .string()
300
+ .describe('The delegation ID (e.g. "d-1", "d-2")'),
301
+ },
302
+ async execute(args) {
303
+ const delegation = delegations.get(args.id);
304
+
305
+ if (!delegation) {
306
+ const available = Array.from(delegations.keys()).join(", ");
307
+ return `Delegation "${args.id}" not found. Available: ${available || "(none)"}`;
308
+ }
309
+
310
+ if (delegation.status === "running") {
311
+ // Try to get current progress from the session
312
+ try {
313
+ const messagesResult = await client.session.messages({
314
+ path: { id: delegation.sessionID },
315
+ });
316
+ const messages =
317
+ (messagesResult.data ?? []) as SessionMessageItem[];
318
+ const lastText = extractLastAssistantText(messages);
319
+
320
+ const elapsed = formatDuration(Date.now() - delegation.startedAt);
321
+ return [
322
+ `Delegation ${args.id} is still running (${elapsed} elapsed).`,
323
+ `Title: ${delegation.title}`,
324
+ `Agent: ${delegation.agent}`,
325
+ "",
326
+ lastText
327
+ ? `Latest progress:\n${lastText}`
328
+ : "No output yet.",
329
+ ].join("\n");
330
+ } catch {
331
+ return `Delegation ${args.id} is still running. No progress available yet.`;
332
+ }
333
+ }
334
+
335
+ // Completed/error/timeout — return stored result
336
+ const header = [
337
+ `# ${delegation.title}`,
338
+ ``,
339
+ `**ID:** ${delegation.id}`,
340
+ `**Agent:** ${delegation.agent}`,
341
+ `**Status:** ${delegation.status}`,
342
+ `**Duration:** ${delegation.completedAt ? formatDuration(delegation.completedAt - delegation.startedAt) : "N/A"}`,
343
+ delegation.error ? `**Error:** ${delegation.error}` : "",
344
+ ``,
345
+ `---`,
346
+ ``,
347
+ ]
348
+ .filter(Boolean)
349
+ .join("\n");
350
+
351
+ // If we have the result in memory, return it
352
+ if (delegation.result) {
353
+ return header + delegation.result;
354
+ }
355
+
356
+ // Otherwise try to read from the session
357
+ try {
358
+ const messagesResult = await client.session.messages({
359
+ path: { id: delegation.sessionID },
360
+ });
361
+ const messages =
362
+ (messagesResult.data ?? []) as SessionMessageItem[];
363
+ const lastText = extractLastAssistantText(messages);
364
+ return header + (lastText || "(No output found)");
365
+ } catch {
366
+ return header + "(Session no longer available)";
367
+ }
368
+ },
369
+ });
370
+
371
+ const delegationListTool = tool({
372
+ description:
373
+ "List all background delegations for the current session. " +
374
+ "Shows running and completed delegations with their IDs, titles, " +
375
+ "agents, and statuses.",
376
+ args: {},
377
+ async execute(_args, context) {
378
+ const all = findByParent(context.sessionID);
379
+
380
+ if (all.length === 0) {
381
+ return "No delegations found for this session.";
382
+ }
383
+
384
+ const lines = all.map((d) => {
385
+ const status =
386
+ d.status === "running"
387
+ ? `RUNNING (${formatDuration(Date.now() - d.startedAt)})`
388
+ : d.status === "complete"
389
+ ? `COMPLETE`
390
+ : d.status === "timeout"
391
+ ? `TIMEOUT`
392
+ : `ERROR`;
393
+ return `- ${d.id} | ${d.title} | ${d.agent} | ${status}`;
394
+ });
395
+
396
+ return ["## Delegations", "", ...lines].join("\n");
397
+ },
398
+ });
399
+
400
+ // ── Return hooks ───────────────────────────────────────────────────────
401
+
402
+ return {
403
+ tool: {
404
+ delegate: delegateTool,
405
+ delegation_read: delegationReadTool,
406
+ delegation_list: delegationListTool,
407
+ },
408
+
409
+ // Watch for child session completion
410
+ event: async ({ event }: { event: Event }) => {
411
+ if (event.type === "session.idle") {
412
+ const sessionID = event.properties.sessionID;
413
+ const delegation = findBySessionID(sessionID);
414
+ if (delegation && delegation.status === "running") {
415
+ await handleCompletion(delegation);
416
+ }
417
+ }
418
+ },
419
+
420
+ // Inject delegation state during context compaction
421
+ "experimental.session.compacting": async (
422
+ input: { sessionID: string },
423
+ output: { context: string[]; prompt?: string },
424
+ ) => {
425
+ const all = findByParent(input.sessionID);
426
+ if (all.length === 0) return;
427
+
428
+ const running = all.filter((d) => d.status === "running");
429
+ const completed = all.filter((d) => d.status !== "running");
430
+
431
+ const sections: string[] = ["<delegation-context>"];
432
+
433
+ if (running.length > 0) {
434
+ sections.push("## Running Delegations");
435
+ for (const d of running) {
436
+ const elapsed = formatDuration(Date.now() - d.startedAt);
437
+ sections.push(
438
+ `- **${d.id}** | ${d.title} | ${d.agent} | running for ${elapsed}`,
439
+ );
440
+ sections.push(
441
+ ` Prompt: ${d.prompt.length > 150 ? d.prompt.slice(0, 150) + "..." : d.prompt}`,
442
+ );
443
+ }
444
+ sections.push("");
445
+ sections.push(
446
+ "> You will be notified via <delegation_completed> when these finish. Do not poll.",
447
+ );
448
+ }
449
+
450
+ if (completed.length > 0) {
451
+ sections.push("");
452
+ sections.push("## Completed Delegations");
453
+ for (const d of completed) {
454
+ const statusLabel =
455
+ d.status === "complete"
456
+ ? "DONE"
457
+ : d.status === "error"
458
+ ? "ERROR"
459
+ : "TIMEOUT";
460
+ sections.push(
461
+ `- **${d.id}** | ${d.title} | ${d.agent} | ${statusLabel}`,
462
+ );
463
+ if (d.result) {
464
+ const preview =
465
+ d.result.length > 200
466
+ ? d.result.slice(0, 200) + "..."
467
+ : d.result;
468
+ sections.push(` Result preview: ${preview}`);
469
+ }
470
+ }
471
+ sections.push("");
472
+ sections.push(
473
+ '> Use delegation_read("id") to retrieve full results.',
474
+ );
475
+ }
476
+
477
+ sections.push("</delegation-context>");
478
+ output.context.push(sections.join("\n"));
479
+ },
480
+ };
481
+ };
482
+
483
+ export default BackgroundAgentsPlugin;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Project ID generation for kdco registry plugins.
3
+ *
4
+ * Generates a stable, unique identifier for a project based on its git history.
5
+ * Used for cross-worktree consistency in delegation storage, state databases,
6
+ * and other plugin data that should be shared across worktrees.
7
+ *
8
+ * @module kdco-primitives/get-project-id
9
+ */
10
+
11
+ import * as crypto from "node:crypto"
12
+ import { stat } from "node:fs/promises"
13
+ import * as path from "node:path"
14
+ import { logWarn } from "./log-warn"
15
+ import type { OpencodeClient } from "./types"
16
+ import { TimeoutError, withTimeout } from "./with-timeout"
17
+
18
+ /**
19
+ * Generate a short hash from a path for project ID fallback.
20
+ *
21
+ * Used when git root commit is unavailable (non-git repos, empty repos).
22
+ * Produces a 16-character hex string for reasonable uniqueness.
23
+ *
24
+ * @param projectRoot - Absolute path to hash
25
+ * @returns 16-char hex hash
26
+ */
27
+ function hashPath(projectRoot: string): string {
28
+ const hash = crypto.createHash("sha256").update(projectRoot).digest("hex")
29
+ return hash.slice(0, 16)
30
+ }
31
+
32
+ /**
33
+ * Generate a unique project ID from the project root path.
34
+ *
35
+ * **Strategy:**
36
+ * 1. Uses the first root commit SHA for stability across renames/moves
37
+ * 2. Falls back to path hash for non-git repos or empty repos
38
+ * 3. Caches result in .git/opencode for performance
39
+ *
40
+ * **Git Worktree Support:**
41
+ * When .git is a file (worktree), resolves the actual .git directory
42
+ * and uses the shared cache. This ensures all worktrees share the same
43
+ * project ID and associated data.
44
+ *
45
+ * @param projectRoot - Absolute path to the project root
46
+ * @param client - Optional OpenCode client for logging warnings
47
+ * @returns 40-char hex SHA (git root) or 16-char hash (fallback)
48
+ * @throws {Error} When projectRoot is invalid or .git file has invalid format
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * const projectId = await getProjectId("/home/user/my-repo")
53
+ * // Returns: "abc123..." (40-char git hash)
54
+ *
55
+ * const projectId = await getProjectId("/home/user/non-git-folder")
56
+ * // Returns: "def456..." (16-char path hash)
57
+ * ```
58
+ */
59
+ export async function getProjectId(projectRoot: string, client?: OpencodeClient): Promise<string> {
60
+ // Guard: Validate projectRoot (Law 1: Early Exit, Law 4: Fail Fast)
61
+ if (!projectRoot || typeof projectRoot !== "string") {
62
+ throw new Error("getProjectId: projectRoot is required and must be a string")
63
+ }
64
+
65
+ const gitPath = path.join(projectRoot, ".git")
66
+
67
+ // Check if .git exists and what type it is
68
+ const gitStat = await stat(gitPath).catch(() => null)
69
+
70
+ // Guard: No .git directory - not a git repo (Law 1: Early Exit)
71
+ if (!gitStat) {
72
+ logWarn(client, "project-id", `No .git found at ${projectRoot}, using path hash`)
73
+ return hashPath(projectRoot)
74
+ }
75
+
76
+ let gitDir = gitPath
77
+
78
+ // Handle worktree case: .git is a file containing gitdir reference
79
+ if (gitStat.isFile()) {
80
+ const content = await Bun.file(gitPath).text()
81
+ const match = content.match(/^gitdir:\s*(.+)$/m)
82
+
83
+ // Guard: Invalid .git file format (Law 4: Fail Fast)
84
+ if (!match) {
85
+ throw new Error(`getProjectId: .git file exists but has invalid format at ${gitPath}`)
86
+ }
87
+
88
+ // Resolve path (handles both relative and absolute)
89
+ const gitdirPath = match[1].trim()
90
+ const resolvedGitdir = path.resolve(projectRoot, gitdirPath)
91
+
92
+ // The gitdir contains a 'commondir' file pointing to shared .git
93
+ const commondirPath = path.join(resolvedGitdir, "commondir")
94
+ const commondirFile = Bun.file(commondirPath)
95
+
96
+ if (await commondirFile.exists()) {
97
+ const commondirContent = (await commondirFile.text()).trim()
98
+ gitDir = path.resolve(resolvedGitdir, commondirContent)
99
+ } else {
100
+ // Fallback to ../.. assumption for older git or unusual setups
101
+ gitDir = path.resolve(resolvedGitdir, "../..")
102
+ }
103
+
104
+ // Guard: Resolved gitdir must be a directory (Law 4: Fail Fast)
105
+ const gitDirStat = await stat(gitDir).catch(() => null)
106
+ if (!gitDirStat?.isDirectory()) {
107
+ throw new Error(`getProjectId: Resolved gitdir ${gitDir} is not a directory`)
108
+ }
109
+ }
110
+
111
+ // Check cache in .git/opencode
112
+ const cacheFile = path.join(gitDir, "opencode")
113
+ const cache = Bun.file(cacheFile)
114
+
115
+ if (await cache.exists()) {
116
+ const cached = (await cache.text()).trim()
117
+ // Validate cache content (40-char hex for git hash, or 16-char for path hash)
118
+ if (/^[a-f0-9]{40}$/i.test(cached) || /^[a-f0-9]{16}$/i.test(cached)) {
119
+ return cached
120
+ }
121
+ logWarn(client, "project-id", `Invalid cache content at ${cacheFile}, regenerating`)
122
+ }
123
+
124
+ // Generate project ID from git root commit
125
+ try {
126
+ const proc = Bun.spawn(["git", "rev-list", "--max-parents=0", "--all"], {
127
+ cwd: projectRoot,
128
+ stdout: "pipe",
129
+ stderr: "pipe",
130
+ env: { ...process.env, GIT_DIR: undefined, GIT_WORK_TREE: undefined },
131
+ })
132
+
133
+ // 5 second timeout to prevent hangs on network filesystems
134
+ const timeoutMs = 5000
135
+ const exitCode = await withTimeout(proc.exited, timeoutMs, `git rev-list timed out`).catch(
136
+ (e) => {
137
+ if (e instanceof TimeoutError) {
138
+ proc.kill()
139
+ }
140
+ return 1 // Treat timeout/errors as failure, fall back to path hash
141
+ },
142
+ )
143
+
144
+ if (exitCode === 0) {
145
+ const output = await new Response(proc.stdout).text()
146
+ const roots = output
147
+ .split("\n")
148
+ .filter(Boolean)
149
+ .map((x) => x.trim())
150
+ .sort()
151
+
152
+ if (roots.length > 0 && /^[a-f0-9]{40}$/i.test(roots[0])) {
153
+ const projectId = roots[0]
154
+ // Cache the result
155
+ try {
156
+ await Bun.write(cacheFile, projectId)
157
+ } catch (e) {
158
+ logWarn(client, "project-id", `Failed to cache project ID: ${e}`)
159
+ }
160
+ return projectId
161
+ }
162
+ } else {
163
+ const stderr = await new Response(proc.stderr).text()
164
+ logWarn(client, "project-id", `git rev-list failed (${exitCode}): ${stderr.trim()}`)
165
+ }
166
+ } catch (error) {
167
+ logWarn(client, "project-id", `git command failed: ${error}`)
168
+ }
169
+
170
+ // Fallback to path hash
171
+ return hashPath(projectRoot)
172
+ }