@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.
- package/config/customize.sh +143 -0
- package/config/kortix-env-setup.sh +25 -0
- package/kortix-master/package.json +22 -0
- package/kortix-master/src/config.ts +22 -0
- package/kortix-master/src/index.ts +44 -0
- package/kortix-master/src/routes/env.ts +65 -0
- package/kortix-master/src/routes/proxy.ts +108 -0
- package/kortix-master/src/routes/update.ts +185 -0
- package/kortix-master/src/services/proxy.ts +43 -0
- package/kortix-master/src/services/secret-store.ts +156 -0
- package/kortix-master/tsconfig.json +14 -0
- package/opencode/agents/kortix-browser.md +142 -0
- package/opencode/agents/kortix-build.md +62 -0
- package/opencode/agents/kortix-explore.md +66 -0
- package/opencode/agents/kortix-image-gen.md +33 -0
- package/opencode/agents/kortix-main.md +450 -0
- package/opencode/agents/kortix-plan.md +100 -0
- package/opencode/agents/kortix-research.md +84 -0
- package/opencode/agents/kortix-sheets.md +61 -0
- package/opencode/agents/kortix-slides.md +64 -0
- package/opencode/agents/kortix-web-dev.md +572 -0
- package/opencode/commands/email.md +36 -0
- package/opencode/commands/init.md +43 -0
- package/opencode/commands/journal.md +44 -0
- package/opencode/commands/memory-init.md +81 -0
- package/opencode/commands/memory-search.md +50 -0
- package/opencode/commands/memory-status.md +56 -0
- package/opencode/commands/research.md +36 -0
- package/opencode/commands/search.md +38 -0
- package/opencode/commands/slides.md +32 -0
- package/opencode/commands/spreadsheet.md +30 -0
- package/opencode/memory.json +37 -0
- package/opencode/ocx.jsonc +10 -0
- package/opencode/opencode.jsonc +103 -0
- package/opencode/package.json +25 -0
- package/opencode/patches/apply.sh +19 -0
- package/opencode/patches/opencode-pty-spawn.txt +49 -0
- package/opencode/plugin/background-agents.ts.disabled +483 -0
- package/opencode/plugin/kdco-primitives/get-project-id.ts +172 -0
- package/opencode/plugin/kdco-primitives/index.ts +26 -0
- package/opencode/plugin/kdco-primitives/log-warn.ts +51 -0
- package/opencode/plugin/kdco-primitives/mutex.ts +122 -0
- package/opencode/plugin/kdco-primitives/shell.ts +138 -0
- package/opencode/plugin/kdco-primitives/temp.ts +36 -0
- package/opencode/plugin/kdco-primitives/terminal-detect.ts +34 -0
- package/opencode/plugin/kdco-primitives/types.ts +13 -0
- package/opencode/plugin/kdco-primitives/with-timeout.ts +84 -0
- package/opencode/plugin/memory.ts +306 -0
- package/opencode/plugin/worktree/state.ts +412 -0
- package/opencode/plugin/worktree/terminal.ts +1002 -0
- package/opencode/plugin/worktree.ts +861 -0
- package/opencode/skills/KORTIX-browser/SKILL.md +478 -0
- package/opencode/skills/KORTIX-cron-triggers/SKILL.md +173 -0
- package/opencode/skills/KORTIX-deep-research/SKILL.md +278 -0
- package/opencode/skills/KORTIX-docx/SKILL.md +398 -0
- package/opencode/skills/KORTIX-docx/scripts/__init__.py +1 -0
- package/opencode/skills/KORTIX-docx/scripts/accept_changes.py +104 -0
- package/opencode/skills/KORTIX-docx/scripts/comment.py +244 -0
- package/opencode/skills/KORTIX-docx/scripts/office/helpers/__init__.py +0 -0
- package/opencode/skills/KORTIX-docx/scripts/office/helpers/merge_runs.py +199 -0
- package/opencode/skills/KORTIX-docx/scripts/office/helpers/simplify_redlines.py +197 -0
- package/opencode/skills/KORTIX-docx/scripts/office/pack.py +159 -0
- package/opencode/skills/KORTIX-docx/scripts/office/soffice.py +183 -0
- package/opencode/skills/KORTIX-docx/scripts/office/unpack.py +132 -0
- package/opencode/skills/KORTIX-docx/scripts/office/validate.py +111 -0
- package/opencode/skills/KORTIX-docx/scripts/office/validators/__init__.py +15 -0
- package/opencode/skills/KORTIX-docx/scripts/office/validators/base.py +847 -0
- package/opencode/skills/KORTIX-docx/scripts/office/validators/docx.py +446 -0
- package/opencode/skills/KORTIX-docx/scripts/office/validators/pptx.py +275 -0
- package/opencode/skills/KORTIX-docx/scripts/office/validators/redlining.py +247 -0
- package/opencode/skills/KORTIX-docx/scripts/render_docx.py +179 -0
- package/opencode/skills/KORTIX-docx/scripts/templates/comments.xml +3 -0
- package/opencode/skills/KORTIX-docx/scripts/templates/commentsExtended.xml +3 -0
- package/opencode/skills/KORTIX-docx/scripts/templates/commentsExtensible.xml +3 -0
- package/opencode/skills/KORTIX-docx/scripts/templates/commentsIds.xml +3 -0
- package/opencode/skills/KORTIX-docx/scripts/templates/people.xml +3 -0
- package/opencode/skills/KORTIX-domain-research/SKILL.md +96 -0
- package/opencode/skills/KORTIX-domain-research/scripts/domain-lookup.py +810 -0
- package/opencode/skills/KORTIX-elevenlabs/SKILL.md +230 -0
- package/opencode/skills/KORTIX-elevenlabs/scripts/tts.py +389 -0
- package/opencode/skills/KORTIX-email/SKILL.md +145 -0
- package/opencode/skills/KORTIX-legal-writer/SKILL.md +409 -0
- package/opencode/skills/KORTIX-legal-writer/references/bluebook.md +152 -0
- package/opencode/skills/KORTIX-legal-writer/references/document-types.md +416 -0
- package/opencode/skills/KORTIX-legal-writer/scripts/courtlistener.py +291 -0
- package/opencode/skills/KORTIX-legal-writer/scripts/ecfr_lookup.py +299 -0
- package/opencode/skills/KORTIX-legal-writer/scripts/verify-legal.py +507 -0
- package/opencode/skills/KORTIX-logo-creator/SKILL.md +293 -0
- package/opencode/skills/KORTIX-logo-creator/references/prompt-patterns.md +134 -0
- package/opencode/skills/KORTIX-logo-creator/scripts/compose_logo.py +406 -0
- package/opencode/skills/KORTIX-logo-creator/scripts/create_logo_sheet.py +258 -0
- package/opencode/skills/KORTIX-logo-creator/scripts/remove_bg.py +96 -0
- package/opencode/skills/KORTIX-memory/SKILL.md +261 -0
- package/opencode/skills/KORTIX-memory/scripts/export-sessions.py +409 -0
- package/opencode/skills/KORTIX-paper-creator/SKILL.md +549 -0
- package/opencode/skills/KORTIX-paper-creator/assets/template.tex +101 -0
- package/opencode/skills/KORTIX-paper-creator/scripts/compile.sh +177 -0
- package/opencode/skills/KORTIX-paper-creator/scripts/openalex_to_bibtex.py +220 -0
- package/opencode/skills/KORTIX-paper-creator/scripts/verify.sh +354 -0
- package/opencode/skills/KORTIX-paper-search/SKILL.md +418 -0
- package/opencode/skills/KORTIX-pdf/SKILL.md +232 -0
- package/opencode/skills/KORTIX-pdf/forms.md +36 -0
- package/opencode/skills/KORTIX-pdf/reference.md +105 -0
- package/opencode/skills/KORTIX-pdf/scripts/check_bounding_boxes.py +65 -0
- package/opencode/skills/KORTIX-pdf/scripts/check_fillable_fields.py +11 -0
- package/opencode/skills/KORTIX-pdf/scripts/convert_pdf_to_images.py +33 -0
- package/opencode/skills/KORTIX-pdf/scripts/create_validation_image.py +37 -0
- package/opencode/skills/KORTIX-pdf/scripts/extract_form_field_info.py +122 -0
- package/opencode/skills/KORTIX-pdf/scripts/extract_form_structure.py +115 -0
- package/opencode/skills/KORTIX-pdf/scripts/fill_fillable_fields.py +98 -0
- package/opencode/skills/KORTIX-pdf/scripts/fill_pdf_form_with_annotations.py +107 -0
- package/opencode/skills/KORTIX-plan/SKILL.md +228 -0
- package/opencode/skills/KORTIX-presentation-viewer/SKILL.md +87 -0
- package/opencode/skills/KORTIX-presentation-viewer/serve.ts +136 -0
- package/opencode/skills/KORTIX-presentation-viewer/viewer.html +559 -0
- package/opencode/skills/KORTIX-presentations/SKILL.md +344 -0
- package/opencode/skills/KORTIX-remotion/SKILL.md +56 -0
- package/opencode/skills/KORTIX-remotion/rules/3d.md +86 -0
- package/opencode/skills/KORTIX-remotion/rules/animations.md +29 -0
- package/opencode/skills/KORTIX-remotion/rules/assets.md +78 -0
- package/opencode/skills/KORTIX-remotion/rules/audio-visualization.md +198 -0
- package/opencode/skills/KORTIX-remotion/rules/audio.md +169 -0
- package/opencode/skills/KORTIX-remotion/rules/calculate-metadata.md +104 -0
- package/opencode/skills/KORTIX-remotion/rules/can-decode.md +75 -0
- package/opencode/skills/KORTIX-remotion/rules/charts.md +120 -0
- package/opencode/skills/KORTIX-remotion/rules/compositions.md +141 -0
- package/opencode/skills/KORTIX-remotion/rules/display-captions.md +184 -0
- package/opencode/skills/KORTIX-remotion/rules/extract-frames.md +229 -0
- package/opencode/skills/KORTIX-remotion/rules/ffmpeg.md +38 -0
- package/opencode/skills/KORTIX-remotion/rules/fonts.md +152 -0
- package/opencode/skills/KORTIX-remotion/rules/get-audio-duration.md +58 -0
- package/opencode/skills/KORTIX-remotion/rules/get-video-dimensions.md +68 -0
- package/opencode/skills/KORTIX-remotion/rules/get-video-duration.md +58 -0
- package/opencode/skills/KORTIX-remotion/rules/gifs.md +141 -0
- package/opencode/skills/KORTIX-remotion/rules/images.md +130 -0
- package/opencode/skills/KORTIX-remotion/rules/import-srt-captions.md +69 -0
- package/opencode/skills/KORTIX-remotion/rules/light-leaks.md +73 -0
- package/opencode/skills/KORTIX-remotion/rules/lottie.md +68 -0
- package/opencode/skills/KORTIX-remotion/rules/maps.md +401 -0
- package/opencode/skills/KORTIX-remotion/rules/measuring-dom-nodes.md +35 -0
- package/opencode/skills/KORTIX-remotion/rules/measuring-text.md +143 -0
- package/opencode/skills/KORTIX-remotion/rules/parameters.md +98 -0
- package/opencode/skills/KORTIX-remotion/rules/sequencing.md +118 -0
- package/opencode/skills/KORTIX-remotion/rules/subtitles.md +36 -0
- package/opencode/skills/KORTIX-remotion/rules/tailwind.md +11 -0
- package/opencode/skills/KORTIX-remotion/rules/text-animations.md +20 -0
- package/opencode/skills/KORTIX-remotion/rules/timing.md +179 -0
- package/opencode/skills/KORTIX-remotion/rules/transcribe-captions.md +70 -0
- package/opencode/skills/KORTIX-remotion/rules/transitions.md +197 -0
- package/opencode/skills/KORTIX-remotion/rules/transparent-videos.md +106 -0
- package/opencode/skills/KORTIX-remotion/rules/trimming.md +53 -0
- package/opencode/skills/KORTIX-remotion/rules/videos.md +171 -0
- package/opencode/skills/KORTIX-secrets/SKILL.md +280 -0
- package/opencode/skills/KORTIX-semantic-search/SKILL.md +213 -0
- package/opencode/skills/KORTIX-session-search/SKILL.md +807 -0
- package/opencode/skills/KORTIX-session-search/Untitled +1 -0
- package/opencode/skills/KORTIX-skill-creator/SKILL.md +163 -0
- package/opencode/skills/KORTIX-web-research/SKILL.md +69 -0
- package/opencode/skills/KORTIX-xlsx/LICENSE.txt +30 -0
- package/opencode/skills/KORTIX-xlsx/SKILL.md +549 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/helpers/__init__.py +0 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/helpers/merge_runs.py +199 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/helpers/simplify_redlines.py +197 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/pack.py +159 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/mce/mc.xsd +75 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/soffice.py +183 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/unpack.py +132 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/validate.py +111 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/validators/__init__.py +15 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/validators/base.py +847 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/validators/docx.py +446 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/validators/pptx.py +275 -0
- package/opencode/skills/KORTIX-xlsx/scripts/office/validators/redlining.py +247 -0
- package/opencode/skills/KORTIX-xlsx/scripts/recalc.py +184 -0
- package/opencode/tools/image-gen.ts +342 -0
- package/opencode/tools/image-search.ts +190 -0
- package/opencode/tools/memory-get.ts +168 -0
- package/opencode/tools/memory-search.ts +247 -0
- package/opencode/tools/presentation-gen.ts +723 -0
- package/opencode/tools/scrape-webpage.ts +115 -0
- package/opencode/tools/scripts/.python-version +1 -0
- package/opencode/tools/scripts/convert_pdf.py +184 -0
- package/opencode/tools/scripts/convert_pptx.py +562 -0
- package/opencode/tools/scripts/pyproject.toml +11 -0
- package/opencode/tools/scripts/uv.lock +287 -0
- package/opencode/tools/scripts/validate_slide.py +74 -0
- package/opencode/tools/show-user.ts +217 -0
- package/opencode/tools/tests/e2e-presentation-fix.ts +277 -0
- package/opencode/tools/tests/image-gen.test.ts +215 -0
- package/opencode/tools/tests/image-search.test.ts +125 -0
- package/opencode/tools/tests/memory-system-benchmark.ts +1076 -0
- package/opencode/tools/tests/presentation-gen.test.ts +389 -0
- package/opencode/tools/tests/scrape-webpage.test.ts +74 -0
- package/opencode/tools/tests/show-user.test.ts +241 -0
- package/opencode/tools/tests/video-gen.test.ts +110 -0
- package/opencode/tools/tests/web-search.test.ts +106 -0
- package/opencode/tools/video-gen.ts +200 -0
- package/opencode/tools/web-search.ts +153 -0
- package/opencode/tsconfig.json +29 -0
- package/package.json +36 -0
- package/patch-agent-browser.js +100 -0
- package/postinstall.sh +88 -0
- package/services/KORTIX-presentation-viewer/run +37 -0
- package/services/agent-browser-viewer/run +48 -0
- package/services/kortix-master/run +16 -0
- package/services/lss-sync/run +22 -0
- package/services/opencode-serve/run +25 -0
- 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
|