@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,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
|
+
}
|